Commit Graph

224 Commits

Author SHA1 Message Date
Coffee
2042380731 chore: remove IronLoop small-fix role instructions (#7415)
* chore: remove IronLoop small-fix role instructions

* ci: classify IronLoop configuration changes
2026-08-10 08:52:31 +00:00
firat.sertgoz
226bd491dd ci(canary): remove provider-matrix lanes and zizmor scan (#7418)
* ci(canary): remove provider-matrix lanes and zizmor scan

* fix(ci): classify nextest config as exhaustive-plan change

.config/nextest.toml is read by every Tests (Reborn) lane, so the
fail-closed planner arm raised 'unclassified pull-request path' on any
PR touching it, skipping all downstream Reborn lanes. Widen it to the
exhaustive plan like crate deletions.
2026-08-10 08:22:40 +00:00
firat.sertgoz
9dd228a62a fix(ci): clear inherited main check failures (#7425)
* fix(ci): scope POSIX trace test import to Unix

* fix(ci): run Windows WebUI setup with Bash

* test(ci): pin Bash for Windows WebUI setup

* fix(ci): clear remaining main check failures

---------

Co-authored-by: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
2026-08-10 08:17:31 +00:00
Pranav Raja
89285c8e70 fix(skills): one DB-backed tree for every skill mount, and make a skill's own commands runnable (closes #7168) (#7171)
* fix(reborn): inject skill bodies by default, not a one-line listing

Reborn defaulted `SkillInjectionMode` to `Listing`, where a non-activated
skill contributes only `- name: description` to context and its body loads
only on an explicit `$name` mention or a `builtin.skill_activate` call. The
intent was to save context budget.

Benchmarking shows the model reads the menu and then never opens the skill.
Over 30 runs with human-curated skills installed (SkillsBench/SkillLearnBench
subset, `deepseek-v4-flash`, nearai/benchmarks#287):

  builtin.skill_list       called in 30/30 runs
  builtin.skill_activate   called in  3/30 runs
  a skill body actually read           0/30 runs

So installed skills were effectively inert. Same 31 tasks, same skills, same
model, varying only this default:

  no skills                 78.5%
  curated skills, Listing   79.8%   (+1.3pp -- skills bought almost nothing)
  curated skills, Full      85.6%   (+7.1pp)

For reference, harnesses that inject skill bodies unconditionally (Hermes,
Claude Code) score 91.5% on these tasks with the same skills, so `Full` closes
most but not all of that gap; the remainder is loop/verification behavior on a
handful of multi-output tasks and is tracked separately.

`Full` is already the library default in `SkillActivationSelectorConfig`; only
the Reborn composition seam opted out. This restores it and adds a guard test
so a revert is deliberate. `IRONCLAW_REBORN_SKILL_INJECTION=listing` still
selects the previous behavior where context budget matters more than skills
being used.

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

* feat(skills): hot-swappable activation strategies so agent-authored skills are reusable

Adds `skill.activation.v1`, a swappable-provider module in the shape of the
memory-provider binding (`ironclaw_host_runtime::memory_binding`): named
strategies, fail-closed resolution, behavior-preserving default, and a
composition seam so nothing downstream names a concrete implementation.

## The bug it addresses

`selector::score_skill` accumulates score ONLY from `activation.keywords` (+10/+5),
`activation.tags` (+3) and `activation.patterns` (+20). A skill's `name` and
`description` contribute nothing, and `select_skills` keeps a skill only
`if score > 0`.

That is fine for curated skills, which ship an `activation` block. It is fatal for
skills an agent writes for itself: measured across the 31-task
SkillsBench/SkillLearnBench subset in nearai/benchmarks#287, **0 of 30**
agent-authored skills contained an `activation` block. Every one scored 0 and was
permanently unselectable — the agent could create a skill via
`builtin.skill_install` and then never reuse it, which makes self-improvement
structurally impossible rather than merely weak.

Claude Code has no such requirement: a skill is selectable from name and
description alone. `ActivationStrategy::NameAndDescription` ports that contract.

## Design

* `CriteriaOnly` (default) — today's rule, byte-identical.
* `NameAndDescription` — whole-word name/description fallback, applied ONLY when
  the criteria pass scored 0, so a curated skill's explicit keywords always decide
  ordering and this can never reorder two skills that both declare metadata.
  `NAME_WORD_SCORE` (8) is deliberately below the selector's exact-keyword award
  (10).
* `Disabled` — explicit mention / `skill_activate` only.
* `ThirdParty { extension_id }` — production requires an admin override.

Whole-word matching and a `MAX_FALLBACK_SCORE` cap keep it from over-selecting;
over-selection is the failure mode that makes injecting an unrelated skill bank
harmful (a whole-catalog injection took `xlsx_recover_data` 1.000 -> 0.271).

## Default stays behavior-preserving

Reborn's default remains `CriteriaOnly`, opt in with
`IRONCLAW_REBORN_SKILL_ACTIVATION=name_and_description`. Flipping the default
changes three existing local-dev expectations (setup-marker suppression, the webui
listing candidate, `skill_activate` context loading), so the strategy ships opt-in
— the same discipline as the memory work, where the bundled native provider stays
the default.

## Tests

`cargo test -p ironclaw_skills --lib` — 239 passed, including:
* `agent_authored_skill_unreachable_by_default_but_selected_under_name_strategy`
  — end-to-end via `prefilter_skills_with_options`: the same
  no-activation skill is dropped under `CriteriaOnly` and selected under
  `NameAndDescription`.
* `name_strategy_does_not_select_an_irrelevant_skill` — no over-selection.
* `name_hit_outranked_by_an_explicit_curated_keyword`, `whole_word_only_...`,
  `fallback_is_capped_...`, `stop_words_do_not_accumulate_score`.

`cargo test -p ironclaw_first_party_extension_ports --lib` — 58 passed.

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

* fix(reborn): ship the Full skill-injection default as opt-in, not a flip

The measurement in the previous commit stands: `Listing` leaves installed skills
unread (`skill_list` 30/30 runs, a body actually opened 0/30) and `Full` is worth
79.8% -> 85.6% on the 31-task SkillsBench subset.

But flipping the product default HANGS three existing local-dev tests, which drive
a mock that expects the one-line listing candidate:

* `local_dev_skill_activate_tool_loads_selected_skill_context`
* `local_dev_webui_bundle_records_selectable_filesystem_skill_context`
* `local_dev_runtime_wires_filesystem_skills_by_default_to_model_calls`

Verified by bisect: all three hang on the previous commit alone, and pass with the
default restored — the activation-strategy work is not implicated.

Changing a documented product default in a way that turns CI red is a maintainer
call, not something to force through, so `DEFAULT_SKILL_INJECTION_MODE` returns to
`Listing` and `Full` ships as `IRONCLAW_REBORN_SKILL_INJECTION=full`. Both switches
in this PR are now opt-in with the evidence attached, matching the memory-provider
discipline where the bundled default is preserved.

The guard test is retargeted to assert the current default, verify the opt-in path
still resolves, and name the three tests that must be updated alongside a future
flip.

cargo test -p ironclaw_reborn_composition --lib -- skill_injection_mode \
  local_dev_selector_config skill_activation  # 14 passed, 0 failed

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

* feat(threads): raise the result_read cap to 64 KiB, env-tunable

A small per-request `result_read` cap turns one large file into a paging loop. On
`manufacturing_equipment_maintenance` (nearai/benchmarks#287) reborn made 8
`read_file` calls and ZERO shell calls, hit the 24 KiB cap, then spent the whole
turn paging — `result_read` at offset 24576, `handbook.pdf` at offsets
400/800/1200 — and never computed anything (`outputs_exist=0.00`). hermes, using
shell to sample the same data, scored 0.522.

* `TOOL_RESULT_RECORD_READ_MAX_BYTES` 24 KiB -> 64 KiB. This is the compile-time
  ceiling the model-observation envelope in `tool_result_reference.rs` is derived
  from (`* 2`, asserted at compile time), so 64 KiB here means a 128 KiB envelope —
  the reason not to go higher.
* `TOOL_RESULT_RECORD_READ_DEFAULT_MAX_BYTES` = 64 KiB — the effective default.
  Enough that a typical data file or document page arrives in one read instead of a
  paging loop.
* `IRONCLAW_TOOL_RESULT_READ_MAX_BYTES` overrides it, clamped to `[4, ceiling]`, so
  an override can never outgrow the envelope. Unparseable values fall back to the
  default rather than failing the run — a malformed tuning knob must not take down
  an agent.

Unlike the skill-injection and skill-activation switches in this branch, this one
does move the default: the paging loop is a silent capability loss rather than a
behavior preference, and the knob exists for deployments that want the old size.

cargo test -p ironclaw_threads --lib   # 88 passed (85 existing + 3 new)

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

* feat(skills): add always_available activation, Claude Code's actual contract

`skill.activation.v1` gains a third binding, `always_available`: every installed
skill is a candidate regardless of what it matches.

This is what Claude Code and Hermes actually do. In both, a skill is a file in a
directory the agent can read, so there is no gate for a correctly-installed skill
to fail. Reborn's selector instead scores only `activation.keywords`/`tags`/
`patterns` and drops anything scoring 0 -- and `name_and_description` (this
branch's earlier binding) only WIDENS that gate: it still needs a lexical hit, so
an applicable skill phrased differently from the prompt is still discarded. The
new test pins exactly that case -- a skill described as "cyclical component /
growth path" against a prompt saying "hp filter" is dropped by both
`criteria_only` AND `name_and_description`, and kept by `always_available`.

Why it matters, measured on the 31-task SkillsBench/SkillLearnBench subset in
nearai/benchmarks#287: 0 of 30 agent-authored skills contained an `activation`
block, so under `criteria_only` a self-authored skill could never be selected
again -- self-improvement was structurally impossible.

Implementation is deliberately tiny: a `floor_score()` of 1 for this binding,
applied via `.max()` in the selector's existing scoring loop. Ordering is
untouched (a real keyword match still outranks a floor skill, so the context
budget spends on the relevant skill first), and the existing budget -- not the
score filter -- decides what is injected, which is also how Claude Code behaves.
`floor_score()` is 0 for every other binding, so non-adopters are byte-identical.

Default remains `criteria_only`; opt in with
IRONCLAW_REBORN_SKILL_ACTIVATION=always_available.

cargo test -p ironclaw_skills --lib   # 241 passed (239 existing + 2 new)

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

* fix(threads): drop the now-unused ceiling import

Validation bounds against `contract::effective_tool_result_read_max_bytes()` (which
applies the env override), so the compile-time ceiling is no longer referenced here.
Removes an unused-import warning introduced by the 64 KiB cap commit.

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

* revert(threads): default result_read back to 24 KiB, keep the knob

The raise to 64 KiB was never isolated: it shipped in a measurement arm alongside two
other switches (skill activation, tool disclosure), so there is no evidence it changed
anything. Defaulting it back keeps this crate byte-identical to pre-PR behavior.

The paging trace that motivated it is real (`manufacturing_equipment_maintenance`,
nearai/benchmarks#287: 8 `read_file` calls, zero shell calls, `result_read` at offset
24576, nothing computed) — but a real trace is not a measured fix, so the larger cap
stays opt-in via IRONCLAW_TOOL_RESULT_READ_MAX_BYTES for whoever wants to measure it
properly.

The compile-time ceiling stays 64 KiB: it now bounds only how far the env override may
reach, and still pins the derived model-observation envelope at 128 KiB. Net effect of
this commit plus its parent: a new env knob, no default change.

cargo test -p ironclaw_threads --lib   # 88 passed

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

* docs(skills): design for agent-authored multi-file skill bundles

@henrypark133 pushed back on "move skills to the filesystem" as an overhaul that a
single aggregate result did not justify. He was right, and stratifying the data shows
why: the entire filesystem gain sits in skills that ship files besides SKILL.md.

  ships resource files (n=16): inject 81.0% -> files 94.2%  (+13.2pp, CI [+0.3, +26.2])
  SKILL.md-only        (n=11): inject 91.5% -> files 84.7%  (-6.9pp,  CI [-20.4, +6.7])

So filesystem-for-everything is a REGRESSION on 13 of 31 tasks, paid to fix the other 18.
The mechanism is not "models prefer filesystems": 81 of the resources are executable
(you cannot run pasted Python -- citation_check scored 0.000 with the script absent,
0.833 with it present), and the text resources are too large to inline
(exceltable_in_ppt would be ~262k tokens folded into SKILL.md).

The design therefore keeps storage, discovery and selection exactly as they are and adds
ONE extension holding the already-existing `/skills` read_write mount:
skill_write_file / skill_read_file / skill_list_files. Discovery already lists from the
same root that mount writes to, so nothing needs plumbing. Executing a bundled script
copies that one file into `/workspace`, which the agent already mounts.

Documents two things the implementation must not miss: SkillBundleDescriptor exposes only
`skill_md_path`, so bundle resources are un-advertisable without skill_list_files; and
`FilesystemSkillBundleRoot::user` marks bundles Trusted, so an agent that can write
executable scripts there needs a distinct trust level -- the real open question.

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

* docs(skills): state explicitly that creation, discovery and indexing are unchanged

The crux of @henrypark133's objection. Spells out, per concern, that skill creation stays
on the `skill_install` tool, discovery stays on the storage-agnostic
`SkillBundleSource` trait with no new impl / trait method / descriptor change, and that
there is no session-start index to migrate at all (selection is per-request; the only
cache is a 5-minute TTL on catalog search). The single behavioral change remains the
opt-in `always_available` selection predicate.

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

* docs(skills): the write tool needs an authoring prompt that asks for code

skill_write_file makes multi-file skills possible; it does not elicit them. Measured: 6 of
31 tasks finished with ZERO skill_install calls despite 'Saving the skill is required', and
the authoring request only ever asks for prose (method, conventions, output contract). An
agent following it writes prose whether or not a write tool exists.

Adds the elicitation requirement and a falsifiable success criterion: agent-authored
bundles are currently 100% prose (0 of 27 ship a resource file) against 18 of 31 curated
skills. If that ratio does not move once the tool ships, the bottleneck was elicitation
rather than capability and the tool alone will not move scores.

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

* feat(skills): let an agent install skill bundles, not just prose

Agents could only ever author the PROSE half of a skill. Measured on the 31-task
SkillsBench/SkillLearnBench subset (nearai/benchmarks#287): **0 of 27** agent-authored
skills shipped a single file besides SKILL.md, against **18 of 31** human-curated ones
(79 .py scripts, 78 .xsd schemas, 84 .md references). So every later run re-derived the
method from prose and could re-make the same mistake -- lake_warming's self-authored skill
described its regression procedure in prose, the next run recomputed it slightly
differently and missed the grader's p<0.05 threshold.

This was NOT a missing capability. `install_skill` has always taken
`files: &[SkillInstallFile]`, and `parse_install_files` has always read an `input["files"]`
array. Two things made it unreachable:

1. `schemas/builtin/skill_install.input.v1.json` advertised only `name`/`content`/`url`
   AND set `additionalProperties: false` -- so a model sending `files` was not merely
   uninformed, it was REJECTED. Across 112 observed skill_install calls, 111 used exactly
   `['content','name']`, which is what the schema permits.
2. The only encodings were `bytes_base64` and a JSON array of byte integers. A bundle file
   an agent writes is a script, a reference doc or a schema fragment -- all UTF-8. Making
   those go through base64 costs ~33% more tokens and turns one encoding slip into an
   InputEncode failure of the whole install.

Changes:
- `parse_install_files` accepts `text` (UTF-8) alongside `bytes_base64`/`bytes`. `text`
  takes precedence when both are given, matching the documented preference. Binary
  payloads are unaffected.
- the schema advertises `files` with `path` + `text`/`bytes_base64`, and the description
  tells the model WHY to use it: put a reusable computation in a script rather than
  describing it in prose, and have SKILL.md name the files it relies on. That last part
  matters because `SkillBundleDescriptor` exposes only `skill_md_path`, so a bundle cannot
  advertise its own resources.
- prose-only installs are untouched: no `files` key still parses to an empty vec.

cargo test -p ironclaw_first_party_extensions --lib install_files_encoding  # 4 passed
cargo test -p ironclaw_host_runtime --test tool_surface_contract            # 43 passed
cargo test -p ironclaw_reborn_composition --test product_live_adapters skill_install  # 1 passed

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

* fix(skills): stop rejecting an install that carries both content and files

`skill_install_input` gated the direct-install arm on `!object.contains_key("files")`, so
`content` + `files` matched NO arm and fell through to `_ => Err(InputEncode)`. An agent
attaching a script had its ENTIRE install refused. `files` was reachable only on the
URL-fetch arm, which builds the array itself.

This was the third of three stacked gates hiding the same capability, and the one that
actually bit. With the schema fixed to advertise `files` and a `text` encoding available,
the model on the 31-task SkillsBench subset (nearai/benchmarks#287) immediately sent 18
correctly-shaped `{path, text}` entries across 9 calls -- `scripts/verify_bib.py`,
`references/fake_patterns.json` -- and every one was rejected here. That is the real reason
0 of 27 agent-authored skills shipped a resource file while 18 of 31 human-curated ones do:
not a missing capability, and not the model failing to try.

`source`/`source_url` stay excluded from the direct arm: those record provenance and are
set by the URL path, so an agent must not be able to forge them.

cargo test -p ironclaw_host_runtime --lib skill_install_input   # 4 passed

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

* style: rustfmt the skill-bundle and activation changes

Test modules were appended programmatically without rustfmt, which is why Formatting,
Code Style and Clippy all went red on this PR.

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

* docs(skills): rewrite to match what was measured, not the abandoned design

The doc recommended a three-tool extension plus a resource-gate. Both are superseded:
the tools turned out to be redundant (install_skill already accepted files -- three
stacked gates were hiding it), and the gate MEASURED WORSE than always advertising a
readable path (-25.7pp on self-creation, -40.6pp vs claude-code), because an
agent-authored skill is usually SKILK.md-only so the gate suppresses the one route the
selector had not already closed.

Rewritten around the durable findings: the three gates and how each masked the next, the
0-of-27 vs 18-of-31 measurement, the SkillBundleDescriptor enumeration gap, and the trust
question. The gate is kept in the doc as a recorded negative result, since its stratified
justification is persuasive and will be proposed again.

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

* docs(skills): correct why always_available is not the default

The previous note claimed the floor score overrides setup-marker suppression. It does not:
`prefilter_skills_with_options` returns None for a satisfied marker BEFORE scoring, and
the host-side filter in activation.rs already removed the candidate.

What actually fails: all 32 bundled skills reach floor 1, so 3-4 unrelated ones land in
plan.activations() in ActivationCriteria mode -- a mode that injects nothing under
Listing. The defect exposed is that a criteria activation which injects no body is still
recorded as an activation, so the count assertions stop being meaningful.

Also records the sequencing against epic #6565 (Slice 0 first; Slice 5's bounded-shortlist
rule constrains what an unbounded floor may do) and the measured detail that under Listing
a zero-scoring skill is still listed -- the model just called skill_activate in only 3 of
30 runs.

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

* fix(skills): a floor-only skill is ranked, not activated

Three defects, all surfaced by trying to make `always_available` the default. It failed 8
tests in ironclaw_reborn_composition; all 8 now pass with the flag on AND off.

1. A criteria selection that injects nothing was still recorded as an activation. Under
   `SkillInjectionMode::Listing` an `ActivationCriteria` entry contributes no body --
   `body_eligible_bundle_ids` already ignores that mode -- so with a floor score every
   installed skill "activated" on every turn. Concretely: all 32 bundled skills reach floor
   1, and 3 of them (6000 token budget / 2000 default per-skill cost) landed in each plan,
   chosen by descriptor order because the score sort is stable. `SelectionOutcome` now
   returns those separately as `ranked_only`, and the activation path does not iterate them.
   They still reach the model through the listing, which is where they belonged.

2. `AlwaysAvailable` also enabled the name/description fallback, which manufactured fake
   merit: a bundled skill whose description shares one word with the message scored above
   zero and was reported as a genuine activation. Under `AlwaysAvailable` the fallback adds
   no reach at all (the floor already admits everything), so it is now scoped to
   `NameAndDescription`, where widening the match is the entire point. This is what kept
   `local_dev_runtime_suppresses_explicit_setup_skill_when_workspace_marker_exists` failing
   after (1).

3. Raising TOOL_RESULT_RECORD_READ_MAX_BYTES to 64 KiB was NOT the no-op this PR claimed.
   `tool_result_reference.rs` derives MAX_MODEL_OBSERVATION_BYTES from it (* 2), so the
   observation envelope silently doubled 48 KiB -> 128 KiB and preview truncation changed
   for every caller. It broke three tests whose fixtures are sized against the envelope
   ("fixture must exceed the preview cap"), independently of any activation setting. The
   contract ceiling is back to 24 KiB and the env override is bounded by a new
   TOOL_RESULT_READ_ENV_CEILING_BYTES that nothing is derived from -- so the knob can raise
   a single read without moving anyone else's behavior.

Correcting the record on an earlier comment in this PR: the failures were never the
setup-marker interaction. Marker suppression returns None before scoring, so a floor score
cannot revive a suppressed skill.

cargo test -p ironclaw_reborn_composition --lib                                    # 634 passed
IRONCLAW_REBORN_SKILL_ACTIVATION=always_available cargo test -p ironclaw_reborn_composition --lib  # 634 passed
cargo test -p ironclaw_skills --lib  # 241    cargo test -p ironclaw_threads --lib  # 88

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

* revert(skills): remove the always_available strategy, it bought nothing

Verified against this branch: `AlwaysAvailable` was a no-op for everything the model can
observe, and it carried a regression. Removing it rather than wiring a compensating half.

Why it bought nothing. Listing membership is decided by VISIBILITY, not selection
(extension_ports/activation.rs partitions candidates on body-eligibility, and everything
not body-eligible still goes into the listing), so the model was ALREADY shown every visible
skill before this strategy existed. The floor score never added reach -- pre-C1 its only
effect was listing ORDER, and after C1 excluded floor-only skills from activations even the
ordering effect was gone, because the ranking input is derived from the activation list.
`SelectionOutcome::ranked_only` had no production reader at all: allocated, populated,
returned, dropped. Under `Full` a floor-only skill could never be injected either, since
`context_candidates_for_plan` renders only activated bundles.

The regression. The floor-only bookkeeping ran for every non-merit entry BEFORE `try_select`,
so under this strategy a chain-loaded companion got its own loop iteration, was recorded as
floor-only, and was then partitioned OUT of `selected` -- i.e. `A requires B` activated only
`A`, where `CriteriaOnly` activates both. Strictly worse than the default for any bundle with
companions, and order-dependent. The comment claiming this could not happen was wrong.

Also removed: ~29 "budget exhausted" notes per turn that reached `feedback` and fired a
SkillActivation live-projection event with empty skill_names, because floor-only skills still
ran the budget loop and `BudgetFull` continues rather than breaks.

Kept: `NameAndDescription`, which has a real effect (matching on name/description, not only
`activation.keywords`/`tags`/`patterns`), and the `skill.activation.v1` seam.

Corrects the record in two places that argued the opposite: the runtime.rs doc comment and
docs/skills/agent_authored_bundles.md. The measured reachability gap is elicitation, not
filtering -- `builtin.skill_activate` was called in 3 of 30 runs and a body read in 0 of 30 --
so the next step is the listing header, not a scoring change. Note the parity numbers in
nearai/benchmarks#327 never depended on this strategy: those arms ran with it off.

cargo test -p ironclaw_reborn_composition --lib   # 634 passed
cargo test -p ironclaw_skills --lib               # 240 passed
cargo test -p ironclaw_threads --lib              # 88 passed

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

* fix(skills): explain refusals, gate requirements, survive discovery limits

Epic #6565, the missing/unusable half. Four causes it names, each of which was
silent.

## A refusal now says why

`select_named_skill_activations` reported a skill that exists but is not `Trusted`
with the same string as a name that does not exist: "requested skill is not
available". The two need opposite responses -- one means "try a different name",
the other means "this needs promoting and no name will work" -- and the model got
neither.

This is the routine outcome of the model doing what it was told: the listing
filters on visibility only while activation requires `Trusted`, and tenant-shared
and URL-installed skills are `Installed`. So the listing advertises a skill and
activation then refuses it.

Deliberately does NOT enumerate alternatives, tempting as that was:
`load_named_activation_candidate_set` scopes the candidate set to the requested
names, so nothing else is loaded at that point and any "available: ..." list
would be empty. I wrote that branch, found it could never fire, and removed it
rather than ship a message that lies. Offering alternatives needs a wider
descriptor load and belongs with #4428.

## Requirements are actually checked

`requires.bins`, `requires.env` and `requires.config` were parsed into the
manifest and never consulted. `check_requirements` exists, but its only callers
are inside `SkillRegistry`, which has no consumers outside its own crate. A skill
declaring a binary it needs was offered, activated cleanly, and failed later in
the shell with nothing connecting the failure to the unmet requirement.

Gated at ACTIVATION time, on both the explicit-mention and model-selected paths.
Not at listing time: that would be three probes per visible skill on every prompt
build and needs a caching design first. At activation it runs for the handful of
skills being loaded, so the cost objection does not apply. Staying unusable is
correct here -- the fix is that the reason reaches the model.

## One oversized root no longer erases itself

`list_root` returned `BundleScanLimitExceeded` when a root held more than
`max_bundles_per_root` directories, which removed EVERY skill in that root from
the model's view. A catalog that grew past the cap lost all its skills at once,
with no signal to the model and only a propagated error to the operator. Now it
keeps the bundles that fit and warns about the truncation.

## Silent skips became warnings that name the reason

Two `debug!` sites -- an invalid bundle directory name, and a manifest that fails
validation (which covers the common authoring mistake of a directory name
disagreeing with the manifest `name:`) -- meant a skill present on disk simply
never appeared and nothing said why. Both are `warn!` with the error attached.

## Tests

Three that pinned the old behaviour were rewritten rather than deleted, each with
why: the two refusal-message assertions, and the scan-limit test that asserted
total root loss. New coverage for the trust-vs-name distinction and for an unmet
binary requirement being refused with the requirement named.

`cargo test -p ironclaw_first_party_extension_ports -p ironclaw_loop_host` — 68 +
420 + 27 + 4 + 88 pass. fmt and clippy clean.

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

* fix(skills): surface the refusal reason to the model, not just the projection

The reason strings were the easy half. `skill_activate`'s result was built from
`plan.selection.activations` only and **discarded `plan.selection.feedback`
entirely**, so every reason the selector produces was constructed and thrown away.
The model saw `{"activated":[],"count":0}` and had to guess whether it had used a
bad name, hit a trust wall, or tripped an unmet requirement -- three situations
that need three different responses.

Caught by measurement rather than review: on the missing/unusable fixtures
`usable` moved after the earlier commit but `diagnosed` stayed flat at 2/7,
because improving the wording of a message nobody receives changes nothing.

Adds `not_activated` alongside `activated`. Routine "activated after model
selection" confirmations are filtered out -- next to `activated` they are noise
and would dilute the refusals that matter.

Output construction is extracted into `build_activation_output` so the contract is
unit-testable in the same style as the rest of the module: a refusal carries its
reason, a clean activation gains no empty field, and a mixed result reports both.

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

* feat(skills): the model decides, and the listing can never be hidden from it

Epic #6941 criteria 1 and 8. Adopts @serrrfirat's position -- "I don't think we
should statically regex match skill names to skill activation... we should show it
to the model and it should decide" -- as the default rather than a flag.

## The default is now ExplicitOnly

`SkillActivationSelectorConfig::default()` no longer runs the keyword/regex scorer.
A profile that wants it must ask for `ExplicitAndCriteria` deliberately; nothing
inherits it silently, which is how #5417 shipped.

The scorer's own record is the argument for retiring it:

* It produced #5417 -- `tech-debt-tracker` declares the keyword `hack`, so "search
  Hacker News for..." activated it.
* Over 328 real prompts `coding` fired on ~220 through *legitimate whole-word* hits
  on `file`/`change`/`code`. No boundary rule or score threshold can fix that.
* Measured against it, the model path made **zero** wrong selections across 28
  tasks over an 88-skill catalog, at **94.8%** precision on what it did activate.

The scorer is not deleted: it is still correct, still tested, and still reachable
for a profile that opts in. It is simply no longer the thing that decides.

## And a trap that had to be fixed in the same commit

In `Full` injection mode both context paths returned an EMPTY candidate set when
nothing was active. That was survivable only while the scorer auto-activated
something. With model-decides it would mean the model is never told a skill exists
and therefore can never activate one -- flipping this default alone would have
blinded the agent. Both paths now fall through to the listing.

## Tests

Three new criterion tests: the default policy is pinned (so a silent revert fails),
the listing survives with nothing activated in BOTH injection modes, and the
listing stays inside a stated character budget at 200 skills -- with the scorer
retired the listing IS the routing interface, so its size is a correctness property
rather than a cosmetic one.

Sixteen existing tests were updated rather than deleted, in two groups, each with
its reason in the code:

* Tests whose SUBJECT is the scorer now call `criteria_config()` to opt in. That is
  the point of the new default -- nothing gets the scorer by accident. Includes
  `prepare_grants_asset_reads_only_for_activated_bundles`, which uses criteria
  selection only as the mechanism to activate a bundle; I checked that one first
  because an asset-grant assertion failing could have been a real security
  regression rather than an expectation change. It was not.
* Tests asserting `selected.is_empty()` now assert
  `assert_no_skill_body_disclosed(..)`. "No candidates" is no longer the right
  question; "no skill BODY reached the model" is what they were really protecting,
  and it is exact -- the listing is a *discoverable* candidate
  (`loaded_skill_md() == None`) while an activated skill is a *loaded* one.

`cargo test -p ironclaw_first_party_extension_ports -p ironclaw_loop_host
-p ironclaw_skills` -- 74 + 420 + 27 + 4 + 88 + 229 pass. fmt and clippy clean.

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

* test(skills): pin #5417 on the path that records the user message

Epic #6941 criterion 2. Criteria selection needs a RECORDED user message
(`take_message_for_run`), and the coordinator path never records one -- so a
coordinator-path test passes vacuously and proves nothing. This records the
message, which is what the product/WebUI surface does, and the issue itself
reports "Run origin: WebUI chat".

Asserts BOTH policies, including the uncomfortable one:

* model-decides (the new default): no body is injected. Fixed.
* criteria explicitly enabled: it STILL mis-activates on this branch. Asserted as
  a known residual rather than omitted.

That second arm is the useful half. It shows the two changes are complementary
rather than redundant: this PR removes the scorer from the decision, #6937's
word-boundary matcher stops `hack` matching inside "Hacker" for any profile that
opts the scorer back in. Neither alone closes #5417 on the criteria path, and
pinning it here means a future reader cannot mistake model-decides for a complete
fix. The assertion message says what to do when #6937 merges and the arm flips.

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

* fix(skills): a truncated skill listing must say so

`MAX_LISTED_SKILLS` is 100 and the listing is source-then-name ordered, so past
the cap whole alphabetical tails vanish -- with no signal to the model and none to
the operator.

Found by walking into it. Running Benchmark A against a 227-skill catalog, `pdf`,
`pptx`, `xlsx` and `timeseries-detrending` all sorted past position 100, so three
of the first four tasks could not reach their own expected skill and the arm was
measuring nothing. Nothing anywhere reported it; I only caught it by diffing the
rendered listing against each task's expected set. That is the failure mode this
epic exists to remove, and it was hiding in the listing itself.

Now the listing states how many skills are hidden, and the host logs a warning
with listed/hidden/total.

This does NOT make a large catalog usable -- that needs `skill_search` (#4428),
and a 227-skill listing costs ~9k tokens of prompt besides. What it does is turn a
silent, invisible failure into a stated one, so a benchmark or a user hitting the
cap finds out.

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

* fix(skills): make "the model decides" reachable, and list every skill instead of the first 100

Two defects found by measuring this PR rather than reviewing it. Both made a claim
the PR already made untrue in practice.

**1. The ExplicitOnly default was dead code on the Reborn path.**

`skill_activation_selector_config` pinned `ExplicitAndCriteria` at the call site, so
changing the default in `activation.rs` could not affect any real Reborn user. The
model-decides change looked like a behaviour change and was not one. No test in
`ironclaw_first_party_extension_ports` could catch this, because those construct
their own config; only a test on the value the composition layer actually returns
can. `reborn_skill_selection_is_model_decided` is that test, and it fails if the
mode is re-pinned.

**2. The skill listing silently dropped everything past 100 skills.**

`MAX_LISTED_SKILLS = 100` with source-then-name ordering meant whole alphabetical
tails were rendered nowhere and logged nowhere. Measured on a 227-skill catalog:
`pdf`, `pptx`, `xlsx` and `timeseries-detrending` all sorted past the cap, and three
of the first four benchmark tasks could not reach their own expected skill. A skill
the model cannot see is one it cannot activate, so this is indistinguishable from
never having installed it — and with the scorer retired, the listing IS the routing
interface, which makes its completeness a correctness property.

The flat count cap becomes a character budget spent differently: every skill's name
is listed, with per-entry descriptions shrinking as the catalog grows (250 chars at
small sizes, 90 at 227), and entries are dropped only when even 60 chars will not
fit — roughly past 380 skills. **This is not a context-size increase.** The budget
is exactly what the old cap already permitted (`100 * (250 + 64)`). What changes is
that it buys reachability for all skills rather than verbosity for the
alphabetically lucky first hundred. When truncation does happen it is stated in the
listing and warned with `listed`/`hidden`/`total`.

`the_listing_stays_within_budget_at_two_hundred_skills` now asserts both that the
listing fits its budget *and* that all 200 skills appear in it. The second assertion
is the one the old cap violated: that test previously passed on budget alone while
hiding half the catalog, which is how this survived.

Beyond ~380 skills the answer is `skill_search` (#4428), not a bigger prompt.

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

* fix(skills): raise the per-root bundle scan cap so a real catalog enumerates whole

Found by measuring: after replacing the listing's 100-skill cap with a character
budget, a 227-skill root STILL only reached the model as 100 skills. The listing was
never the binding limit.

`DEFAULT_MAX_BUNDLES_PER_ROOT = 100` truncates one layer earlier, at enumeration:
`skill bundle root exceeds the per-root scan limit ... limit=100 skipped=127`. The 127
skipped bundles were invisible to the selector, to the listing, and to the model —
the same outcome as never installing them. Two caps, and only the lower one decides,
so raising the listing budget alone accomplished nothing.

Raised to 512. The cap exists to bound an unbounded directory walk, not to bound a
catalog; 512 keeps that protection (a bundle is one directory read plus a manifest
parse, cached per root) while leaving real catalogs whole. Past it, truncation is
still partial-and-warned rather than fatal, and the answer is `skill_search` (#4428)
rather than a larger number.

`a_two_hundred_and_twenty_seven_skill_root_enumerates_whole` asserts the default cap
does not truncate a real catalog. The existing test only proved truncation *degrades
gracefully*, which is why a default too low to fit anything real passed it.

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

* fix(skills): let the scorer rank the listing, while the model alone decides

Retiring the criteria scorer from ACTIVATION is right and stays. Retiring it from
ORDERING was a mistake, and the benchmark caught it.

Measured on the 31-task routing benchmark, 88 candidates, same model, paired:

|                          | criteria on | ExplicitOnly |
|--------------------------|-------------|--------------|
| >=1 correct skill        | 57.1%       | 28.6%        |
| recall over expected set | 42.9%       | 25.0%        |
| correct skill REQUESTED  | 75.0%       | 35.7%        |
| never called skill_activate | 25.0%    | 57.1%        |

The model was not being refused -- refusals were 0% in both arms. It stopped asking.
The scorer was the only thing making a long listing legible: with it off, the listing
collapses to source-then-name alphabetical order, and the relevant skill sits among
dozens of equally-weighted lines with nothing marking it. At 227 candidates this is
worse, not better.

So the scorer keeps its useful job and loses its harmful one. Under `ExplicitOnly` the
prefilter still runs, and its output populates `SkillActivationSelection::ranking_only`,
which feeds `criteria_ranked_bundle_ids` and therefore listing order ONLY. It never
appends to `activations`, and it deliberately does not extend `feedback` (those notes
explain activation decisions, and nothing was activated).

This is the distinction @serrrfirat's objection actually draws. "Don't statically regex
match skill names to skill activation" is not "don't use scoring to rank what the model
is shown". Ordering a menu is not choosing from it. The host recommends; the model
decides; a wrong recommendation costs a listing line rather than the skill budget.

`explicit_only_ranks_the_listing_without_activating_anything` pins both halves in one
test, because they pull in opposite directions: the matched skill must LEAD the listing,
and no body may be disclosed.

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

* Revert "fix(skills): let the scorer rank the listing, while the model alone decides"

This reverts 2f8acabe7. I added it, and neither of its justifications survived.

The reasoning was already withdrawn publicly: I claimed retiring criteria selection
collapsed the listing to alphabetical order and made the model stop asking, but **0 of 62
benchmark catalog skills declare an `activation:` block** (32 of 32 bundled ones do), so
the scorer could never rank a single skill these tasks need. Listing order for every
expected skill was identical in both arms and the mechanism did not exist.

The data now declines to support it too: **33.3% >=1-correct with ranking vs 40.0%
without**. That is worse than neutral, and the reason is the same fact -- the only skills
the scorer CAN rank are the 32 irrelevant bundled ones, so ranking promotes distractors
above the skill the task actually needs. On any realistic catalog, where users' and
agents' skills carry no activation metadata, ranking is systematically wrong.

Also: the drop I built this on was small-n noise. The arm I read as 28.6% reads 40.0% at
n=25.

Worth revisiting only if descriptor metadata coverage ever becomes the norm rather than
the exception. Until then #6938 ships no heuristic in the selection path at all, which is
the point of the change.

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

* fix(skills): tell the model what a skill is FOR, not just what the tool does

Studied how claude-code routes to skills and ported the mechanism. It reaches 60% correct
activation on 5 SkillsBench tasks over a 227-skill catalog; reborn reached 0% on the same
tasks and the same catalog. The gap was not discovery -- every skill was listed and reachable
-- and it was not refusals, which were 0%. The model simply had no reason to ask, so it solved
each task with `shell` instead.

Three differences, in order of how much they mattered.

**1. The tool description.** local-dev's said:

    "Activate one or more listed Reborn skills for the current loop run"

That states what the tool does and nothing about when to use it or why. claude-code's states
what a skill IS, that a listing exists, that a matching skill should be activated FIRST, and
-- the load-bearing clause -- that the loaded instructions REPLACE the model's default
approach. A model that does not know a skill supersedes its own plan has no reason to prefer
one. Both descriptors now carry the same text; they had drifted, and local-dev (what every
local user gets, and what the benchmark measures) had the weaker one.

**2. Full descriptions for every skill.** The listing budget previously shrank per-entry
descriptions to fit more names -- 90 chars each at 227 skills. That traded away the wrong
thing: 52% activation with 88 full-length entries, 0% with 227 shrunken ones. Names make a
skill addressable; descriptions are what let the model judge relevance, and 90 chars does not.
claude-code pays this cost outright, listing every skill with its whole one-line description,
so the budget is now sized to do the same. The per-entry cap and the 512-bundle enumeration cap
still bound it -- this is a budget for a real catalog, not a licence for an unbounded one.

**3. The listing header** now opens the way claude-code's system-reminder does ("The following
skills are available for use with builtin.skill_activate") and repeats the supersedes-your-
default-approach point where the model reads the menu, not only where it reads the tool schema.

Measured after the port, same 5 tasks, same 227 candidates, same model:

|                     | before | after | claude-code |
|---------------------|--------|-------|-------------|
| >=1 correct         | 0%     | 50%   | 60%         |
| recall over expected| 0%     | 33.3% | 36.7%       |
| precision           | --     | 100%  | 100%        |
| never activated     | 100%   | 50%   | 40%         |

Precision is identical: when reborn now activates, it is not wrong. Recall still trails, and
the remaining gap is tasks where it never consults the catalog at all -- the same failure mode
claude-code has, just more often.

Also worth recording from the study, not ported here: claude-code injects the listing as a
per-turn system-reminder rather than static prompt text, and emits a second reminder ("New
skills discovered in <dir>, now available via the Skill tool") when a skill appears
mid-session. That second one is the install-then-use-immediately flow, and is a candidate
follow-up.

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

* feat(skills): lint routing metadata at authoring and learned-skill write time

Epic #6941 criterion 7. This is the one piece of the closed #6937 worth keeping, and
model-decided selection made it MORE relevant rather than less: with the scorer out of the
decision, the description is the model's only signal in the listing, so a generic or
over-long one degrades routing for every later request.

`lint_skill_routing_metadata` flags generic keywords, generic tags, a description past the
250-char listing cap, and a keyword that also appears in `exclude_keywords`. It gates both
learned-skill authoring paths (`parse_distillation`, `parse_refinement` both funnel through
`parse_skill_md`), so a model cannot write a skill that poisons the catalog.

Deliberately no pattern rule. An earlier draft flagged unanchored wildcards and failed 25 of
32 catalog skills; narrowing it to "ends in an open wildcard" still mis-flagged 3 of 4.
Wildcard position is not what makes a pattern promiscuous -- required literal specificity is
-- and one measured bad pattern is not enough evidence for a heuristic with that false-positive
rate. A lint people switch off protects nothing.

The checked-in catalog now passes it (`the_checked_in_catalog_passes_the_routing_metadata_lint`).
Six of 32 skills failed before the fix: five with descriptions of 269-338 chars, which the
listing was silently truncating, and `coding` with ten generic keywords (`code`, `fix`, `file`,
`test`, `build`, `error`, `change`, `delete`, `add`, `update`).

Fixing `coding` moved the reviewed routing baseline (#6595) on 7 of 8 cases, and the pattern is
the whole argument for the lint -- it was being selected for:

    security-audit, qa-test-plan, track-github-repository, commit-staged-changes,
    park-product-idea, local-web-ui-validation

and it drops out of every one. On `single-pr-code-review` it survives but demotes from 3rd to
5th. Baselines updated, which is what that test asks for when the change is intentional.

One honest scoping note: the keyword half of this is INERT at runtime under `ExplicitOnly`,
since the scorer no longer selects anything -- it matters for any future keyword-consuming path
and as catalog hygiene. The half that changes behaviour today is the description-length rule:
those five over-long descriptions were being truncated in the model-visible listing, which is
exactly the signal the model routes on.

cargo test: 945 passing across ironclaw_skills, ironclaw_first_party_extension_ports,
ironclaw_loop_host and ironclaw_architecture.

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

* fix(skills): raise the active-skill cap from 4 to 8, it made correct routing impossible

`DEFAULT_MAX_ACTIVE_SKILLS = 4` was not a budget, it was a ceiling on correctness. On the
SkillsBench routing set, **7 of 31 tasks expect 4 or more skills and 3 expect 5**, so a
perfectly-routing agent could not satisfy them: recall was bounded by this constant rather than
by anything the model did. A benchmark measured against a limit the harness imposes on itself
reports nothing about the system under test.

Raised to 8. The real guard on skill context is `max_context_tokens`, which bounds how much
body text loads regardless of how many skills get named -- a large skill still consumes that
budget and pushes the effective count back down on its own. This constant only stops a model
from naming an unbounded list, which 8 still does.

Also updates every place that hardcoded "four": the tool description, the `names` schema
description and its `maxItems`, and the listing header. Those had to move together, since a
model told "at most four" while the selector allows eight will leave skills on the table.

One test assertion needed rewriting rather than retargeting:
`standalone_skill_activate_tool_loads_selected_skill_context` pinned the old description's
exact phrases. Its intent -- the description must tell the model WHEN to use the capability,
and must not imply every visible bare name is actionable -- is preserved and extended with a
third assertion on the clause that actually moved the metric ("instead of your own default
approach"), because that is the sentence that took activation from 0% to 40%.

1782 tests green across ironclaw_skills, ironclaw_first_party_extension_ports,
ironclaw_loop_host and ironclaw_reborn_composition.

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

* fix(skills): ask for the complete skill set, not the smallest one

The tool description and the listing header both said "pick the smallest relevant set". That
is a minimisation instruction, and the measurements say it has no justification and a real
cost.

**Nothing to justify it:** precision over activated skills is **100%** in the ported build --
zero wrong activations across the measured tasks. There is no over-activation problem for a
minimisation instruction to prevent.

**A real cost:** recall over the expected set is **26.7%**, and the failure is under-activation
rather than mis-activation. On `powerlifting_coef_calc` the model activated `powerlifting` and
stopped, ignoring the other two skills the task needed -- 1 of 3. Of the tasks that activated
anything, half activated fewer skills than the task required. The prompt told it to do that.

So the guidance is inverted: activate every skill the task needs, name them together in one
call, and note explicitly that a task often needs several (a file format, a domain method and a
reporting step are three different skills). Precision is protected by a different clause that
stays untouched -- "do not activate skills that are unrelated to the task" -- which is the one
actually doing that work.

`standalone_skill_activate_tool_loads_selected_skill_context` gains an assertion pinning the
completeness instruction, since silently reverting to "smallest set" would regress recall with
no failing test.

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

* Revert "fix(skills): ask for the complete skill set, not the smallest one"

This reverts 9eec1af53. I proposed it, predicted recall would rise and precision would hold,
and measured the opposite. Task-matched over 9 tasks at 227 candidates:

| metric                 | with "smallest set" | with "complete set" |
|------------------------|---------------------|---------------------|
| >=1 correct            | 44.4%               | 33.3%               |
| recall@R               | 22.8%               | 10.9%               |
| precision@R            | 100.0%              | 80.0%               |
| tasks w/ a wrong skill | 0                   | 1                   |

Worse on every metric, including the one it was meant to fix.

The per-task traces say why, and it is not the mechanism I assumed. On `exoplanet_period` the
original activated 3 of the 5 needed skills; with the change it activated **nothing**. On
`court_form_filling` the change activated one skill and it was the wrong one. So asking for
completeness did not make the model add the skills it was missing -- it made selection less
decisive overall, trading a confident partial answer for a guess or for paralysis.

My reasoning was that "pick the smallest relevant set" was unjustified because precision was
already 100%, so there was nothing for a minimisation instruction to protect against. That
inverted cause and effect: precision was 100% BECAUSE of the minimisation instruction, not
independently of it. I attributed the guard entirely to "do not activate skills unrelated to
the task", and the measurement says the two clauses were doing that work together.

Under-activation on multi-skill tasks is real -- 14.0% set completeness, and claude-code is no
better at 14.8% -- but it is not fixable by asking harder in the prompt. It needs a different
mechanism, and it belongs in its own issue with its own evidence rather than a prompt tweak
that makes three metrics worse.

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

* test(skills): measure what the HOST picks — the wrong-skill failure, 0 of 83 correct

The benchmark could not see the defect this work is about. Two things activated a skill before
this change:

  1. the model asks, via `builtin.skill_activate` -- a trace entry
  2. the HOST decides, scoring each skill's declared `activation:` keywords against the user's
     message during prompt assembly, and injecting the winners -- NO trace entry

#5417 is (2). The benchmark's scorer reads `skill_activate` calls, so it measured only (1), and
the pre-change arm therefore scored 100% "precision" while the host picked freely. That was an
invisible failure mode being reported as clean routing, and I reported it that way.

Host selection is `prefilter_skills_with_options`: a pure deterministic function of (message,
skill metadata). No LLM required, so this measures it exactly, in ~4 seconds, over every task's
real prompt against the real 227-skill catalog:

    27 tasks | 83 skills picked | 0 correct | 83 WRONG
    precision over host picks     0.0%
    tasks with >=1 wrong pick     27/27 (100%)

    wrongly picked most often:  routine-advisor (26 tasks), llm-council (12),
                                commitment-triage (10), coding (5)

`earthquake_plate`, a geospatial task, gets `routine-advisor`, `new-project`, `commit`,
`llm-council`. `citation_check` gets `security-review`, `review-readiness`, `llm-council`.

It is 0% rather than merely poor for a structural reason: the only skills carrying `activation:`
metadata are ironclaw's own bundled 32, and **none of those is ever a benchmark task's expected
skill**. So every host pick is necessarily wrong, and it fills all four activation slots with
them, displacing the skills the task actually needed. Keyword scoring did not rank badly here --
it had nothing correct available to rank.

Against the model-decided path measured on the same catalog: 22 of 23 activations correct
(95.7%). That is the before/after on the metric this epic is named after, and it was hidden
because the two paths are observed through different channels.

Assertions are upper bounds rather than equalities, so catalog drift will not fail this
spuriously while a return to host-side picking still trips it.

Needs the benchmarks checkout for its corpus; skips cleanly when absent, and
`NEARAI_BENCH_ROOT` overrides the location.

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

* test(skills): pin that a skill's scripts cannot execute under multi-tenant hosting

#6745 lets a skill ship `scripts/*.py`, which is what makes a learned skill reusable rather than
a prose description. @serrrfirat asked the right question on the epic: "What if it's a malicious
script and we run it on host and ggs."

The answer today is that a multi-tenant agent has nothing to run it WITH. `builtin.shell` is the
only process-port-backed builtin, and it is removed from the capability package outright when the
resolved process backend cannot execute, so the script sits inert in the skill store with no tool
able to invoke it.

That guarantee was a chain of three unasserted inferences -- `HostedMultiTenant` →
`RuntimeProfile::SecureDefault` → `ProcessBackendKind::None` → shell removed. Every link is
correct and none was pinned, so changing any one of them would silently enable script execution
for every tenant. This asserts the property directly.

The second test is the anti-vacuity half: execution-capable backends MUST still expose
`builtin.shell`. Without it the first assertion would pass just as happily if the capability were
renamed or dropped everywhere, proving nothing about the multi-tenant case.

`TenantSandbox` is deliberately asserted as execution-CAPABLE rather than blocked, because it is
@henrypark133's sandbox work: `crates/ironclaw_process_sandbox` is already a complete Docker
backend (`--cap-drop ALL`, `no-new-privileges`, `readonly_rootfs`, `--network none`, non-root
uid) with no non-test caller. When it is wired, multi-tenant execution becomes safe *because it
is sandboxed*, and the first assertion should be revisited rather than deleted. Until then
composition refuses a policy requesting `TenantSandbox` without a port
(`MissingTenantSandboxProcessPort`), so the unsafe combination cannot be configured.

Scope note, unchanged: execution is gated, INSTALLATION is not. A multi-tenant agent can still
write `scripts/*.py` into its own store -- inert, but present. Gating the install path needs a
profile flag plumbed through static schema resolution and is documented on the epic rather than
half-implemented.

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

* test(skills): reconcile the install-rejection test with #6745, which allows content + files

`builtin_skill_install_rejects_hidden_url_install_fields` asserted that a `content` + `files`
install is refused. That expectation came from `main`; #6745 deliberately changed the handler to
accept it, and the merge left the two disagreeing -- so the test failed for the right reason and
against the intended behaviour.

Rejecting `content` + `files` WAS the bug. An agent attaching `scripts/analyze.py` had its entire
install refused, which is why 0 of 27 agent-authored skills shipped a resource file while 18 of 31
human-curated ones do. A skill that cannot carry a script is a prose description, and reusing it
means re-deriving the method every time.

Removed that case; kept `source` and `source_url`. Those are set by the URL-fetch path to record
provenance, so accepting them on a direct install would let an agent label its own output as
fetched from a trusted URL. Renamed to `builtin_skill_install_rejects_forged_provenance_fields`,
which is what the test now checks, and the doc comment records why the `files` case was removed so
a future reader does not restore it as an oversight.

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

* fix(skills): the descriptor lint must not refuse to write a skill the agent authored

The lint gated `parse_distillation` and `parse_refinement`, so a learned skill failing it was never
written. Run against the skills agents actually wrote during the self-creation runs:

    26 agent-authored skills
    19 would be REFUSED by the write gate
    15 have no description whatsoever

**Scope, stated precisely.** The lint sits only on the DISTILLATION path — ironclaw's automatic
skill-learning. It does NOT sit on `skill_install`/`skill_manage`, which is what the benchmark's
`_selfcreate` tasks use ("you MUST save a reusable skill"), so those 26 skills were written through
an unlinted path and the benchmark's self-creation arm would not have broken. What the 19-of-26
figure shows is what distillation WOULD refuse when handed output of the same shape as real
agent-authored skills. That is an inference about the product's automatic learning path, not a
demonstrated break of the measured arm, and I am not going to overstate it.

It matters anyway, because distillation is the mechanism by which the product learns skills without
being told to. Refusing 3 of every 4 of them for a missing description would quietly disable it.

The lint is now split by who pays for the defect.

**Blocking** — `lint_skill_routing_metadata_blocking`, rules about declared activation TERMS. A
generic keyword poisons routing for *other* skills: `coding` declares `file` and `change`, and on
the reviewed baseline corpus it was being selected for security audits, QA plans and commit
staging. That cost is paid by every later request, so refusing the write is proportionate. Only 4 of
26 authored skills declare keywords or tags at all, so this gates without blocking authoring while
still stopping a model that tries to declare `file`.

**Advisory** — `lint_skill_routing_metadata_advisory`, rules about the skill's own description.
Empty or over-long hurts that skill's discoverability and nothing else, and refusing the write hurts
it strictly more: the agent produced something that works and gets nothing. These now warn with the
skill name attached, and are recorded rather than enforced.

`lint_skill_routing_metadata` still returns both, so authoring-time UI and CI keep the full list;
only the write gate narrowed.

`agent_authored_skills_pass_the_lint.rs` pins it against the real corpus and skips cleanly when the
stores are absent. It reports the advisory count too, so the 19 real description problems stay
visible rather than silently tolerated — worth fixing in the authoring prompt, which is a different
change from refusing the write.

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

* test(skills): drive the PRODUCTION composition, and draw the boundary of disk-seeded validation

Asked whether this work would hold in production. It is a fair question, and the answer turned out
to be "we do not know yet", with a specific reason.

This builds the real thing — `RebornCompositionProfile::Production` over libSQL under the hosted
multi-tenant policy (scoped-virtual filesystem, brokered secrets, network deny, ask-always
approvals, no process backend). No mounts, no env switches. It opens a conversation and completes a
skill-execution turn, so skills ARE wired on the production path.

But a skill written to `tenants/<t>/users/<u>/skills/` on the host filesystem activates **nothing**
there: production resolves the skill store through the scoped-virtual filesystem, not the host disk.
Measured, not inferred — explicit `$name` activation returned an empty activation set.

That is the storage contract rather than a bug, and it has a consequence worth stating plainly:
**every other validation of this work seeds skills on disk, so none of it exercises production's
storage path.** The benchmark mounts `system/skills`; the local-dev tests write files. Both are real
paths for their own profiles and neither is production's.

The same class of mistake has now appeared four times in this work, each time as an artifact of the
instrument rather than the system: a copy-out scanning disk while the store is libSQL; agent-authored
skills escaping to the host's `~/.claude/skills`; a benchmark scoring only what the model requested
while the host picked freely; and a phase-2 arm that swapped in 195 unrelated skills and called the
result routing. This test exists so the next reader does not make it a fifth.

Closing the gap needs a seam this crate does not expose: installing a skill into the production
scoped-virtual store from a test, i.e. driving `builtin.skill_install` through the production
capability port instead of writing bytes to a directory. That is infrastructure work with an owner
other than this PR, so it is recorded rather than approximated. A test that seeds disk and asserts
success would report production coverage it does not have, which is worse than no test.

The assertion is therefore the negative one, with a message telling a future reader what to do if it
ever flips: if a disk-seeded skill becomes visible, the storage contract changed and the disk-seeding
validation elsewhere finally covers production.

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

* test(skills): drive the PRODUCTION composition and seed its DB-backed virtual filesystem

Asked for a real production E2E rather than more local-dev validation. This is as far as it goes
without infrastructure I do not own, and the boundary is stated rather than papered over.

**Proven.** `RebornCompositionProfile::Production` over libSQL, under the hosted multi-tenant policy
(scoped-virtual filesystem, brokered secrets, network deny, ask-always approvals, no process
backend), builds, opens a conversation, and completes a skill-execution turn. Its DB-backed virtual
filesystem then ACCEPTS a skill write at a tenant/user-scoped path via
`LibSqlRootFilesystem::write_file`.

That write is the new part, and it took two corrections to get right. The first version wrote to the
host disk, which production does not read at all -- activation returned an empty set. The second
wrote to the virtual filesystem before building the runtime and failed with `no such table:
root_filesystem_entries`, because migrations run at build time. Both are recorded in the test so the
next reader does not repeat them.

**Not proven.** A skill written there is not DISCOVERED. Either the production bundle source scans a
different scoped root, or it enumerates once at build time and does not observe a later write. That
is answerable by whoever owns the production composition; trying paths until one passes would yield
a test proving only that I found a path.

The assertion is therefore the negative one, with instructions to invert it once discovery is wired
-- at which point this becomes the end-to-end production claim the epic wants. A failing expectation
a reader can act on beats a comment nobody reads.

**Why this matters more than it looks.** Every other validation in this work seeds skills on disk:
the benchmark mounts `system/skills`, the local-dev tests write files. Both are real paths for their
own profiles and neither is production's. So the routing and self-creation numbers describe local-dev
behaviour, and that limit belongs next to them rather than discovered later by someone else.

The same class of error has appeared five times here, always the instrument rather than the system: a
copy-out scanning disk while the store is libSQL; authored skills escaping to the host's
`~/.claude/skills`; a benchmark counting only model requests while the host picked freely; a phase-2
arm that swapped in 195 unrelated skills and called it routing; and now disk-seeding on a profile
that reads a database.

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

* test(skills): full production E2E — a skill in the DB-backed store is activatable, fresh and after restart

The production claim, verified rather than inferred. `RebornCompositionProfile::Production` over
libSQL under the hosted multi-tenant policy (scoped-virtual filesystem, brokered secrets, network
deny, ask-always approvals, no process backend). No mounts, no env switches, no local-dev.

Two tests:

* a skill written into production's virtual filesystem is activatable by name in the same session;
* and it is still activatable by a runtime BUILT AFTER it was written -- the realistic shape, since a
  tenant installs a skill in one session and uses it in a later one against the same database.

Reaching this took three corrections, all mine, and each is recorded in the test because each
produces a plausible-looking test that proves nothing:

1. **Seeded the host disk.** Production reads a scoped-virtual filesystem, so activation came back
   empty -- the skill was never in the store. Every other validation in this work seeds disk, which
   is why none of it spoke to production.
2. **Seeded before building.** Migrations create `root_filesystem_entries` at build time, so the
   write failed with `no such table`.
3. **Used `/tenants/<t>/users/<u>/skills`.** The real mount is
   `/projects/tenants/<t>/users/<u>/skills` (`scoped_skill_context_mount_view`). Without the
   `/projects` prefix the write lands where nothing scans, which is what made this look like an
   unanswerable infrastructure question rather than a wrong string.

The restart test is what isolated (3) from a build-time-caching explanation: it failed too, which
ruled out enumeration timing and left the path. Worth keeping for that reason alone.

Consequence for the rest of the work: the routing and self-creation numbers were measured on
local-dev, and this establishes that the mechanism they exercise is reachable on production with the
same activation contract. That is the gap this PR previously documented as open, now closed.

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

* fix(skills): read skills from the DB-backed tree the writer uses — closes #7168

An agent installs a skill, gets `{"installed":true}`, sees it in `skill_list` for the rest of the
session, and it is then gone forever: absent from Settings → Skills, unactivatable in every later
conversation. Reproduced by hand on the WebUI, not in a benchmark.

The reader and the writer were pointed at different trees:

| side | function | `/skills` resolves to | backend |
|---|---|---|---|
| write (`skill_install`) | `production_skill_management_mount_view` | `/tenants/{t}/users/{u}/skills` | **database** |
| read (discovery)  | `scoped_skill_context_mount_view` | `/projects/tenants/{t}/users/{u}/skills` | **host disk** |

`mount_database_roots` routes `/tenants` to the database; `mount_host_disk_roots` routes `/projects`
to the host disk. So the install landed in the DB and discovery listed the disk. Nothing reports
this: the install returns success, `skill_list` reflects the writer's own view within the session,
and the failure surfaces only as "the model ignored its own skill" — which reads as a model-quality
problem.

Adds `production_skill_context_mount_view`, the read-side mirror of the write view, and wires it on
the production path. Skills now live entirely in the virtual filesystem, database-backed, which is
also the only coherent answer under hosted multi-tenancy: a host-disk path for tenant skill data is
wrong on its own terms when there is no host disk for a tenant.

`/system/skills` stays where the writer leaves it so bundled skills keep resolving. Moving that tree
into the database too is the remaining step to skills being wholly DB-backed, and it needs bundled
seeding to write into the database at boot rather than ship on disk.

Guarded by `production_skill_read_and_write_mounts_resolve_to_the_same_tree`, which resolves a probe
path through both views and fails naming the divergence. It is a mount comparison rather than an
install-then-list round trip on purpose: the two views are the entire bug surface, it runs in
milliseconds, and a round trip would report "skill not found" without saying why. A second assertion
pins the tree to `/tenants/`, so a future change cannot quietly move skills back onto disk.

The two existing production E2E tests failed on this commit until their seed paths moved from
`/projects/tenants/...` to `/tenants/...`, which is the evidence the read tree actually moved rather
than the assertion being tautological. Both now pass fresh and after restart.

Also drops a stale assertion requiring a `names` property on the `skill_activate` schema; that input
became a single `skill` string earlier in this PR.

cargo test: ironclaw_reborn_composition 554, ironclaw_skills 181, production_runtime_skills 3.

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

* fix(skills): one DB-backed tree for every skill mount, and never install a skill discovery skips

Skill mounts were three views over two trees. The agent's in-run skill port and
discovery both resolved `/skills` to `/projects/tenants/<t>/users/<u>/skills` on
the host disk; Settings -> Skills and the product capabilities resolved it to
`/tenants/...` in the database. So `skill_install` inside a turn wrote to the
disk, Settings listed the database, and the skill was gone for good: `installed:
true`, present in `skill_list` for the rest of that turn, absent from Settings,
unactivatable in every later conversation (nearai/ironclaw#7168).

Every skill mount now derives from a single `db_backed_skill_grants`, so a reader
cannot drift from a writer. `/system/skills` stays on the host disk, where
bundled seeding writes; the two disk-backed views are deleted rather than left
available to wire in again.

The first attempt at this fixed only the branch hosted multi-tenant Postgres
takes. Local-dev, local-storage production and hosted single-tenant supply their
own `workspace_filesystems`, so they kept the disk reader and stayed broken in
exactly the reported way. Both readers are now pinned against the writer by
separate tests.

Two consequences handled here:

- Skills that already sit on the host disk -- from the legacy backfill, or from
  any agent install before this change -- are imported into the database at boot.
  Copied, not moved, and a database entry always wins, so it is idempotent and a
  downgrade is not destructive. Without this an upgrade silently drops every
  skill the user had.

- A manifest with an empty `description:` is repaired instead of persisted.
  Discovery rejects such a bundle (`InvalidSkillBundle`) and only warns, so the
  skill was accepted and then skipped forever. Measured with a real model: asked
  to save a reusable skill, it wrote frontmatter carrying `name:` alone. The
  description is derived from the skill's own opening prose. Repaired rather than
  refused because `SkillManagementCapabilityError` carries no message -- a
  refusal reaches the model as "the tool input could not be encoded", naming
  neither the field nor the fix, so the authoring turn would just be lost.
  Synthesized frontmatter for plain-markdown installs had the same defect and now
  carries a description too.

Verified end to end on a live local-dev server with a real model: the agent
authored a skill mid-turn, it appeared in Settings -> Skills, survived a server
restart, and a fresh conversation activated it with zero `skipping skill bundle`
warnings.

Tests that seeded skills on the host disk were testing the tree the runtime no
longer reads; they now seed and assert against the database.

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

* fix(skills): seed built-in skills in production, and make skill files readable

Two gaps found by driving the real product.

1. Hosted multi-tenant production shipped with ZERO built-in skills. The bundled
   seeder is only reachable from `bootstrap_standalone_host`, which the Postgres
   path does not run -- correctly, since it writes through a host-disk filesystem
   and a tenant there has no host disk. But `/system/skills` IS mounted on that
   path, to the database, and nothing ever wrote to it. Settings -> Skills read an
   empty root and said "No skills installed" while local-dev listed all 32, with
   nothing logged either way.

   Every helper in the seeder already took `&dyn RootFilesystem`; only the entry
   point was disk-bound. Extracted `ensure_bundled_reborn_skills_installed_in` and
   seeded through the production filesystem at boot. The marker, install lock, and
   stale-skill removal are unchanged, so it stays idempotent across boots and safe
   when several instances share one database.

   `create_dir_all` is now best-effort: it walks up to `/system`, which is not a
   known virtual root, so it can never succeed for a root that is itself a mount --
   and `RootFilesystem::create_dir_all` is documented as deprecated anyway, because
   the entry plane infers directories from path prefixes.

2. Skill files were unreachable from the ordinary filesystem tools. Skill mounts are
   granted to the skill capabilities only, so `read_file` saw nothing but
   `workspace`. Observed on a real production turn: the model installed a skill,
   tried to read it back to verify it, got "does not resolve inside an available
   scoped root (available roots: workspace)", burned a tool call, and fell back to
   `skill_activate`.

   A parity gap, not just a poor error: in Claude Code a SKILL.md is a file, so
   models are trained to read it, and skills reference sibling files
   (`references/*.md`, `scripts/*.py`) that progressive disclosure expects the agent
   to open on demand -- dead ends without a readable path. The run's filesystem view
   now includes the skill roots READ-ONLY; writes stay exclusive to
   `skill_install`/`skill_update`, which validate the manifest.

Also fixes two skill_learning refiner tests that were failing on this branch: the
fixture's `keywords: [file, count]` trips the blocking generic-keyword lint, so
refinement returned `UnusableRoutingMetadata` and the merge silently degraded to
`KeepExisting`.

Reporting `has_scripts`/`has_requirements` to the Skills page is NOT here -- it
belongs with #6745, which is what lets an agent author a skill containing a script.

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

* feat(skills): show that an agent-authored skill carries scripts

This PR is what lets an agent author a skill containing a script. The Skills page
could not show that it did: `skill_info` hardcoded `has_requirements: false` and
`has_scripts: false`, so a scripted skill was indistinguishable from a prose-only
one. The WebUI has rendered the chips since #6194 and the wire fields have existed
since #7002 -- only the server never populated them.

It was not just agent-authored skills. `portfolio`, a BUNDLED skill, ships four
Python scripts (`weekly_report.py`, `backtest_strategy.py`,
`concentration_warning.py`, `alert_if_health_below.py`) and has always displayed as
prose-only.

- `SkillSummary::has_scripts`, from one stat on the bundle's sibling `scripts`
  path. Absent is the common case and is not an error, so only a genuine backend
  failure is logged -- a skill listing must never fail because a skill has no
  scripts.
- The bundled-summary path reads it from the embedded bundle files, so `portfolio`
  reports correctly there too.
- `has_requirements` comes from `requires_skills`, which was already on the summary.

Verified on a live production server: 33 skills listed, `portfolio` the one
reporting `has_scripts`, six reporting `has_requirements`.

Note: skill scripts still cannot EXECUTE under hosted multi-tenant --
`HostedMultiTenant` + `SecureDefault` resolves to `ProcessBackendKind::None`, which
strips `builtin.shell`. That is deliberate pending the tenant sandbox. So the chip
tells a multi-tenant user their skill has scripts the agent can read but not run.

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

* test(skills): e2e for skill self-creation across a restart and a second conversation

The loop #6941 item 4 is about cannot be covered hermetically: it needs a live
server, a real model, a restart, and two separate conversations. Every hermetic
guard around it passed while the product was broken, because each tested a layer in
isolation -- the mount views agreed in the shape the test constructed, the install
reported success, discovery listed the tree the test had seeded. The failure only
appears when the writer and reader are the real ones, chosen by the real
composition, with a restart in between so nothing in memory carries the result.

Phase A covers a prose skill: authored mid-turn, listed in Settings, stored in the
database and NOT on the host disk, surviving a restart, and activated by a new
conversation with no 'skipping skill bundle' warnings.

Phase B covers a skill carrying a runnable script (the #6745 feature): the bundle
holds scripts/*.py in the database, Settings reports has_scripts, and a new
conversation activates it.

B5 asserts the bundled script is actually EXECUTED, and is reported as a known gap
rather than a failure unless E2E_REQUIRE_SCRIPT_EXEC=1. builtin.shell spawns a host
process while the script exists only in the DB-backed virtual filesystem, so there is
no path to run: the agent tries 'ls -la skills/<name>/<script>.py || echo NOT FOUND'
and falls back to 'python3 -c <algorithm re-typed inline>'. Right answers, wrong
mechanism, and it defeats the argument for shipping a script at all. Flip the env var
when that is fixed and the gap becomes an assertion.

Runs on its own port and IRONCLAW_REBORN_HOME so it never disturbs a dev server.

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

* fix(test): create the e2e log dir before redirecting into it

`nohup … > "$LOG_DIR/server-1.log"` fails silently when the directory does not
exist, so the server never launched and the log that would have explained it was
never created either -- the run reported "server failed to start" with an empty
log, which reads like a product failure and was not one.

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

* chore: remove an agent-written artifact committed by mistake

`tenants/reborn-cli/users/reborn-cli/convert_labs.py` is not source. An agent wrote
it into the repository working tree during a live WebUI test and `git add -A` swept
it into 2a63accb4.

It is also evidence of a real defect, now recorded on #6941: `builtin.shell` inherits
the SERVER PROCESS's working directory, so a relative path in an agent's shell command
lands wherever the operator launched `ironclaw serve` -- here, the checkout itself.
The same run also created `skills/egfr-ckdepi/` and a `tenants/<t>/users/<u>/skills/`
mirror at the repo root.

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

* fix(test): stop reporting a failure when zero bundles were skipped

`grep -c` exits 1 when the count is zero, so `|| echo 0` appended a second zero and
the compare against "0" failed -- the healthy case reported "FAIL A6 0". Same shape
of bug as the missing log directory: the test was wrong, not the product.

Also records why B5's execution check is best-effort: display-preview subtitles redact
paths, so `python3 <path>` cannot be attributed to the bundle from the preview alone.
The inline `python3 -c` fallback is the reliable signal, which makes a false negative
possible and a false positive not.

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

* fix(shell): default a command's working directory to the workspace, not the server's cwd

`resolve_local_host_workdir` fell back to `std::env::current_dir()` when a caller named
no workdir, so a shell command with a relative path ran wherever the operator launched
`ironclaw serve`.

Observed on a live local-dev server started from an ironclaw checkout. An agent asked
to save a skill wrote into the REPOSITORY: `skills/egfr-ckdepi/{SKILL.md,scripts/}`, a
`tenants/<t>/users/<u>/skills/` mirror, and two loose `.py` files. One of them
(`convert_labs.py`, from an earlier turn) was then swept into a commit by `git add -A`
and had to be removed. In production the same behaviour puts agent writes in whatever
directory the service was started from.

It also made the agent's own tools disagree, which is the expensive part.
`write_file` resolves through the scoped virtual filesystem; the shell resolved
against this cwd. The same relative path therefore meant two different places: the
model wrote SKILL.md and scripts/egfr.py, could not see them from the shell, ran
`ls`/`glob`/`find /` (the last dying on the 10s shell cap) to work out why, concluded
from `ls skills/` -- the checkout's own bundled-skill sources -- that skills live at the
repo root, and rewrote everything there. Twelve of that turn's twenty-one tool calls
went on this, and its final verification checked the copy in the repo rather than the
installed bundle.

Now: the `/workspace` alias by name, any other registered alias next, and the process
cwd only when a caller registered no aliases at all -- the ambient-host case, which has
no workspace to resolve against. An explicitly named workdir is unaffected.

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

* Revert "fix(shell): default a command's working directory to the workspace, not the server's cwd"

This reverts commit a81cba9de9.

* chore: remove a second agent-written artifact swept in by git add -A

`tenants/reborn-cli/users/reborn-cli/ckd_epi_egfr.py` was written into the repository
by an agent during the e2e run and picked up by `git add -A` in 0e58adff8, the same way
`convert_labs.py` was in 2a63accb4.

Not a product defect. Local-dev sets `workspace_root = std::env::current_dir()`
(runtime/mod.rs), so the agent's workspace IS the directory `ironclaw serve` was
launched from -- deliberately, so it works on the project in front of you. Launching it
from a checkout therefore puts agent writes in that checkout. The lesson is for the
operator, not the code: run the dev server from a scratch directory, and stage files
explicitly rather than with -A.

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

* test(skills): run the e2e in a scratch workspace and prove the checkout is untouched

Local-dev resolves `workspace_root` from the SERVER'S CURRENT DIRECTORY
(`build_standalone_local_runtime_services_input`), so whatever directory `serve` starts
in becomes the agent's workspace. The test launched the server from the repository root,
which made the repository the workspace: agent writes landed among tracked files, and
two agent-written scripts were swept into commits by `git add -A` before being removed
again.

That is the operator's mistake, not the product's -- production runs the service in its
own directory. So the test now does too: `serve` is launched from a scratch workspace,
and two checks close the loop. One diffs `git status --porcelain` before and after and
fails if the checkout changed at all; the other prints what the agent actually wrote
into its workspace, which is the useful signal for whether a skill's script can ever be
run from there.

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

* test(skills): add a production-profile mode, where the whole filesystem is the database

The local-dev shape is not production, and the difference is the source of most of the
filesystem confusion in these traces:

  local-dev   /projects and /projects/workspace are HOST DISK, and the workspace root is
              the server's own cwd. builtin.shell exists (LocalHost backend), so the
              agent has two namespaces -- virtual aliases and host paths -- that can
              disagree.

  production  production_database_root_filesystem routes /tenants, /projects, /memory
              and /system/* to the DATABASE, so the workspace is DB-backed too and there
              is no host disk at all. HostedMultiTenant + SecureDefault resolves to
              ProcessBackendKind::None, which strips builtin.shell. One namespace,
              nothing to disagree with it.

E2E_PROFILE=production writes the storage/policy config the production build fails
closed without, recreates its database so 'still listed after a restart' cannot pass on
stale rows, and reads bundle contents back out of Postgres rather than libSQL.

B5 inverts in that mode: the question is not whether a skill's script executed -- it
cannot, by policy -- but whether the shell was correctly withheld. A shell call under
hosted multi-tenant would be a policy escape, which matters more than the script.

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

* fix(test): write the production config after recreating the home, not before

The production block wrote $HOME_DIR/config.toml and the run then did
`rm -rf "$HOME_DIR"`, deleting it -- so the build failed closed with 'profile=production
requires [storage] backend = "postgres"', which reads like a misconfiguration and was an
ordering bug in the fixture.

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

* fix(coding): answer list_dir "/" with the roots, and say why a read-only root refused

Two failures an agent hit on a production-profile server, both diagnostics rather than
missing capability, and both making the traces unreadable.

`list_dir "/"` failed with "path  is not under an available scoped root (available
roots: skills, system skills, tenant-shared skills, workspace)". The offending path
renders BLANK because the safe-summary encoder maps `/` to a space, so the message named
nothing. The agent was doing something reasonable -- asking what the filesystem contains
before writing to it -- and the roots it wanted were already being computed for that very
error. `/` now lists the mount aliases.

`apply_patch` on a skill file failed with only "the tool was denied filesystem access".
`/skills` is deliberately read-only for the filesystem tools -- writes go through
`skill_install`/`skill_update`, which validate the manifest discovery requires -- but
nothing said so, and the agent fell back to skill_remove + skill_install to edit its own
skill. The denial now names the path, lists the writable roots, and mentions the skill
tools ONLY for a skill root: an existing test caught the first version telling an agent to
use `skill_update` for a read-only workspace, which is nonsense.

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

* fix(skills): stop a skill instructing execution where the environment forbids it

A skill body that says "execute it with python3 scripts/egfr.py" is a trap under
ProcessBackendKind::None: there is no shell and no interpreter, so the instruction cannot
be followed. A model told to do something impossible does not stop -- it improvises.

Measured on a production-profile server. Asked to apply its own eGFR skill, the agent
activated it, read scripts/egfr.py three times, enumerated its tools and correctly
concluded it had no way to execute anything, hand-expanded Taylor series for ln/exp, and
then POSTed the patient's creatinine and age to api.mathjs.org -- three times, receiving
46.493475453297044 and 31.625515257780567, the numbers in its final answer. Correct
results, obtained by shipping clinical values to a third-party service from a tenant
runtime.

So when no process backend exists, a skill body mentioning execution now carries an
explicit note: the instruction cannot be followed here, apply the documented method
directly from the skill text, and do not call an external service to compute. The last
clause is the point, not decoration.

Narrow by design: only bodies that actually mention execution (scripts/, python3, bash,
...) get the note. Appending it everywhere would spend context and train the model to skim
past it.  defaults to true, so no existing shape changes.

Derived from the resolved policy in filesystem_skill_context_source, which means this
needs no edit when the tenant sandbox lands: the moment HostedMultiTenant resolves to
TenantSandbox instead of None, it flips to true and the note disappears.

docs/skills/multi_tenant_enablement.md records the full enablement path -- what already
works on multi-tenant, why execution is off, the flip points in order (this note, then
giving a bundle a path the sandbox can reach, then making the e2e's B5 a hard assertion
via E2E_REQUIRE_SCRIPT_EXEC=1, then re-deciding network egress), and the profile matrix.

The e2e gains a single-tenant mode, which is the shape most deployments run: Postgres
storage with the local-host runtime policy, so a real process backend and a host-disk
workspace. E2E_PROFILE is now local-dev | single-tenant | multi-tenant, with 'production'
kept as an alias for multi-tenant, and each shape recreates its own database.

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

* feat(filesystem): report which virtual paths a host process can open

There were three authorities on where a virtual path lives: the composite's mount
table, DiskFilesystem's own mount_local table, and HostProcessPort's separate
workdir_aliases list built at composition from a different value. They disagreed
silently.

Under hosted single-tenant the file tools resolve /workspace to
<workspace_root>/tenants/<t>/users/<u> (per-caller scoping) while the shell's alias
resolves the same string to <workspace_root>. So an agent wrote scripts/egfr.py with
write_file, ran , and landed three
directories above its own file. Neither side errored -- the script simply was not
there, and the model gave up and re-typed the algorithm into .

 makes host reachability a property the filesystem
reports, from the same table that serves the data: DiskFilesystem answers through the
same joiner every read and write uses, database backends answer None by the trait
default, and the composite routes by longest prefix exactly as it does for reads.

 is the load-bearing case. A skill bundle lives in the database, so no host
process can ever open it, and a caller that asks now learns that instead of spawning
against a path that does not exist.

Also adds SkillBundleSource::list_skill_bundle_files (default empty, implemented for
the filesystem source with file-count and depth caps) -- a bundle's files could be read
one known path at a time but never enumerated, which is required to copy one somewhere
a process can reach.

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

* feat(skills): stage an activated skill's files where its own commands can run

A skill body says `python3 scripts/egfr.py`. That is only meaningful from the skill's
own directory, and the bundle lives in the database, so no host process could ever open
it. Agents did not fail cleanly: one ran `cd /workspace && python3 scripts/egfr.py`
against a path three directories from its own file, another invented a
`tenants/<t>/users/<u>/skills/...` tree in its workspace, and both ended by re-typing
the algorithm into `python3 -c` -- losing the exact thing a shipped script exists to
preserve.

On activation, a bundle's non-manifest files are now copied into `.skills/<name>/` in
the caller's workspace and the body is told the directory to run them from. SKILL.md is
not staged: it is already model context, and a second copy invites edits discovery never
reads.

The directory is DERIVED, not assumed. `/workspace` does not mean one thing: under
per-caller scoping the file tools resolve it to `<root>/tenants/<t>/users/<u>` while the
shell's alias -- registered once at composition, knowing nothing about callers --
resolves it to `<root>`. Assuming either spelling produces a path that works for one
tool and silently misses for the other, which is the bug being fixed. So the stager asks
the filesystem where the staged directory really is, asks where the shell's `/workspace`
really is, and expresses one relative to the other via `host_path_for`.

Gated on a writable workspace and on `process_execution_available`: hosted multi-tenant
has neither a shell nor a writable workspace today, so nothing is staged there and a
body promising execution keeps the "cannot execute processes" note. When the tenant
sandbox lands, staged files sit under the workspace the sandbox already binds, so the
path works there with no second mechanism.

Every failure degrades rather than propagates -- no stager, no execution backend, an
unreadable bundle, or a failed write all mean "no staged path", and the skill still
activates with its instructions intact.

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

* fix(skills): express the staged path against the shell's workspace, and name workdir

Two defects in the first staging attempt, both found by running it.

The derived path was measured against the FILE TOOLS' workspace root, so under per-caller
scoping the two per-caller segments cancelled and the note said `/workspace/.skills/<name>`
-- which the shell resolves to <root>/.skills/<name>, missing tenants/<t>/users/<u>
entirely. It is now measured against the root the shell's alias actually resolves to,
which composition owns.

And the note merely stated the directory. The model then ran the body's
`python3 scripts/egfr.py` with no working directory at all and missed the file, exactly as
before. The note now names the shell's `workdir` parameter and shows it in use.

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

* fix(shell): scope /workspace to the caller, as the file tools already do

One alias named two directories in one process. `scoped_workspace_mount_view` resolves
`/workspace` to `<root>/tenants/<t>/users/<u>`, while `HostProcessPort`'s alias list --
built once at composition, before any caller exists -- resolved it to `<root>`.

So an agent wrote `scripts/egfr.py` through `write_file`, ran `python3 scripts/egfr.py`
in the shell, and landed three directories above its own file. Neither side errored. It
then re-typed the algorithm inline, and in another run invented a
`tenants/<t>/users/<u>/` tree by hand trying to reconcile the two.

The port already receives the caller's `ResourceScope` on every request, so it now
derives the same subtree per request rather than depending on a composition-time value it
cannot know. Only `/workspace` is narrowed; `/host` and the raw host-home aliases are
ambient by construction and have no per-caller subtree.

This affects every file an agent writes and then runs, not just skills.

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

* test(skills): hermetic two-thread fixture -- author a scripted skill, then run it

Distilled from ten live demo runs against real models. Every one failed somewhere in
this sequence and no existing hermetic test caught any of them, because each tested a
layer while the failures lived between layers:

  1. skill_install reported success and the skill never appeared again -- writer and
     reader resolved /skills to different trees (#7168).
  2. A manifest with no `description:` installed fine and was skipped by discovery
     forever, with only a warn.
  3. Thread 2 activated the skill, read scripts/egfr.py, and could not execute it: the
     bundle lives in the database, so no host process can open it.
  4. Deprived of execution, one agent POSTed the patient's creatinine to api.mathjs.org
     to do the arithmetic.
  5. After staging landed, the path the model was TOLD still missed, because /workspace
     means <root>/tenants/<t>/users/<u> to the file tools and <root> to the shell.

So the fixture asserts the chain rather than a layer. It installs through the real
product capability, then TEARS DOWN AND REBUILDS the runtime over the same store -- that
is what makes it a later conversation, and a fixture holding one runtime open cannot see
#7168 at all. It then activates by name, locates the staged bundle, and runs
`python3 scripts/egfr.py` from the staged directory exactly as the skill body instructs.
The script prints a marker that re-derived arithmetic cannot produce, so "it ran" is not
inferred from a log line.

The staged bundle is found by SEARCH, not by assuming a path. That caught a real thing on
the first run: standalone uses the shared workspace policy, so there is no
tenants/<t>/users/<u> segment, and a fixture hardcoding either spelling would have tested
the spelling instead of the mechanism.

A second fixture covers the description case directly: a manifest carrying `name:` alone
must still activate in a later conversation.

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

* fix(skills): advertise the plain workspace path, not a doubled one

The staged directory handed to the model was derived by measuring it against the shell's
workspace root and expressing one relative to the other. That was correct while the shell
and the file tools disagreed about `/workspace`, and became wrong the moment they were
unified: it emitted `/workspace/tenants/<t>/users/<u>/.skills/<name>`, which BOTH tools
then resolved beneath the per-caller root a second time.

The doubled directory does not exist, so every shell command failed with
`Failed to spawn command: No such file or directory (os error 2)` -- which reads like a
missing interpreter and is not -- and `list_dir` on the same advertised path failed too. An
agent following its own skill's instructions could run nothing, and went back to copying
files by hand.

Now simply `/workspace/.skills/<name>`, which is correct precisely because
`HostProcessPort` applies the same caller scoping the mount view applies.

Pinned where the string is produced, since the two-thread fixture cannot catch this class:
staging writes through the caller's own view, so the bytes land correctly even when the
advertised string is wrong. That limitation is now recorded in the fixture.

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

* fix(coding): treat / as the workspace in every tool, not just list_dir

`scoped_path_input` mapped `""` and `.` to the workspace but passed `/` through, and
`ScopedPath::new("/")` rejects the bare root. The resulting summary rendered the offending
path BLANK, because the safe-summary encoder maps `/` to a space:

    path  is not under an available scoped root (available roots: skills, system skills,
    tenant-shared skills, workspace)

Agents hit it constantly -- a leading-wildcard glob, or looking at the root to see what
exists -- and were told nothing. `list_dir` was special-cased earlier; `glob`, `grep`,
`read_file`, `write_file` and `apply_patch` all still failed this way.

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

* test(coding): fixture the blank-path failure and the write-then-read round trip

Two regressions from live demo traces, neither covered.

`/` failing with `path  is not under an available scoped root` -- the offending path
rendering blank because the safe-summary encoder maps `/` to a space. Now table-driven over
glob, grep and list_dir, so one tool cannot be fixed in isolation again while the others
keep failing (which is exactly what happened: list_dir was special-cased and the rest were
not).

And the write-then-read round trip on a relative path, which is the invariant an agent
depends on when it authors a script and then runs it -- the one that silently broke when
`/workspace` meant two different directories to different tools.

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

* feat(skills): report where a skill is stored and where it can be run

Thread 1 of the demo spent most of its tool calls working out something the tools should
have told it. After installing a skill carrying a script it tried to execute the file from
`/skills/<name>` -- the read-only, database-backed store that no process can open -- then
`ls`'d around, hand-copied the script into its workspace, ran the copy, compared the two,
and closed by asserting that "the installed skill's script is directly executable from the
skill root". That is false in every deployment, and it said it because nothing said
otherwise.

So `skill_install` and `skill_list` now report both places: `store_path_read_only` and,
for a bundle carrying files, `runnable_path_after_activation`. Both tool descriptions state
the rule once -- the skill root is never executable; bundled files become runnable in the
workspace on activation.

Not staged at install time: the skill capability holds the skill-management mount view and
no workspace view, so staging there would mean writing outside the view that authorizes it.
Activation is one call away and already stages, so the agent needs the path, not an earlier
copy.

`runnable_skill_dir` is the one place the spelling lives, and it carries a note that
`bundle_staging` must agree with it -- if they drift, the symptom is an agent running a
command in a directory that does not exist.

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

* fix(skills): make the install-then-use ordering explicit instead of discoverable

Reporting the runnable path was not enough. An agent installed a skill, took the path out
of the install result, and read it immediately -- before activating -- so it got the generic
"can't access your workspace file" twice, activated, and only then succeeded. Two wasted
calls and a confusing trace, for an ordering the tools knew and did not state.

Two changes, both at the point of the mistake:

`skill_install` now returns `bundled_files_runnable` as a structure -- `requires_first`
(skill_activate with this name), `then_at` (the path), and why -- rather than a field named
`..._after_activation` that reads as a label instead of a precondition.

And a miss under `.skills/<name>` now says so: "does not exist yet ... call skill_activate
with name=<name> first, then read or run it from there", in both read_file and list_dir. A
missing file anywhere else is still just a missing file.

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

* style: clear the CI gates on the changed crates

`cargo clippy --all --tests -- -D warnings` and the panic checker are merge gates, and both
failed on this branch. All of it is mine.

- `activation.rs` tested `is_some_and(...)` and then `expect`ed the same Option, so the
  invariant lived in a comment rather than the types. Bound by pattern instead, which the
  production panic gate accepts.
- `bundle_staging` kept a `shell_workspace_root` field that went dead when the derived path
  was replaced by the plain workspace spelling; removed here, along with the parameter it
  was threaded through and the composition constant that fed it.
- Needless borrows, `sort_by` -> `sort_by_key`, `&[x.clone()]` -> `slice::from_ref`, a
  single-element `for` loop, an orphaned doc comment left behind by an earlier deletion, and
  four `pub` items in `ironclaw_threads::contract` that nothing outside the crate uses.
- Constant-valued `assert!`s moved into `const {}` blocks.

Also verified: composition mass budget (6.48% of 23.98% ceiling), hermetic env guard,
include_str/Docker COPY coverage, and no tracked files matching .gitignore.

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

* test(skills): pin the direct-install input contract at the capability boundary

Four cases covering what a caller may and may not put in a `builtin.skill_install`
input, asserted through runtime dispatch rather than against whichever helper
currently normalizes the input.

The normalizer has already moved once (host runtime -> ironclaw_extension_support,
WS3) and is about to be merged across that move again. Written so the same four
pass on both sides: a resolution that quietly re-tightens the inline arm fails the
first one instead of silently dropping the capability this PR adds.

The dividing line these pin is provenance, not shape:
- `content` + `files` installs, and the script lands on disk verbatim
- `bytes_base64` works on the direct arm too, not only the rewritten URL payload
- `content` + `files` + `source`/`source_url` is still refused whole
- a `../..` bundle path is refused and writes nothing outside the skill directory

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

* fix(skills): repair the post-merge build and close the review findings

Build breakage from the merge: main added two `SkillSummary` construction sites
(`skill_learning`, `lifecycle_product_service`) that this branch's new
`has_scripts` field left incomplete. Both are fixtures whose assertions do not
depend on it, so both set `false` with a note saying why.

CI, all under `-D warnings`:
- Four `result_read` cap items in `ironclaw_threads::contract` were `pub` in a
  private module (`unreachable_pub`). Nothing outside the crate reads them, so
  they are `pub(crate)`.
- Four constant-value asserts (two in the same contract module, two in
  `activation_strategy`) move into `const {}` blocks, which is what they always
  meant: they are compile-time invariants, not runtime checks.

Review findings:
- The stale-default doc note (coderabbit, ironloop) was against `ecabcb5fe`, before
  `4951d76bb` reverted the flip. Docs and `DEFAULT_SKILL_INJECTION_MODE` both say
  `Listing` with `full` as the opt-in, so there is nothing left to correct.
- The unset-env branch is now reachable from a test (coderabbit).
  `skill_injection_mode_from_env_value` takes the lookup's `Result`, so the product
  default can be asserted without `remove_var` racing every other test in this
  binary. Covered along with `full`, trimming/case, empty, unrecognized, and
  non-unicode.

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

* fix(skills): gate criteria activation on requirements, and address the review

The Major finding first, because it is a real hole: `unmet_requirements_refusal`
was wired into the explicit-mention loop and `select_named_skill_activations` but
NOT into the criteria (keyword/regex) loop, so a skill declaring an unmet
`requires.bins`/`env`/`config` still auto-activated "cleanly". That is the worse
half of the two: a criteria selection is the one the user never asked for by name,
so nothing at all connects the later shell failure back to the missing binary.
Same gate, same message, now on both paths.

Also from review:
- The refusal message rendered `SkillTrust` with `{:?}`. It goes to the model, so
  it renders through `Display` (`installed`/`trusted`) -- which is also the
  spelling `ironclaw_skills` documents as the one that gates content exposure.
  Two tests asserting the debug spelling move with it.
- The truncation warning fired on every prompt-context build. A catalog over the
  budget stays over it, so that repeated one line for the life of the process.
  Now warned when the hidden count CHANGES, which is the only new information;
  the model-visible hidden-count message stays unconditional.
- Corrected a comment claiming ExplicitAndCriteria is the default (it is
  ExplicitOnly; `criteria_config()` opts in).

And the caller-level coverage the reviewer asked for, which
`crates/ironclaw_skills/AGENTS.md` now also requires for changes to skill-content
exposure: three cases driven through `SkillActivationHandler::invoke` with a
capturing result writer, asserting the PERSISTED payload -- clean activation,
trust refusal, unknown name. The builder-level tests could not see the defect
this contract exists to fix, which was a payload built correctly and then not
delivered.

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

* style: rustfmt the skill-reachability files

`cargo fmt --all --check` was red on five files this branch touches. Formatting
only; no behavior, no reordering beyond rustfmt's own import sort.

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

* docs(skills): state accurately why the inline arm now accepts a bundle

The comment claimed a review decision had been "reversed on evidence", which
overstates what happened and would read as overriding the team.

What actually happened: the refusal predates #7141 entirely -- it lived in the
host-runtime copy of this resolver, and #7141 carried it across the move to this
crate verbatim, declining a reviewer's suggestion to relax it there. That was the
right call for a move-only refactor. This PR is where the behavior change belongs,
and it is made deliberately with the measurement attached.

Comment only.

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

* test(skills): pin the criteria-path requirement gate, fix two lints

The gate added in the previous commit changed production behavior with zero test
movement, which is the signal that nothing covered it. Confirmed by removing it
again: the new case fails at the body-disclosure assertion, i.e. the skill with the
absent binary really did activate and its prompt really did reach the model.

Asserted through `set_activation_observer` rather than a return value, because that
is where the criteria path's feedback actually goes -- it is the seam the live
projection consumes, so a refusal invisible there is invisible in the product.

Two lints under `-D warnings`, both pre-existing on this branch:
- `set_activation_observer` returns a `Result` that the new test dropped.
- An orphaned doc comment for `criteria_config()` sat above
  `assert_no_skill_body_disclosed`, documenting the wrong function. Moved onto the
  function it describes.

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

* fix(skills): clear this branch's CI gates and one stale assertion

A test breakage that predates the merge: `standalone_skill_activate_tool_loads_
selected_skill_context` required the `skill_activate` schema to advertise a `names`
property, which directly contradicts the `skill`-is-a-string assertion two lines
above it. The schema was narrowed to one skill per call on this branch and this
assertion was not updated. Inverted to pin the actual decision -- a legacy `names`
array is still ACCEPTED by `parse_skill_activate_names` so an in-flight caller does
not hard-fail, but advertising it is what invited the multi-skill calls that
produced every wrong activation in the 29-run measurement.

Gates:
- `check_no_panics.py`: `plan.expect("checked above")` after an `is_some_and` guard
  is now bound by pattern. Equivalent today; only the pattern form stays correct if
  the condition is edited, which is why the gate flags the other.
- clippy `-D warnings`: a one-element `for` loop in
  `multi_tenant_skill_scripts_cannot_execute` (named as one backend instead, so a
  second non-executing backend needs its own case and message rather than a silent
  extra iteration), and two `sort_by` comparators that are `sort_by_key`.

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

* fix(skills): address the review — one blocking path bug, one broken invariant

**Blocking (ironloop, High).** `/tenant-shared/skills` resolved to
`/tenants/<t>/tenant-shared/skills`, repeating the alias inside the target. Tenant-shared
state lives under `/tenants/<t>/shared` — that is what `invocation_mount_view` resolves
the alias to, and both siblings (`reborn-projects`, `reborn-identity`) follow it. Nothing
writes or migrates the misspelled subtree, so a tenant that HAD shared skills would
silently stop discovering them. Fixed, and pinned by
`tenant_shared_skills_resolve_under_the_canonical_shared_subtree`, which asserts against
the sibling layout rather than a hardcoded string.

**The PR's own invariant was half true (2 findings, Medium).** "Every skill mount view
derives from one DB-backed tree" — except a disk-backed `skill_management_mount_view()`
survived and fed `PolicyApprovalLeaseTermsProvider`, in production, not just tests. So the
lease terms a user approved for `skill_install` named `/projects/skills` while the install
wrote to `/tenants/<t>/users/<u>/skills`. It is now derived per gate from the gate's own
scope (`skill_mounts_for`), exactly as workspace mounts already were, and the disk-backed
view is deleted along with the runtime field and test accessor that carried it. The
`skill_mounts_for_test` helper became a scope-taking free function, so the harness asserts
the same tree production mints.

Real bugs:
- `insert_frontmatter_description` took `lines().next()` as the `---` delimiter, but the
  parser tolerates leading blank lines. A skill starting with one had `description:`
  inserted above the delimiter, turning a repairable install into a failed parse.
- `import_host_disk_skills_into_database` ran every boot and the disk copy is deliberately
  left behind, so a skill the user REMOVED came back at the next restart. Now gated on a
  one-shot marker under `/system/settings`: a migration, not a standing sync.
- `is_skill_alias` matched every `*skills` alias, so a denied write to `/system/skills` or
  `/tenant-shared/skills` was told to use `skill_install`, which cannot write either.
- `.skills` is now actually in `DEFAULT_EXCLUDED_DIRS`, which the staging module doc
  already claimed. Staged copies no longer show up in a workspace glob/grep.
- Staged bundles are cached by caller + bundle + content hash. `body_context` runs on every
  turn's activation path and was re-walking, re-reading and re-writing an unchanged bundle
  each time.
- `host_path_for` is removed from `RootFilesystem` and both `ScopedFilesystem` wrappers. It
  had no production consumer: staging writes through the filesystem, so the reachability
  probe it was added for is not needed. Its contract test goes with it.

Docs that contradicted the code: the `/system/skills` note claimed host-disk-only while
this PR seeds it into Postgres for multi-tenant (now describes both, and says the composite
decides); `ensure_manifest_description` carried two stacked doc blocks, an earlier
"refuse at the write" draft above the "repair, don't refuse" one that matches the code.

`.gitignore` now covers `/tenants/`, the local-dev agent workspace — an agent-written
script from a demo session is how the stray file in this PR's first comparison got there.

Also: one arch-gate false positive from my own prose ("no notion of enumeration" tripped
the concrete-extension check — reworded rather than allowlisted, keeping the register at
zero), and two ratchet baselines LOWERED for the debt this PR deletes (frozen count 51->50,
aggregate members 276->275), which #7147's own doc comment requires in the deleting PR.

Verified: workspace `cargo check --all-targets` and `clippy -D warnings` clean; full
`ironclaw_architecture` suite 37/37; reborn_composition lib 508 + integration suites green;
skills/filesystem/extension_support/first_party_extension_ports/host_runtime/extension_host
suites green; panic gate, hermetic-env, include-str and composition-budget gates OK.

Not done, deliberately, and left for a follow-up: the seven "add a test for X" findings
(bundle-enumeration bounds, staged_path traversal, runnable_dir literal duplication,
NO_PROCESS_EXECUTION_NOTE end to end, the advertised install/list fields,
derive_install_description escaping, update_skill blank-description repair) and the
has_scripts one-stat-per-skill listing cost, which this PR's plan already recorded as
separate.

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

* ci: raise the composition mass ceiling for this layer's assembly wiring

CI's "Check composition mass budget" step reds this branch by 62 LOC. Not because this
branch is large: main sits only 54 LOC under the effective ceiling (40,499 + 150 tolerance
against 40,595 observed), so the gate currently trips on any PR adding more than that to
composition, and this one adds skill-summary and product-surface assembly.

Raised to the measured 40,711 in both places the gate pairs — `[gate].loc_ceiling` in the
manifest and `COMPOSITION_ABSOLUTE_SRC_LOC` in `reborn_restructure_baselines.rs`, since a
second ratchet fails when they disagree, which is how it enforces recording the change in the
PR that causes it. Measured with `check-composition-budget.sh --print`, set to current rather
than padded, per the manifest's own protocol.

A raise is a reviewed decision by that file's rules, not routine wiring, so it is flagged
here and in the PR body rather than left in a diff. The next wave close should re-ratchet.

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

* ci: correct the composition ceiling to the clean measured count

41,414 was measured on a working tree a bad `git stash pop` had polluted. The clean count is
41,096, and the 318-LOC difference read as unclaimed headroom — which the gate's own C11
self-test refuses, since a ceiling that far above the live count is the inert-ratchet failure
the absolute bound exists to prevent. Corrected in both paired places, with the wrong figure
and its cause recorded in the manifest rather than quietly overwritten.

Gate green, 76/76 self-tests pass, both ratchets green.

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

* docs(skills): cut 346 lines of comment prose, and three duplicated blocks with it

The narrative belongs in commit messages, where it already is. Kept the one-liners that stop a
regression and the measurements that justify a constant; dropped the retellings.

Three of the deletions were duplicated blocks, not verbosity: an earlier draft left stacked above
its own replacement above `SKILL_LISTING_*` and `DEFAULT_SKILL_ACTIVATION`, and a copy-paste of the
criteria-gate comment repeated verbatim. Each read as two competing explanations of one thing.

Comment share of this stack's diff: 29% -> 25%, 1,970 -> 1,624 lines. Composition production LOC
41,096 -> 40,995, so `[gate].loc_ceiling` and its paired `COMPOSITION_ABSOLUTE_SRC_LOC` come DOWN
by 101 rather than being inherited; the manifest note records both earlier figures and why each
moved.

No behavior touched: the only non-comment deletions in this diff are two assertion strings replaced
with shorter ones, plus the two paired budget numbers.

Verified: both threads of the demo fixture pass (`thread_one_authors_a_scripted_skill_and_thread_
two_executes_it` and `a_skill_installed_without_a_description_is_still_discoverable`, 2.26s),
`fmt --check` clean, workspace `clippy -D warnings` clean, 49 test binaries green across skills /
first_party_extension_ports / extension_support / architecture, and the budget (incl. its 76
self-tests), panic and hermetic-env gates pass.

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

* test(skills): drive the demo's second thread through a mocked model and the real shell

The existing two-thread fixture proves the staged bytes land somewhere runnable, and says in its own
comment that it "cannot catch a wrong ADVERTISED path" -- it finds the script by walking the
workspace and runs it with `std::process::Command`. That is the half the demo actually failed on:
the string handed to the model was `/workspace/tenants/<t>/users/<u>/.skills/<name>`, the shell and
the file tools resolved it a second time beneath the per-caller root, and every command died with
`Failed to spawn command: No such file or directory`.

This closes it. Thread 1 installs the skill with its script; thread 2 is a fresh runtime over the
same store, and `SkillShellGateway` -- a mocked model -- does what the real one does: reads the
activated body, PARSES the workdir out of the staged-files note rather than reconstructing it, and
calls the real `builtin.shell` capability with that workdir. Everything between the model and the
file is the production path: activation, staging, the mount views, the process port.

Verified by reintroducing the bug rather than trusting a green run. With `runnable_dir` emitting the
old per-caller spelling again, this fails with

    left:  "/workspace/tenants/two-thread-tenant/users/two-thread-owner/.skills/egfr-calc"
    right: "/workspace/.skills/egfr-calc"

and passes once the fix is restored. All three skill fixtures green in 2.67s; the file sits under
`src/runtime/tests/`, so the composition mass count and its ceiling are unchanged.

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

* fix(skills): narrow STAGED_SKILLS_DIRNAME after the crate move

CI's clippy runs `--all-features`, which I had not; it flags what the plain run did not. After
`bundle_staging` moved into `ironclaw_loop_host`, the constant was re-exported from the module but
not from `lib.rs`, leaving it both unreachable-pub and unused. Nothing outside this crate reads it —
the only other mention is a comment in the coding tools' config — so it is `pub(crate)` and off the
module's export list.

All three demo fixtures still pass (2.82s), `--all-features` clippy clean, fmt clean.

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

* fix(skills): bound the listing budget by the snippet cap it ships inside

Two review findings, both real, both from the same blind spot: a constant was checked against what
looked reasonable rather than against the limit downstream actually enforces.

**The listing could take the runtime down (serrrfirat, High).** `LISTING_CHAR_BUDGET` was
`512 * (250 + 64)` = 160,768 chars, and the listing ships as ONE model-visible snippet.
`skill_context.rs` rejects a snippet over `LOOP_CONTEXT_SNIPPET_MODEL_CONTENT_MAX_BYTES` (65,536)
with `ContextBudgetExceeded`, which is a hard error that fails the whole skill-context build — not a
truncation. So past roughly 209 full-length entries a large catalog did not list fewer skills, it
failed. The budget is now derived FROM that cap with headroom for the header and hidden-count note,
and a `const` assert ties the two together so the old value cannot come back: restoring it fails the
build outright with `evaluation panicked: the rendered listing must fit the single snippet it ships
as`. A test asserts the rendered listing in BYTES (512 entries of multibyte descriptions, the worst
case the enumeration cap allows) rather than in chars against the budget, because the cap is a byte
cap.

**`ActivationStrategy::Disabled` was inert (coderabbit, Major).** `criteria_enabled()` had no
production caller at all — only its own unit test — so binding `Disabled` still ran keyword
activation, exactly like `CriteriaOnly`. It is now the third gate on the criteria path, next to the
global auto-activate switch and the selection mode.

Verified: 635 + 54 + 4 tests green across loop_host, `--all-features` clippy clean (which is what CI
runs), fmt clean.

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

* fix(threads): make the result_read env knob actually raise the cap

serrrfirat, High: `IRONCLAW_TOOL_RESULT_READ_MAX_BYTES` was inert. It widened
`validate_tool_result_record_read`, which sits DOWNSTREAM, while the caller-facing gate in
`result_read.rs` stayed pinned to the compile-time `TOOL_RESULT_RECORD_READ_MAX_BYTES` (24 KiB) and
the advertised schema still said `maximum: 24576`. A larger read was rejected before it could reach
the widened validator, so setting the variable changed nothing.

The gate and the schema now resolve `effective_tool_result_read_max_bytes()` per request.

This also corrects a fix I made earlier in this PR for the wrong reason. Clippy flagged
`effective_tool_result_read_max_bytes` as `unreachable_pub` and I narrowed it to `pub(crate)` — but
it had no cross-crate caller precisely BECAUSE the wiring was missing. The lint was reporting the
bug, not dead code. It is `pub` again, with the caller it was always supposed to have.

Tested as a wiring identity (gate == effective cap, schema == gate) rather than by setting the env
var: these tests run in-process and in parallel, so mutating process environment races every other
test reading it, and the identity is exactly what regressed.

Verified: workspace `cargo check --all-targets` clean, 12 test binaries green across loop_host and
threads, fmt clean.

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

* fix(threads): satisfy the production-target lint lane

CI's PR lane lints the DEFAULT target set (lib + bins, no tests or examples) with
\`--all-features\`; I had been running \`--all --tests --examples\`, and the extra targets masked
both of these.

- \`TOOL_RESULT_READ_ENV_CEILING_BYTES\` was widened to \`pub\` alongside
  \`effective_tool_result_read_max_bytes\` in the previous commit, but only the function is
  re-exported from \`lib.rs\`, so the constant was unreachable-pub. Only that function reads it, so
  it is \`pub(crate)\`.
- \`result_read.rs\` no longer reads \`TOOL_RESULT_RECORD_READ_MAX_BYTES\` now that the gate resolves
  the effective cap, so the import goes.

Verified with the lane CI actually runs (\`cargo clippy --workspace --all-features -- -D warnings\`,
no test targets), plus fmt and the loop_host/threads suites.

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

* fix(skills): record the listing-budget const assert for the panic gate

\`check_no_panics.py\` flags the \`assert!\` in the new \`const _: () = ...\` block. It cannot panic at
runtime — rustc evaluates it, so restoring the old budget fails the BUILD — but the scanner reads
the macro, not the const context.

Suppressed with the inline \`// safety:\` rationale the tool documents, placed INSIDE the call's span
where the scanner looks for it, rather than parked in the reviewed-invariant baseline: the baseline
is for real runtime panics that were audited, and this is not one. (I tried the baseline first; it
then correctly reported the entry as stale once the inline marker took effect.)

All three panic checks pass: diff-scoped, reborn baseline (50 invariants, unchanged), and self-test.

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

* docs(skills): cut the comment bloat on this layer

Comment share of this PR's diff: 33% -> 26%, 471 -> 330 lines. The measurements that justify a
constant stay; the retellings of how we got there go, since they are already in the commit history.

One of these was a duplicate rather than verbosity: `DEFAULT_SKILL_ACTIVATION` carried an earlier
draft stacked directly above its own replacement, so the file gave two competing accounts of the same
default. I had removed that copy at the top of the stack only; removing it here means all three
layers carry one version instead of conflicting on every merge.

Also corrects a doc that contradicted the code, which Copilot flagged:
`effective_tool_result_read_max_bytes` was documented as clamping to
`TOOL_RESULT_RECORD_READ_MAX_BYTES` when it clamps to `TOOL_RESULT_READ_ENV_CEILING_BYTES` — the
whole point of the separate ceiling.

Comments only; no code touched. `--all-features` clippy on the production target set (the lane CI
runs) clean, fmt clean, 19 test binaries green across skills / threads / loop_host /
extension_support.

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

* docs(skills): cut the comment bloat on this layer

Same pass as the layer below. The blocks that went were retellings: how the requirement-gating
module came to be deleted and restored, three paragraphs on why the listing budget is the size it
is, the full derivation of the active-skill cap. What stays is the measurement that justifies each
number, because deleting those makes the constants look arbitrary.

`gating.rs` lost the most (-24): its module doc explained the delete-and-restore history at length,
which belongs in #6943's trail, not at the top of the file.

Comments only. Production-target `--all-features` clippy clean, fmt clean, panic gate clean, 13 test
binaries green across skills and loop_host.

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

* chore: resolve conflicts with main and collapse the diff to this layer

Main had advanced past #6938's merge, so the branch was carrying its own
now-merged copy of the middle layer and diffing that against main. Merging
current main drops it: 77 files/+7559 -> 44 files/+3152.

Where a conflict was comment prose only, main's wording wins -- re-trimming
text that already merged is churn this PR should not carry. Ours is kept only
where the code differs: the bundle-staging body_context calls, the
process_execution_available plumbing, and skills::gating, which main never
received.

approval/tests.rs takes main's file-split, with the skill-mounts argument
dropped at its 7 call sites since skill_mounts_for now derives it per gate.
docs/skills/agent_authored_bundles.md was resurrected by the merge and is
removed; multi_tenant_enablement.md moves under docs/internal/skills/ for the
publication boundary gate. Composition ratchet re-recorded 40_747 -> 41_149,
ceiling and arch-test constant together.

* test(arch): ratchet the struct-debt member baseline down to 274

skill_mounts_for derives the skill mount view from the gate, so the member
that carried it is production-used rather than test-support-only. The gate
requires the baseline to drop in the PR that removes the debt, otherwise the
1-member gap is untracked slack a later change could spend silently.

* test(composition): seed production skills through the production namespace

The merge took main's copy of these two seed paths, but they belong to main's
scoped_skill_* mount views, which resolve under /projects. The production
runtime reaches skills through db_backed_skill_grants, whose namespace is
/tenants/<t>/users/<u>/skills — pinned by
production_skill_management_mounts_use_production_namespace. Seeding at
/projects/... wrote outside the tree the runtime scans, so both tests saw an
empty activation set.

* ci: map the skill self-creation e2e script in the Reborn test planner

Repo-root scripts/ is deliberately not prefix-classified, so adding
e2e-skill-self-creation.sh raised 'unmapped test or CI path' and failed
Detect Reborn test scope, which skipped every downstream Reborn lane. It is
referenced by no workflow and needs live model credentials no lane has, so it
selects no lane -- the same decision already recorded for run-reborn-webui.sh.

* chore: drop staged skill bundles from the tree and ignore .skills/

Activation stages a skill's bundle into the workspace, so running the agent
from the repo left 16 staged files committed under ironclaw_cli/. They are
build output of the feature this PR adds, never source, and they inflated the
diff by ~1500 lines.

* fix(skills): key the host-disk skill import per skill, not per store

A skill dropped into the store after the first boot was never imported, and
since skills read only from the database it stayed invisible — permanently,
because the store-wide marker outlives every restart.

The marker is not gratuitous: the disk copy survives a deletion made through
the product, so an import that only checks "is it already in the database?"
copies a removed skill straight back. Keying the marker per skill keeps the
migration one-shot per skill, which picks up a newly appearing one on the next
boot without resurrecting anything.

Both directions are pinned, and they fail in opposite directions on the old
code: a_skill_appearing_on_disk_after_the_first_import_is_still_imported fails
before this change, an_imported_skill_deleted_from_the_database_is_not_resurrected
fails if the marker is simply removed.

* test(skills): seed user skills before boot, and cover bundle staging

The three skill_activate cases seeded a user skill through the capability
harness AFTER the group was built. That worked while skills were read from the
host disk; they are read from the database tree now, and the store is migrated
into it at boot, so a later write was never picked up and the run listed the
system catalog with none of the user's own skills.

Seeds them through the harness instead, which writes the store before the
runtime boots — the ordering rule with_system_skill_fixture already documents,
and the shape a real user's existing skills have. Both ids come from the
group's already-resolved binding, since actor_user_id is an opaque hash that
cannot be rebuilt from the profile's owner string.

Also covers bundle_staging, which had one test for a 200-line file. The
path-traversal guard in staged_path had none at all despite being the boundary
that keeps a model-authored bundle inside its own directory; removing the guard
now fails a_bundle_path_that_escapes_the_skill_directory_is_refused. The rest
pin the best-effort contract: a bundle with nothing stageable advertises no
workdir, and one unsafe entry does not cost the safe files.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:48:29 +00:00
Benjamin Kurrek
0b5fcde996 feat!: a run acts as its invoker — remove shared-route subject binding (#7157 follow-ups) (#7377)
* feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted

Re-landed PR #7157 on current main (aa8b748c3967f33aa4, across the WS2
composition inversion, the WS6 crate renames, and the WS7 family moves), with
all 44 review comments dispositioned.

Two-lane delivery model: a run's final reply always lands in its own
conversation (lane 1); reaching any other surface is the model's explicit
`builtin.outbound_deliver` call (lane 2 — bot identity, one catalog target
per call, synchronous through the DeliveryCoordinator, provider-issued
message refs as evidence). Background-run notices fan out to a user-configured
notification-channel set (new record field + read-side legacy migration,
`builtin.notification_channels_set`, first-approve-wins, WebUI multi-select).
The stored delivery heuristics are deleted (route_current, builtin:web_app,
outbound_delivery_target_set, per-trigger delivery_target_id + precedence
chains + four-slot preference fallback), with an idempotent boot migration of
stored trigger targets into explicit prompt steps and the retired vocabulary
pinned in reborn_retired_taxonomy.rs.

Port notes (old → new homes): ironclaw_reborn_composition→app/ironclaw_composition,
ironclaw_product→product/ironclaw_assistant, first_party_extensions→extensions/packages,
run-profile vocabulary→ironclaw_loop_contracts, PreferenceTargetCodec→
ironclaw_extension_contracts, wire DTOs→ironclaw_product_contracts::product_wire.
The model-delivery implementation moved extension_host→assistant
(CoordinatedModelChannelDelivery) — the WS2 port inversion forbids extension_host
naming product types; the deferred-slot registration and post-coordinator bind now
live in composition's production assembly, mirroring TriggeredRunDeliveryDriver.

Re-folded 2026-08-05 onto b2023bc8f (#7258): channel-adapter vocabulary
re-imported from ironclaw_extension_contracts, product-adapter/inbound
vocabulary from host_api/product_contracts, module-charter map's outbound
row renamed to the two-lane vocabulary. Post-branch CI gates adapted in
the same change: skills/ classified in the PR test planner (test-first,
sabotage-verified), panic baseline ratcheted down, nested test fixtures
renamed to the scanner-sanctioned support_tests.rs shape, composition's
inline trigger-migration tests split to tests.rs (mass budget green with
no ceiling raise), extension_contracts size ceiling 7727 -> 7748 (+21:
the ActivePreferenceTargetCodecs port), loop_contracts ceiling
re-captured down 14479 -> 13850 after the delivery-vocabulary deletion.

Third fold 2026-08-05 onto b72d7da66 (#6831, standardized messaging
framework): the two-lane guidance moved into the canonical messaging core
prompt (host_api prompts/messaging/send_message.core.md), now naming
builtin__outbound_deliver with the arrive-twice and trigger caveats for
every messaging extension; slack vendor addendum/manifest taken as #6831
shipped them; ceiling-table union (host_api 18570 beside this PR's two
re-captures); retired slack schema embed and deleted preferences
capability stay deleted; golden context-surfacing snapshot regenerated
(one surface-hash line).

Fourth fold 2026-08-06 onto c69ed2d70 (#7263 program-closure batch +
sibling fixes): ceiling-table union (product_contracts 15685 from #7230
beside this PR's re-captures) and main's tracing-target syntax sweep
(target = -> target:, gate-enforced) applied over this PR's kept lines;
deleted delivery-heuristic code stays deleted.

Fifth fold 2026-08-06 onto 0c297cb24 (#7264 guidance-layer sweep):
zero conflicts; guidance/doc-pointer changes auto-merged over this delta.

Routing-UX slice 2026-08-06 (product thread + follow-ups): result routing
is prompt-owned with a pinned source-surface default (bare "send me" =
the surface you asked from; web app = no delivery step; explicit
destinations override, one delivery step each) — iterated against live
recordings until a real model followed it, with two live-recorded QA
fixtures (bare-webui, multi-channel) plus contracts and replays. The
automations-page panel is retained as the notification-channel selector
(notices only); the conversational notification_channels_set tool writes
the same validated set.

Delivery-evidence fix (theredspoon's flag; #7029 fixes the same swallow
on main): mark_terminal reports whether the durable write committed and
a confirmed send whose Delivered row failed to commit returns
DeliveredUnconfirmed (refs retained, durably_recorded: false), never a
fabricated Delivered — regression-tested and sabotage-verified. Plus a
CodeRabbit triage batch: correctable coordinator errors stay
model-visible, omitted target_ids no longer clears the set, the success
schema requires evidence, the composition outbound facade is dissolved,
and guidance/contract docs are aligned.

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

* fix: harden channel delivery routines and replay

* fix: preserve automation loading and identity freshness

* fix: close channel delivery review defects

* fix: skip paused routine catch-up slots

* ci: record channel delivery composition budget

* test: align composition baseline with channel delivery

* fix(auth): survive interrupted OAuth callbacks

* fix(auth): keep callback coordination panic-free

* fix(ci): reconcile channel delivery merge seams

* fix(ci): recapture merged contracts ceiling

* fix(delivery): close review findings across delivery, migration, and guidance

Fixes the findings from the multi-agent review of this PR. Every behavioral
fix ships with a regression test that fails before it.

CI (red on this head)
- `standalone_yolo_notification_channels_set_bypasses_approval_gate`
  expected the shared "invalid outbound delivery request" summary for a
  `builtin__notification_channels_set` call. Production deliberately
  specializes that message per operation and pins it with
  `notification_channel_failure_names_the_operation_the_model_can_correct`;
  the assertion was the stale side.

Delivery evidence (kernel + assistant + outbound)
- `AlreadyDelivered` replays reported `delivered: false` "unverified"
  because the ledger row retains no provider refs, inviting the duplicate
  resend the at-most-once claim exists to prevent. Evidence gained
  `already_delivered`; a replay now reads as delivered with an honest
  "not resent" summary. The classification suite had no `AlreadyDelivered`
  case at all, which is why this shipped.
- `DeliveredUnconfirmed` is the one non-`Delivered` outcome that actually
  sent something, but `delivered_messages_from_outcome` dropped its refs,
  so gate reply-routes went unrecorded and a live OAuth prompt could never
  be retracted on that path.
- `content` is now rejected when empty: the input schema advertised
  minLength 1 and nothing enforced it, so empty content reached the channel
  as an empty part and returned an opaque provider error.

Background-run notifier (assistant)
- One run legitimately emits several `RunBlocked` notices (re-auth
  stand-in, unserviceable-auth cancellation, run failure), but all three
  derived the same projection ref, and the delivery id hashes it. The
  second notice to a target came back `AlreadyDelivered`, was treated as
  success, and was never sent — a user could be told a routine needed
  re-authorization and never told it then failed. Notices carry a
  discriminator; once-per-run kinds keep their historical id shape, so
  existing delivery identities are unchanged.
- When every catalog lookup failed, the empty result was recorded as
  `NoDefaultConfigured`, reporting a backend outage as the benign "user
  configured nothing" state. It now records `Failed`.

Boot migration (composition)
- The retired `builtin:web_app` target meant "no external delivery". It was
  being rewritten into a delivery step to an id nothing can resolve,
  inverting the stored intent on every later fire. It now clears without
  adding a step.
- One unmigratable row aborted the entire composition boot, with the error
  telling the operator to shorten a prompt through the UI that no longer
  starts. It now pauses its own routine — a paused trigger cannot fire, so
  "never fire unrouted" still holds per record — and boot continues. Only a
  systemic store failure stays boot-fatal. A row deleted during the CAS
  retry ends that record instead of failing boot.
- The CAS retry loop, its bounded exhaustion, and the vanished-row arm had
  no caller-level coverage; adds a delegating repository double that forces
  CAS misses. The prior fail-closed test is rewritten to pin the invariant
  it documented (route survives, record not half-migrated) under the new
  per-record mechanism.

Model-visible messages (composition)
- The targets-list denial said "not permitted to change the outbound
  delivery target" for a read-only call, and the lease denial named the
  retired delivery-target concept on the notification-channel path that is
  its only production caller. Both are now operation-specific and pinned.

WebUI (frontend)
- `setNotificationChannels()` with no argument posted `target_ids: []`,
  turning an omitted argument into a destructive clear-all and defeating
  the backend contract that deliberately rejects an omitted field.
- The notification-channels panel stayed editable after a failed read, so
  toggling one row full-replaced the stored set from an empty baseline and
  silently dropped every channel the user never saw. Editing is now locked
  on a failed read, with a rendered explanation.
- Adds the missing `tools.description.builtin.notification_channels_set`
  key to all 11 locales, plus save-failure coverage for the hook (which was
  correct, but untested) and locale-parity tests.

Guidance
- The new `.claude/rules/tools.md` was ported from a pre-restructure branch:
  it named `ironclaw_dispatcher` (deleted) and `ironclaw_extensions` (never
  existed), and its review command grepped three paths removed by WS6/WS7.
  Its `paths:` frontmatter also never matched the product/composition
  callers its rules govern, so the rule never loaded for them.
- `ironclaw_loop_contracts` now records both embedded prompt assets; this
  PR added a second one while the crate's Known-debt entry still said one.
- Bumps `skills/delegation` (rewritten guidance, unlike its two siblings in
  this PR which both bumped) and fixes a pre-rename path in the
  extension-runtime checklist.

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

* fix(delivery): unify the DM-rule enforcement point and re-ratchet composition

CI (composition mass budget, red on the previous head): the per-record
migration quarantine pushed composition 6 LOC over its absolute ceiling.
Resolved by the reduction the budget file itself blesses rather than a
raise — `runtime/approval.rs`'s 417-line inline `#[cfg(test)]` module split
verbatim into `runtime/approval/tests.rs` (the gate excludes test-only
files but counts inline test modules). Composition is now 40,432 LOC,
smaller than before these fixes, and `loc_ceiling`/`loc_observed` plus the
arch-test record are re-captured together at the measured value per the
gate's one-directional ratchet rule.

The codec-scan that decodes a binding and enforces "an OAuth authorization
URL only ever lands in a personal DM" existed twice — once in
`TriggeredReplyTargetAuthority`, once as `CodecChannelTargetResolver` —
with both copies commented as "the single enforcement point". They are now
one implementation, shared by the notifier and `builtin.outbound_deliver`,
with a context label so each path keeps its own diagnostic.

That rule turned out to be UNGUARDED: sabotaging it (`if false && ...`)
failed no test in the crate. The vendor codecs pin the predicate in
isolation and the coordinator test pins rejection handling with a double
that decides the verdict itself, so nothing covered the wiring that joins
them. Adds a contract test driving the real resolver through
`DeliveryCoordinator::deliver` for both verdicts, asserting a non-DM target
never reaches the vendor adapter. Sabotage-verified: the test fails with
the rule disabled and passes with it restored.

Smaller findings: the notification-channel schema cap now derives from
`ironclaw_outbound::NOTIFICATION_TARGETS_CAP` instead of hand-mirroring
`8`; `triggered_run_delivery`'s module and trait docs described the retired
result-push model this PR deletes; the two new notification strings used a
different brand spelling and dash style from the nine siblings in their own
module; and several new comments navigated by pre-rename paths
(`ironclaw_product::`, `local_dev::`, `crates/ironclaw_webui/`) plus a
citation of a test symbol that does not exist.

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

* fix(outbound): scope the delivery catalog to the authenticated actor

`builtin.outbound_deliver` resolved its destination catalog under
`ResourceScope.user_id` while performing the send as
`authenticated_actor_user_id`. Those are the same user on a personal thread
and on an automation fire, but they diverge on a shared-route channel
conversation: the scope user is the route's SUBJECT
(`TurnScope::explicit_owner_user_id`) and the actor is whoever sent the
message. Any participant of such a channel could therefore name the
subject's target ids and push bot-identity content into the subject's own
destinations — their personal DM included — from a conversation the subject
may never read.

The catalog now follows the actor, so a caller stays inside their own
connected surfaces on every path and an unfamiliar target simply does not
resolve. Behavior is unchanged wherever owner and actor already agree,
which is every non-shared-route path.

Regression test drives the divergent case through the port (participant
denied with `TargetUnavailable`, nothing reaching a vendor adapter) plus a
control proving the owner's own delivery still works. Sabotage-verified:
restoring owner-scoping fails it.

NOT changed here, and flagged for a product decision: the sibling
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derive their caller from the same
owner-preferring `effective_user_id`, so on a shared route a participant
can still enumerate — and, with the approval gate auto-approved, rewrite —
the subject's notification channels. That helper also scopes approval
gates and capability leases, so flipping its precedence risks breaking
approval raise/resume matching in a path no test covers; it needs its own
change with that coverage.

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

* fix(delivery): stop rewriting the DM-target row on every message

The post-admission backfill calls `FilesystemChannelDmTargetStore::upsert`
for every admitted inbound direct message, and the store unconditionally
wrote a fresh row. After the first message the stored record is already
correct, so the steady state was one durable backend write per DM message,
forever, whose only effect was a new `updated_at` — and each message's
reply-delivery observation was serialized behind it. An unchanged record
now short-circuits; the existing row is loaded here anyway to preserve
`created_at`, so the comparison costs nothing.

Also adds the regression test the `NoDefaultConfigured` -> `Failed`
classification fix landed without: the notifier's `SkipEntry` lookup lane
had no coverage at all (no test ever made a catalog lookup error), so
neither the skip nor the all-failed arm was exercised. The triggered
harness gains an injectable catalog provider for it. Sabotage-verified.

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

* fix(loop): bound the connected-channels line so the runtime slice fits

Confirmed live, not theoretical: a worst-case runtime context renders 4,391
bytes against the 4,096-byte `PromptTextSurface::SafeSummary` cap that
`instruction_bundle::push_runtime_context` validates the whole slice on —
and exceeding it is a run-ending error on EVERY prompt build for that user,
not a one-off.

This PR's fixed ~1.1 KiB delivery-guidance block is what pushes a
previously-fitting context over. The individual parts are each bounded
(location 200 chars at its producer, locale 35, per-label safe-text
validation), but nothing bounded their SUM, and the connected-channels line
is the one part that grows without limit: up to 20 entries whose names and
presentation hints are only individually capped.

That line now renders as many channels as fit a 1 KiB budget and folds the
rest into the "+N more" counter it already carried, so the fixed guidance
can never be squeezed out by variable content. The worst-case test that
found this stays as the pin.

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

* fix(outbound): resolve outbound capabilities as the acting user

`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derived their caller from
`effective_user_id`, which prefers the thread owner over the actor. Those
agree on a direct message and on an automation fire, but diverge on a
shared-route channel conversation, where the owner is the route's
configured subject — the deployment operator by default
(`channel_workflow.rs`) — and the actor is whoever posted.

So any participant of a shared channel could enumerate the operator's
connected destinations and rewrite the operator's notification-channel set,
which is where approval prompts, re-auth prompts and failure notices are
delivered. The caller now follows the acting user, matching the fix already
applied to `builtin.outbound_deliver`.

This deliberately REVERSES a previously pinned preference. Two tests
asserted the owner won when the two differ; that pin predates shared-route
subjects defaulting to the operator, and it contradicts the rule that a run
acts as whoever invoked it. Both are updated to pin the actor, with the
reversal recorded at each site rather than silently relaxed, and the
notification-channel write is now asserted to land under the acting user
with the thread owner's own set left untouched.

INTERIM, by design: `resource_scope_for_run` and `settings_scope_for_run`
still follow the owner, because they scope the approval-gate raise and the
capability lease and those must stay matched between raise and resume.
Unifying them belongs with the follow-up that removes shared-route subject
binding entirely so a shared channel runs wholly as its invoker; that needs
approval raise/resume coverage which does not exist yet. A new test pins
the split so the interim state is explicit rather than accidental.

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

* fix(ci): keep loop_contracts under its size ceiling and ratchet down

The runtime-context byte-budget fix and its worst-case pin pushed
`ironclaw_loop_contracts` to 14,032 production lines against a 13,949
ceiling. Resolved by the reduction the gate prefers over a raise:
`runtime_context.rs`'s 919-line inline `#[cfg(test)]` module split verbatim
into a `runtime_context/tests.rs` sibling, which `production_rust_files`
excludes (an inline test module inside a production file is counted; a
test-only file is not).

The crate now measures 13,115 — 834 lines below the previous ceiling and
smaller than before this review round — so the ceiling is re-captured
downward at the measured value rather than raised, per the gate's
one-directional ratchet. Count read from the gate's own failure message,
not by eye.

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

* fix(delivery): key observer gate notices by their gate ref

One run can park on several approval/auth gates in sequence (the observer's
blocked-state loop re-announces whenever the (status, gate) marker changes),
but the live observer derived every gate notice's projection id with no
discriminator, so all ApprovalNeeded notices in one run collapsed to a single
durable delivery identity. The second gate's prompt came back AlreadyDelivered
from the coordinator, was treated as success, and was never sent — the user
was never told about the gate their run was parked on, and no reply route was
recorded for it, so a bare `approve` could not resolve it either.

Key the projection id by the notification's gate ref (the mechanism #7157
added for the triggered notifier's RunBlocked notices). A repeat announcement
of the SAME gate still dedupes; kinds that carry no gate ref (FinalReplyReady)
keep the historical undiscriminated id shape so existing delivery identities
are not re-keyed.

Regression: observer_delivers_a_prompt_for_each_distinct_approval_gate drives
the real DeliveryCoordinator over the real outbound store through two distinct
scripted gates and asserts two delivered prompts plus a recorded reply route
for each. Sabotage-verified: reverting the discriminator to None fails exactly
this test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(composition): pin the notification-channels gate dance when owner ≠ actor

The full builtin.notification_channels_set approval dance — raise, replay
payload, user approve (store + lease mint from the stored row), approved
resume, lease claim, dispatch, lease consume — driven through the real
capability port on a run whose thread owner differs from its acting user.

Pins two properties ahead of unifying the scope derivation onto the actor:
raise and resume must derive the same scope (every store in the dance is
scope-keyed, so a half-unified derivation strands the approved capability),
and whose identity that scope carries (the thread owner, under the interim
split #7157 shipped). The approve step mints the lease from the stored
request's own scope, grantee, and fingerprint — the same material the
production click-approval resolution uses — never a re-derivation.

Capability-host tier rather than tests/integration because the product rule
"a run acts as its invoker" makes owner ≠ actor unconstructible through every
product front door; the run-context shape remains legal kernel state (runs
parked across the deploy boundary carry it). The owner == actor dance stays
covered end-to-end at the integration tier (outbound_target.rs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(outbound): scope the whole notification-channels gate dance as the acting user

Unify the interim #7157 split: resource_scope_for_run and
settings_scope_for_run now derive from the acting user like caller_for_run
already did, and effective_user_id (the owner-first ladder) is deleted. The
approval-gate raise, the replay payload, the durable gate record, the lease,
and the approval-settings read all follow the user who invoked the run — so
the invoker sees and approves the gate, and their settings govern it.

The raise/resume coverage added one commit earlier ran before and after this
change and caught a real half-unification in between: the resume-side replay
load lives in ironclaw_loop_host's synthetic-capability wrap (a different
crate from the raise-side save in notification_channels_set) and still
derived owner-first, stranding an approved resume with "replay payload is
unavailable". The acting-identity ladder now has exactly one definition —
LoopRunContext::acting_user_id in ironclaw_loop_contracts — and both sides
delegate to it, so the hand-synced-copy class is closed rather than re-synced.

notification_channels_set's replay/gate-record writes move from the
capability_host-wide owner-first helper onto the outbound module's
base_resource_scope_for_run so every store in one dance derives one user; the
capability_host-wide helper itself is unchanged (thread/durable-result
scoping legitimately follows thread ownership, and owner == actor on every
binding created under the run-acts-as-invoker rule).

Loop-contracts size ceiling: +16 lines for the shared ladder, paid for by
splitting host/run_context.rs's 104-line inline #[cfg(test)] module into its
run_context/tests.rs sibling; ceiling re-captured DOWN 13_115 -> 13_028 from
the gate's own failure message.

Runs raised before this change with owner != actor and resumed after it will
miss their replay payload once and fail closed; re-requesting approval
recovers. Documented in the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(conversations): key shared-route bindings per (conversation, actor)

A run acts as the user who invoked it, so a shared conversation binds one
thread per paired actor — each owned by that actor — instead of one
conversation-wide thread owned by a configured subject. BindingKey gains a
serde-defaulted shared_actor_user_id component (None for Direct routes, whose
identity stays the conversation alone); the trusted-owner parameter is
deliberately ignored on Shared creates (it remains the trigger lane's way to
bind Direct conversations for their creator), and the legacy shared-owner
backfill is removed with it.

Migration is ignore-but-retain, pinned with a restart-path test: legacy
Direct keys deserialize byte-identically (continuity), while legacy
conversation-keyed shared rows deserialize to a key no per-actor lookup
builds — retained in durable state untouched, and every participant
(including the old subject) starts a fresh thread they own.

Morphed legacy pins record what became structural: a shared probe/lookup can
no longer address (or widen) a Direct binding at all; stored reply targets
are isolated per actor; an actor's unpair cannot take the conversation away
from other participants' own threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(product)!: remove shared-route subject binding; scope = invoker

Owner ruling: a run acts as the user who invoked it, in a DM and in a shared
channel alike, with one thread per (conversation, user). This removes the
subject half of shared-route configuration end to end and keeps the admission
half, fail-closed:

- ironclaw_product_contracts: subject_route becomes shared_admission — the
  SharedConversationAdmission port answers only "is this shared conversation
  connected"; ProductConversationRouteKey survives as the admission key.
  ResolvedBinding loses subject_user_id (retired-field JSON still
  deserializes; persisted-shape test updated); the actor is the one identity.
- ironclaw_assistant: ProductInstallationScope drops the default-subject,
  static-route, and subject-resolver knobs for one shared_conversation_admission
  port; resolve/lookup/reset check admission fail-closed (no port wired, or an
  unlisted conversation, rejects with a not-connected BindingRequired);
  resolve passes no trusted owner — the conversations domain keys and owns
  shared bindings by the paired actor. Thread and turn scopes derive their
  owner from the binding's actor on every route kind.
- ironclaw_extension_host: channel_subject_routes.rs becomes
  channel_shared_admission.rs; ChannelConfigSharedAdmission admits by
  membership in the operator-saved *_allowed_channels JSON array; the managed
  derived subject (user:{ext}-channel:{sha16}) is deleted; legacy
  *_subject_routes values are inert (pinned by test). Shared conversations are
  no longer offered as per-user notification delivery targets — their
  ownership came from the retired subject map — and stored channel-target
  preferences fail closed at resolution; DM targets are unchanged.
- slack manifest: slack_shared_subject_user_id and slack_subject_routes are
  retired with a gravestone comment; slack_allowed_channels is the admission
  surface (saves to the retired handles already fail closed as unknown
  fields — the extension-config analog of the config.toml retired-section
  gravestone).
- architecture tests: the INVERTED_PORTS row moves with the port rename.

User-visible consequences (also in the PR body): each shared-channel
participant now gets their own persistent thread and must be paired; no
cross-user shared context; the operator's identity is never a fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(reborn): align guidance, specs, and live-QA scripts with invoker scope

Guidance rows and the moved-ports list rename the inverted port
(SharedConversationAdmission, ex ProductConversationSubjectRouteResolver);
the assistant boundary prose states the new rule (one thread per
(conversation, actor), admission is the only shared-conversation
configuration, fail-closed on resolve/lookup/reset). The composition
CONTRACT.md's never-shipped per-channel subject admin API section is excised
with a dated correction; CHECKLIST/PROPOSAL get dated amendments beside the
historical text. Operator docs teach slack_allowed_channels + per-user
pairing. CHANGELOG records the behavior change and the retired config
fields. The live-QA scripts drop subject handling for allowed-channels
admission (200 script tests green), and the orphaned canary env var is
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(telegram): connect group chats via telegram_allowed_channels

Fail-closed shared-conversation admission left Telegram groups with no
operator affordance to connect one — the manifest declared no
*_allowed_channels handle, so every group/supergroup @-mention was
unadmittable. Declare the handle (the same generic [channel.config]
convention Slack uses): listed chats are served with each participant
running as themselves once paired; unlisted groups stay fail-closed.
Previously any group the bot was added to ran as the deployment operator,
which is the exposure this branch removes.

Surfaced by the integration scenario
telegram_update_becomes_a_turn_and_a_coordinated_reply failing closed after
the admission change — kept red until this ruling rather than narrowed to a
private chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(reborn): morph the test tier to invoker scope

Every fixture and pin that carried the retired subject model moves to the
per-actor rule, with a recorded rationale at each semantic morph:

- extension-host channel e2e: an admitted (allowed-channels) shared channel
  runs as the paired actor; unlisted conversations stay rejected; stored
  shared-channel outbound targets and binding refs fail closed; the
  telegram supergroup journey admits its chat via the new
  telegram_allowed_channels handle and proves the reply as the invoker.
- assistant contract suites: admission replaces subject-route coverage
  (recording/failing/admit-all doubles; not-connected rejections on
  resolve/lookup/reset including existing bindings — a deliberate flip from
  the old existing-binding exemption; admission precedes actor-pairing side
  effects; direct routes never consult admission; per-actor threads for two
  participants; lookups never surface another actor's thread).
- root integration harness + journeys: the binding fake, thread/turn scopes,
  and the group canonical user derive from the actor; multi-actor isolation
  pins unchanged and strictly stronger.
- parity QA binary harness: subject resolution returns the actor.
- webui product API redaction pin: the new telegram admission handle joins
  the admin-metadata forbidden list.

Suites: extension_host 390/0; assistant 1084/0; conversations 105/0;
architecture suite full pass; integration bins: extension_delivery 21/0
(Postgres legs under colima), delivery_user_journeys 22/0, mcp 22/0,
trace_capture 14/0, generated_gate_sequences 29/0, group_journeys 16/0,
group_multiuser 14/0. Workspace cargo fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(changelog): record the telegram_allowed_channels admission field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(merge): reconcile composition ceilings and capability_wiring test arity

Post-merge fixups after folding main (#7157 squashed + #7214 + the
inspector prompt-diagnostic work) into run-acts-as-invoker:

- Re-capture the composition absolute-mass ceiling 40_747 -> 40_811 in
  both the budget manifest and reborn_restructure_baselines.rs: the
  acting-user scope helper and shared-admission wiring add +64
  production LOC on the merged tree. Recorded rather than parked in the
  150-line tolerance.
- Add the 10th `tool_diagnostic_sink` argument (None) to the invoker's
  capability_wiring test call — main grew the signature after this
  branch wrote that call site.

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

* fix(conversations): refuse legacy-row route-kind mismatches; drop unreachable widen

A Direct request is the one key shape a retained legacy conversation-scoped
shared row can collide with. Resolve, lookup, reset, and link now refuse the
mismatch outright (BindingRequired) instead of trusting adapters never to
re-classify a conversation's route kind — pinned by a Direct-probe leg on the
legacy restart-path test. The forward half of the migration contract is pinned
too: per_actor_shared_bindings_keep_their_threads_across_reopen proves a new
per-actor shared binding survives a restart (a deserialize-side regression
would previously have orphaned every group thread silently).

widen_binding_route_access and ReplyRouteAccess::allow_shared are deleted:
every Shared-keyed row is born shared under per-actor keying, so both widen
call sites were unreachable. The persisted flag stays for legacy reads.

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

* fix(delivery): key gate notices by gate ref on the triggered lane too

The gate-collapse fix shipped on the observer lane only; the background lane
still minted undiscriminated projection ids for ApprovalNeeded/AuthRequired,
so an automation run parking on a SECOND gate deduped to AlreadyDelivered,
recorded the whole delivery Failed, and the gate was never announced or
reply-routable (AGENTS.md: fix the sibling when a pattern bug is fixed).
TriggeredNotification's discriminator now carries the gate ref for gate
prompts (RunBlocked stand-ins compose their label with it), matching the
observer keying, with a triggered two-gate regression pinning outcome,
prompts, and both reply routes.

Also pinned: same-gate re-announcement dedupe (g1->g2->g1), two distinct AUTH
gates, and the refless id shapes incl. FinalReplyReady. Over-long
discriminators are bounded with a stable FNV-1a suffix so a maximal legal
TurnGateRef can never overflow ProjectionUpdateRef and silently lose a notice.

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

* refactor(identity): one contract derivation for every acting-identity scope

LoopRunContext::acting_resource_scope joins acting_user_id on the contract
type: the raise/resume scope recipe both gate-dance crates hand-synced is now
declared once, and every surviving ladder delegates — composition's
owner-first resource_scope_for_run (workspace/skill mounts) and the inline
grant-minting copy, loop_host's synthetic resume load, and
project_create_capability's effective_user_id (deleted; its doc claimed a
mirror that no longer existed). On the only run shape where owner and actor
differ — legacy runs parked across the deploy — mounts and grants now follow
the ACTOR like the rest of the dance; the pin flip is recorded in
visible_capability_request_uses_acting_user_for_runtime_scope.

The ladder is unit-pinned in its owning crate (all three rungs) and the
accepted deploy-boundary resume-miss is pinned on the synthetic port with an
acting-scope positive control. loop_contracts ceiling re-captured 13094 ->
13107 with provenance (the +13-line contract method).

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

* fix(extension-host): collapse admission handles; operator-identity channels never admit

ChannelConfigSharedAdmission now holds the one declared *_allowed_channels
handle as a plain String (the scan returns Option<String>): 'installed but
handle-less' is no longer representable and the per-request Option branch is
gone. The root pub use of the admission items is removed — consumers are
crate-local and use the module path.

Structural closure of the no-auth-vendor residual: a channel whose actor
identity is not per-user (no OAuth vendor, no pairing strategy) never
receives an admission resolver at all — an operator-identity channel that
admitted a group would run every participant as the operator, the exact
exposure run-acts-as-invoker removed. Previously this was unreachable only by
manifest inventory.

The extension_manager wire-shape pin gains the telegram_allowed_channels row
(production projection was already correct), and extension_delivery gains the
caller-path rejection leg: a correctly-signed webhook for an UNLISTED
supergroup is acknowledged but produces no turn and no reply.

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

* test+docs(reborn): re-pin parity to per-actor scopes; align contract, docs, vocabulary

The identity-parity bin now pins the run-acts-as-invoker property its fixture
can actually express: the shared-room support binding keeps ONE thread (the
per-actor thread model is locked at the integration tier by
scenario_two_actors_own_threads), and inside that shared thread each RUN's
scope is owned by its own invoking actor with identity context never
crossing. The shared-admission suite gains the reset checkpoint leg (deny
before rotation, thread survives), and the connect-nudge suite's shared leg
is documented as the deliberate unpaired-participant silence contract.

docs/reborn/contracts/conversation-binding.md (the owning contract) is
amended: per-actor key in rule 8, participant widening retired in rule 14,
subject ownership struck in rule 24, and the admission/retention semantics
recorded. Operator docs and CHANGELOG state the real unpaired-shared behavior
(silence; pairing via Extensions; DMs still nudge), the CHANGELOG gains the
both-lanes gate-announcement entry and Added-first ordering, CHECKLIST's
contradictory open-status is reconciled with a dated note, the new
REBORN_WEBUI_V2_LIVE_QA_SLACK_ALLOWED_CHANNELS is threaded through
live-canary.yml, observer.rs carries its arch-exempt annotation, and retired
'subject' vocabulary is renamed out of live test support and doc comments.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 20:52:45 +00:00
Benjamin Kurrek
a275895cc0 fix(live-qa): verify triggered Slack delivery through the two-lane contract (#7389)
* fix(live-qa): verify triggered Slack delivery through the two-lane contract

Since #7157 a triggered fire's result is never pushed by the completion
driver: the fire itself calls builtin.outbound_deliver, and the
background-run notifier's triggered-run-delivery record describes NOTICE
delivery only — a cleanly completed fire records `skipped`. The delivery
cases still required that record to say `delivered`, which no longer
exists for results, so qa_3d/qa_8d/qa_9b/qa_9d hard-failed every
scheduled run from the first post-#7157 canary (2026-08-08 00:24 UTC)
even though all four live fires verifiably delivered (three had the
marker sitting in Slack history; the fourth was provider-confirmed).

The waiter now verifies what the product actually guarantees:

- success = the fire's durable outbound/deliveries model-delivery record
  for the exact run (delivered, expected DM) PLUS the independent Slack
  history read-back finding the marker;
- notifier records: `skipped`/`no_default_configured`/`delivered` are
  healthy terminals, only `failed`/`denied` fail the case, and unknown
  future vocabulary surfaces through timeout diagnostics;
- a completed outbound_deliver whose composed content lacks the marker
  fails deterministically (the qa_8d mode: the stale prompt bound the
  marker to the final answer, which is no longer the delivered payload);
- the readback-inconclusive flake classification accepts an
  exactly-one-verified-send through either lane.

Case prompts now bind the marker to the delivered Slack message itself
(and still to the final answer), via one shared prompt-requirement
helper.

Also fixes the QA 6D-6E strict-scrub false positive: progressive tool
disclosure (#6958) records tool_search output in traces, and the
builtin.extension_register_hosted_mcp description's "bearer for a static
API token or PAT sent as a Bearer token" prose tripped the bearer
pattern, deleting the trace and failing the shard with all cases green.
The bearer pattern now requires 16+ token-alphabet characters.

All delivery-wait decision logic is pinned by new unit tests against the
production record shapes captured from the failing canary artifacts.

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

* fix(live-qa): close review findings on the two-lane delivery contract

- Gate marker_deliver_count on completed previews: a failed or in-flight
  outbound_deliver whose content carries the marker never reached Slack,
  and counting it could fake the exactly-one-verified-send inconclusive
  classification or suppress the deterministic markerless red. Fixture
  gains a failed marker-bearing preview, observed red before the fix.
- Align emit_results_json.py's bearer pattern with the scrub script's
  16-char floor so description prose in results.json is not mangled to
  "Bearer <REDACTED>"; prose-preservation regression added.
- Namespace the readback-inconclusive evidence per lane
  (vendor_evidence/deliver_evidence) — both dicts carry
  parse_error_count and the flat merge let one overwrite the other.
- Reuse the production root_filesystem schema helpers in the new test
  fixtures instead of a hand-written CREATE TABLE.
- Document why the deterministic content check keys on
  skipped/no_default_configured rather than the whole healthy_terminal
  class: `delivered` includes a fire parked on an approval gate whose
  run resumes — and may deliver — after the notice.

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

* test(live-qa): pin the exact 16-char bearer floor on both redaction rules

The prose-preservation tests prove prose survives but not the threshold
itself — a {15,} regression would have passed both. Pin the 15/16
boundary explicitly in the emitter suite and the shell scrubber suite,
since the two rule sets are documented as kept in sync.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:00:21 +00:00
Benjamin Kurrek
a443a5f421 fix(ci): recapture extension_host coverage floor + run goldens on prompt-surface PRs (#7371)
* fix(ci): schedule the golden lane for prompt-surface production changes

A production change to the model-visible prompt surface (the capability
surface digest, the instruction bundle, the communication-context
renderer, or a shipped loop-tier prompt asset) ran only crate buckets on
the PR lane, so stale golden_payload snapshots surfaced first as a
merge-queue bounce (#7361, 2026-08-07: surface.rs changed the surface
digest; the PR lane never ran the golden bucket). Add a curated
prompt-surface owner table that ADDITIONALLY schedules the golden
integration lane without consuming the path's normal package
classification. Self-tested per entry plus a negative control pinning
that ordinary production changes keep the narrow plan.

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

* fix(ci): recapture the extension_host coverage floor with measured wobble tolerance

The 2026-08-04 entry was an adjustment whose own text ordered the next
real recapture. Since then code left the crate (denominator 24529 ->
24415) while the ratio ROSE to 88.12%, and the same commit df90072c4e
measured >=21560 covered lines in the merge-queue lane but 21515 twice
on the push lane — a >=45-line same-commit spread over a 20-line
tolerance, redding main on noise (run 31208592262). Recapture both
fields from that run's own gate output and size tolerance_lines to the
measured cross-lane wobble.

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

* fix(ci): cover the host-managed prompt composer and derive per-entry tests

Review findings on #7371: the host_managed_ports pair (prompt.rs drives
the InstructionBundleBuilder, model.rs shapes the pinned request) was
missing from the prompt-surface table — the exact gap class the mapping
exists to close. And the self-test enumerated entries by hand, so a new
entry could ship untested. Add the host_managed_ports prefix and derive
the positive cases from the tables themselves; an entry whose crate the
fixture lacks now fails the suite explicitly instead of skipping.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:26:41 +00:00
Henry Park
cae1a04f95 feat(sandbox): add explicit Docker and Railway user sandbox profiles (#7214)
* feat(sandbox): add Docker and Railway user sandbox profiles

* fix(sandbox): close local review gaps

* test(sandbox): cover production local profile wiring

* fix(ci): schedule sandbox paths and classify tests

* fix(sandbox): bound Railway user lifecycle state

* test(cli): cover sandbox profiles in profile list

* test(architecture): shrink composition support debt

* fix(sandbox): harden Railway lifecycle review gaps

* fix(sandbox): fail closed on Railway provider errors

* fix(sandbox): close remaining review gaps

* test(sandbox): remove Railway timing flake

* chore(composition): record sandbox assembly budget

* fix(sandbox): retain Railway parse causes

* fix(sandbox): address post-merge review findings

* fix(sandbox): close railway lifecycle review gaps

* fix(sandbox): retain failed railway cleanup state

* feat(sandbox): enable direct egress for sandbox profiles

* test(sandbox): keep public egress in live canary

* chore(sandbox): document bounded slice safety

* test(architecture): ratchet user sandbox contract growth

* test(sandbox): strengthen profile regression coverage

* test(sandbox): exercise harness validation at boundary

* test(sandbox): place setup check with harness contracts

* fix(sandbox): preserve nonzero Docker exit results

* fix(host-runtime): resolve hosted tenant workspace mounts (#7214)

* fix(sandbox): address review feedback on bounded startup (#7214)

* fix(ci): derive sandbox Docker paths from crate inventory (#7214)

* fix(sandbox): finalize Railway lifecycle and profile parity

* test(sandbox): update hosted process failure contract

* fix(sandbox): address CodeRabbit review feedback (#7214)
2026-08-08 00:18:56 +00:00
Benjamin Kurrek
90f5532fcb feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted (#7157)
* feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted

Re-landed PR #7157 on current main (aa8b748c3967f33aa4, across the WS2
composition inversion, the WS6 crate renames, and the WS7 family moves), with
all 44 review comments dispositioned.

Two-lane delivery model: a run's final reply always lands in its own
conversation (lane 1); reaching any other surface is the model's explicit
`builtin.outbound_deliver` call (lane 2 — bot identity, one catalog target
per call, synchronous through the DeliveryCoordinator, provider-issued
message refs as evidence). Background-run notices fan out to a user-configured
notification-channel set (new record field + read-side legacy migration,
`builtin.notification_channels_set`, first-approve-wins, WebUI multi-select).
The stored delivery heuristics are deleted (route_current, builtin:web_app,
outbound_delivery_target_set, per-trigger delivery_target_id + precedence
chains + four-slot preference fallback), with an idempotent boot migration of
stored trigger targets into explicit prompt steps and the retired vocabulary
pinned in reborn_retired_taxonomy.rs.

Port notes (old → new homes): ironclaw_reborn_composition→app/ironclaw_composition,
ironclaw_product→product/ironclaw_assistant, first_party_extensions→extensions/packages,
run-profile vocabulary→ironclaw_loop_contracts, PreferenceTargetCodec→
ironclaw_extension_contracts, wire DTOs→ironclaw_product_contracts::product_wire.
The model-delivery implementation moved extension_host→assistant
(CoordinatedModelChannelDelivery) — the WS2 port inversion forbids extension_host
naming product types; the deferred-slot registration and post-coordinator bind now
live in composition's production assembly, mirroring TriggeredRunDeliveryDriver.

Re-folded 2026-08-05 onto b2023bc8f (#7258): channel-adapter vocabulary
re-imported from ironclaw_extension_contracts, product-adapter/inbound
vocabulary from host_api/product_contracts, module-charter map's outbound
row renamed to the two-lane vocabulary. Post-branch CI gates adapted in
the same change: skills/ classified in the PR test planner (test-first,
sabotage-verified), panic baseline ratcheted down, nested test fixtures
renamed to the scanner-sanctioned support_tests.rs shape, composition's
inline trigger-migration tests split to tests.rs (mass budget green with
no ceiling raise), extension_contracts size ceiling 7727 -> 7748 (+21:
the ActivePreferenceTargetCodecs port), loop_contracts ceiling
re-captured down 14479 -> 13850 after the delivery-vocabulary deletion.

Third fold 2026-08-05 onto b72d7da66 (#6831, standardized messaging
framework): the two-lane guidance moved into the canonical messaging core
prompt (host_api prompts/messaging/send_message.core.md), now naming
builtin__outbound_deliver with the arrive-twice and trigger caveats for
every messaging extension; slack vendor addendum/manifest taken as #6831
shipped them; ceiling-table union (host_api 18570 beside this PR's two
re-captures); retired slack schema embed and deleted preferences
capability stay deleted; golden context-surfacing snapshot regenerated
(one surface-hash line).

Fourth fold 2026-08-06 onto c69ed2d70 (#7263 program-closure batch +
sibling fixes): ceiling-table union (product_contracts 15685 from #7230
beside this PR's re-captures) and main's tracing-target syntax sweep
(target = -> target:, gate-enforced) applied over this PR's kept lines;
deleted delivery-heuristic code stays deleted.

Fifth fold 2026-08-06 onto 0c297cb24 (#7264 guidance-layer sweep):
zero conflicts; guidance/doc-pointer changes auto-merged over this delta.

Routing-UX slice 2026-08-06 (product thread + follow-ups): result routing
is prompt-owned with a pinned source-surface default (bare "send me" =
the surface you asked from; web app = no delivery step; explicit
destinations override, one delivery step each) — iterated against live
recordings until a real model followed it, with two live-recorded QA
fixtures (bare-webui, multi-channel) plus contracts and replays. The
automations-page panel is retained as the notification-channel selector
(notices only); the conversational notification_channels_set tool writes
the same validated set.

Delivery-evidence fix (theredspoon's flag; #7029 fixes the same swallow
on main): mark_terminal reports whether the durable write committed and
a confirmed send whose Delivered row failed to commit returns
DeliveredUnconfirmed (refs retained, durably_recorded: false), never a
fabricated Delivered — regression-tested and sabotage-verified. Plus a
CodeRabbit triage batch: correctable coordinator errors stay
model-visible, omitted target_ids no longer clears the set, the success
schema requires evidence, the composition outbound facade is dissolved,
and guidance/contract docs are aligned.

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

* fix: harden channel delivery routines and replay

* fix: preserve automation loading and identity freshness

* fix: close channel delivery review defects

* fix: skip paused routine catch-up slots

* ci: record channel delivery composition budget

* test: align composition baseline with channel delivery

* fix(auth): survive interrupted OAuth callbacks

* fix(auth): keep callback coordination panic-free

* fix(ci): reconcile channel delivery merge seams

* fix(ci): recapture merged contracts ceiling

* fix(delivery): close review findings across delivery, migration, and guidance

Fixes the findings from the multi-agent review of this PR. Every behavioral
fix ships with a regression test that fails before it.

CI (red on this head)
- `standalone_yolo_notification_channels_set_bypasses_approval_gate`
  expected the shared "invalid outbound delivery request" summary for a
  `builtin__notification_channels_set` call. Production deliberately
  specializes that message per operation and pins it with
  `notification_channel_failure_names_the_operation_the_model_can_correct`;
  the assertion was the stale side.

Delivery evidence (kernel + assistant + outbound)
- `AlreadyDelivered` replays reported `delivered: false` "unverified"
  because the ledger row retains no provider refs, inviting the duplicate
  resend the at-most-once claim exists to prevent. Evidence gained
  `already_delivered`; a replay now reads as delivered with an honest
  "not resent" summary. The classification suite had no `AlreadyDelivered`
  case at all, which is why this shipped.
- `DeliveredUnconfirmed` is the one non-`Delivered` outcome that actually
  sent something, but `delivered_messages_from_outcome` dropped its refs,
  so gate reply-routes went unrecorded and a live OAuth prompt could never
  be retracted on that path.
- `content` is now rejected when empty: the input schema advertised
  minLength 1 and nothing enforced it, so empty content reached the channel
  as an empty part and returned an opaque provider error.

Background-run notifier (assistant)
- One run legitimately emits several `RunBlocked` notices (re-auth
  stand-in, unserviceable-auth cancellation, run failure), but all three
  derived the same projection ref, and the delivery id hashes it. The
  second notice to a target came back `AlreadyDelivered`, was treated as
  success, and was never sent — a user could be told a routine needed
  re-authorization and never told it then failed. Notices carry a
  discriminator; once-per-run kinds keep their historical id shape, so
  existing delivery identities are unchanged.
- When every catalog lookup failed, the empty result was recorded as
  `NoDefaultConfigured`, reporting a backend outage as the benign "user
  configured nothing" state. It now records `Failed`.

Boot migration (composition)
- The retired `builtin:web_app` target meant "no external delivery". It was
  being rewritten into a delivery step to an id nothing can resolve,
  inverting the stored intent on every later fire. It now clears without
  adding a step.
- One unmigratable row aborted the entire composition boot, with the error
  telling the operator to shorten a prompt through the UI that no longer
  starts. It now pauses its own routine — a paused trigger cannot fire, so
  "never fire unrouted" still holds per record — and boot continues. Only a
  systemic store failure stays boot-fatal. A row deleted during the CAS
  retry ends that record instead of failing boot.
- The CAS retry loop, its bounded exhaustion, and the vanished-row arm had
  no caller-level coverage; adds a delegating repository double that forces
  CAS misses. The prior fail-closed test is rewritten to pin the invariant
  it documented (route survives, record not half-migrated) under the new
  per-record mechanism.

Model-visible messages (composition)
- The targets-list denial said "not permitted to change the outbound
  delivery target" for a read-only call, and the lease denial named the
  retired delivery-target concept on the notification-channel path that is
  its only production caller. Both are now operation-specific and pinned.

WebUI (frontend)
- `setNotificationChannels()` with no argument posted `target_ids: []`,
  turning an omitted argument into a destructive clear-all and defeating
  the backend contract that deliberately rejects an omitted field.
- The notification-channels panel stayed editable after a failed read, so
  toggling one row full-replaced the stored set from an empty baseline and
  silently dropped every channel the user never saw. Editing is now locked
  on a failed read, with a rendered explanation.
- Adds the missing `tools.description.builtin.notification_channels_set`
  key to all 11 locales, plus save-failure coverage for the hook (which was
  correct, but untested) and locale-parity tests.

Guidance
- The new `.claude/rules/tools.md` was ported from a pre-restructure branch:
  it named `ironclaw_dispatcher` (deleted) and `ironclaw_extensions` (never
  existed), and its review command grepped three paths removed by WS6/WS7.
  Its `paths:` frontmatter also never matched the product/composition
  callers its rules govern, so the rule never loaded for them.
- `ironclaw_loop_contracts` now records both embedded prompt assets; this
  PR added a second one while the crate's Known-debt entry still said one.
- Bumps `skills/delegation` (rewritten guidance, unlike its two siblings in
  this PR which both bumped) and fixes a pre-rename path in the
  extension-runtime checklist.

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

* fix(delivery): unify the DM-rule enforcement point and re-ratchet composition

CI (composition mass budget, red on the previous head): the per-record
migration quarantine pushed composition 6 LOC over its absolute ceiling.
Resolved by the reduction the budget file itself blesses rather than a
raise — `runtime/approval.rs`'s 417-line inline `#[cfg(test)]` module split
verbatim into `runtime/approval/tests.rs` (the gate excludes test-only
files but counts inline test modules). Composition is now 40,432 LOC,
smaller than before these fixes, and `loc_ceiling`/`loc_observed` plus the
arch-test record are re-captured together at the measured value per the
gate's one-directional ratchet rule.

The codec-scan that decodes a binding and enforces "an OAuth authorization
URL only ever lands in a personal DM" existed twice — once in
`TriggeredReplyTargetAuthority`, once as `CodecChannelTargetResolver` —
with both copies commented as "the single enforcement point". They are now
one implementation, shared by the notifier and `builtin.outbound_deliver`,
with a context label so each path keeps its own diagnostic.

That rule turned out to be UNGUARDED: sabotaging it (`if false && ...`)
failed no test in the crate. The vendor codecs pin the predicate in
isolation and the coordinator test pins rejection handling with a double
that decides the verdict itself, so nothing covered the wiring that joins
them. Adds a contract test driving the real resolver through
`DeliveryCoordinator::deliver` for both verdicts, asserting a non-DM target
never reaches the vendor adapter. Sabotage-verified: the test fails with
the rule disabled and passes with it restored.

Smaller findings: the notification-channel schema cap now derives from
`ironclaw_outbound::NOTIFICATION_TARGETS_CAP` instead of hand-mirroring
`8`; `triggered_run_delivery`'s module and trait docs described the retired
result-push model this PR deletes; the two new notification strings used a
different brand spelling and dash style from the nine siblings in their own
module; and several new comments navigated by pre-rename paths
(`ironclaw_product::`, `local_dev::`, `crates/ironclaw_webui/`) plus a
citation of a test symbol that does not exist.

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

* fix(outbound): scope the delivery catalog to the authenticated actor

`builtin.outbound_deliver` resolved its destination catalog under
`ResourceScope.user_id` while performing the send as
`authenticated_actor_user_id`. Those are the same user on a personal thread
and on an automation fire, but they diverge on a shared-route channel
conversation: the scope user is the route's SUBJECT
(`TurnScope::explicit_owner_user_id`) and the actor is whoever sent the
message. Any participant of such a channel could therefore name the
subject's target ids and push bot-identity content into the subject's own
destinations — their personal DM included — from a conversation the subject
may never read.

The catalog now follows the actor, so a caller stays inside their own
connected surfaces on every path and an unfamiliar target simply does not
resolve. Behavior is unchanged wherever owner and actor already agree,
which is every non-shared-route path.

Regression test drives the divergent case through the port (participant
denied with `TargetUnavailable`, nothing reaching a vendor adapter) plus a
control proving the owner's own delivery still works. Sabotage-verified:
restoring owner-scoping fails it.

NOT changed here, and flagged for a product decision: the sibling
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derive their caller from the same
owner-preferring `effective_user_id`, so on a shared route a participant
can still enumerate — and, with the approval gate auto-approved, rewrite —
the subject's notification channels. That helper also scopes approval
gates and capability leases, so flipping its precedence risks breaking
approval raise/resume matching in a path no test covers; it needs its own
change with that coverage.

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

* fix(delivery): stop rewriting the DM-target row on every message

The post-admission backfill calls `FilesystemChannelDmTargetStore::upsert`
for every admitted inbound direct message, and the store unconditionally
wrote a fresh row. After the first message the stored record is already
correct, so the steady state was one durable backend write per DM message,
forever, whose only effect was a new `updated_at` — and each message's
reply-delivery observation was serialized behind it. An unchanged record
now short-circuits; the existing row is loaded here anyway to preserve
`created_at`, so the comparison costs nothing.

Also adds the regression test the `NoDefaultConfigured` -> `Failed`
classification fix landed without: the notifier's `SkipEntry` lookup lane
had no coverage at all (no test ever made a catalog lookup error), so
neither the skip nor the all-failed arm was exercised. The triggered
harness gains an injectable catalog provider for it. Sabotage-verified.

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

* fix(loop): bound the connected-channels line so the runtime slice fits

Confirmed live, not theoretical: a worst-case runtime context renders 4,391
bytes against the 4,096-byte `PromptTextSurface::SafeSummary` cap that
`instruction_bundle::push_runtime_context` validates the whole slice on —
and exceeding it is a run-ending error on EVERY prompt build for that user,
not a one-off.

This PR's fixed ~1.1 KiB delivery-guidance block is what pushes a
previously-fitting context over. The individual parts are each bounded
(location 200 chars at its producer, locale 35, per-label safe-text
validation), but nothing bounded their SUM, and the connected-channels line
is the one part that grows without limit: up to 20 entries whose names and
presentation hints are only individually capped.

That line now renders as many channels as fit a 1 KiB budget and folds the
rest into the "+N more" counter it already carried, so the fixed guidance
can never be squeezed out by variable content. The worst-case test that
found this stays as the pin.

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

* fix(outbound): resolve outbound capabilities as the acting user

`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derived their caller from
`effective_user_id`, which prefers the thread owner over the actor. Those
agree on a direct message and on an automation fire, but diverge on a
shared-route channel conversation, where the owner is the route's
configured subject — the deployment operator by default
(`channel_workflow.rs`) — and the actor is whoever posted.

So any participant of a shared channel could enumerate the operator's
connected destinations and rewrite the operator's notification-channel set,
which is where approval prompts, re-auth prompts and failure notices are
delivered. The caller now follows the acting user, matching the fix already
applied to `builtin.outbound_deliver`.

This deliberately REVERSES a previously pinned preference. Two tests
asserted the owner won when the two differ; that pin predates shared-route
subjects defaulting to the operator, and it contradicts the rule that a run
acts as whoever invoked it. Both are updated to pin the actor, with the
reversal recorded at each site rather than silently relaxed, and the
notification-channel write is now asserted to land under the acting user
with the thread owner's own set left untouched.

INTERIM, by design: `resource_scope_for_run` and `settings_scope_for_run`
still follow the owner, because they scope the approval-gate raise and the
capability lease and those must stay matched between raise and resume.
Unifying them belongs with the follow-up that removes shared-route subject
binding entirely so a shared channel runs wholly as its invoker; that needs
approval raise/resume coverage which does not exist yet. A new test pins
the split so the interim state is explicit rather than accidental.

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

* fix(ci): keep loop_contracts under its size ceiling and ratchet down

The runtime-context byte-budget fix and its worst-case pin pushed
`ironclaw_loop_contracts` to 14,032 production lines against a 13,949
ceiling. Resolved by the reduction the gate prefers over a raise:
`runtime_context.rs`'s 919-line inline `#[cfg(test)]` module split verbatim
into a `runtime_context/tests.rs` sibling, which `production_rust_files`
excludes (an inline test module inside a production file is counted; a
test-only file is not).

The crate now measures 13,115 — 834 lines below the previous ceiling and
smaller than before this review round — so the ceiling is re-captured
downward at the measured value rather than raised, per the gate's
one-directional ratchet. Count read from the gate's own failure message,
not by eye.

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

* fix(ci): chart the notification-channel handlers in the WebUI charter map

`handlers_module_charter` failed: `get_notification_channels` and
`set_notification_channels` — handlers this PR adds — had no sub-owner row
in `CONTRACT.md`'s enforced charter map, and the row they belong to still
named `get_outbound_preferences`, `set_outbound_preferences` and
`outbound_preferences_activity_id`, all deleted by this PR.

Both halves are fixed together because the gate checks both in one test:
unclaimed items first, then entries naming items that no longer exist. Only
the first had fired, so the stale half was still latent behind it.

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

* fix(ci): re-capture the loop_contracts ceiling after main's merge

Main's #7361/#7363 landed 66 lines in
`ironclaw_loop_contracts/src/instruction_bundle.rs`, which this branch picked
up when folding onto main. That put the crate at 13,181 against the 13,115
ceiling re-captured earlier in this PR.

Not growth from this PR. The gate's upward check is a hard `lines > ceiling`
with no headroom — `TOLERANCE` (400) governs only the downward
ratchet-nudge — so a ceiling captured at the exact observed value reddens
every open branch the moment anyone adds a line to that crate, including
from main. Re-captured at the measured value; count read from the gate's own
failure message.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:39:07 +00:00
firat.sertgoz
5888190ca6 fix(json): add bounded collection analysis (#7339)
* feat(json): add bounded collection analysis

* test(reborn): refresh JSON capability snapshots

* fix(json): address review — exact integer aggregates, bounded errors, dedup helpers (#7299)

* test(reborn): refresh reviewed JSON snapshots

* test(reborn): restore scoped JSON root query

* ci: retrigger Railway preview deploy

---------

Co-authored-by: firat <>
2026-08-07 13:00:37 +00:00
Benjamin Kurrek
8b32989d0a Guidance unification: one canonical home per fact, a measured loader story, and a gate that keeps it true (#7306)
* ci(guidance): add check-guidance.py — guidance must reference the tree that exists

Four mechanical drift classes become build failures: every repo path named
by agent guidance (root AGENTS.md/CLAUDE.md, crates/** AGENTS/CLAUDE/
CONTRACT/README, .claude/rules/*.md, .claude/skills/*/SKILL.md) must
resolve in the tracked tree; every rules/skills frontmatter paths: glob
must match at least one tracked file (the dead-trigger class that let
skills.md never fire); every crate directory appears in its family's
AGENTS.md crate table (the guidance half of check-target-tree.py); and
every crate has a README.md (measured 62/62, so it gates).

Extraction is designed against false positives: fenced blocks, placeholder
tokens, MCP method names, dated-correction (✎) lines, and
'check-guidance: path-ok' lines are not claims; resolution honors the
citation forms measured on the live tree (root-relative, doc-relative,
name-prefix, crate-qualified-by-context, module-relative within the citing
crate). KNOWN_MISSING is a shrink-only suppression table — a row whose
reference stops dangling fails the gate until deleted, and surviving rows
print as warnings every run.

Fails closed on unreadable files, unparseable frontmatter, broken crate
discovery, and near-empty scans (floor constants). Self-test in
test-check-guidance.py (23 cases, refusals first, real repository last),
wired beside check-target-tree.py in code_style.yml; the test planner
classifies all three paths as static-control (verified exit 0).

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

* docs(guidance): consolidate crate-tier CLAUDE.md files; rename module specs to CONTRACT.md

Steps 2+3 of the guidance unification (docs/reborn/guidance-conventions.md):

- Rename the four Module Specs table specs CLAUDE.md -> CONTRACT.md (llm,
  filesystem, webui, composition), matching the identity/trust precedent.
  Charter gates repointed (llm module_charter, webui handlers_module_charter)
  and every live reference updated; pointer stubs left behind so tooling that
  loads CLAUDE.md still lands on the spec.
- Fold the nine substantive out-of-table CLAUDE.md files: wasm, mcp, sandbox,
  auth, assistant, trace_commons, extension_manager become AGENTS.md-canonical
  (gates repointed with pinned phrases kept verbatim: the wasm_sandbox_core
  arch pin, mcp module_charter, auth module_charter, assistant
  reborn_services_module_charter); network and secrets fold into their README
  Invariants sections and drop the crate guidance pair entirely.
- Mark with the convention's absence-claim annotation the five crate-tier
  lines grandfathered by check-guidance KNOWN_MISSING (llm CONTRACT.md x3,
  composition CONTRACT.md, hooks AGENTS.md) and mark trace_commons'
  prescribed tests/queue.rs mirror as prescriptive-future.
- tests/CLAUDE.md: replace the retired root Current-Limitations citation with
  the measured ironclaw_observability description.

End state: zero prose CLAUDE.md outside the Module Specs table at the crate
tier (the four ironclaw_agent_loop src/tests directory guides stay, same
footing as the tests-tree harness guides).

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

* docs(guidance): unify the root pair — AGENTS.md canonical, CLAUDE.md adapter

Step 1: root AGENTS.md (198 lines) and root CLAUDE.md (286 lines) shared zero
identical lines — the forked-pair drift the guidance convention forbids at
crate level, live at the root. Root AGENTS.md is now the canonical
tool-neutral contract (build/run/debug commands, hard invariants including
the unified extension model and the credential_name/extension_name identity
rules, the Module Specs table — now uniformly CONTRACT.md and gaining the
existing ironclaw_trust/CONTRACT.md row — testing discipline, tree map,
discovery, change discipline; 152 lines). Root CLAUDE.md is an @AGENTS.md
adapter plus the genuinely Claude-specific tail: skills/rules index,
codebase-graph MCP recipes, and the REPL info!/warn! logging rule (51 lines).

Cut while merging, each measured against the tree: the v1 Job State Machine
(no such state machine exists under crates/), Current Limitations (stale —
the observability claim no longer matches the crate), the Skills System
section (.claude/rules/skills.md and the domain crate own it), Extracted
Crates, the re-derivable key-traits list, and the long channel-onboarding
narrative (now three lines pointing at crates/extensions/AGENTS.md and the
worked slack example).

Every live citation of the root pair's moved sections is repointed (crates/
routing map + README, the deslop-reborn command, types/type-placement rules,
skills/common/config crate docs, a loop_host doc comment). The git-ignored
.codebase-memory/artifact.json mention carries the absence-claim annotation
for the check-guidance KNOWN_MISSING handoff.

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

* docs(guidance): make CLAUDE.md a symlink to AGENTS.md at every tier

The loader question is now measured, not assumed. Headless canary experiment
with a discriminating control: a symlinked nested CLAUDE.md's target content IS
injected when a file in that directory is read, an @AGENTS.md import inside a
nested CLAUDE.md also expands, and a nested AGENTS.md alone is NOT read. So one
uniform rule holds everywhere: wherever an AGENTS.md exists, CLAUDE.md sits
beside it as a symlink — same bytes, zero maintenance, no second document to
drift.

64 pointer stubs become symlinks. The four spec crates keep CONTRACT.md as
canonical; their AGENTS.md routes there, so the spec stays one hop away while
the working rules now auto-inject instead of costing a voluntary read.

Also reconciled check-guidance.py's shrink-only KNOWN_MISSING table: all 8 rows
deleted because the content pass fixed the underlying lines, and the three
absence-claims the gate then surfaced carry markers. The table is empty.

Caveat recorded for the convention: nested injection fires only below cwd, and
appears not to fire in subagent sessions — family docs must stand alone when
read deliberately.

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

* docs(guidance): amend the convention with measured loader mechanics and budgets

The first version made crate AGENTS.md canonical and CLAUDE.md a pointer, which
moved working rules out of Claude Code's auto-inject path. Records what was
measured instead: subtree CLAUDE.md injects lazily, symlinks and @imports both
carry content, nested AGENTS.md is not read natively, and injection does not
fire in subagent sessions — so every doc must stand alone when read deliberately.

Adds size budgets per tier, extends scope to .claude/rules and .claude/skills
(where the worst drift was), names check-guidance.py as the enforcement with its
suppression markers, warns that some guidance is test-parsed (including the
heading-shadowing trap), and adds the remove/rename checklist that mirrors add.

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

* fix(guidance): composition keeps a real CLAUDE.md, not a symlink

The blanket symlink pass broke composition_root_embeds_no_prompt_content, and
the gate is right to refuse: its ownership walks do not follow symlinks, so
stepping over one would let it report clean on a subtree it never read. This
crate keeps a regular pointer file, with the reason written in the file so the
next person does not 'fix' the inconsistency back into a break.

The uniform alias rule now has two stated exceptions: the root (real file, it
carries a Claude-only tail) and composition (real file, this gate).

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

* ci(guidance): enforce the CLAUDE.md alias rule; scope path-ok to the marked reference

The branch's central invariant — a `CLAUDE.md -> AGENTS.md` symlink beside
every AGENTS.md at the root and under crates/ — was unguarded: the audit
proved a committed symlink deletion left the gate green (a working-tree
deletion only tripped the accidental "cannot read guidance file" refusal).
Check 5 now judges the git index (`git ls-files -s` + `cat-file`): the
alias must be tracked, mode 120000, targeting exactly `AGENTS.md`. The two
real-file exceptions are named rows with reasons (the root adapter's
Claude-only tail; composition's symlink-refusing ownership walks), and a
row that stops matching the tree fails the gate rather than lingering.
Sabotage-verified on the real tree: `git rm --cached` on an alias went red
naming the pair; converting one to a tracked regular file went red;
restore went green (65 aliases verified).

Also from the audit:

- A `path-ok` marker now vouches for the one reference immediately
  preceding it instead of exempting its whole line — the audit slipped a
  fresh dangling path onto a marked line and passed. The `✎` glyph stays
  line-scoped by documented design. Both in-tree marker usages already
  sit marker-after-reference and keep working.
- Document the structural blind spot: a dead reference whose first
  segment died with its whole tree (the v1 `src/…` monolith) reads as
  historical narration and cannot be flagged; only review catches it.
- Re-measure the fail-closed floor comment — the shipped one claimed
  174 guidance files / ~800 references / 30 globs against a tree that
  measures 237 / ~2070 / 38 — and add a floor for alias-site discovery.

Self-test grows six cases: index-deleted alias, regular-file alias,
wrong-target alias, the load-bearing root exception row, exception rows
matching reality, and the marker-narrowing exploit. The `--tracked-files`
override marks symlinks as `<path> -> <target>`.

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

* docs(guidance): repoint dead skill refs, record alias carve-outs, honest size budgets

Content half of the guidance-unification audit fixes:

- architecture-video SKILL.md told readers to read `src/tools/README.md`
  and `src/workspace/README.md` — the v1 monolith is gone
  (`git ls-files | grep -c '^src/'` is 0) and the gate structurally
  cannot flag first-segment-dead paths. Repointed at the Reborn
  successors: `crates/extensions/AGENTS.md` and
  `crates/domains/ironclaw_memory/README.md`.
- guidance-conventions.md now records what only commit messages knew:
  the composition real-file exception beside the root one; the four
  sanctioned ironclaw_agent_loop sub-module CLAUDE.md guides; and the
  alias rule's actual scope (root + crates/**), naming the two
  out-of-scope AGENTS.md (docs/reborn/contracts, ironclaw_silk_decoder)
  instead of a "wherever" wording the tree contradicted.
- Size budgets re-derived from measurement (family <=220, crate <=160)
  with the four crate-tier exceptions named and reasoned. The shipped
  <=150/<=80 numbers were exceeded by 3 family and 24 of 54 crate docs
  on day one, which made the budget unreadable as a signal. No document
  was padded or truncated to fit.
- Root CLAUDE.md used the dated-correction glyph on the deliberately
  untracked `.codebase-memory/artifact.json` reference — suppression
  duty outside the glyph's documented historical-prose meaning. Swapped
  for `<!-- check-guidance: path-ok -->` beside the reference.

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

* review(7306): CodeRabbit triage — guidance gate runs for the files it governs, brace globs, honest floors, identity-column family tables, doc-truth fixes

Trigger (Major, the inert-guard finding): fast-checks was gated on has_code,
whose regex covers none of .claude/, the root AGENTS.md/CLAUDE.md pair, or
docs/ — so a PR editing only a rule's paths: trigger skipped the gate built
for exactly that change. New has_guidance output OR-s those surfaces into
fast-checks only (clippy/JS lanes stay code-scoped); has_code keeps its
pinned meaning. Pinned by a ws12_workflow_contracts.py row and verified by
replaying representative change lists through the workflow's own extracted
EREs.

check-guidance.py: glob_to_regex now translates {a,b} brace alternation
(nested; unmatched braces stay literal) so a legitimate crates/**/*.{rs,toml}
trigger counts as live instead of being reported dead; MIN_RULE_GLOBS 1->20
and MIN_ALIAS_PAIRS 10->40 (~half of measured 38/65, so a degraded parser
refuses instead of passing); family-table coverage now requires the crate in
a row's identity (first) column — an incidental mention in another row's
prose no longer counts (measured 0 regressions on the live tree). Self-tests:
+3 (brace trigger end-to-end, duplicate KNOWN_MISSING rows, identity-column
regression) and the real-repository case documents its deliberate git
coupling. Floors sabotage-verified.

Doc truth, measured against code: composition CONTRACT — WS stream shares
SseCapacity (stream_events_ws try_acquire, pinned test) replacing 'No WS
surface to bound', webui_v2_app returns Result<Router, WebuiServeError>;
llm CONTRACT — the circuit breaker wraps failover (apply_decorator_chain
order), not the reverse; filesystem CONTRACT — dependency rule now names the
real manifest set (+libsql_runtime, +observability); extension_manager
AGENTS — the loops layer flip landed (layer = "loops"); four stale 'has no
CLAUDE.md' claims updated for the new symlink aliases (config, common,
event_store x2); root AGENTS — clippy line gains -- -D warnings (CI denies
warnings; unflagged clippy exits 0 with them) and the error bullet routes to
.claude/rules/error-handling.md; assistant/webui validation sections document
the real lane structure (self-dev-dep unifies test-support on, so the missing
shape is the no-dev-deps production lane, the #7119 class).

Stale pre-family paths in .rs prose: 594 crates/ironclaw_* citations
measured; 130 sit in comments, of which 106 repointed to their family homes
(every rewritten path verified to resolve), 10 of those needed deeper
repoints (files that moved crates: capability_host.rs, channel_pairing.rs,
approval_store_contract.rs, secret_store.rs, loop_contracts
instruction_bundle.rs, assistant communication_context.rs, loop_host
surface_disclosure.rs, resolver_tests.rs), 24 left deliberately (flat-
spelling narration about the family move itself, deleted-crate history,
synthetic fixture names, and two #6945-class pointers whose target is gone
at every spelling). 464 string-literal citations left: the specificity
test resolves legacy spellings through the crate inventory by design.

Triage of PR #7306 review comments; no gate weakened, both alias
exceptions preserved.

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

* review(7306): drop the one comment repoint in tests/e2e_trace_runtime_policy_org_ceiling_yolo.rs

reborn_pr_test_plan.py has no mapping for this root test (it matches
neither the tests/reborn_* partition inventory nor any other arm), so ANY
PR touching it fails 'Detect Reborn test scope' — a pre-existing planner
gap, confirmed against origin/main with a one-file changed list. The stale
crates/ironclaw_runtime_policy comment path inside it stays until the
planner learns the file; noted for follow-up rather than smuggling planner
surgery into a review-triage branch.

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

* docs(contributing): stop annotating the loose iteration clippy line as 'zero warnings'

Same class as the root AGENTS.md fix: unflagged clippy exits 0 with
warnings, so the annotation overclaimed. CONTRIBUTING's two-tier design
(loose iteration block, then a stricter pre-PR block that already carries
-- -D warnings) is deliberate and stays; only the claim is aligned.

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

* docs: recount the frozen WebUI route table after the #7306 merge — 93 -> 97

#7236 (main) added the four operator inspector routes without bumping the
stated counts; re-derived on the merged tree:
rg -c 'pub const WEBUI_V2_ROUTE_' crates/product/ironclaw_webui/src/webui_v2/descriptors.rs -> 97.
Updates the two live claims (webui README, PROPOSAL SS6.9.4 with its
strike-through recount convention); historical/superseded 92-row mentions
stay as written.

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

* fix(ci): the code-style roll-up must judge fast-checks for guidance-only PRs

The has_guidance trigger made fast-checks RUN for .claude/ and root-pair
changes, but the roll-up's has_code==false branch exits 0 before it ever reads
fast-checks' result — so check-guidance.py could fail and Code Style would
still report success. The gate ran and could never block: exactly the
inert-guard shape this change exists to remove, reintroduced one layer up.

Fixed the way main's docs-publication gate already does it — judged before the
early exit, with the reason in a comment. Sabotage-verified: has_guidance=true
plus fast-checks=failure now exits 1.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:18:15 +00:00
Benjamin Kurrek
a849373c41 fix(slack): restore personal delivery and standardized canaries (#7300)
* fix(qa): align Slack canaries with messaging standard

* fix(slack): restore recency-ordered message search

* fix(slack): retain provisioned personal DM targets

* test(slack): cover default sort and DM scope resolution
2026-08-07 12:15:00 +00:00
firat.sertgoz
4a11fc832a fix(capabilities): unify disclosure and enforcement policy (#7233)
* fix(capabilities): unify disclosure and enforcement policy

* fix: share resolved capability surface with host

* fix: address capability surface review feedback

* fix: map golden snapshots to Reborn test lane

* refactor: unify visible surface filtering

* test: enforce filtered batch outcome contract

* fix: validate filtered batch suspension state

* test: pin golden snapshot lane ownership

* test: avoid artifact mapping order dependency

* fix(review): address remaining PR feedback
2026-08-07 08:08:59 +00:00
Josh Ford
50311eab44 docs: enforce the docs/ publication boundary (frozen .mintignore + CI gate) and consolidate internal docs under docs/internal/ (#7259)
* docs: enforce the docs/ publication boundary with a frozen .mintignore and CI gate

docs/ mixes the public Mintlify site with internal engineering docs, and
omission from docs.json navigation is not a publication boundary: a page
left out of navigation is still deployed, reachable by URL, and indexable.
docs/design/ and docs/research/ were never added to docs/.mintignore, so
both internal docs have been served as hidden pages on the public site.

Close the gap and the process hole behind it:

* Move docs/design/ and docs/research/ under docs/internal/, the one
  growing home for internal material — new internal docs now land inside
  the fence by default instead of requiring a .mintignore edit.
* Freeze docs/.mintignore: scripts/ci/docs_publication_boundary.py rejects
  any new entry (legacy directories stay listed until consolidated into
  internal/; entries may only be removed).
* Gate in CI (Code Style): every .md/.mdx under docs/ must be in docs.json
  navigation, matched by .mintignore, or carry `hidden: true` frontmatter
  marking a deliberately unlisted public page; navigation entries must have
  a source file. The gate has its own has_docs trigger because docs-only
  PRs skip every Rust lane, and it is checked in the roll-up before the
  has_code early exit so it blocks docs-only PRs too.

Regression coverage: scripts/ci/test_docs_publication_boundary.py (16
cases, run by the CI job before the check; one pins the real docs/ tree as
clean). Red/green verified: the checker flagged exactly
docs/design/agent-activity-streaming.md and
docs/research/pi-agent-deep-dive.md before the fix and passes after.

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

* docs: relocate legacy internal doc directories under docs/internal/ — text-only

Move plans/, superpowers/, qa/, adr/, architecture-video/, and
reborn-binary.md from docs/ into docs/internal/, and rewrite every repo
reference to the old paths (guidance files, script and workflow comments,
Rust doc comments, the render-architecture-video VIDEO_DIR, the
architecture-video skill, .coderabbit.yaml). Behavior unchanged: all
references to these directories were textual except the video script's
VIDEO_DIR, the skill paths, and the .coderabbit.yaml ignore, which are
updated in step.

docs/reborn/ deliberately stays put: its path is load-bearing
(ironclaw_capabilities and ironclaw_architecture_tests read contract files
from it at test time, and reborn-e2e.yml scope filters match it — pinned
by scripts/ci/ws12_workflow_contracts.py). It consolidates into internal/
in a follow-up when those consumers can move with it; docs/.mintignore and
FROZEN_MINTIGNORE_PATTERNS shrink to internal/ + reborn/ accordingly.

Verified: docs publication boundary check green, its 16 self-tests green,
ws12 workflow contracts green (46), touched YAML parses, zero references
to the old paths remain outside git history.

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

* test: cover the docs gate's entry point and pin its trigger in ws12 contracts

Fixes the three findings from the multi-agent code review of this branch
(security/bugs/performance/conventions clean; tests reviewer found 3):

* main() was never called by any test — the exit-code contract, the three
  stderr violation blocks, and main()'s MintignoreSyntaxError handling were
  uncovered, so a regression returning 0 despite violations would have
  passed all tests while turning the CI gate into a no-op. Three new tests
  drive main() directly (clean tree, all violation classes, syntax error).
* The has_docs trigger grep and the fail-closed roll-up guard had no pin.
  ws12_workflow_contracts.py now carries a has_docs CrateScopeFilter
  (docs/, the gate's own files, and the workflow in scope; crates and
  README out) plus code_style.yml REQUIRED_MARKERS for the job, both
  steps, and the roll-up guard — with sabotage tests proving narrowing
  the grep or removing a marker fails loudly. Red/green verified.
* is_ignored()'s slash-glob pattern branch (contains '/' but not
  trailing) had no fixture; covered with design/*.md.

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

* test: pin the docs-gate guard's ordering, not just its presence

Fixes the three findings from review round 2 (security/bugs/performance
clean; tests found 2, conventions found 1):

* REQUIRED_MARKERS is presence-only, so relocating the docs-gate roll-up
  guard to after the has_code early exit — the exact silent-skip bug the
  guard exists to prevent — passed every contract check. New
  validate_code_style_docs_guard_order() pins guard-before-early-exit in
  code_style.yml, with a sabotage test that relocates the guard line and
  a checked-in-order pass test. Red/green verified.
* CrateScopeFilterSabotageTests' docstring still described "the three
  remaining crate-keyed filters"; updated for the fourth, non-crate-keyed
  has_docs pin (review-discipline.md: guardrail docs must match the code).
* is_ignored()'s nested-directory pattern branch (a trailing-slash entry
  with an internal slash, e.g. `design/sub/`) never executed under the
  suite; covered by test_mintignore_nested_directory_pattern_fences.

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

* docs: fix relative ADR links missed by the path sweep; align checker hint with the frozen fence

Addresses the Copilot review on #7259:

* The reference sweep rewrote literal `docs/adr` strings but not relative
  markdown links: `../../adr/` in target-architecture/{CHECKLIST,PROPOSAL}.md,
  `../../../adr/` in families/domains.md, and the hooks CLAUDE.md link (which
  also carried a pre-existing wrong depth from the WS7 family move) all
  resolved to the old location. A repo-wide relative-link scan found exactly
  these seven move-caused breaks; the remaining broken links predate this
  branch (WS7 crate-move fallout in testing-playbook.md, one dead June plan
  link) or are Mintlify extensionless links that resolve on the site.
* The checker's remediation hint said "add its directory to docs/.mintignore",
  contradicting the frozen-fence rule the same script enforces; it now directs
  authors to move internal material under docs/internal/.

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

* test: only executable guard occurrences satisfy the docs-gate order pin

CodeRabbit (Major, #7259): validate_code_style_docs_guard_order used a raw
str.find, so a commented-out copy of the guard above the has_code early
exit — a realistic refactor leftover — satisfied the pin while the
executable guard sat below the exit, silently unhooking the gate for
docs-only PRs. The validator now strips comment lines before matching and
requires EVERY live guard occurrence to precede the first early-exit
occurrence; a comment-only occurrence reports the order as unassertable.
New decoy sabotage test red/green verified. Also parenthesized the
implicit string concatenations Ruff flagged (ISC004).

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

* fix(docs): align all Remotion packages to 4.0.499

CodeRabbit flagged the moved architecture-video project's manifest: Remotion
requires every @remotion/* package at one identical version, but dependabot
#6658 bumped only @remotion/cli and @remotion/tailwind-v4 to 4.0.499,
leaving remotion, @remotion/transitions, and @remotion/eslint-config-flat
at 4.0.447 — a pre-existing break on main that surfaced here because the
directory rename presents as a new project. Aligned all five to 4.0.499 and
regenerated the lockfile; `npx remotion versions` now reports all packages
at the correct version.

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

* test: assert both signals in the literal-pattern fencing case

Copilot (#7259): test_mintignore_literal_file_fences used a pattern outside
the frozen allowlist but discarded the `unexpected` result, so it passed
while the checker it pins would fail — a misleading regression pin. The
case now asserts both independent signals explicitly: the literal entry
still fences its file (no publication leak) AND trips the frozen-list rule,
with the interplay documented in the test.

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

* docs: mark the architecture video as stale pre-Reborn content

Copilot flagged the relocated video scenes for citing crates/ironclaw_engine
paths that no longer exist. The scenes are untouched April 2026 content
(#2365) presenting as new because of the directory rename; regenerating
them against the Reborn architecture is deliberately out of scope for this
move-only PR. Until that regeneration happens, a prominent README banner
states what the video describes, why it is wrong today, where current docs
live (openwiki/), and how to regenerate (architecture-video skill) — so the
content cannot mislead contributors in the meantime.

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

* test: probe docs/docs.json in the has_docs scope pin

Copilot (#7259): navigation is half the publication-boundary contract — a
nav-only edit can orphan a page into hidden-page territory or reference a
missing source file — but no has_docs probe covered docs.json, so a future
markdown-only narrowing of the trigger grep (e.g. ^docs/.*\.(md|mdx)$)
would silently skip the gate for nav changes while every existing probe
stayed green.

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

* fix(ci): map the two sweep-touched dev scripts and satisfy rustfmt

Two root causes behind the red CI on #7259, both fallout from the docs
path sweep touching files no docs-only change normally touches:

* reborn_composition_boundaries.rs reads the (moved) composition pub-use
  snapshot; the longer docs/internal/plans/ path pushed the line past
  rustfmt's width. Reformatted.
* The Reborn PR test planner fails closed on unmapped repo-root scripts.
  The sweep touched two local dev tools no workflow invokes —
  check-type-duplicates.py (docstring path) and
  render-architecture-video.sh (VIDEO_DIR path) — mapped both with
  per-file decisions in the planner's established style. Verified by
  running the planner against this branch's full 173-file changed list
  (green) plus its 61 self-tests.

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

* docs: commit the messaging-framework path rewrites dropped by the previous merge

The prior merge commit staged files before running the path sweep, so its
rewrites of #6831's new files (docs/superpowers -> docs/internal/superpowers
in three Rust doc comments, the plan, and standard-operations.md) were left
unstaged and its message wrongly called the sweep a no-op. This commit is
those rewrites.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 00:53:02 +00:00
Benjamin Kurrek
0c297cb240 Guidance layer: a family AGENTS.md for every family, a README for every crate, and a repo-wide stale sweep (#7264)
* docs(target-arch): resolve the await-edge design question by measurement (D-S) and re-walk the WS9 verify row

Appends §12.13 D-S under delegated authority at owner direction, flagged
for post-hoc review by Illia Polosukhin (#6696's author): the await-edge
store is measured to be a pure projection over ProcessDependencyPort
(that half of the shed happened inside #6696 itself), and the resolver
is a genuine loop-tier responsibility journal edges cannot express
(owner recovery, sanitized transcript result materialization, batch-gate
resume-once drain, BlockedDependentRunGate resume policy). §6.7.3 is
amended (scheduler DONE / store DONE / resolver KEEP) instead of the
shed being executed; the 2.9k figure is corrected to 1,459 production +
1,448 cfg(test) lines. The §12.10 bullet, §2 divergence flag, §9 row 49,
§13 validation row, CHECKLIST header/WS4 pointer, README and PLAN all
carry the dated resolution.

WS9 verify row ticked with evidence: one lifecycle authority (the
process journal; TurnRunState/TurnRunRecord are projections via
AgentTurnProcessRuntime, ProcessRecord is a capability-invocation view,
no bare RunRecord exists) and §7 T4 re-walked clause-by-clause against
merged code — matches, including the checkpoint-gated no-auto-retry
mechanism (BeforeModel precedes ModelStage; requeue only when
checkpoint-free under the 3-claim cap).

Docs-only; no code, no tests, no gates touched.

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

* docs(ws12): rows 1-2 — package-set tick (64==64/1/0, gate+selftest+independent rederivation) and the 74-row §9 mapping audit (45 L / 15 L-A / 14 OBD / 0 NOT-LANDED; 3 findings recorded)

Row 1: check-target-tree.py reports 64 workspace members == 64 documented
packages, 1 documented exclusion (tools/ironclaw_silk_decoder), 0 owned
exceptions (EXCEPTIONS table empty — §5 steady state); self-test 17/17;
cargo-metadata name set diffed empty against an independent §5 parse.

Row 2: docs/reborn/target-architecture/ws12-mapping-audit.md is the audit
record — per-row executed-evidence, delete-clauses read against WS8's
execution notes, all 14 open rows cite their owning CHECKLIST/PROPOSAL
row or issue. Findings (recorded, not fixed): F1 prompt_envelope
manifest-description fix has no owner row; F2 WS6:429's '#5618 residue
deleted' overstates vs the live adopt_migrated_identity + open WS8:523;
F3 stale-docs cluster where the tree is ahead of the prose (trace
re-export drop, TurnRunTransitionPort, processes->resources).

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

* fold(7154): squash-port fix/red-main-7119 onto family-world main — defect train #7146/#7115/#7104/#7103/#7144 (+#7119 CI lane), 34-hunk contribution.rs port into the split modules, planner entrypoint classification, D-R loopback exception on the widened HTTPS credential guard

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

* test(extractors): issue-number + assertion-rationale doc refinement (rescued 844964fb8 from rescue/7154-parked-guard)

Ports only the doc/assertion refinement commit; the guard-parking commit
e8f5a31a2 on that branch is deliberately NOT taken — superseded by the
D-R loopback ruling.

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

* docs(target-arch): record D-R — the loopback credential-guard ruling, wiring choice, and regression pins (PROPOSAL §12.13, 2026-08-05)

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

* review(7154): CodeRabbit round-1 triage — fail-closed tracing-target scan traversal (+node_modules), bounded sidecar output draining (capped capture + discard drain), deadlock regression asserts successful redaction (no seq), XLSX/DOCX empty-classification via extract_document, raise_for_status annotations

Threads already addressed by the fold: latency.rs caller-contract wording
(merged doc scopes the requirement to latency-trace callers), BodyJsonPointer
coverage (the plaintext-refusal test drives all four injection shapes).
Deliberately not taken: un-xfailing the four Slack-catalog projections —
the xfail is a documented tripwire (unexpected-pass goes red) and clearing
them is the #6520 projection-modeling follow-on its comment specs.

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

* fix(assistant): re-point the one field-form tracing target the #7146 gate caught — main's relocated triggered_run_delivery_services carried the drift the PR fixed at its old channel_host address

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

* closure fixups: execute the mapping-audit findings — prompt_envelope manifest description (F1), dated ✎ corrections for the #5618 overstatement (F2) and the stale-prose cluster (F3)

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

* docs(ws12): second-reviewer security spot-audit + extension-journey re-verification (rows 5-6)

Adversarial second-reviewer pass over PROPOSAL §12.1a/b/c and the batch's own
§12.13 D-R loopback carve-out, plus a re-run of the five extension journeys.
Attacks were executed rather than argued: two sabotage files and a 38-shape
hostile-URL probe were planted, run, and reverted.

Verdicts — mint consolidation HOLDS-WITH-RESIDUAL, secrets tightening
HOLDS-WITH-RESIDUAL, host/verifier colocation HOLDS, D-R HOLDS. No HOLE.

Four findings recorded rather than fixed (report-not-repair):
- F1 test_verified/_for_tenant are ungranted mint constructors gated only by
  the `test-support` feature, in no mint-name table, with nothing pinning the
  feature to [dev-dependencies]; the shipped binary is measured feature-free.
- F2 §12.1b's products-layer residue undercounts by one (ironclaw_assistant).
- F3 journey coverage hole: gsuite-with-credential-injection is proven in two
  halves that no committed test joins.
- F4 both recorded census evasions and both fail-open reads are CLOSED on this
  tree, so §11.2.5/§12.1a/CHECKLIST:552/:597 now understate the seal.

Rows 5-6 ticked; only lines 631-632 of CHECKLIST touched so the concurrent
rows 3-4 edit folds cleanly.

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

* ratchet(closure): lock the budget gate at the program's end state

Dispatch ceiling 1122 -> 814 (today's observed, nudge taken; WS0 record 827
stays within effective 829). Mass-share ceiling 2398 -> 658 bp (the WS0
baseline floor — the arch-test assert refuses lower, and observed 578 bp sits
inside the nudge window). Absolute LOC re-equalized at 40423: #6831 added 4
governed LOC through the queue's tolerance window; ceiling, observed, and
COMPOSITION_ABSOLUTE_SRC_LOC move together here. Both tightenings
sabotage-verified red (dispatch 9-over at 790; abs 73-over at 40200).

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

* docs(ws12): gauntlet report — row 3 ticked (full gauntlet green, 0 REAL in scope), row 4 verified-but-open on two pre-existing Postgres-leg test-isolation defects

WS12 rows 3-4 verification on the assembled batch tip 0c6c0cfb9d:

Row 3 (ticked): fmt, clippy default/all-features/--lib --bins, workspace
tests (495 targets, 15,203 passed, 0 failed; the smoke.rs:3132
CPU-saturation flake passed first try), arch suite 285/0, the
integration-feature lane 1,665/0, recorded-fixture QA (61 fixtures clean,
41/0), frontend (typecheck 1,588 files; vitest 1,088/0; build + bundle
budgets), e2e smoke = the CI browser lane under the hermetic wrapper
(50 + 21 + 5 passed), and all 41 scripts/ci self-tests (two mapfile/bash-3.2
casualties green under bash 5, the CI shape).

Row 4 (stays open, dated note added): both-backend parity proven with
legs demonstrably executed for the fabric (57 pg + 81 libsql), triggers
(ADR 0003, REQUIRE_POSTGRES), hooks (ADR 0004, all three backends),
composition, processes journal, extension-registry, host-runtime libSQL
restart, and the backend matrix; fabric-delegated domains enumerated.
Two REAL blockers (one class): the Postgres legs of the event-store and
assistant-ledger contract suites assert against shared-database state and
cannot pass as-written (each failing test passes alone on a virgin
database; files byte-identical to origin/main; no CI lane sets their env
vars). Full evidence: docs/reborn/target-architecture/ws12-gauntlet-report.md.

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

* docs(guidance): set the crate/family guidance convention

The base commit for the family-guidance program: one canonical home per fact,
measured-not-aspirational claims, boundaries stated as exclusions, and the note
that guidance files can be gate-pinned. Every family/crate document written on
top of this branch follows this shape.

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

* fix(tests): per-test isolated Postgres databases for the two WS12 parity-blocking contract suites

The WS12 gauntlet (ws12-gauntlet-report.md §P6/§P8) measured the Postgres
legs of ironclaw_event_store's durable_event_store_contract and
ironclaw_assistant's durable_ledger_contract as test-isolation-defective:
absolute database-global asserts (event cursors; settled-entry prune
bookkeeping) run against the single external database named by their
IRONCLAW_*_POSTGRES_URL env vars. Every failing test passes alone on a
virgin database - store semantics correct, suites not self-isolating
(PROPOSAL §12.13 D-T).

Fix: each affected test provisions a private database on the configured
server - the fabric contract's IsolatedDatabase pattern
(db_root_filesystem_contract.rs) ported locally into each suite: CREATE
DATABASE per test, store/pool + migrations against it, courtesy
DROP ... WITH (FORCE), and a once-per-binary stale-name sweep. Every
assertion preserved byte-identical; libsql/jsonl twins untouched. In the
ledger suite only the two retention tests move - the other six Postgres
tests keep their proven fingerprint-suffix isolation.

Regression pins are the fixed tests themselves:
- postgres_replay_advances_next_cursor_past_trailing_filtered_records
- postgres_runtime_and_audit_logs_survive_rebuild_with_filtered_cursor_semantics
- postgres_settled_entry_limit_prunes_oldest_when_configured
- postgres_settled_prune_interval_defers_until_interval_when_configured
Green proven on a shared dirty database twice in a row (parallel default
threading) and serially on a virgin database; red-first reproduction
captured before the fix.

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

* docs(reborn): record §12.13 D-T (parity-suite isolation ruling) and close CHECKLIST WS12 row 4

D-T (after D-S): the WS12 gauntlet's two REAL findings were one defect
class — absolute database-global asserts against the single shared
env-var Postgres database — in two suites (event store cursor contract,
assistant settled-ledger retention). Ruling executed in commit 864d93ee9:
per-test isolated databases via the fabric contract's IsolatedDatabase
pattern, assertions preserved; alternatives (baseline-relative asserts,
serial-only, leave-open) recorded with why they lost; regression pin =
the four fixed tests themselves.

CHECKLIST WS12 backend-parity row ticks [x] with a dated addendum: red-first
reproduction, the three green isolation runs (dirty shared DB twice in
parallel; failing pairs serial on virgin), parity now green 10/10.

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

* docs(extensions): family guidance layer — AGENTS.md rewrite to the guidance-conventions shape, READMEs for all 4 family crates and 14 packages, duplicate-guidance consolidation

The family AGENTS.md now teaches the unified extension model (extension =
the only product object; channel/tool/auth are manifest surfaces; runtime
is loading, never taxonomy; ExtensionId vs VendorId; retired vocabulary
pinned by reborn_retired_taxonomy.rs), carries the self-containment and
package-to-crate rules from families/extensions.md, the four-responsibility
lookup, the measured package catalog, the exclusion list, and the armed
gates by test name.

Every crate and package gains a README.md (ironclaw_extension_host had no
guidance of any kind). ironclaw_extension_registry and memory-native each
had both an AGENTS.md and a CLAUDE.md saying overlapping things: AGENTS.md
is now canonical, CLAUDE.md a pointer, and memory-native's stale v1
references (src/workspace, src/db/libsql) are dropped in the merge. The
slack/telegram agent maps get package framing and a contracts-tier pointer
in place of the stale ironclaw_assistant one. Every path literal verified
to resolve on disk; all figures (tool counts, dep sets, consumers, layer
declarations) measured from the tree at 8d13454a1d.

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

* docs(guidance): substrates + lanes family guidance per guidance-conventions.md

Family AGENTS.md rewritten to the spec shape for crates/substrates/ and
crates/lanes/: boundary, crate table, exclusion lists (mechanism-not-authority
for substrates; kernel-decides-lane-executes for lanes), armed gates by test
name, and measured deviations stated as deviations (sandbox's three substrate
deps, script.rs direct spawn). The lanes wit/-is-load-bearing note is kept.

A README.md for every crate in both families (10 new), measured against
cargo metadata 2026-08-05: public surface, workspace edges, consumer counts,
and enforced invariants each citing their gate. ironclaw_libsql_runtime and
ironclaw_wasm_limiter previously had no guidance of any kind; their READMEs
carry the sole-pool-home rule (ADDITIONAL_DRIVER_ALLOWLISTS: deadpool =
{filesystem, libsql_runtime}) and the outbound-only limiter gate
(wasm_sandbox_core_module_stays_domain_free_v1_parity_kernel; no BoundaryRule
names the limiter).

Duplicate guidance consolidated per rule 1: for the six crates holding both
AGENTS.md and CLAUDE.md (filesystem, network, secrets, mcp, sandbox, wasm),
CLAUDE.md stays canonical (module spec for filesystem; gate-pinned wording for
mcp and wasm) and AGENTS.md becomes a short pointer. No gate-pinned file was
edited. Stale reference removed: safety AGENTS.md pointed at
src/NETWORK_SECURITY.md, which exists nowhere in the tree.

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

* docs(crates-map): rewrite the three top-level maps family-first after the restructure

crates/AGENTS.md (264 -> 175 lines): routing map only — the ten families,
the read order (family AGENTS.md -> crate README.md -> working rules/module
spec -> docs/reborn/contracts/), the enforced seven-layer matrix with the
family/layer divergences, measured workspace facts (64 packages, 1 documented
exclusion, 0 owned exceptions per scripts/ci/check-target-tree.py), and a
verified command block. The 40-row per-crate map is gone: family AGENTS.md
files own crate routing per docs/reborn/guidance-conventions.md.

crates/README.md (141 -> 119 lines): human map — mental model in family
vocabulary, the ten families with measured crate counts, the 14 extension
packages (4 crates + 10 data-only), and the two workspace members outside
crates/.

crates/Architecture.md (1019 -> 1059 lines): audited against the live tree;
every named symbol/path re-verified 2026-08-05. Corrected: retired
ProductAdapter vocabulary (zero residue in code), the stale pre-rename
dependency ladder that still cited the deleted gateway/TUI crates, run-state
store mentions, lane-table crate anchors (sandbox/extension_support),
declared-in vs minted-by owners in the core data model, and the subagent
deny-filter status note (re-verified). Marked the pre-restructure
'partial or evolving' list as unmeasured rather than asserting it.

Also documents that scripts/check-boundaries.sh fails on a clean tree
(check-5 grep false positives) and greps the deleted v1 src/ in 4 of 6
checks — boundary enforcement for crates/ is the architecture suite.

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

* docs(crates-map): package directories carry their own README.md (coordinator sync with extensions-family agent)

Every extensions package dir — the 10 data-only ones included — now ships a
README.md, so both maps extend the read order to package level. The sibling
branch also confirmed what this map already derived per-crate: packages/ is
not uniformly products-layer (memory-native and mem0 declare substrates).
The other two coordinator corrections targeted rows of the old per-crate
map, which this rewrite deleted wholesale; nothing here cites
memory-native's CLAUDE.md or claims ironclaw_extension_host lacks guidance.

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

* docs(guidance): contracts + events family guidance layer per docs/reborn/guidance-conventions.md

- crates/contracts/AGENTS.md and crates/events/AGENTS.md rewritten to the
  family shape: exclusion lists with destinations, armed gates by test name,
  layer-matrix rows, crossing guide, measured header counts.
- README.md added for all 10 crates (ironclaw_prompt_envelope previously had
  no guidance of any kind — the CHECKLIST WS11 gap).
- One canonical guidance file per crate, other file a pointer:
  A+C merges for ironclaw_host_api, ironclaw_event_log,
  ironclaw_event_projections, ironclaw_event_streams; CLAUDE-only content
  moved to AGENTS.md for ironclaw_loop_contracts,
  ironclaw_extension_contracts, ironclaw_product_contracts (none of these are
  root module-spec crates, so AGENTS.md is the working-rules home).
- Stale guidance fixed against the live tree:
  * loop_contracts dep list contradicted the enforced allowlist (manifest is
    host_api + extension_contracts; common/prompt_envelope are permitted,
    unused).
  * event_log still documented the deleted jsonl parse/replay helpers.
  * event_projections still claimed EventStreamManager,
    DurableMemoryAuditSink, MemoryAuditProjectionMetadata, and
    PendingGateProjection — all deleted per PROPOSAL 6.3.3.
  * product_contracts still carried the pre-D-E open vendor decision under
    the nonexistent module name llm_config, and a Deferred section
    contradicting its own operator_llm/operator_service rows.
  * extension_contracts module table was missing the WS3 runtime module
    while counting 18.
  * common's llm_costs note carried the ModelCostTable seam claim refuted by
    PROPOSAL 12.11 D-F; now cites the pricer-port ruling and the vendor
    census residue.
- Deleted crates/events/ironclaw_event_projections/PENDING_GATE_PROJECTION.md:
  every claim in it referenced deleted symbols or the removed v1 src/ tree,
  and its only inbound reference was the crate's own CLAUDE.md.

Verified: all consumer counts reproduce via the printed grep commands; 147
path literals across the 28 touched files resolve on disk; no architecture
test reads any of these files by name; conflict-marker scan clean.

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

* docs(target-arch): three measured corrections surfaced by the guidance program

memory packages are substrates-layer, not products (families/extensions.md);
memory_native declares no extension_contracts dep (PROPOSAL §6.8.4); wasm's
extension_contracts edge is dev-only and the wasm 'never depends on' bullet is
lane-scoped, not family-wide (families/lanes.md).

Three further reported defects were checked and NOT corrected — they were
misreads: the sandbox 'never above the runtime tier' rule holds (substrates sit
below it), and PROPOSAL's safety consumer count already reads 17, matching the
tree.

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

* docs(kernel): family guidance layer — perimeter AGENTS.md, nine crate READMEs, AGENTS/CLAUDE consolidation

Family-guidance program, kernel family (guidance-conventions.md shape):

- crates/kernel/AGENTS.md rewritten to the family shape: the nine-stage
  effect pipeline with stage ownership, the sealed-mint table (witness /
  trust ceiling / approval lease / verified-inbound evidence, each with its
  mint site and its seal mechanism), the per-stage fail-closed table with
  file:line or test citations, the sharp exclusion list, and the armed
  gates by test name (authorized-seal ratchet, sealed-evidence mint
  ratchet, BoundaryRules, same-layer edge inventory at 21 kernel edges,
  empty LAYER_MATRIX_EXCEPTIONS register, driver boundary, process storage
  scan, origin-gate matrix ratchet).
- A README.md for each of the nine crates, per the crate shape: measured
  workspace deps and consumer counts (cargo metadata), public surface with
  verified citations, enforced invariants naming their gates.
  ironclaw_processes states the single-lifecycle-authority direction of
  truth (journal = store; TurnRunState/ProcessRecord/await-edge =
  projections; PROPOSAL §12.13 D-S); ironclaw_host_runtime documents the
  D-R literal-loopback carve-out and names its two regression tests.
- Duplicate guidance reconciled in all nine crates: AGENTS.md is canonical
  (guardrails absorbed), CLAUDE.md reduced to a pointer; ironclaw_trust's
  CONTRACT.md untouched as the co-located cross-crate contract.
- Stale references fixed inside owned paths: the deleted capability-profile
  conformance module (evaluate_profile_conformance — zero hits
  workspace-wide) removed from ironclaw_capabilities guidance; trust's
  'staging branch' / 'PR3' phrasing updated; capabilities' 'later
  obligation slices' updated to the landed host_runtime obligations split;
  cross-crate path mentions fully qualified. Every path literal in all 29
  kernel .md files verified to resolve on disk; every named symbol swept
  against crate sources.

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

* docs(domains): family guidance layer — AGENTS.md boundary doc, 12 crate READMEs, duplicate-guidance consolidation, stale-path fixes

Family guidance for crates/domains/ per docs/reborn/guidance-conventions.md:

- crates/domains/AGENTS.md rewritten to the family shape: charter table with
  go-here-when routing, the exclusion list, every armed gate named by test
  (BoundaryRules + identity/memory allowlists, the 5-entry in-family edge
  inventory, the naming gates, trusted-trigger ownership, the memory-provider
  residue ledger, persistence-driver boundary, the two module-charter gates).
- A measured README.md for each of the 12 crates: charter, use-when /
  don't-use-when routing, public surface, measured normal deps + named
  consumers, enforced invariants with their gates, exact test commands.
  ironclaw_attachments and ironclaw_identity had no guidance of any kind;
  identity's README points at CONTRACT.md (the module spec), llm's at its
  CLAUDE.md module spec.
- Duplicate guidance consolidated to one canonical file + pointer per crate:
  threads/conversations/memory/outbound rules now live in AGENTS.md (CLAUDE.md
  is a pointer); auth/llm keep CLAUDE.md canonical because their
  tests/module_charter.rs gates read it (AGENTS.md is the pointer). One
  misstatement fixed in the conversations merge: transcript content belongs to
  ironclaw_threads' SessionThreadService, not InboundConversationService.
- Staleness fixed inside the family: identity CONTRACT.md two-edge allowlist
  claim reconciled with D-Q's three entries; trace_commons CLAUDE.md gains the
  capture module row and strikes its two discharged Known Gaps (recording/paths
  shims deleted, rename done); llm CLAUDE.md reasoning.rs caller corrected to
  crates/loop/ironclaw_loop_host; triggers lib.rs 'feature-gated' repo doc
  comments corrected; pre-family path literals in comments repointed
  (kernel/approvals+processes, loop/hooks, app/architecture_tests,
  domains/auth) and the deleted-v1-engine references in skills marked
  historical.

Verified: cargo test -p ironclaw_llm --no-fail-fast (922 passed, exit 0 —
CLAUDE.md is gate-pinned); cargo check --all-targets on all six crates with
source edits; every cited path literal resolves on disk; conflict-marker scan
clean.

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

* docs(target-arch): repair the corrupted kernel bullet and correct two family laws

kernel.md: ironclaw_authorization's 'Security & authority role' bullet has been
textually corrupted since #6918 — an approvals sentence was spliced into it
mid-clause, orphaning its continuation line. Reconstructed, with the spliced
sentence restored to the approvals entry where it is true.

lanes.md: 'a lane never depends on a substrate' is false as a family-wide law
(ironclaw_sandbox holds network/safety/secrets normal deps, which its own entry
licenses); the accurate law is the layer ladder, and the narrow claim holds for
ironclaw_wasm alone.

lanes.md + events.md: the 'every crate ships both an AGENTS.md and a CLAUDE.md'
requirement is superseded by docs/reborn/guidance-conventions.md — two files
restating one rule is the drift the guidance program removes.

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

* test(arch): govern the ProtocolAuthEvidence test seam — WS12 audit F1

Two new gates in reborn_sealed_evidence_mint_ratchet (closed paths #12/#13),
per the audit's remedy spec:
(a) TEST_SEAM_MINT_FNS governs test_verified/test_verified_for_tenant — any
    production-text call site outside ironclaw_host_api is an offender
    (comments/strings stripped, #[cfg(test)] blocks stripped, tests.rs /
    *_tests.rs and cfg-test-only files excluded via the shared census);
(b) test-support may appear in no normal dependency table workspace-wide
    (dependencies / build-dependencies / target.* variants /
    workspace.dependencies), and no [features] key other than test-support
    may forward to it — the laundering shape that would evade (b) by one
    rename. [dev-dependencies] enablement stays legal (cargo-features.md
    bar 4, the sanctioned dev seam).

Measured zero offenders on this tree in both directions before pinning;
sabotage-proven red->green both ways (planted production call named with
file:line-text; [dependencies] enablement named with its table path).
Self-tests drive the same pipelines the gates run (zero-match principle);
the definition-location and partition tests now cover the new table.

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

* test(integration): join the gsuite credential-injection journey — WS12 audit F3

WS12 row 5 leg 3 was verified in two halves no committed test joined: gsuite
handler -> staged credential (crate tier) and staged obligation -> wire
(GitHub/Slack only). Scenario 5 already drives gmail.list_messages through
production dispatch on a Google-OAuth-configured group; it now also asserts
the JOIN: the seeded google account's token (itest-google-token) lands on
the recorded outbound gmail.googleapis.com request as
'authorization: Bearer ...', injected at the host egress chokepoint
(apply_credential_injection) per the gmail manifest's declared recipe —
store -> dispatch-time staging -> chokepoint -> wire, through the caller.

Sabotage-proven: disabling the Header injection arm reds exactly this
scenario with 'no network egress request matching url gmail.googleapis.com
has header authorization' while the request itself still reaches the wire
(headers seen: content-type only) — the injection reason, not a setup
error; restore -> green.

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

* docs(target-arch): correct five measured dependency claims in families/domains.md

conversations does not depend on safety (its BoundaryRule now forbids it);
triggers depends on libsql_runtime + safety and NOT filesystem, so its
'filesystem-routed persistence path alongside SQL' is one path, not two;
memory's live set is host_api alone (prompt_envelope is allowlisted, unused);
auth was short by extension_contracts + product_contracts.

Each verified against the manifest before editing.

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

* docs(guidance): family AGENTS.md + crate READMEs + guidance consolidation for loop/product/app

Family-guidance program, families 8-10 (the top of the stack), per
docs/reborn/guidance-conventions.md:

- Rewrite crates/{loop,product,app}/AGENTS.md from routing stubs to the
  spec's family shape: exclusion lists, armed gates by test name, layer
  rows, crossing guides. Loop carries the trust story + the declared
  Loop*Port decorator chain; product carries the frozen-surface rule,
  the transports-consume-contracts rule (with the D-B frozen-constant
  qualification), the evidence-mint prohibition, and the two vendor
  exceptions; app carries the wires-owners-never-becomes-one charter,
  the binary-names-packages rule, config's zero-dep guarantee, and the
  composition mass ratchet (loc 40423 / Arc<dyn> 814).
- Add a README.md to all 13 crates (12 new; webui's rewritten to the
  spec shape) with measured public surface, deps, and consumer counts.
- Consolidate duplicate AGENTS.md/CLAUDE.md per spec rule 1: AGENTS.md
  is canonical and CLAUDE.md a pointer for agent_loop, loop_host,
  turn_runner, hooks, host_ingress, openai_compat, operator, and
  architecture_tests; CLAUDE.md stays canonical (module spec /
  gate-pinned) for webui, composition, and assistant, with
  composition's AGENTS.md reduced to the pointer.
- Fix stale references in owned paths: hooks' dependency diagram and
  AgentLoopDriver home (ironclaw_loop_contracts, not ironclaw_turns),
  loop_host/agent_loop port-home claims, turn_runner's pre-#6696
  scheduler description, webui's ProductSurface path
  (product_contracts, not host_api), route count (93, measured), and
  webui's allowed-dependency list (7 of 10 were listed), the D-S
  await-edge ruling reflected in turn_runner guidance, composition's
  llm_admin residue (nearai_login_serve left for operator).

Verified: cargo test -p ironclaw_architecture_tests --no-fail-fast
(39 binaries, 0 failures — covers the CLI AGENTS.md phrase pin and the
composition guidance-markdown scan), scripts/ci/check-target-tree.py,
path-literal resolution over all 37 changed files, conflict-marker scan.

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

* docs(target-arch): record the closed scan evasions (F4) and the secrets-consumer correction (F2)

The sealed-mint census weaknesses PROPOSAL §11.2.5/§12.1a and CHECKLIST recorded
as live and owed to WS10 are all closed on this tree, verified by re-attacking
the seam with both evasions at once; the docs understated the seal. Ratchet is
23 tests. One residual replaces them: the test_verified test-seam constructors,
now pinned by two gates.

§12.1b's 'only products-layer crate with the edge' is false by one —
ironclaw_assistant carries ironclaw_secrets as port-declaration vocabulary with
no expose_secret call. Not a value-reach bypass; joins #7095's inventory.

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

* docs(target-arch): correct the app-family layer, config's consumer set, and the webui route count

ironclaw_config declares layer=substrates while living in crates/app/;
its consumers include operator, extension_manager and extension_host, not just
the assembly crate and the binary; webui is 93 contract-locked routes, not 92
(#6780 landed after the last recount).

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

* docs(stale-sweep): fix agent guidance outside crates/ for the family restructure

Audit-and-fix pass over every stale document outside crates/ (PR 2 of the
family-guidance program). Live guidance verified against the tree; records
kept with dated notes instead of rewrites.

Guidance fixes (verified against HEAD before writing):
- .claude/commands/trace.md: MCP tool prefix codebase-memory -> codebase-memory-mcp
  (allowed-tools never matched the real server), ProductSurface home ->
  ironclaw_product_contracts, capabilities host.rs -> host/ module split,
  scripts lane -> script-sandbox; deleted the redundant v1-anchors section.
- .claude/commands/add-sse-event.md: deleted the banner-quarantined v1 scaffold
  steps (every path deleted with the monolith); now an honest redirect to the
  Reborn projection/SSE path. Frontmatter no longer advertises a working scaffold.
- .claude/commands/deslop-reborn.md: three dead crates/*/Cargo.toml globs (family
  layout added a level), ls crates/ -> family-aware listing, v1-only consumer
  logic retired, per-crate --features integration phrasing.
- .claude/rules/type-placement.md: crates/*/src globs matched nothing; recipes
  re-pointed and numbers re-measured 2026-08 (3,495 structs/enums, 385 traits,
  fan-in host_api 53 / common 20 / turns 12).
- .claude/rules/skills.md: paths trigger pointed at a nonexistent
  bundled_skills.rs (rule never fired); SKILLS_REGEX_ACTIVATION_ENABLED /
  SKILLS_MAX_TOKENS env vars are read by nothing -> documented the real
  config-file setting and DEFAULT_MAX_SKILL_CONTEXT_TOKENS.
- .claude/rules/testing.md, ironclaw-reborn-testing skill, CONTRIBUTING.md,
  .github/pull_request_template.md, testing-playbook, deslop: the workspace-root
  `integration` feature is empty with zero consumers - all "cargo test
  --features integration" guidance re-pointed to crate-level suites.
- .claude/skills/reborn-extension-surfaces: four pre-colocation assets/ paths,
  CapabilitySurfaceKind home, conformance-suite move to
  ironclaw_extension_contracts, ingestion test move to the registry crate,
  gate-banned migration exemplar replaced with the live behavioral pin, [mcp]
  instead-of claim softened (nearai-mcp pins a static [[tools]]).
- .claude/skills/ironclaw-reborn-orientation: turn_runner labels, prompt-crate
  list re-derived (turns/first_party_extension_ports out; host_api,
  loop_contracts, assistant in), consumer-grep glob fixed.
- .claude/skills/reborn-feature + docs/reborn/how-to-port-channel-to-reborn.md:
  ProductSurface/ProductView/descriptors/caller types live in
  ironclaw_product_contracts; recipes re-pointed.
- CLAUDE.md: dead root --features integration line replaced; project tree
  redrawn with the ten families; trait homes corrected; ProviderId -> VendorId;
  CapabilitySurfaceKind + ChannelAdapter homes; [channel.config] ->
  [channel.connection]/[admin_configuration]; v1 Job State Machine section
  deleted (no such machine in Reborn); prompt-crates recipe fixed; MCP server
  name; LLM backend list re-derived from LlmBackendKind.
- docs/extensions/building-a-tool.md: product-adapter crates row -> channel
  surface model; package registration -> PACKAGES collector in
  ironclaw_extension_support (available_extensions.rs is being dissolved);
  hosted-MCP policy home -> ironclaw_extension_host/src/mcp.rs; dead v1 bullets
  dropped.
- docs/internal/mutation-audit.md: runnable command blocks re-pointed (family
  paths; ironclaw_dispatcher example replaced - crate deleted in WS0).
- docs/reborn/harness/e2e.md: dispatcher row -> the capabilities dispatch
  contract suites. docs/reborn/contracts/host-api.md: three ironclaw_dispatcher
  mentions -> capabilities dispatch module. standard-operations.md: renamed
  crate + arch-test package name.
- scripts: mutation-audit.sh usage header, check-hermetic-env.sh env_helpers
  pointer, check-generic-without-concrete.sh mirror pointer,
  telegram_smoke/README regression step (target deleted with v1 in #6375).
- .env.example: dead SKILLS_REGEX_ACTIVATION_ENABLED entry -> config-file doc.
- docs/qa/telegram-coverage-map.md: nine not-automated reasons re-worded to the
  crate-level integration tier.

Records (dated notes, no rewrites): ADR 0003/0004 path notes (evidence pinned
to their measured SHA), FEATURE_PARITY state-migration paragraph marked
historical with a git-show recovery pointer, engine-v2 parity record's
"coexist on main" claim corrected with a historical note, subagent-spawn
legacy scope re-tensed.

Pre-family path reproduction count: 73 -> 70 files; every remaining file is a
dated record (docs/plans, docs/superpowers, ADRs, audits, CHANGELOG history,
historical-marked train docs) or a deliberate past-tense mention.

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

* docs(ws12): tick row 7 — the fresh-agent placement probe passed on the final tree

All three placements correct with high confidence, each naming the trait, the
tests, and the tempting wrong place it rejected. The probe doubled as a docs
audit and independently hit four defects, three of which the stacked guidance
PR fixes — it succeeded despite them.

WS12 is now 7/7. The restructure is complete.

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

* docs(target-arch): the product→loop_host recount was wrong on the day it was written

Eight importing files across four seams, not seven across three — the fourth
being a skill-activation-observer seam (projection.rs, projection/live_progress.rs)
this bullet never named, which §6.4.7's own same-day note already implied.
Surfaced by the plan-conformance audit.

The recount history is 3→5→6→7→8, wrong at four of five attempts. That retires
the prose count as a method: the sever slice should land an inventory ratchet
before or with the move, not another number.

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

* review(7263): CodeRabbit round-1 triage — 4 code fixes (2 sabotage-proven, 2 red-first) + 6 doc-truth corrections

Code, each verified red-first or by sabotage matrix:
- sealed-mint ratchet: per-name sighting floor for TEST_SEAM_MINT_FNS
  (closed path #12). Proven: renaming test_verified_for_tenant away plus one
  extra legitimate sibling mention passed the old aggregate floor (silent
  disarm) and fails the new per-name floor naming the constructor; suite
  23/23 after revert. (CodeRabbit's claimed baseline ">2 mentions today" is
  wrong — each name has exactly one kept sighting — but the doc/enforcement
  mismatch and at-threshold fragility were real.)
- trace credit: non-finite novelty_score/duplicate_score are treated as
  absent before clamping (clamp preserves NaN, which poisoned online_score
  and credit_points_estimate); NaN cases added to the #7144 regression test,
  red first.
- trace submission: a 2xx whose body stream dies mid-read now maps through
  request_failed (network telemetry kind, true I/O cause) instead of
  collapsing to an empty body that the #7144 strict parse misreported as
  response_invalid/Submission; truncated-body regression test, red first.
- Postgres contract suites (event store + assistant ledger): isolated-DB
  names now carry a creation epoch and the once-per-binary sweep is
  age-gated (1h), closing the cross-process window where a sibling's fresh
  zero-backend database (between CREATE DATABASE and first connection) was
  sweepable; legacy pid-scheme leftovers still collect immediately. Proven
  on live Postgres 16: planted stale name swept, planted fresh name
  survives, 13/13 x2 and 20/20 x2 with zero leftovers.

Docs (target-architecture truth pass):
- PROPOSAL section 9: the WS6 rename sweep (#7152) had rewritten the source
  column of the 12 renamed rows to their post-rename names, turning their
  rename dispositions into no-ops (rows 13/14/28/30/49/51/59/61/64/66/67/70);
  pre-restructure names restored with a dated footnote.
- PROPOSAL:69: removed the superseded 3->5->6->7 recount sentence (the
  corrected 3->5->6->7->8 passage subsumes it).
- PROPOSAL row 34: ToolPermissionOverrideStorePort deletion marked landed
  (2026-08-05 WS8, matching section 6.5.3; zero workspace hits).
- CHECKLIST:631: dated note recording that the WS12 F3 gsuite join landed in
  this batch (scenario_uninstalled_tool_call_denied_until_active.rs asserts
  the seeded google token on the gmail.googleapis.com wire; suite run green).
- CHECKLIST:632: dated note spending F4 (the audit's 19 was correct at its
  SHA; the ratchet file now holds 23 tests, re-counted at lines 552/597).
- ws12-gauntlet-report P6 heading: first of TWO real failures (one class),
  matching P8 and the report's own summary.
- ws12-mapping-audit rows 49/137: dated D-S closure notes (await-edge store
  half = journal projection already; resolver retained loop-tier; no shed
  owed) so the backlog register no longer lists it as in-flight.

Not fixed, with evidence: the span-helper macros gate suggestion
(info_span!(target = ...) is a hard compile error, E0425 — no silent trap),
the webui tracing-subscriber workspace-dep suggestion (no
[workspace.dependencies] entry exists; suggestion would not build; 8
siblings use the identical direct shape), and the mapping-audit
regeneration (the audit is accurate at its pinned SHA; the in-batch F1 fix
is recorded in its dated coordinator note).

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

* review(7263): CodeRabbit round-2 — rejection-body read keeps its cause; 200 {} is not a submission acknowledgement; lanes.md family dep rule matches measured Cargo.tomls

- submission.rs non-2xx path: a failed rejection-body read no longer collapses
  to an empty detail via .unwrap_or_default() (banned by
  .claude/rules/error-handling.md); the read error folds into the
  http_rejection detail so the received status keeps driving the 401/403
  auth-retry and the Credential/HttpRejection telemetry split.
  Regression: submit_preserves_rejection_body_read_failure_cause_with_status.

- TraceSubmissionReceipt.status: serde default removed — it fabricated
  status "submitted" from a proxy's 200 {} (the #7144 synthesis, resurfacing
  through the wire type's defaults), after which the flush caller recorded
  Submitted and deleted the only retryable queued copy. The acknowledgement is
  the server naming what happened to the submission — every workspace fixture
  sends status and callers persist it unconditionally as server_status — so a
  status-less 2xx body now fails the strict receipt parse as response_invalid.
  Regression: submit_rejects_success_response_without_explicit_server_status
  (covers 200 {} and a status-less non-empty object).

- docs(lanes.md): the family Dependency-direction rule no longer claims every
  lane takes the extension-surface vocabulary crate — measured across
  crates/lanes/*/Cargo.toml: mcp + sandbox hold ironclaw_extension_contracts
  under [dependencies], wasm only under [dev-dependencies]; dated ✎
  cross-references the ironclaw_wasm entry's 2026-08-05 correction.

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

* review(7263): CodeRabbit round-3 — shared Postgres test provisioner (the "new dep edge" premise measured false), entrypoint self-test armed (sabotage-proven), six doc self-contradictions reconciled

Code:
- ironclaw_filesystem gains a `postgres_isolation` test-support module — the
  single home of the per-test isolated-database scaffolding (once-per-binary
  age-gated stale sweep, epoch-in-name convention, DROP WITH (FORCE) cleanup),
  parameterised by suite/env-var/prefix/unreachable-policy. Zero new
  production edges: event_store already normal-deps filesystem, filesystem
  already owns tokio-postgres, and the dev-dep+feature pattern is the one 17
  crates already use. The event-store and product-workflow-ledger suites
  migrate onto it; both Postgres legs proven live against postgres:16 (12
  tests, zero leftover databases). The fabric original keeps its older
  variant with the differences documented at its IsolatedDatabase.
- ironclaw_event_store drops the duplicate tokio-postgres dev-dep (the normal
  dep already reaches tests).
- test-reborn-docker-entrypoint.sh: the missing-argv check now exits the
  command-substitution subshell instead of incrementing a counter the parent
  never sees — red-proven (a migrate-but-never-exec entrypoint passed with 7
  FAIL lines printed), green after the fix both sabotaged and restored.
- trace_commons submission test additionally pins !auth_rejection() for the
  503 rejection (the structural assert the API affords; the prescribed
  payload asserts are refuted — status is private and source is None by
  design, with the message derived from the structured status in the same
  constructor).

Docs (each reconciled to one canonical statement, measured):
- kernel.md: lease ownership decided from code — authorization stores,
  matches, and expires leases (CapabilityLeaseStore + port + expiry all live
  there); approvals constructs and issues into that store. The round-1
  re-homing of the spliced sentence into approvals was wrong and is corrected
  in the dated repair note.
- app.md: "nothing depends on app" scoped to the three app-layer crates;
  ironclaw_config's consumers restated by dependency kind (normal:
  composition, cli, operator, extension_host; dev-only: extension_manager,
  root integration-tests package).
- lanes.md: the mediated-services sentence now states the family law as
  layer-ladder + injected authority; the no-secrets/network/filesystem-dep
  claim is scoped to ironclaw_wasm, matching the file's own corrections.
- CHECKLIST 429/430: the one open traces clause is named (ScopedFilesystem
  adoption); the stale "other two" count corrected against the F3a strike.
- PROPOSAL:69 + CHECKLIST:72: the project-create route repointed —
  first_party_extension_ports dissolved into loop_host::skill_activation
  (WS8, §9 row 55) — still unattempted.
- PROPOSAL §9 rows 57/62 synced to §6.8.4 (telegram: dependency-set equality
  with Slack's four contract-tier crates) and §6.9.4 (webui -> assistant is a
  charter-permanent edge, §12.11 D-B).
- PLAN top summary records Wave 6's design question as resolved (D-S,
  2026-08-05).
- deploy-reborn-cli-docker.md: the two migration paragraphs unified on the
  entrypoint's actual behavior — only enabled = false beside
  signing_secret_env/bot_token_env is migrated; every other retired-key shape
  fails startup with the migration pointer.
- composition-budget.toml: the stale "2398 bp, a true ratchet" header
  replaced with the WS0-floor truth the baselines test asserts.

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

* docs: move the guidance convention into this PR so its citations resolve

families/lanes.md and families/events.md cite docs/reborn/guidance-conventions.md
when superseding their 'every crate ships both an AGENTS.md and a CLAUDE.md'
requirement, but the file was only on the stacked guidance branch — a forward
reference that dangles if this PR merges alone. The convention is the rule those
notes invoke, so it belongs with them.

Caught by the CodeRabbit round-3 pass.

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

* fix(ci): give the hoisted postgres provisioner its safety rationales

The round-3 hoist moved test provisioning into a production src/ path, so
check_no_panics flagged its four panic/expect sites and reddened Code Style via
fast-checks. The gate is right to flag them: it deliberately does NOT exempt
#[cfg(feature = "test-support")] modules, because a cargo feature is not a
privilege boundary in this workspace (PROPOSAL 12.1a proved exactly that) —
so a test-support module still compiles into a build where any sibling enables
the feature.

Suppressed with the gate's documented inline rationale, which must trail the
statement rather than precede it. The panics themselves stay: a configured but
unusable Postgres must fail the suite loudly rather than skip it, which is the
inert-guard rule the isolation fix exists to serve.

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

* fix(ci): classify the three planner-unknown paths this PR touches

The Reborn PR test planner fails closed on any unclassified path and
raises on the FIRST failure in sorted order, so CI only ever showed
.github/pull_request_template.md. Classifying that unmasked two more
paths in this PR's own diff: scripts/mutation-audit.sh and
scripts/telegram_smoke/README.md. All three are classified; the
fail-closed arm is untouched:

* .github/pull_request_template.md -> IGNORED_PREFIXES, beside its
  exact sibling .github/ISSUE_TEMPLATE/ (both GitHub UI templates;
  classify-test-scope.sh already pairs them in its docs-only arm).
* scripts/mutation-audit.sh -> PR_STATIC_CONTROL_PATHS, beside its
  self-test scripts/test-mutation-audit.sh; both run only in
  nightly-deep-ci.yml's mutation-frontier job.
* scripts/telegram_smoke/ -> QA_HARNESS_PREFIXES; a live, by-hand
  release smoke harness referenced by no workflow, same class as
  scripts/reborn_qa_matrix/.

Each entry is pinned red-first in test_reborn_pr_test_plan.py (entry
commented out, new assertion fails with the exact production error,
entry restored, green): a new PR-template test with paired
accept-AND-select-nothing assertions plus unknown-.github/-sibling
refusal probes, and the two existing class tests extended. Planner
self-test: 65 tests OK. The planner CLI over this PR's full 209-path
diff now exits 0.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 11:53:29 +00:00
Benjamin Kurrek
c69ed2d709 Program closure: the defect train, the await-edge ruling, and the WS12 100% gate (#7263)
* docs(target-arch): resolve the await-edge design question by measurement (D-S) and re-walk the WS9 verify row

Appends §12.13 D-S under delegated authority at owner direction, flagged
for post-hoc review by Illia Polosukhin (#6696's author): the await-edge
store is measured to be a pure projection over ProcessDependencyPort
(that half of the shed happened inside #6696 itself), and the resolver
is a genuine loop-tier responsibility journal edges cannot express
(owner recovery, sanitized transcript result materialization, batch-gate
resume-once drain, BlockedDependentRunGate resume policy). §6.7.3 is
amended (scheduler DONE / store DONE / resolver KEEP) instead of the
shed being executed; the 2.9k figure is corrected to 1,459 production +
1,448 cfg(test) lines. The §12.10 bullet, §2 divergence flag, §9 row 49,
§13 validation row, CHECKLIST header/WS4 pointer, README and PLAN all
carry the dated resolution.

WS9 verify row ticked with evidence: one lifecycle authority (the
process journal; TurnRunState/TurnRunRecord are projections via
AgentTurnProcessRuntime, ProcessRecord is a capability-invocation view,
no bare RunRecord exists) and §7 T4 re-walked clause-by-clause against
merged code — matches, including the checkpoint-gated no-auto-retry
mechanism (BeforeModel precedes ModelStage; requeue only when
checkpoint-free under the 3-claim cap).

Docs-only; no code, no tests, no gates touched.

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

* docs(ws12): rows 1-2 — package-set tick (64==64/1/0, gate+selftest+independent rederivation) and the 74-row §9 mapping audit (45 L / 15 L-A / 14 OBD / 0 NOT-LANDED; 3 findings recorded)

Row 1: check-target-tree.py reports 64 workspace members == 64 documented
packages, 1 documented exclusion (tools/ironclaw_silk_decoder), 0 owned
exceptions (EXCEPTIONS table empty — §5 steady state); self-test 17/17;
cargo-metadata name set diffed empty against an independent §5 parse.

Row 2: docs/reborn/target-architecture/ws12-mapping-audit.md is the audit
record — per-row executed-evidence, delete-clauses read against WS8's
execution notes, all 14 open rows cite their owning CHECKLIST/PROPOSAL
row or issue. Findings (recorded, not fixed): F1 prompt_envelope
manifest-description fix has no owner row; F2 WS6:429's '#5618 residue
deleted' overstates vs the live adopt_migrated_identity + open WS8:523;
F3 stale-docs cluster where the tree is ahead of the prose (trace
re-export drop, TurnRunTransitionPort, processes->resources).

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

* fold(7154): squash-port fix/red-main-7119 onto family-world main — defect train #7146/#7115/#7104/#7103/#7144 (+#7119 CI lane), 34-hunk contribution.rs port into the split modules, planner entrypoint classification, D-R loopback exception on the widened HTTPS credential guard

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

* test(extractors): issue-number + assertion-rationale doc refinement (rescued 844964fb8 from rescue/7154-parked-guard)

Ports only the doc/assertion refinement commit; the guard-parking commit
e8f5a31a2 on that branch is deliberately NOT taken — superseded by the
D-R loopback ruling.

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

* docs(target-arch): record D-R — the loopback credential-guard ruling, wiring choice, and regression pins (PROPOSAL §12.13, 2026-08-05)

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

* review(7154): CodeRabbit round-1 triage — fail-closed tracing-target scan traversal (+node_modules), bounded sidecar output draining (capped capture + discard drain), deadlock regression asserts successful redaction (no seq), XLSX/DOCX empty-classification via extract_document, raise_for_status annotations

Threads already addressed by the fold: latency.rs caller-contract wording
(merged doc scopes the requirement to latency-trace callers), BodyJsonPointer
coverage (the plaintext-refusal test drives all four injection shapes).
Deliberately not taken: un-xfailing the four Slack-catalog projections —
the xfail is a documented tripwire (unexpected-pass goes red) and clearing
them is the #6520 projection-modeling follow-on its comment specs.

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

* fix(assistant): re-point the one field-form tracing target the #7146 gate caught — main's relocated triggered_run_delivery_services carried the drift the PR fixed at its old channel_host address

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

* closure fixups: execute the mapping-audit findings — prompt_envelope manifest description (F1), dated ✎ corrections for the #5618 overstatement (F2) and the stale-prose cluster (F3)

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

* docs(ws12): second-reviewer security spot-audit + extension-journey re-verification (rows 5-6)

Adversarial second-reviewer pass over PROPOSAL §12.1a/b/c and the batch's own
§12.13 D-R loopback carve-out, plus a re-run of the five extension journeys.
Attacks were executed rather than argued: two sabotage files and a 38-shape
hostile-URL probe were planted, run, and reverted.

Verdicts — mint consolidation HOLDS-WITH-RESIDUAL, secrets tightening
HOLDS-WITH-RESIDUAL, host/verifier colocation HOLDS, D-R HOLDS. No HOLE.

Four findings recorded rather than fixed (report-not-repair):
- F1 test_verified/_for_tenant are ungranted mint constructors gated only by
  the `test-support` feature, in no mint-name table, with nothing pinning the
  feature to [dev-dependencies]; the shipped binary is measured feature-free.
- F2 §12.1b's products-layer residue undercounts by one (ironclaw_assistant).
- F3 journey coverage hole: gsuite-with-credential-injection is proven in two
  halves that no committed test joins.
- F4 both recorded census evasions and both fail-open reads are CLOSED on this
  tree, so §11.2.5/§12.1a/CHECKLIST:552/:597 now understate the seal.

Rows 5-6 ticked; only lines 631-632 of CHECKLIST touched so the concurrent
rows 3-4 edit folds cleanly.

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

* ratchet(closure): lock the budget gate at the program's end state

Dispatch ceiling 1122 -> 814 (today's observed, nudge taken; WS0 record 827
stays within effective 829). Mass-share ceiling 2398 -> 658 bp (the WS0
baseline floor — the arch-test assert refuses lower, and observed 578 bp sits
inside the nudge window). Absolute LOC re-equalized at 40423: #6831 added 4
governed LOC through the queue's tolerance window; ceiling, observed, and
COMPOSITION_ABSOLUTE_SRC_LOC move together here. Both tightenings
sabotage-verified red (dispatch 9-over at 790; abs 73-over at 40200).

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

* docs(ws12): gauntlet report — row 3 ticked (full gauntlet green, 0 REAL in scope), row 4 verified-but-open on two pre-existing Postgres-leg test-isolation defects

WS12 rows 3-4 verification on the assembled batch tip 0c6c0cfb9d:

Row 3 (ticked): fmt, clippy default/all-features/--lib --bins, workspace
tests (495 targets, 15,203 passed, 0 failed; the smoke.rs:3132
CPU-saturation flake passed first try), arch suite 285/0, the
integration-feature lane 1,665/0, recorded-fixture QA (61 fixtures clean,
41/0), frontend (typecheck 1,588 files; vitest 1,088/0; build + bundle
budgets), e2e smoke = the CI browser lane under the hermetic wrapper
(50 + 21 + 5 passed), and all 41 scripts/ci self-tests (two mapfile/bash-3.2
casualties green under bash 5, the CI shape).

Row 4 (stays open, dated note added): both-backend parity proven with
legs demonstrably executed for the fabric (57 pg + 81 libsql), triggers
(ADR 0003, REQUIRE_POSTGRES), hooks (ADR 0004, all three backends),
composition, processes journal, extension-registry, host-runtime libSQL
restart, and the backend matrix; fabric-delegated domains enumerated.
Two REAL blockers (one class): the Postgres legs of the event-store and
assistant-ledger contract suites assert against shared-database state and
cannot pass as-written (each failing test passes alone on a virgin
database; files byte-identical to origin/main; no CI lane sets their env
vars). Full evidence: docs/reborn/target-architecture/ws12-gauntlet-report.md.

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

* fix(tests): per-test isolated Postgres databases for the two WS12 parity-blocking contract suites

The WS12 gauntlet (ws12-gauntlet-report.md §P6/§P8) measured the Postgres
legs of ironclaw_event_store's durable_event_store_contract and
ironclaw_assistant's durable_ledger_contract as test-isolation-defective:
absolute database-global asserts (event cursors; settled-entry prune
bookkeeping) run against the single external database named by their
IRONCLAW_*_POSTGRES_URL env vars. Every failing test passes alone on a
virgin database - store semantics correct, suites not self-isolating
(PROPOSAL §12.13 D-T).

Fix: each affected test provisions a private database on the configured
server - the fabric contract's IsolatedDatabase pattern
(db_root_filesystem_contract.rs) ported locally into each suite: CREATE
DATABASE per test, store/pool + migrations against it, courtesy
DROP ... WITH (FORCE), and a once-per-binary stale-name sweep. Every
assertion preserved byte-identical; libsql/jsonl twins untouched. In the
ledger suite only the two retention tests move - the other six Postgres
tests keep their proven fingerprint-suffix isolation.

Regression pins are the fixed tests themselves:
- postgres_replay_advances_next_cursor_past_trailing_filtered_records
- postgres_runtime_and_audit_logs_survive_rebuild_with_filtered_cursor_semantics
- postgres_settled_entry_limit_prunes_oldest_when_configured
- postgres_settled_prune_interval_defers_until_interval_when_configured
Green proven on a shared dirty database twice in a row (parallel default
threading) and serially on a virgin database; red-first reproduction
captured before the fix.

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

* docs(reborn): record §12.13 D-T (parity-suite isolation ruling) and close CHECKLIST WS12 row 4

D-T (after D-S): the WS12 gauntlet's two REAL findings were one defect
class — absolute database-global asserts against the single shared
env-var Postgres database — in two suites (event store cursor contract,
assistant settled-ledger retention). Ruling executed in commit 864d93ee9:
per-test isolated databases via the fabric contract's IsolatedDatabase
pattern, assertions preserved; alternatives (baseline-relative asserts,
serial-only, leave-open) recorded with why they lost; regression pin =
the four fixed tests themselves.

CHECKLIST WS12 backend-parity row ticks [x] with a dated addendum: red-first
reproduction, the three green isolation runs (dirty shared DB twice in
parallel; failing pairs serial on virgin), parity now green 10/10.

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

* docs(target-arch): three measured corrections surfaced by the guidance program

memory packages are substrates-layer, not products (families/extensions.md);
memory_native declares no extension_contracts dep (PROPOSAL §6.8.4); wasm's
extension_contracts edge is dev-only and the wasm 'never depends on' bullet is
lane-scoped, not family-wide (families/lanes.md).

Three further reported defects were checked and NOT corrected — they were
misreads: the sandbox 'never above the runtime tier' rule holds (substrates sit
below it), and PROPOSAL's safety consumer count already reads 17, matching the
tree.

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

* docs(target-arch): repair the corrupted kernel bullet and correct two family laws

kernel.md: ironclaw_authorization's 'Security & authority role' bullet has been
textually corrupted since #6918 — an approvals sentence was spliced into it
mid-clause, orphaning its continuation line. Reconstructed, with the spliced
sentence restored to the approvals entry where it is true.

lanes.md: 'a lane never depends on a substrate' is false as a family-wide law
(ironclaw_sandbox holds network/safety/secrets normal deps, which its own entry
licenses); the accurate law is the layer ladder, and the narrow claim holds for
ironclaw_wasm alone.

lanes.md + events.md: the 'every crate ships both an AGENTS.md and a CLAUDE.md'
requirement is superseded by docs/reborn/guidance-conventions.md — two files
restating one rule is the drift the guidance program removes.

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

* test(arch): govern the ProtocolAuthEvidence test seam — WS12 audit F1

Two new gates in reborn_sealed_evidence_mint_ratchet (closed paths #12/#13),
per the audit's remedy spec:
(a) TEST_SEAM_MINT_FNS governs test_verified/test_verified_for_tenant — any
    production-text call site outside ironclaw_host_api is an offender
    (comments/strings stripped, #[cfg(test)] blocks stripped, tests.rs /
    *_tests.rs and cfg-test-only files excluded via the shared census);
(b) test-support may appear in no normal dependency table workspace-wide
    (dependencies / build-dependencies / target.* variants /
    workspace.dependencies), and no [features] key other than test-support
    may forward to it — the laundering shape that would evade (b) by one
    rename. [dev-dependencies] enablement stays legal (cargo-features.md
    bar 4, the sanctioned dev seam).

Measured zero offenders on this tree in both directions before pinning;
sabotage-proven red->green both ways (planted production call named with
file:line-text; [dependencies] enablement named with its table path).
Self-tests drive the same pipelines the gates run (zero-match principle);
the definition-location and partition tests now cover the new table.

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

* test(integration): join the gsuite credential-injection journey — WS12 audit F3

WS12 row 5 leg 3 was verified in two halves no committed test joined: gsuite
handler -> staged credential (crate tier) and staged obligation -> wire
(GitHub/Slack only). Scenario 5 already drives gmail.list_messages through
production dispatch on a Google-OAuth-configured group; it now also asserts
the JOIN: the seeded google account's token (itest-google-token) lands on
the recorded outbound gmail.googleapis.com request as
'authorization: Bearer ...', injected at the host egress chokepoint
(apply_credential_injection) per the gmail manifest's declared recipe —
store -> dispatch-time staging -> chokepoint -> wire, through the caller.

Sabotage-proven: disabling the Header injection arm reds exactly this
scenario with 'no network egress request matching url gmail.googleapis.com
has header authorization' while the request itself still reaches the wire
(headers seen: content-type only) — the injection reason, not a setup
error; restore -> green.

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

* docs(target-arch): correct five measured dependency claims in families/domains.md

conversations does not depend on safety (its BoundaryRule now forbids it);
triggers depends on libsql_runtime + safety and NOT filesystem, so its
'filesystem-routed persistence path alongside SQL' is one path, not two;
memory's live set is host_api alone (prompt_envelope is allowlisted, unused);
auth was short by extension_contracts + product_contracts.

Each verified against the manifest before editing.

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

* docs(target-arch): record the closed scan evasions (F4) and the secrets-consumer correction (F2)

The sealed-mint census weaknesses PROPOSAL §11.2.5/§12.1a and CHECKLIST recorded
as live and owed to WS10 are all closed on this tree, verified by re-attacking
the seam with both evasions at once; the docs understated the seal. Ratchet is
23 tests. One residual replaces them: the test_verified test-seam constructors,
now pinned by two gates.

§12.1b's 'only products-layer crate with the edge' is false by one —
ironclaw_assistant carries ironclaw_secrets as port-declaration vocabulary with
no expose_secret call. Not a value-reach bypass; joins #7095's inventory.

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

* docs(target-arch): correct the app-family layer, config's consumer set, and the webui route count

ironclaw_config declares layer=substrates while living in crates/app/;
its consumers include operator, extension_manager and extension_host, not just
the assembly crate and the binary; webui is 93 contract-locked routes, not 92
(#6780 landed after the last recount).

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

* docs(ws12): tick row 7 — the fresh-agent placement probe passed on the final tree

All three placements correct with high confidence, each naming the trait, the
tests, and the tempting wrong place it rejected. The probe doubled as a docs
audit and independently hit four defects, three of which the stacked guidance
PR fixes — it succeeded despite them.

WS12 is now 7/7. The restructure is complete.

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

* docs(target-arch): the product→loop_host recount was wrong on the day it was written

Eight importing files across four seams, not seven across three — the fourth
being a skill-activation-observer seam (projection.rs, projection/live_progress.rs)
this bullet never named, which §6.4.7's own same-day note already implied.
Surfaced by the plan-conformance audit.

The recount history is 3→5→6→7→8, wrong at four of five attempts. That retires
the prose count as a method: the sever slice should land an inventory ratchet
before or with the move, not another number.

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

* review(7263): CodeRabbit round-1 triage — 4 code fixes (2 sabotage-proven, 2 red-first) + 6 doc-truth corrections

Code, each verified red-first or by sabotage matrix:
- sealed-mint ratchet: per-name sighting floor for TEST_SEAM_MINT_FNS
  (closed path #12). Proven: renaming test_verified_for_tenant away plus one
  extra legitimate sibling mention passed the old aggregate floor (silent
  disarm) and fails the new per-name floor naming the constructor; suite
  23/23 after revert. (CodeRabbit's claimed baseline ">2 mentions today" is
  wrong — each name has exactly one kept sighting — but the doc/enforcement
  mismatch and at-threshold fragility were real.)
- trace credit: non-finite novelty_score/duplicate_score are treated as
  absent before clamping (clamp preserves NaN, which poisoned online_score
  and credit_points_estimate); NaN cases added to the #7144 regression test,
  red first.
- trace submission: a 2xx whose body stream dies mid-read now maps through
  request_failed (network telemetry kind, true I/O cause) instead of
  collapsing to an empty body that the #7144 strict parse misreported as
  response_invalid/Submission; truncated-body regression test, red first.
- Postgres contract suites (event store + assistant ledger): isolated-DB
  names now carry a creation epoch and the once-per-binary sweep is
  age-gated (1h), closing the cross-process window where a sibling's fresh
  zero-backend database (between CREATE DATABASE and first connection) was
  sweepable; legacy pid-scheme leftovers still collect immediately. Proven
  on live Postgres 16: planted stale name swept, planted fresh name
  survives, 13/13 x2 and 20/20 x2 with zero leftovers.

Docs (target-architecture truth pass):
- PROPOSAL section 9: the WS6 rename sweep (#7152) had rewritten the source
  column of the 12 renamed rows to their post-rename names, turning their
  rename dispositions into no-ops (rows 13/14/28/30/49/51/59/61/64/66/67/70);
  pre-restructure names restored with a dated footnote.
- PROPOSAL:69: removed the superseded 3->5->6->7 recount sentence (the
  corrected 3->5->6->7->8 passage subsumes it).
- PROPOSAL row 34: ToolPermissionOverrideStorePort deletion marked landed
  (2026-08-05 WS8, matching section 6.5.3; zero workspace hits).
- CHECKLIST:631: dated note recording that the WS12 F3 gsuite join landed in
  this batch (scenario_uninstalled_tool_call_denied_until_active.rs asserts
  the seeded google token on the gmail.googleapis.com wire; suite run green).
- CHECKLIST:632: dated note spending F4 (the audit's 19 was correct at its
  SHA; the ratchet file now holds 23 tests, re-counted at lines 552/597).
- ws12-gauntlet-report P6 heading: first of TWO real failures (one class),
  matching P8 and the report's own summary.
- ws12-mapping-audit rows 49/137: dated D-S closure notes (await-edge store
  half = journal projection already; resolver retained loop-tier; no shed
  owed) so the backlog register no longer lists it as in-flight.

Not fixed, with evidence: the span-helper macros gate suggestion
(info_span!(target = ...) is a hard compile error, E0425 — no silent trap),
the webui tracing-subscriber workspace-dep suggestion (no
[workspace.dependencies] entry exists; suggestion would not build; 8
siblings use the identical direct shape), and the mapping-audit
regeneration (the audit is accurate at its pinned SHA; the in-batch F1 fix
is recorded in its dated coordinator note).

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

* review(7263): CodeRabbit round-2 — rejection-body read keeps its cause; 200 {} is not a submission acknowledgement; lanes.md family dep rule matches measured Cargo.tomls

- submission.rs non-2xx path: a failed rejection-body read no longer collapses
  to an empty detail via .unwrap_or_default() (banned by
  .claude/rules/error-handling.md); the read error folds into the
  http_rejection detail so the received status keeps driving the 401/403
  auth-retry and the Credential/HttpRejection telemetry split.
  Regression: submit_preserves_rejection_body_read_failure_cause_with_status.

- TraceSubmissionReceipt.status: serde default removed — it fabricated
  status "submitted" from a proxy's 200 {} (the #7144 synthesis, resurfacing
  through the wire type's defaults), after which the flush caller recorded
  Submitted and deleted the only retryable queued copy. The acknowledgement is
  the server naming what happened to the submission — every workspace fixture
  sends status and callers persist it unconditionally as server_status — so a
  status-less 2xx body now fails the strict receipt parse as response_invalid.
  Regression: submit_rejects_success_response_without_explicit_server_status
  (covers 200 {} and a status-less non-empty object).

- docs(lanes.md): the family Dependency-direction rule no longer claims every
  lane takes the extension-surface vocabulary crate — measured across
  crates/lanes/*/Cargo.toml: mcp + sandbox hold ironclaw_extension_contracts
  under [dependencies], wasm only under [dev-dependencies]; dated ✎
  cross-references the ironclaw_wasm entry's 2026-08-05 correction.

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

* review(7263): CodeRabbit round-3 — shared Postgres test provisioner (the "new dep edge" premise measured false), entrypoint self-test armed (sabotage-proven), six doc self-contradictions reconciled

Code:
- ironclaw_filesystem gains a `postgres_isolation` test-support module — the
  single home of the per-test isolated-database scaffolding (once-per-binary
  age-gated stale sweep, epoch-in-name convention, DROP WITH (FORCE) cleanup),
  parameterised by suite/env-var/prefix/unreachable-policy. Zero new
  production edges: event_store already normal-deps filesystem, filesystem
  already owns tokio-postgres, and the dev-dep+feature pattern is the one 17
  crates already use. The event-store and product-workflow-ledger suites
  migrate onto it; both Postgres legs proven live against postgres:16 (12
  tests, zero leftover databases). The fabric original keeps its older
  variant with the differences documented at its IsolatedDatabase.
- ironclaw_event_store drops the duplicate tokio-postgres dev-dep (the normal
  dep already reaches tests).
- test-reborn-docker-entrypoint.sh: the missing-argv check now exits the
  command-substitution subshell instead of incrementing a counter the parent
  never sees — red-proven (a migrate-but-never-exec entrypoint passed with 7
  FAIL lines printed), green after the fix both sabotaged and restored.
- trace_commons submission test additionally pins !auth_rejection() for the
  503 rejection (the structural assert the API affords; the prescribed
  payload asserts are refuted — status is private and source is None by
  design, with the message derived from the structured status in the same
  constructor).

Docs (each reconciled to one canonical statement, measured):
- kernel.md: lease ownership decided from code — authorization stores,
  matches, and expires leases (CapabilityLeaseStore + port + expiry all live
  there); approvals constructs and issues into that store. The round-1
  re-homing of the spliced sentence into approvals was wrong and is corrected
  in the dated repair note.
- app.md: "nothing depends on app" scoped to the three app-layer crates;
  ironclaw_config's consumers restated by dependency kind (normal:
  composition, cli, operator, extension_host; dev-only: extension_manager,
  root integration-tests package).
- lanes.md: the mediated-services sentence now states the family law as
  layer-ladder + injected authority; the no-secrets/network/filesystem-dep
  claim is scoped to ironclaw_wasm, matching the file's own corrections.
- CHECKLIST 429/430: the one open traces clause is named (ScopedFilesystem
  adoption); the stale "other two" count corrected against the F3a strike.
- PROPOSAL:69 + CHECKLIST:72: the project-create route repointed —
  first_party_extension_ports dissolved into loop_host::skill_activation
  (WS8, §9 row 55) — still unattempted.
- PROPOSAL §9 rows 57/62 synced to §6.8.4 (telegram: dependency-set equality
  with Slack's four contract-tier crates) and §6.9.4 (webui -> assistant is a
  charter-permanent edge, §12.11 D-B).
- PLAN top summary records Wave 6's design question as resolved (D-S,
  2026-08-05).
- deploy-reborn-cli-docker.md: the two migration paragraphs unified on the
  entrypoint's actual behavior — only enabled = false beside
  signing_secret_env/bot_token_env is migrated; every other retired-key shape
  fails startup with the migration pointer.
- composition-budget.toml: the stale "2398 bp, a true ratchet" header
  replaced with the WS0-floor truth the baselines test asserts.

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

* docs: move the guidance convention into this PR so its citations resolve

families/lanes.md and families/events.md cite docs/reborn/guidance-conventions.md
when superseding their 'every crate ships both an AGENTS.md and a CLAUDE.md'
requirement, but the file was only on the stacked guidance branch — a forward
reference that dangles if this PR merges alone. The convention is the rule those
notes invoke, so it belongs with them.

Caught by the CodeRabbit round-3 pass.

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

* fix(ci): give the hoisted postgres provisioner its safety rationales

The round-3 hoist moved test provisioning into a production src/ path, so
check_no_panics flagged its four panic/expect sites and reddened Code Style via
fast-checks. The gate is right to flag them: it deliberately does NOT exempt
#[cfg(feature = "test-support")] modules, because a cargo feature is not a
privilege boundary in this workspace (PROPOSAL 12.1a proved exactly that) —
so a test-support module still compiles into a build where any sibling enables
the feature.

Suppressed with the gate's documented inline rationale, which must trail the
statement rather than precede it. The panics themselves stay: a configured but
unusable Postgres must fail the suite loudly rather than skip it, which is the
inert-guard rule the isolation fix exists to serve.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:22:01 +00:00
ironloopai[bot]
2816b0f91b fix(ci): recognize node assert methods in regression gate (#7211)
* fix(ci): recognize node assert methods in regression gate

* fix(ci): parse nested node assert arguments

* fix(ci): ignore non-executable TypeScript assertions

* fix(ci): lex TypeScript diff fragments instead of whole source

The masker added in 7d9338b4d assumed it was reading complete TypeScript,
but `has_meaningful_typescript_assertion` is fed `added_text`: the `+`
lines of a diff spliced together, where quoting need not balance. One
apostrophe in JSX prose, one hunk landing inside a template literal, or a
JSDoc continuation line was enough to mask every assertion after it, and
the gate then rejected a legitimate fix. It also had no notion of regex
literals, so `assert.match(url, /^https?:\/\//)` was truncated at the
escaped `//`.

Rewrite the scanner around one rule: an unterminated literal is a diff
artifact, so leave it alone rather than let it swallow the rest of the
text. Comments still win over the regex heuristic (no regex starts with
`/` or `*`), regex literals are recognized by the preceding token and
masked body-only, and literal interiors are filled rather than blanked so
`expect("fixed").toBe("fixed")` is still visible as a tautology.

Also fold the three assertion loops into the one balanced-parse path.
The `expect(...)` and bare `strictEqual(...)` loops still used `(.*?)`,
which drops the last character of a nested operand and so let
`expect(f(1)).toEqual(f(1))` through as regression evidence -- the same
defect already reported against the `assert.*` path. Named imports
(`deepEqual`, `notStrictEqual`) are recognized now; `match` and `ok` stay
qualified-only, since bare they are `url.match(...)` and anybody's helper.

Fixtures cover each case, and all of them fail against the pre-fix
checker.

---------

Co-authored-by: aiworkbot <220660587+aiworkbot@users.noreply.github.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-08-06 05:52:50 +00:00
Benjamin Kurrek
b72d7da662 feat(reborn): standardized messaging framework — host-owned standard ops with enforced canonical contracts (#6831)
* feat(reborn): standardized messaging framework — host-owned standard ops with enforced canonical contracts

Squash-port of branch inky-cast (43 commits, head 93b74fa73) onto main
d3791e0f8. The branch's prior refreshes were merge-based, so a per-commit
rebase across the WS6 renames + WS7 family moves re-conflicted on every
replay; this is the sanctioned fallback: one 3-way application of the full
reviewed delta (ort merge, rename detection on), preserving the reviewed
end state exactly, then re-homed onto the restructured tree:

- crates/ironclaw_host_api           -> crates/contracts/ironclaw_host_api
- crates/ironclaw_host_runtime       -> crates/kernel/ironclaw_host_runtime
- crates/ironclaw_extensions         -> crates/extensions/ironclaw_extension_registry
- crates/ironclaw_reborn_composition -> crates/app/ironclaw_composition
- new prompts/ + schemas/ messaging assets relocated with the host_api move

Conflict resolutions (9 text + 1 binary): workspace Cargo.toml dep table,
extension_host active.rs/mcp.rs imports + standard_op field, slack package
asset comment, three host_runtime test import lists, wasm-src digest table
(placeholder pending rebuild), Slack WASM artifact (placeholder pending
rebuild), reborn-extension-surfaces SKILL.md path refresh.

Follow-up commits adapt stale crate names/paths in ported content and
rebuild the Slack WASM artifact.

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

* chore(reborn): re-point ported content at post-WS6/WS7 crate names and paths

Mechanical adaptation of the squash-ported messaging framework to the
restructured tree: crate-name fixes in code (ironclaw_extensions:: ->
ironclaw_extension_registry:: in production.rs and manifest_v3_contract.rs)
and path/name refreshes in comments, docs, the affected-test planner
fixture, the changed-coverage exemption keys, and the messaging
design/plan docs. 75 lines, 1:1 swaps, no behavior change.

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

* chore(reborn): recapture slack wasm-src digest and shifted coverage-exemption line

The merged slack wasm-src tree differs from both parents (PR feature delta
+ post-move path comments), so its source digest is recomputed after
rebuilding the artifact per scripts/ci/wasm-src-digests.toml's own
instructions; the rebuilt slack_user_tool.wasm is byte-identical to the
branch's committed artifact, and the github/google digests stay at main's
values. Also re-keys the capability.rs changed-coverage exemption from
line 256 to 265 — the only exempted line the merge shifted (verified by
content match; the other three files' exempted lines are unshifted).

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

* chore(ci): fold the planner's snapshot-prefix mapping into #7133's landed mechanism

Main independently added INTEGRATION_SNAPSHOT_PREFIX_OWNERS (with the
golden-payload mapping this branch carried as
INTEGRATION_SUPPORT_PREFIX_OWNERS); the rebase adopts main's vocabulary and
drops this branch's now-duplicate prefix loop from the support-owner test —
#7133's dedicated golden-payload test pins the same behavior. Golden
snapshots regenerated against the merged tree (surface-hash lines only,
the PR's reviewed footprint); 61 planner self-tests green and both live
selections verified (snapshot -> lane 1, acme fixture -> lane 2).

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

* chore(arch): lower the cross-crate include ratchet to 16 for the retired slack schema embed

#7258's two-directional include inventory (merge-queue red on this PR)
demands the ceiling drop in the PR that removes a reach-in: standard_op
binding retires the ironclaw_extension_support -> packages/slack schema
asset embed (schemas resolve from the compiled-in host_api messaging
registry), taking the live cross-crate count 17 -> 16. Constant lowered
and the measurement note updated (slack/telegram owner itemization 5 -> 4).
Gate re-run green locally; it was the single root cause of all three
merge-queue failures (the same gate runs in Code Style's smoke job, the
architecture-misc test bucket, and E2E's boundaries job).

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

* chore(arch): raise the host_api size ceiling for the messaging vocabulary (17501 -> 18570)

The second #7258 gate this PR trips: contracts crates carry a checked
production-line ceiling, and the standardized-messaging vocabulary grows
ironclaw_host_api to 18570 lines. Raised to the exact live count (zero
banked slack, matching the table's captured-at-live convention) with the
rationale inline and in the PR body's architecture-audit section: the
growth is declarations only — op enum, error taxonomy, compiled-in schema
and prompt constants, test-support conformance — while executable
validation lives in ironclaw_host_runtime; moving the vocabulary up is
structurally circular (CapabilityDescriptor.standard_op anchors it).
Full architecture suite green locally with both gate fixes (241+ tests).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 23:23:50 +00:00
Benjamin Kurrek
b2023bc8fa The narrowing tail: WS5/WS6/WS8/WS10 closures + both crate dissolutions (batch of 7 slices) (#7258)
* 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>

* refactor(triggers,conversations): scan trusted trigger prompts at the mint (WS6)

PROPOSAL §6.4.2 asked for the trusted-trigger prompt safety scan to move
"behind the triggers/kernel seam it guards". It was not a module: it was
three lines inside `ConversationTrustedTriggerSubmitter::submit_trusted_trigger_fire`
— one of the two implementations of `ironclaw_triggers::TrustedTriggerFireSubmitter`
— holding its own `Arc<dyn InjectionScanner>` from `Sanitizer::new()`.

That placement is a fail-open: a guard that lives inside one implementation
of a port is lost the moment a second implementation exists, and nothing in
the tree forced a new submitter to re-run it.

The seam is `TrustedTriggerFireSubmitter`, whose only input is the sealed
`TrustedTriggerSubmitRequest`, which `ironclaw_triggers` is the sole minter
of. So the scan moved to the mint: `TrustedTriggerSubmitRequest::new` is now
fallible and calls the new `ironclaw_triggers::prompt_safety` first, making
"this prompt passed the trusted-prompt scan" an invariant of the type rather
than a step some submitter performs. `new_for_test` delegates to `new`, so
the test-support seal bypasses visibility only, never the scan.

Behaviour at the fire level is unchanged — same rejection point, same
`TriggerError::InvalidMaterialization`, same permanent disposition — and
composition's pre-materialization scan is untouched, so defence in depth
survives with the second scan relocated and now covering every submitter.

`ironclaw_conversations` drops `ironclaw_safety` entirely (the scan was its
only use). Enforcement: triggers' boundary rule stops forbidding
`ironclaw_safety` (a same-layer, I/O-free `substrates` leaf — a peer edge,
not a reach upward), and a NEW `BoundaryRule` for `ironclaw_conversations`
forbids it, plus `ironclaw_threads` (§6.4.2's "Never: transcript content"),
a crate that was unruled until now.

Regression coverage at the caller tier, not on the helper:
`tick_rejects_injection_prompt_before_any_trusted_submitter_is_reached`
drives the real `TriggerPollerWorker::tick_once` with a materializer that
does NOT scan and a submitter configured to accept, and asserts the
submitter is never reached. A companion pins that a medium-severity-only
prompt still submits, so the mint cannot drift into a blanket filter.

Tests: conversations 97 -> 97 (name-identical), triggers 169 -> 173
(+2 worker, +2 prompt_safety unit), architecture 206 -> 206.
LAYER_MATRIX_EXCEPTIONS unchanged at 10.

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.

* refactor(traces): drop the boundary-laundering re-export modules (WS6)

PROPOSAL §6.4.14: "drop the boundary-laundering re-export modules
(`recording`, `paths`) — consumers import the owners".

`ironclaw_reborn_traces::{recording, paths}` were two `pub use <other
crate>::*` passthroughs whose own doc comments stated their purpose
plainly: "so reborn-cli does not need a direct `ironclaw_llm`
dependency, preserving the architectural boundary". They preserved
nothing — the edge existed either way; the wildcard only hid which crate
owned the type, so the dependency graph read as a lie.

All three call sites were in `ironclaw_reborn_cli`. Note the literal
reading of "consumers import the owners" is not available here: the CLI's
dependency allowlist (`reborn_cli_binary_crate_stays_separate_from_v1_root`)
deliberately excludes `ironclaw_llm`, so importing the owner would have
traded a laundered re-export for a breached, tested boundary. Satisfied
instead by giving the owning crate the operation, which is what the
laundering was standing in for:

- `onboarding::onboard_instance(invite, consents)` — resolves the
  contribution root itself. Path layout under the base dir is this
  crate's own knowledge; the CLI no longer needs base-dir vocabulary.
- `TraceClientHost::build_envelope_from_recorded_trace_json(json, opts)`
  — parses `ironclaw_llm::recording::TraceFile` inside the crate that
  already depends on `ironclaw_llm`. The CLI hands over raw JSON.
- the CLI's private `trace_contribution_dir()` now delegates to
  `contribution::trace_contribution_dir_for_scope(None)` instead of
  re-deriving `<base>/trace_contributions`. Verified byte-identical:
  `trace_contribution_dir_for_scope(None)` is
  `trace_contribution_dir_for_scope_at(&ironclaw_base_dir(), None)`,
  whose `None` arm returns `base.join("trace_contributions")`.

No dependency was added to any crate. Semantics unchanged.

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

* refactor(llm): make providers.json a crate asset with a boundary rule (WS6)

CHECKLIST WS6: "`llm` `providers.json` becomes a crate asset/composition
input + boundary rule added".

The provider catalog sat at the **repository root**. A root-level data
file has no owning crate, so no boundary rule could govern who edits it,
and every consumer compiled it in behind Cargo's back with an escaping
`include_str!` — the "repo-root asset reach-in" shape §11.2.7's scanner
inventories. `git mv`'d to `crates/ironclaw_llm/assets/providers.json`
and the 20 `include_str!("../../../providers.json")` sites in
`registry.rs` become in-crate `../assets/providers.json`.

⚠ Correcting the row's inherited premise: a prior lane recorded the
"load-bearing include site is in `ironclaw_reborn_cli`" and judged the
item "needs a new mechanism, not a new path". Measured on main: the
load-bearing site is `crates/ironclaw_llm/src/registry.rs:383`
(`builtin_provider_definitions`), inside the owning crate. No new
mechanism was needed — only the path.

**Path-keyed gates rewritten in the same commit** (WS10: these fail
*silently* under a move):
- `Dockerfile` — both `COPY providers.json providers.json` lines deleted;
  `COPY crates/ crates/` already covers the new location in both stages.
  Verified by `scripts/ci/check-include-str-paths.sh` (OK, 119 refs).
- `.github/workflows/reborn-e2e.yml` — the literal `providers.json` path
  filter and its regex alternative removed; the depth-independent
  `crates/**` entry already matches. `ws12_workflow_contracts.py` passes.
- `scripts/ci/classify-test-scope.sh` — kept at its **shared** (both
  lanes) classification under the new path rather than letting it fall
  through to crate scope, so CI breadth does not silently narrow; the
  now-redundant entry in the reborn-only branch is dropped.

**The one consumer that could not simply be repointed.** The CLI's
`default_llm_consts_match_the_real_providers_json_nearai_entry` embedded
the catalog from five directories up to check its mirrored `DEFAULT_LLM_*`
constants. Repointing it would have turned a repo-root reach-in into a
*cross-crate* reach-in — the category §11.2.7 turns into a hard failure —
and the CLI may not depend on `ironclaw_llm`. A cross-crate consistency
rule belongs in the cross-crate suite, so the assertions moved into
`ironclaw_architecture` and read both files from disk at runtime, needing
no compile-time coupling at all.

Test accounting: `ironclaw_reborn_cli` config-init tests 2 -> 1; the
removed one is reborn as `reborn_provider_catalog_is_owned_by_its_crate`
in `reborn_dependency_boundaries.rs`, strictly stronger (it also pins the
asset's location, the repo root's emptiness, and single-embedder
ownership). Net test count +0.

**The new rule is sabotage-tested** — five cases, each red with the right
message, each restored to green:
1. catalog copied back to the repo root -> "must not sit at the
   repository root"
2. a foreign crate `include_str!`s it -> names the offending file
3. catalog `default_model` drifts from the CLI mirror -> names the const,
   the field and both files
4. walker pointed at a non-existent dir -> "walked only 0 Rust files ...
   would pass no matter what the tree contained" (reachability)
5. mirrored const renamed -> "no longer declared as a plain const ...
   update the extraction rather than deleting the drift check"

Case 2 caught a real false positive in the first draft of the guard: a
file-level `include_str!` AND `providers.json` conjunction flagged
`cli/tests/smoke.rs`, which names the *runtime*
`$IRONCLAW_REBORN_HOME/providers.json` and separately embeds something
else. The matcher now inspects the macro argument, not the file.

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

* 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.

* docs(skills): rewrite the stale v1 lib.rs charter note (WS6)

CHECKLIST WS6 domain-internal cleanups: "`skills` stale v1 lib.rs doc
rewritten".

The crate doc claimed "In v1, trust-based tool filtering happens via
`src/skills/attenuation.rs`. In v2, the Python orchestrator handles trust
labels and the policy engine controls tool access via capability leases."
Both halves are dead vocabulary: there is no `src/` monolith on this tree
and no Python orchestrator anywhere in Reborn.

Replaced with what is true and checkable — this crate owns the trust
*label* and none of its enforcement; the ceiling is applied at the
capability tier (`host_api` capability/invocation attenuation via
`first_party_extension_ports`' activation and execution paths) and the
decision belongs to `ironclaw_authorization`. Also points at the existing
`SkillTrust` `Ord` safety note, which the old text left unconnected.

Doc-only; no code change.

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

* 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.

* refactor(crates): execute the WS6 crate renames, no shims (WS6)

Three CHECKLIST WS6 rename rows, executed together as one pure rename.
No compatibility re-export shims (WS6 discipline); every consumer, doc,
CI script and snapshot repointed in this commit.

**Row 1 — stutter kills (decided 2026-07-29):**
- `ironclaw_events`             -> `ironclaw_event_log`
- `ironclaw_extensions`         -> `ironclaw_extension_registry`
- `ironclaw_product`            -> `ironclaw_assistant`

**Row 2 — naming audit (decided 2026-07-30):**
- `ironclaw_architecture`       -> `ironclaw_architecture_tests`
- `ironclaw_runner`             -> `ironclaw_turn_runner`
(`ironclaw_first_party_extensions` -> `ironclaw_extension_support` landed
early with WS2.6 and is already ticked.)

**Row 3 — the `reborn_` batch (decided 2026-07-30):**
- `ironclaw_reborn_composition`   -> `ironclaw_composition`
- `ironclaw_reborn_config`        -> `ironclaw_config`
- `ironclaw_reborn_event_store`   -> `ironclaw_event_store`
- `ironclaw_reborn_identity`      -> `ironclaw_identity`
- `ironclaw_reborn_openai_compat` -> `ironclaw_openai_compat`
- `ironclaw_reborn_traces`        -> `ironclaw_trace_commons` (§6.4.14:
  the crate is the Trace Commons client, not trace machinery)
- root package `ironclaw_reborn_integration_tests` -> `ironclaw_integration_tests`

4,806 occurrences rewritten across 901 files, plus 11 `git mv`'d crate
directories (`git diff -M` reports them as renames). Replacement used
word-boundary matching, which is what keeps `ironclaw_product` from
touching `ironclaw_product_contracts` and `ironclaw_extensions` from
touching the four `ironclaw_extension_*` siblings.

**Semantics: none.** No type was renamed, no module moved, no signature
changed. `cargo check --workspace --all-targets` is clean.

**Path-keyed gates rewritten in the same commit** — WS10 lists these as
the ones that fail *silently* under a rename, and each was re-run to
prove it still scans a non-zero tree rather than merely passing:
- `scripts/no_panics_reborn_baseline.txt` — 3 entries repointed, 0 stale
  names left; `--reborn-baseline` reports "OK ... (1203 files, 51
  reviewed invariant(s))" and `--self-test` passes 34 tests.
- `docs/plans/composition-pubuse.snapshot` — 5 entries. This one is not
  documentation despite its path: `composition_public_pub_use_surface_matches_snapshot`
  compares against it byte-for-byte, and it failed loudly when the rename
  first landed without it. Caught by running the suite, not by inspection.
- `scripts/ci/classify-test-scope.sh`, `scripts/ci/reborn-crate-test-buckets.sh`
  (+ its self-test), `scripts/ci/discover-reborn-package-crates.sh`,
  `scripts/ci/package-feature-flags.sh`,
  `scripts/ci/check-generic-without-concrete.sh`,
  `scripts/ci/ws12_workflow_contracts.py`, `scripts/dev_metrics.py`,
  `scripts/reborn-e2e-rust.sh`, `scripts/pre-commit-safety.sh`.
- **CI lane names**, which the `ironclaw_architecture` row calls out
  explicitly: `.github/workflows/code_style.yml`'s `cargo test -p
  ironclaw_architecture reborn` step and its changed-paths regex.

Verification: `cargo check --workspace --all-targets` clean;
`ironclaw_architecture_tests` 32/32 suites green; `ws12_workflow_contracts.py`,
`test-classify-test-scope.sh`, `test-reborn-crate-test-buckets.sh`,
`check-include-str-paths.sh` all pass. `LAYER_MATRIX_EXCEPTIONS` counted
with Python between the const and its `];` — **6**, unchanged.

Deliberately not rewritten: `docs/reborn/subagent-spawn/diagrams/*.{d2,svg}`
and the historical prose in `docs/`. Those describe an unlanded design
authored against the old tree; renaming inside them would misrepresent
what was designed, and the `.svg`s are generated artifacts.

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

* 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>

* refactor(cli): move the binary crate to crates/app/ironclaw_cli (WS6)

Last clause of the WS6 `reborn_` rename row: "cli directory ->
`app/ironclaw_cli`". Package name stays `ironclaw` (unchanged, as the row
requires); this is a directory move plus the crate-directory rename.

82 path references rewritten across 39 files, plus the crate's own 18
`path = "../X"` dependencies re-based to `../../X` now that it sits one
level deeper. `cargo check --workspace --all-targets` clean.

This is the first crate to live at a nested family path, which is exactly
the shape WS10 warns about: a gate keyed to the flat `crates/<name>/`
layout stops matching and goes green having scanned nothing. Two gates
were found by running them, not by reading them:

1. **`scripts/ci/ws12_workflow_contracts.py` failed loudly and correctly** —
   `.github/workflows/code_style.yml`'s `has_reborn_cli` filter named the
   crate `ironclaw_reborn_cli`, which the crate inventory could no longer
   resolve: "expected exactly one crate directory named
   'ironclaw_reborn_cli' under crates/, found 0 ... repoint the gate that
   names it rather than letting it measure an empty tree." Repointed
   there, in ws12's own probe table, and in
   `check-generic-without-concrete.sh`. The workflow regex already used
   the depth-independent `crates/([^/]+/)*` form, so the nesting itself
   was safe — only the crate *name* needed repointing.

2. **`docs/plans/composition-pubuse.snapshot` regenerated after `cargo
   fmt`**, not before. The rename lengthened a `pub use` line past the
   width limit, so fmt rewrapped it and the snapshot went stale a second
   time. Diff is exactly one alphabetical re-sort
   (`ironclaw_product`->`ironclaw_assistant`) and one rewrap; no symbol
   added or removed.

**Pre-existing bug fixed in passing, with evidence it predates this PR.**
`check-generic-without-concrete.sh` listed `"ironclaw_reborn_cli"` among
its sanctioned assemblers, but that set is matched against cargo
*package* names and the CLI package is `ironclaw`. The exemption
therefore matched nothing and the gate was **already red on clean
`origin/main` @ 283e1f6b7c**, reporting the two concrete extension crates
DEL-7 explicitly allows the binary to link:

    ironclaw: dependency graph contains concrete extension crate ironclaw_slack_extension
    ironclaw: dependency graph contains concrete extension crate ironclaw_telegram_extension

Reproduced on a clean checkout before assuming this PR caused it. Fixed
by naming the package, with a comment recording that these are package
names — the same directory-vs-package confusion that
`boundary_rule_names_are_package_names_not_crate_directories` exists to
catch on the dependency-boundary rules.

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>

* docs(target-arch): tick the three WS6 rename rows, amend four others (WS6)

Dated amendments, each quoting or naming the text it replaces.

**Ticked (condition verified on the merged tree):**
- the three `Renames executed` rows — stutter kills, naming audit, and
  the `reborn_` batch. All 14 clauses across them are done.

**Amended without ticking, because a clause is genuinely unmet:**
- `Domain-internal cleanups` — three of six clauses done (traces
  re-export modules, `llm providers.json`, `skills` lib.rs doc), one
  refuted (`identity` absorbing `host_api::user_identity`), two open
  (`triggers` SQL ADR, `projects` composition adapter).
- `Retire the local_dev misnomer` — the row stays ticked; its *residue
  clause* is re-scoped with measurements.

**Two row texts were wrong and are corrected rather than executed:**
1. The `traces` `ScopedFilesystem` clause says the type is "dropped".
   §6.4.14 says the crate should *take* one. §6.4.14 is right and the
   row is the error — the type exists (`ironclaw_filesystem::ScopedFilesystem`)
   and is absent from the traces crate, so this is adoption, not removal.
   Also corrects "~91 raw `fs` call sites" (that counted test code; the
   production surface is 11 in `contribution.rs` plus ~7 in
   `device_key.rs`).
2. The `local_dev` residue said "the local variable at
   `composition/src/runtime.rs:3016`". It is not one variable — it is 14
   distinct identifiers; #7098's "public type" claim is wrong
   (`RebornLocalRuntimeIdentity` is `pub(crate)`); and #7098's
   explanation for why the ratchet missed it is wrong, because a
   *second* ratchet (`reborn_deployment_mode_typename_ratchet`) already
   inventories the name and records that the sanctioned exit is Slice B,
   not a rename. Every obvious rename target is also already taken by a
   different concept.

**One clause refuted with measurements (delegated authority).** "`identity`
absorbs `host_api::user_identity` ports" would move a ports module out of
the neutral contracts crate into a crate that neither implements nor
consumes it — the sole production implementor is
`extension_host::channel_identity_store::FilesystemChannelIdentityStore`
— and, because `ironclaw_identity` depends on `ironclaw_host_api` and not
the reverse, would force `extension_host` to take a new dependency to
name a port it implements. The ports stay in `host_api`. The dual
binding-store ambiguity is resolved as nominal, not structural: principal
identity (`ironclaw_identity::identity_store`) and post-OAuth channel
binding (`extension_host::channel_identity_store`) are distinct concerns
and neither subsumes the other.

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>

* fix(ci): repoint the release-cut scripts at the moved CLI manifest

`origin/main` added `scripts/ci/cut_ironclaw_release.py` and its
self-test while this branch was in flight; both locate the version to cut
via `crates/ironclaw_reborn_cli/Cargo.toml`, which this PR moved to
`crates/app/ironclaw_cli/Cargo.toml`.

Caught by re-scanning the merge for reintroduced old crate names rather
than trusting a clean `git merge` — the merge was conflict-free precisely
because these files are new on main and touch nothing this branch edited,
which is the shape that reintroduces a stale path silently.

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>

* fix(ci): classify the Dockerfile in the Reborn PR test planner

`Detect Reborn test scope` failed on this PR with:

    Reborn PR test planner failed: unclassified pull-request path: Dockerfile

and took `Tests (Reborn)` down with it ("changes failed: failure").

`scripts/ci/reborn_pr_test_plan.py` classifies every changed path and its
fail-closed arm raises on anything no rule claims. `PR_STATIC_CONTROL_PATHS`
held `Cargo.toml`, the toolchain files and the coverage manifests, but not
`Dockerfile` — so **any** PR editing the container build context aborted
the planner. This PR is simply the first to do so: moving `providers.json`
into its owning crate made the two `COPY providers.json` lines redundant.

The Dockerfile is owned by the `Docker` workflow (its own trigger on this
path) and its COPY coverage by `check-include-str-paths.sh` under Code
Style. No Reborn test lane reads it, so it belongs with the other
de-escalating static-control paths: `mode: none`, `coverage_mode: none`,
no buckets selected.

The existing `test_unclassified_build_input_fails_fast` used `Dockerfile`
as its *example* of an unclassified path. The invariant it protects is the
fail-closed arm, not the filename, so it keeps that arm with a genuinely
unowned fixture (`unowned-root-input.mk`, fictional and never touched on
disk — same convention as `test_unmapped_crate_path_fails_fast`), and a new
`test_dockerfile_is_static_control_not_a_planner_abort` pins the new
decision by asserting the mode, the coverage mode, the empty bucket list
and the reason string.

Sabotage-tested: removing `"Dockerfile"` from the set turns the new test
red; restoring it returns 44/44 green.

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>

* refactor(cli): keep the rename flat; sever the app/ relocation to WS7 (WS6)

**Reverts the `crates/app/` family directory this branch created.** The
crate keeps its WS6 **rename** — `ironclaw_reborn_cli` -> `ironclaw_cli`,
package name `ironclaw` unchanged — at the flat path
`crates/ironclaw_cli`.

The defect was in the row, not in executing it. CHECKLIST WS6's CLI row
names `app/ironclaw_cli` as its rename target, and PROPOSAL §5's tree
confirms that destination — but family directories are WS7 (Wave 5), so a
Wave-4 row named a Wave-5 path. The row's own `[decision — severable]`
tag shows the authors knew a call was owed; it was never made, so
following the row literally does both halves at once. **Owner ruling
2026-08-04: Waves 0–4 close before anything touches Wave 5.** Severed.

This matters beyond tidiness: PLAN marks the WS10 nested-tree-safe gate
rewrites a hard prerequisite *before the first family `git mv`*, because
path-keyed gates fail **silently** under family directories rather than
loudly — #7083 (a coverage regex that blinded 11 crates the moment
`crates/extensions/` appeared) is the worked example. WS10 still has open
rows.

`crates/app/` was the **only** family directory this branch created;
`crates/extensions/` pre-exists on `main`.

**Recorded as a class, not an instance** (docs commit alongside): any
pre-WS7 row quoting PROPOSAL §5 inherits the same collision. The
established precedent is to land flat — WS1's three `contracts/ironclaw_*`
rows all say `contracts/` and all landed at `crates/ironclaw_*`; there is
no `crates/contracts/` directory. Two sibling rows carry the same defect
and are now flagged not-to-execute-as-written: WS3's
`lanes/ironclaw_sandbox` and WS4's `crates/lanes/wit/`.

**Also: the Reborn PR test planner could not classify a rename PR at all.**
`Detect Reborn test scope` failed the whole run — first on `Dockerfile`,
then on `clippy.toml` — and each fix surfaced the next, because
`reborn_pr_test_plan.py` fails closed on any unclassified path and had
never seen a diff of this shape. Fixed as a class:
- root workspace policy files decided: `clippy.toml`, `deny.toml`,
  `release-plz.toml` (beside the already-classified `Cargo.toml`);
- root scripts decided per-file as that set requires:
  `check_no_panics.py`, `dev_metrics.py`, `pre-commit-safety.sh`,
  `test-mutation-audit.sh`;
- prose/standalone trees ignored: `openwiki/` (generated wiki),
  `test-tools/`, `harness/` (standalone cargo project, own Cargo.lock);
- **`scripts/live_canary/`** added to the QA harness prefixes — the set
  listed only `scripts/live-canary/` and **both directories exist**,
  differing by hyphen-vs-underscore, so the underscore one fell through;
- files sitting directly in `crates/` (`crates/AGENTS.md`) classified as
  tree-wide prose — they belong to no package, so the crate arm raised;
- **paths removed by the diff** classified instead of fatal. This is the
  one that matters for the programme: renaming 11 crates puts ~600 deleted
  paths in the diff, none of which map to a package. Without it every WS6
  rename PR and every WS7 family move fails closed here.
- the shared-E2E-harness wall is kept but made *satisfiable*: a
  `DECIDED_E2E_HARNESS_PATHS` set records a decision. The guard's purpose
  is "changing a shared fixture must be deliberate"; as written it had no
  way to record a decision, so it blocked even a mechanical rename with no
  route forward. `tests/e2e/reborn_webui_harness.py` is decided (the E2E
  workflow owns it); everything else still raises, on both fail-closed
  arms.

Its self-test goes 43 -> 49. Two existing tests used as their *example* a
path this commit classifies; both keep their invariant with an undecided
fixture instead. **Sabotage-tested each new arm**: disabling the
removed-path arm, emptying the decided set, and disabling the `crates/`
prose arm each turn the suite red; restoring returns green. The prose arm
initially passed while sabotaged — it had no test — which is precisely the
green-while-checking-nothing shape, so a test was added and the sabotage
re-run to confirm it now fails.

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

* fix: repoint crate names reintroduced by the merge-down from main

`git merge origin/main` (fb776f3c62) was conflict-free — main's new work
touches files this branch had not edited — which is exactly the shape that
reintroduces stale crate names silently. 77 occurrences across 33 files,
found by re-scanning for every old name after the merge rather than
trusting the clean merge.

Dated historical prose under `docs/reborn/target-architecture/` is
deliberately excluded: those rows record what was true when they were
written, and rewriting them would misrepresent the record.

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>

* ci(test-plan): classify the whole repo-root metadata class, not one file per red run

`.gitattributes` is touched by this PR (the rename left its `wix/main.wxs`
rule pointing at `crates/ironclaw_reborn_cli/`, a path that no longer
exists), and the planner fails closed on unclassified paths — so it aborted
`Tests (Reborn)` with "unclassified pull-request path: .gitattributes".

Every entry already in this set was added the same way: a rename-shaped diff
touches root files a feature PR never touches, the planner dies on the first
one, and the next only appears after that one is fixed — Dockerfile, then
clippy.toml, then six more. Rather than add a ninth, this enumerates the
remaining class: all 19 unclassified root paths were found by driving the
planner over every tracked root file, and 17 are listed.

The two that are NOT listed are the point. Membership requires that no
Reborn test lane reads the file, checked per file against `crates/**/*.rs`
and `tests/**`. That check found real readers for `.dockerignore`
(`tests/dockerfile_runtime_home.rs`) and `.env.example` (`ironclaw_cli`,
`ironclaw_host_runtime`), so both stay fail-closed. Classifying a file a
test depends on would silently skip that test — worse than an aborted
planner.

Verified: planner self-test 48/48; every tracked root file except those two
now classifies; the full PR diff plans without error.

* fix(ci): repoint the test-scope classifier off the dead `ironclaw_reborn_*` glob

`Fast deterministic checks` failed on `test-classify-test-scope.sh`:

    FAIL reborn binary crate
    Expected: has_legacy_tests=false has_reborn_tests=true
    Actual:   has_legacy_tests=true  has_reborn_tests=false

`is_reborn_test_path` matched the CLI through `crates/ironclaw_reborn_*/*`.
The WS6 renames dropped that prefix from all seven crates that carried it, so
the glob now matches **nothing** and every one of them silently reclassified
as legacy. Enumerated the seven new names instead of re-globbing: they share
no prefix, and this is the second time a prefix glob has rotted here.

Fixed the classifier, not the fixture. The self-test's expectations describe
the intended behaviour; flipping them to match the break is how a gate goes
quiet.

**This class fails OPEN**, which is why only one crate's assertion caught it —
the classifier keeps answering, just wrongly. Added a guard asserting every
`crates/…` pattern in the classifier matches at least one real path, the same
shape as `sanctioned_paths_all_match_real_files`: an exemption may not outlive
the code it exempts. Two pre-existing dead arms
(`crates/ironclaw_extension_support/`, `crates/ironclaw_oauth/`) are listed
known-dead and shrink-only rather than repointed — both match nothing today,
so neither is load-bearing, and repointing them would change which tests those
crates select. That is a behaviour change, not this PR's business.

Swept the siblings: every `crates/<name>` literal and glob stem across
`scripts/`, `.github/`, and the architecture tests was checked against the
real tree. The only dead reference attributable to the 13 WS6 renames is the
one fixed here; the rest are synthetic self-test fixtures or crates deleted
long before this branch.

Sabotage-tested both, confirming red with the RIGHT message and green after
restore: (1) restoring the dead glob reproduces `FAIL reborn binary crate`;
(2) adding `crates/ironclaw_totally_invented/*` trips the new guard with
`classifier pattern matches no real path`.

Also recorded the ALLOWLIST union recount in the constant's own doc comment:
this branch carried 129, `main` 125, and the merge inherited 125 without
measuring. Recounted off the compiler (constant → 0, read `ALLOWLIST grew to
125 entries`): 125 is the live count with zero slack (#7147).

* 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>

* fix(ci): restore the composition-budget negative case the rename collapsed

T4 asserts the budget gate fails LOUDLY when the composition crate is absent.
It builds a fixture under the crate's real name and renames it away so the
gate cannot find it. The destination was hard-coded `ironclaw_composition` —
which is exactly what the WS6 rename turned the crate's real name into, so
both sides of the `mv` became the same path.

`mv X X` does not rename; it tries to nest a directory inside itself and dies
with "Invalid argument". The negative case stopped running.

Renamed the destination to `composition_renamed_away` — deliberately
synthetic, so no future crate rename can collide with it again — and wrote the
reason into the test.

Found by running the nine `Static-check self-tests` scripts that CI never
reached: that step stops at the first failure, so fixing the classifier only
uncovered what was behind it. Ran all of them, plus the nine skipped steps
after it, rather than discovering them one CI cycle at a time. This was the
only other failure; the other seventeen checks pass.

Sabotage-tested: skipping the rename (so the crate is present) makes T4 fail
with `expected exit 1, got 0` and the missing-message assertion — 49 passed,
2 failed. Restored: 51 passed, 0 failed. The case genuinely exercises the
absence again rather than passing because it never ran.

* 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>

* style: cargo fmt after the #7155/#7062 merge

`Check formatting` (step 6 of Fast deterministic checks) went red on
cca5884b47: the merge was pushed under time pressure without running fmt.

Only the two files whose crate references I rewrote by hand are affected —
`ironclaw_reborn_composition` -> `ironclaw_composition` is 9 characters
shorter, so call sites that were wrapped at the old width now fit on one line.
No semantic change.

* 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>

* WS2: clear the extension_host->product vocabulary residue (ports 4->1, ledger 9->5)

Three of the four frozen ports and four of the nine reference-ledger rows fall
by one move: the port-facing vocabulary is declared where it already lives, and
product maps at its boundary.

- `ExternalActorBindingEpoch` moves `ironclaw_conversations` ->
  `ironclaw_extension_contracts::external`, beside the `ExternalActorRef` whose
  binding it versions. Zero new crate edges (conversations already depends on
  extension_contracts). Its constructor error becomes
  `ProductAdapterError::InvalidIdentifier`, matching its siblings in that module
  byte-for-byte on the three validation rules.
- `ProductActorUserResolver` + `ProductActorUserResolutionRequest` +
  `ResolvedProductActorUser` invert into
  `ironclaw_product_contracts::actor_identity`, error swapped to
  `ProductOperationFailure` (product absorbs it with the existing total `From`,
  discriminants preserved).
- `AuthChallengeProvider`, `BlockedAuthFlowCanceller`, `AuthChallengeView`,
  `PairingAuthChallengeView` and `auth_prompt_view_for_blocked_auth` move to
  `ironclaw_auth::product_prompt`; `ChannelConnectionService` and
  `ChannelAuthAccountState` to `ironclaw_auth::channel_connection`, beside
  `project_auth_account_state` whose argument pair the latter is. Zero
  vocabulary narrowing. `ironclaw_auth` gains a `product_contracts` dependency
  (substrates -> contracts, the same downward edge and rationale
  `ironclaw_attachments` already carries).
- `ExtensionAccountSetupRegistry` stays product-owned state; extension_host now
  holds the two-method read port `ExtensionAccountSetupReader` declared in
  `product_contracts::account_setup`. `None` == empty registry.
- The approval-prompt projection, gate-ref parse and lookup scope move to
  `ironclaw_product_contracts::approval_prompt`, collapsing product's two copies
  and letting the extension host read the approval store itself instead of
  reaching up into `ironclaw_product::projection`. The scope derivation's
  equivalence with `ApprovalInteractionScope` is pinned in product.

Gate updated in the same change: residue 4 -> 1, baseline 4 -> 1, ledger 9 -> 5,
workflow-error residue 2 -> 1, `ProductActorUserResolver` added to
`INVERTED_PORT_IMPLEMENTORS`.

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

* WS2.5: gate + CHECKLIST reconciliation, and two pre-existing clippy reds

- `reborn_extension_host_port_inversion.rs`: `channel_host.rs`'s ledger reason
  loses its stale `ProductActorUserResolver` half (that port is inverted now).
- `reborn_extension_specificity.rs`: the moved `ChannelConnectionService` doc
  carried a `slack` example into `ironclaw_auth`. Reworded generically rather
  than carved, which also made the product entry stale — deleted, allowlist
  baseline 123 -> 122. The gate reported both directions; neither was allowlisted.
- Two clippy reds that pre-exist on this base and bite a `-D warnings` bar: an
  empty line splitting a doc-comment run in the specificity gate, and a
  never-used negative-control fixture in `ironclaw_extension_support`. The
  fixture is `#[allow(dead_code)]`-ed rather than deleted, with the reason.
- CHECKLIST WS2 re-layer row, blockers half: dated and measured annotation of
  what fell, why the "narrow the vocabulary out" framing was only half right,
  and that §12.11 D-A's factory port is unstarted.

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

* WS2: invert channel_host's product-stack construction behind the D-A factory port

§12.11 D-A's factory port, built. `ChannelWorkflowFactory` is declared in
`ironclaw_product_contracts::channel_workflow`, implemented by
`ironclaw_product::RebornChannelWorkflowFactory`, and injected through the
`GenericChannelHostDeps` bundle composition already builds — so
`channel_host.rs` states the shape of the per-extension product cone and
consumes the result instead of inline-constructing product's concrete stack.

`channel_triggered_delivery.rs` sheds through the same seam, but its port
could not live in contracts: it drives the driver with
`TriggerCommunicationContext`, which `ironclaw_outbound` owns and a contracts
crate may not name. So `TriggeredRunDelivery` and `TriggeredRunDeliveryRequest`
are declared in `ironclaw_outbound` beside that vocabulary — the same placement
rule WS2.5 applied to the auth ports, and zero new crate edges. Composition
builds one driver per codec-bearing binding through the same factory; routing
policy stays in the host.

The conversations wrinkle resolved as sanctioned, with no mirror type.
`RebornFilesystemConversationServices` is constructed, consumed and dropped
inside product's factory. What crosses the port is `ChannelWorkflowStorageRoots`
(a `VirtualPath` pair — placement is host policy) in, and the surface, the
binding resolver and the run-delivery observer out.

The last residue port had to be renamed, not just moved:
`ConversationBindingService` is now `ironclaw_product_contracts::binding::
ProductBindingResolver`, because `ironclaw_conversations` already defines a
trait by the old name and §11.2.4's one-home rule refuses two definitions of a
contracts name. The boundary error grew `BindingRequired`,
`UnknownInstallation` and `TurnSubmissionRejected` rather than weakening: all
three are constructed by the port's implementor, `BindingRequired` is what an
unpaired external actor is told, and every one carries `String`/nothing so the
contracts ceiling is untouched.

Gates:
  EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT  5 -> 3
  EXTENSION_HOST_PRODUCT_REFERENCE_FILE_BASELINE        5 -> 3
  PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS 1 -> 0
  WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE            1 -> 0
  EXTENSION_HOST_FILES_STILL_NAMING_THE_WORKFLOW_ERROR  1 -> 0

`the_extension_host_manifest_names_product_only_while_a_residue_needs_it` is
re-keyed on the trait residue OR the reference ledger. That is a correction,
not a relaxation: keyed on the trait list alone it would now demand the
manifest edge be deleted while three adapter-registry rows still name the
crate — failing a correct tree and passing an impossible one. Both directions
stay enforced against the union.

Regression coverage: the ingress/delivery/trigger integration suites are
unchanged in behaviour and green; the only edits to them are import repoints
for the renamed port. `unknown_manifest_command_fails_generic_graph_assembly`
still pins that an undeclarable command fails the whole graph build.

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

* WS2 flip: extension_host products -> loops — manifest edge deleted, ledger/residue 0/0, DowngradePin armed

The batch-2 union (via #7181) and the D-A factory port each discharged
exactly the rows the other left, so the port-inversion biconditional
demanded the flip: layer line + manifest edge in one change. Same-layer
inventory 74 -> 72 net (+1 loops edge extension_host->loop_host, -3
products rows), pin frozen at the four normal-dep consumers. Two typed
ExtensionId seams reconciled between batch-2 and the D-A branch.

* fix(arch): equality-assert the zeroed reference ledger; fmt

* review(7181): architecture-gate hardening from CodeRabbit round 1

Three armed gates were reporting on shapes they could not actually see.

- `reborn_composition_boundaries.rs`: the consumer-annotation scan walked
  back over the attribute block by line prefix, so a multiline
  `#[cfg(any(...))]` between the annotation and the `pub use` stopped the
  walk and rejected a correctly annotated re-export. The walk is now
  bracket-aware and extracted into `pub_use_consumer_annotations` so it is
  testable on synthetic input; the new fixture covers the multiline shape,
  the single-line shape, a bracketed comment, and the unannotated
  sabotage case.
- `reborn_dependency_boundaries.rs`: the MCP/sandbox lane-existence probes
  searched raw concatenated source, so a comment, doc example, string
  literal, or `#[cfg(test)]` fixture naming `McpRuntime<C>` would have kept
  them green after the production runtime was gone. They now scan
  production tokens only (`production_rust_files` +
  `strip_comments_and_strings`), with a regression fixture that plants the
  marker in each of those non-production forms.
- `ironclaw_webui/tests/handlers_module_charter.rs`: `top_level_items`
  stripped only `pub `/`pub(crate) `, so a `pub(super)`/`pub(in ...)` item
  was silently excluded from `charted_surface()` and therefore never
  registered as unassigned. `strip_visibility` now handles every
  visibility form.
- `ironclaw_auth/tests/module_charter.rs`: the two-engine severance scan
  dropped only lines beginning `//`, so a block comment, a trailing
  comment, or a string literal naming the other engine reached the probes
  and a documentation edit could fail the charter gate. A lexical stripper
  replaces the prefix filter, with fixtures for each shape plus a
  must-still-be-seen `use` case.
- `reborn_extension_host_port_inversion.rs`: the reference-ledger history
  still described a 9 -> 5 reduction with five survivors; the live ledger
  has two rows and the baseline is 2. Corrected to the actual 9 -> 5 -> 2.

Every strengthened scanner was sabotage-tested (broken, watched fail,
restored). `cargo test -p ironclaw_architecture` is green across all 37
binaries with no new violations surfaced.

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

* review(7181): MCP lane — arm the charter's failure-string rule, close its exceptions

The crate charter's load-bearing clause — "no module builds a failure
string of its own" — was stated in three files and enforced in none, and
the crate carried live exceptions.

- `egress.rs` minted `"runtime_http_egress_panicked"` inline and forwarded
  `stable_runtime_reason()` verbatim into `McpClientError`. Both are now
  `diagnostics::McpEgressCause` variants named through `egress_failure`.
- `impl From<String> for McpClientError` was the implicit bypass: any `?`
  in the crate could turn an arbitrary String into a model-visible
  reason. It had exactly one user (`client.rs`'s credential-injection
  check, whose reason already came from `diagnostics`), now an explicit
  `map_err(McpClientError::client)`. The impl is deleted.
- `diagnostics.rs` claimed "every reason is capped here" but appended the
  server-supplied `JsonRpcError.message` verbatim. The only production
  producer bounds it upstream, but the cap is this module's invariant,
  not the caller's, so it now goes through `bound_mcp_reason_detail`.
- New `tests/module_charter.rs` arms the rule: a new `reason: "..."` /
  `reason: format!(...)` outside `diagnostics.rs` fails, a re-added
  `From<String>` fails, and the charter text in `lib.rs` + `CLAUDE.md`
  must keep naming the rule and its gate. The rule's one remaining
  carve-out — `runtime.rs`'s two `McpError` descriptor/invocation reasons,
  which echo manifest ids rather than classify a failure — is an
  enumerated list, not a wildcard, and both docs now say so.

Also in `runtime.rs`: the `transport == "stdio"` process-count branch is
unreachable (`prepare_client_request` rejects stdio and everything that
is not http/sse before it), so it is replaced by a comment saying why no
process accounting happens here; and `release_after_failure`'s discarded
`Result` gets the required `// silent-ok:` annotation plus a `debug!` so a
leaked reservation leaves a trace without masking the caller-facing error.

Sabotage-tested: re-inlining the egress reason makes the new gate fail
with 3 inline reasons instead of the 2 grandfathered rows.

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

* review(7181): type the channel-connection port, and fix the trace-prune lock

Two Major findings with real failure modes.

**Typed channel identifier at the `ChannelConnectionService` boundary.**
The port exchanged channel package ids as `String` map keys and a `&str`
disconnect argument, so a malformed or non-canonical id could become a
key no lookup would ever match — a channel that silently reads as "not
connected" instead of failing. The sibling map on the very same product
call (`installed_activation_errors`) was already keyed by `ExtensionId`,
so the untyped half was the odd one out. All three signatures now use
`ironclaw_host_api::ids::ExtensionId`; the generic service applies the
same skip-invalid-vocabulary rule its own discovery walk already used,
and `extension_info` resolves the id once for all three lookups.

**`std::sync::Mutex` held across filesystem I/O in the trace prune step.**
`trace_scope_has_pending_queue` is a synchronous `read_dir` per scope and
was called from inside `observed_scopes.retain`, under the guard, on the
runtime worker thread — while `record_observed_scope` takes the same lock
from the capture path, so a stalled filesystem blocked capture-time scope
recording. The probe now runs on the blocking pool against a snapshot and
the guard is re-acquired only to apply the result, which also leaves
scopes recorded mid-probe alone. (This pattern predates the WS6 move —
it was introduced 2026-06-15 in 410db7720 and relocated verbatim by this
batch — but it is contained enough to fix here.)

**Fire-access unavailable-precedence coverage.** New WS6 policy code
decided what a transient backend fault becomes (retryable `Err` when the
final answer is a denial, but never over a grant) with no test driving a
failing checker at all. Added, test-first: breaking the precedence branch
makes it fail with `Denied` where `Unavailable` is required. Also pins the
last-position fault, which the other two cases never reach.

**Product-adapter section invariants.** `DuplicateCredentialHandle`,
`DuplicateEgressTarget`, and the RFC 7230 token rule (including
`auth.timestamp_header_name`, the optional field a rename could quietly
drop from validation) came over from `ironclaw_product::adapter_registry`
with WS5 and had no assertion anywhere. Covered through the real
deserialize + resolve + validate path.

**Smaller items.** The relocated trigger-fire contract no longer keeps a
second import path through composition (`runtime_input`'s `pub use` and
the four names in the lib.rs surface are gone, consumers repointed at
`ironclaw_triggers`, snapshot recaptured); `repository_contract.rs` uses
`var_os` for presence so a non-UTF-8 `IRONCLAW_REQUIRE_POSTGRES` cannot
silently disarm the parity guard, with a regression fixture;
`ironclaw_reborn_identity`'s stale `Self::bind` rustdoc link, the
`ironclaw_auth` AGENTS.md `loopback_oauth` contradiction, and the
`ironclaw_extension_contracts` charter row missing
`ExternalActorBindingEpoch` are corrected.

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

* Re-arm the union's ratchets: recount, swap one same-layer edge, repoint a coverage exemption

Three gates fired on the merged tree; each is fixed by measurement, not by
lowering a bar.

**Extension-specificity baseline: 125 (ours) / 122 (batch) -> 122.** Neither
side's number is evidence for the union, so the constant was set to `0` and the
true length read out of the ratchet's own panic. The batch's three vendor-pair
removals are the only entries either side removed and this branch's renames
repoint entries in place without adding any, so the union is the batch's number.

**SAME_LAYER_EDGE_BASELINE stays 72 -- one row moved, the count did not.** The
gate found both halves by itself: `triggers -> safety` tripped the
not-inventoried arm and `conversations -> safety` tripped the stale-row arm.
They are the two sides of one swap -- the trusted-trigger prompt scan moved
behind the seam into `TrustedTriggerSubmitRequest::new`, so the edge changed
crate rather than appeared. This merge is the first tree where both halves
exist, which is why nothing had inventoried it before. The equality is what made
the second half loud: under a `<=` ratchet the stale row would have sat green as
one entry of slack.

**changed-coverage exemption #113 repointed 1276 -> 975.** Inherited red, not
caused here: reproduced on a pristine `git archive` of `ws2/da-factory-port`
with the same message. The extension_host products -> loops flip shrank
`channel_host.rs` from 1405 to 1098 lines and left the exemption past EOF.
Repointed to the same construct rather than deleted -- `observe_error`'s `error`
parameter is the only `product_adapter_error::ProductAdapterError` in the file,
so the exemption still names exactly what it always named.

* ci(test-plan): classify the two path classes a rename PR reaches and the planner did not

`Detect Reborn test scope` aborts on the first path no rule claims, and the
nine steps after it are then skipped — so the set gets discovered one CI red at
a time. Both gaps below are the shape #7152 already records for `Dockerfile`
and `clippy.toml`: fail-closed with no rule, surfaced only because a rename
diff touches files a feature PR never touches.

Found as a class rather than one-per-red-run: `build_plan` was driven over all
1,250 paths in this PR's diff with `cargo metadata` resolved once. Two came
back unclassified; after the fix the sweep reports **0**, and the planner
produces a real plan for the actual diff.

- **`openwiki/**`** — the auto-generated wiki, regenerated by
  `openwiki-update.yml` and explicitly not hand-edited. No build or test
  surface, so it joins `docs/` in `IGNORED_PREFIXES`. A crate rename touches it
  by construction: its prose names crate directories.
- **`scripts/live_canary/**`** (UNDERSCORE) — a *second* real directory beside
  the already-classified `scripts/live-canary/` (hyphen), differing only by
  that character. It is the canary's importable Python package; the rename
  reaches it through a `RUST_LOG` string naming a crate. The ⚠ note about the
  two directories is restored beside the constant.

Both are pinned: the wiki test asserts the plan is *equal* to a `docs/` plan
(so a later change that escalates it to a lane fails here too) and that a real
change riding along still selects its lane; the canary paths join the existing
QA-harness subTest list.

* fix(ci): repoint changed-coverage exemption #113 past the flip's channel_host shrink (1276 -> 975)

* test(triggers): hold the workspace env mutex across the non-UTF-8 presence fixture

The hermetic env-mutation guard rejects raw set_var/remove_var without
lock_env(); the fixture now holds the guard across both mutations.

* refactor(crates): move every crate into its §5 family directory (text-only)

Wave 5 / WS7, PR 1 of 2. Creates the ten family directories PROPOSAL §5
specifies — contracts/ substrates/ events/ domains/ kernel/ lanes/ loop/
extensions/ product/ app/ — and `git mv`s 56 crates into them. No crate is
renamed, no code moves between crates, no behavior changes: every diff outside
a manifest, a path literal, or a gate's path resolution is a pure rename.

What moved, and what deliberately did not:

  * 56 of the 58 §5 rows. `ironclaw_extension_support` was already at
    `crates/extensions/`; `ironclaw_wasm` is WS7 2/2's (its `wit/` travels with
    it and forces the guest components' `wit-bindgen` paths plus a rebuild of
    the committed `.wasm` binaries — a binary change that does not belong in a
    text-only move).
  * `tools/` is untouched per the owner ruling, so `ironclaw_stress` stays at
    `tools/ironclaw_stress`.
  * `ironclaw_projects`, `ironclaw_first_party_extension_ports` and the
    workspace-excluded `ironclaw_silk_decoder` stay flat under `crates/`; each
    has an open disposition of its own and is listed in the PR's exceptions
    table.

Fail-open gates hardened BEFORE the first `git mv` (all three were already
inventory-resolved by WS10; each was sabotage-tested here to prove it goes red
rather than silently passing on an unresolvable tree):

  * `reborn_boundary_rules_active_crates_are_workspace_members` — forcing
    resolution to fail drops `checked` to 1 and the `>= 30` floor fires.
  * `boundary_rule_names_are_package_names_not_crate_directories` — adding
    `"ironclaw_cli"` (a directory, package `ironclaw`) to a forbidden list
    produces the directory-vs-package violation.
  * `concrete_extension_crates_link_only_from_the_binary_and_tests` — a fake
    `CONCRETE_EXTENSION_CRATES` resolves nothing and the non-vacuity assert
    fires.

Loud path-inventory repoints (every one of these FAILED first and was fixed by
resolving through the crate inventory — no gate was weakened, no scope
narrowed):

  * 12 architecture gates: composition-boundaries walk root; conversations /
    extension-manager-split / operator-port-inversion scan roots; the three
    contract-location scans' owner attribution (first-path-component under
    `crates/` now answers the FAMILY name, so it moved to
    `ratchet_support::owning_crate_name`); the persistence-driver walk; the
    vendor-census CENSUS/carve-out keys and its sanctioned module; the
    manifest-reparse ALLOWLIST keys; the service-method-freeze sources; the
    provider-catalog ownership test.
  * `scripts/ci/ws12_workflow_contracts.py`: the WebUI lockfile's "one level
    deeper" cache-dependency-path sibling is now `crates/*/*/…`, because the
    single-`*` form matches the crate's real location post-move and the gate
    correctly rejects a probe that is broad rather than depth-tolerant.
  * `scripts/ci/test-classify-test-scope.sh`: the "every arm names a real
    crate" check now resolves through the inventory instead of globbing the
    filesystem — the arms are keyed to the classifier's NORMALIZED
    `crates/<crate>/…` identity, which is not a path on disk.
  * `tests/integration/changed-coverage-exemptions.toml` and the changed-
    coverage self-test fixtures.
  * `Dockerfile`, `.dockerignore`, `.gitattributes`, `.coderabbit.yaml`, the
    seven workflows carrying WebUI/stress path literals, and `README.md`'s
    `cargo install --path`.
  * 39 cross-crate `include_str!`/`include_bytes!` literals and eight
    `CARGO_MANIFEST_DIR`-relative test helpers; the four that resolved the repo
    root by counted `..` hops now search upward for the nearest ancestor
    holding both `crates/` and `Cargo.toml`, because their wrong answer
    (`crates/`) is a directory that exists.

Guidance: one `AGENTS.md` per family directory (charter, member list with each
crate's enforced layer, link to `families/<name>.md`), plus a family index in
`crates/AGENTS.md`. Live agent-facing docs were repointed; generated
(`openwiki/`) and historical (`docs/plans/`, `docs/superpowers/`, `docs/adr/`,
`CHANGELOG.md`, the target-architecture docs) were not.

Measurements unchanged by the move, which is the evidence it is text-only:
composition budget 40582 / 691597 LOC (identical to the base branch — the
ratchet followed the crate by name), specificity allowlist 122, same-layer edge
inventory 72, architecture suite 36 test binaries / 261 tests green.

The projects→identity merge (§12.10) was measured and SKIPPED — see the PR
body's finding: its consumers are two crates and five files, not the single
wiring site the audit counted.

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

* fix(ci): repoint the four path-keyed baselines onto the family tree

Four data files key their entries on a repository path rather than on a crate
name, so the family move leaves every row naming a directory that no longer
exists. Each fails loudly, which is how they were found:

  * `scripts/no_panics_reborn_baseline.txt` — `check_no_panics.py
    --reborn-baseline` reported all 50 audited invariants as *stale* and the
    same 50 as *new*, because the fingerprint's first field is the file path.
  * `tests/integration/coverage-exemptions.toml`,
    `tests/integration/coverage-floor.toml`,
    `tests/integration/critical-mutation-functions.toml` — same shape; the
    critical-mutation manifest validator resolves each row against the live
    crate tree and refuses a row it cannot attribute.

Paths only: no entry added, removed, or re-justified, so every floor,
exemption and reviewed invariant keeps exactly the scope it had.

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

* style(arch): drop two needless borrows the family-move repoint introduced

`crate_dir(root, OWNER)` inside the two `fn …(root: &Path)` helpers took
`&root`, which clippy's `needless_borrow` rejects under `-D warnings`. Found by
the workspace clippy lane, not by review.

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

* docs(crates): sharpen three family AGENTS.md entries

`extensions/` now says what `packages/` holds beyond its four crates (the
data-only packages) and the rule for when a package earns a crate. `app/` names
the one directory whose name and package name differ, and replaces two
descriptions that read as tautologies. `domains/` records why
`ironclaw_projects` is not in the table.

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

* docs(ci): correct the cache-dependency-path comment for the landed move

The comment described the pair as "flat line + one family directory down".
Post-WS7 the first line IS the family path, and the spare is one level below
that — so the comment now says which is which, and names the rule that forces
them not to overlap (`ws12_workflow_contracts.py` rejects a spare that already
matches the real location).

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

* fix(ci): repoint two path-keyed self-tests the family move took dark

Both fired in CI, not in review.

`crates/app/ironclaw_cli/tests/smoke.rs` asserts against *repository paths*
written into the Dockerfile and the release workflows, and those carry the
crate's family. Three assertions read a flat `crates/ironclaw_*` path: the
Dockerfile's WebUI frontend install, and two reads of the CLI manifest / WiX
manifest — the latter two failed with `NotFound`, the first with a
"Dockerfile must install WebUI frontend dependencies" message that pointed at
the Dockerfile rather than at the test. They now derive the crate directory by
walking `crates/` for the outermost directory owning a `Cargo.toml`, and panic
on absent-or-ambiguous rather than answering an empty path.

`scripts/check_no_panics.py`'s `test_test_only_path_detection` names a REAL
repository file, because that branch of `is_test_only_path` reads the file to
confirm its `#[cfg(test)] mod` declaration. Pointed at the crate's family path
so the assertion measures the file it claims to.

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

* refactor(lanes): move ironclaw_wasm into crates/lanes/

The last WS7 family move. `wit/` lives inside the crate (PROPOSAL §6.6.1), so
the ABI travels with it — which is the whole point of putting it there, and
also the reason this move could not ride the text-only batch in WS7 1/2: nine
`wit-bindgen` guests reach the ABI by relative path, and six of them commit a
`.wasm` keyed to a digest of their whole `wasm-src/` tree.

Repointed:
  * root `members` + the `ironclaw_wasm` workspace dep, and
    `ironclaw_host_runtime`'s `path = "../../lanes/ironclaw_wasm"`;
  * the moved manifest's own eight `path =` deps, one level deeper — except
    `ironclaw_wasm_limiter`, which became a plain sibling hop now that both
    lane crates share a family directory;
  * nine guests: six `crates/extensions/packages/*/wasm-src/src/lib.rs`
    (`../../../../lanes/ironclaw_wasm/wit/tool.wit`) and three
    `test-tools/*/wasm-src/src/lib.rs`. The host's own `bindings.rs` is
    crate-relative (`path: "wit/tool.wit"`) and needed no edit — the tenth
    site the WS10 row counted is free by construction.

Two path-keyed gates were silently darkable by this move and are now keyed to
the crate rather than to a literal:

  * `scripts/check-version-bumps.sh` resolved the WIT *constant* through the
    crate inventory but still triggered on the literal
    `crates/ironclaw_wasm/wit/tool.wit`. After the move that grep matches
    nothing, so the whole WIT version-parity gate would have passed vacuously
    on every future ABI change. The trigger paths are now derived from
    `crate-dir.sh ironclaw_wasm` alongside the constant, and resolution failure
    exits non-zero.
  * `.githooks/pre-commit`'s selector is depth-agnostic
    (`^crates/([^/]+/)*ironclaw_wasm/wit/`), matching the precedent already set
    by `platform-and-compat.yml`'s `has_direct_wasm_abi_risk` filter. Its
    self-test now resolves the gated prefix from the inventory instead of
    holding a second copy of the literal, so a *rename* — which no regex can
    follow — still fails loudly.

Arch-test path literals needed no change: they already spell logical
`crates/<crate>/…` and resolve through `crate_path`.

* chore(wasm): re-record the six guest source digests after rebuilding

`check-wasm-artifact-freshness.py` keys each committed `wasm/<name>.wasm` to a
digest of its whole `wasm-src/` tree, and its contract forbids re-recording
without rebuilding ("the digest asserts a claim about the artifact, and
updating it without rebuilding launders a stale one"). The nine `path:` edits
in the previous commit invalidated all six digests, so all six were rebuilt
with `./scripts/build-wasm-extensions.sh --first-party` before this re-record.

Measured refutation of the planning estimate: CHECKLIST WS10's `wit/` row point
6 and PLAN's Wave-3 note both budgeted "six rebuilt `.wasm` artifacts (~2 MB,
in their own commit) whose byte deltas are mostly fresh `Cargo.lock`
resolution". All six rebuilds were **byte-identical** to what is committed —
`git status` over `crates/extensions/packages/*/wasm/*.wasm` is empty after a
full rebuild — so this commit ships 6 changed digest lines and zero artifact
bytes. `wit-bindgen` embeds the WIT *contents*, not the path it read them from,
and the guests re-resolved to the same dependency versions, so a `path:` literal
that resolves to byte-identical WIT is codegen-neutral in fact as well as in
principle. That is the motivating case the WS10 row named for a future
"source change provably cannot affect codegen" escape hatch; this run is
evidence the escape hatch would have been sound here, not a reason to add it
untested.

* refactor(tools): relocate ironclaw_silk_decoder to tools/ (retain-excluded)

Discharges CHECKLIST WS7's `wire or remove [decision]` row and PROPOSAL
§12.10's `silk_decoder wiring-or-removal` item under delegated authority. The
ruling, its alternatives, and the evidence are written up as §12.13 D-P in the
next commit; the short form:

  * **Wire — loses.** There is nothing to wire it to. Its sole historical
    caller was `src/channels/wasm/attachment_hydration.rs`, deleted with the v1
    monolith (#6375), and Reborn has no WeChat channel package and no audio
    path in `ironclaw_extractors`. FEATURE_PARITY row 89 marks WeChat Reborn-
    side 🚧/P2. Wiring means building that channel first; that work owns the
    wiring, not WS7.
  * **Remove — loses.** It is the only SILK v3 → WAV implementation in the
    repository and the same P2 parity row names "SILK-to-WAV voice fallback"
    as in scope. Its carrying cost is measurably zero: `[workspace]`-rooted and
    `exclude`d, so it is in no inventory, no coverage denominator, no
    composition budget and no CI build.
  * **Retain excluded — wins**, which is §5's default and what PROPOSAL's
    `tools/` paragraph already says.

Retaining it properly means putting it where §5 draws it. Two doc sites agree
on `tools/` — the §5 tree and the `tools/` paragraph — and after WS7 `crates/`
holds exactly the ten family directories, so a flat crate there is the stray
top-level entry §11.2.1's check exists to reject. The functional change surface
is one line: the root `exclude` path. Everything else was comments.

One inventory consequence, recorded rather than left to be discovered: the
crate was the only non-`wasm-src` member of `crate_tree.py`'s
"declares its own `[workspace]`" rule, so `workspace_root_directories()` now
returns 6 instead of 7 and the crate inventory is 64 instead of 65. The rule
stays — it is what keeps a future `[workspace]`-rooted directory under
`crates/` out of the denominators — but the silk decoder is now excluded by
*scope* rather than by that rule, and every comment that said otherwise is
corrected here. Both floors are 20, so nothing binds.

* feat(ci): add check-target-tree.py — the §5 tree verifier

Closes CHECKLIST WS7's last row ("Verify after the last move: tree matches
PROPOSAL §5 exactly (a script comparing `cargo metadata` paths to the
documented tree)").

Why this gate cannot be one of the existing ones. Every path gate in this
repository answers "where is crate X?" by *discovery* through
`scripts/ci/lib/crate_tree.py`. That is exactly right for a gate that must
survive a family move, and exactly why not one of them can tell you the move
went where the design said: a crate landing in `substrates/` instead of
`domains/` is invisible to all of them, and shows up only when a reader trusts
§5 and finds it wrong. So this one compares the two directly — `cargo metadata
--no-deps` against the fenced tree under PROPOSAL §5, which is the only copy.
The script deliberately embeds no second copy of the tree; a table it held
itself would drift from the document it claims to enforce.

Three claims: placement (every member at its §5 path, and §5 draws no crate
that does not exist), naming (§5.1's directory rule, with its two *written*
exceptions read out of the tree — `app/ironclaw_cli` holds `ironclaw` per its
own annotation, and package directories under `extensions/packages/` write
their crate name beside the marker), and exclusions (a `◇` package must exist
on disk and must not be a workspace member, which is what now pins the silk
decoder's new home).

The exceptions table is **shrink-only in both directions**: an uncovered delta
fails, *and* a row that no longer describes a real delta fails. Closing a
disposition therefore means deleting its row, and no row can outlive the thing
it excuses. Two rows today, each naming the row that closes it —
`ironclaw_projects` (its §12.10 merge into `identity` is decided; WS7 measured
it as a source merge across 2 consumer crates / 5 files with an
equality-baseline and origins-row cost, not a `git mv`) and
`ironclaw_first_party_extension_ports` (deletion owned by its own §9 row; waves
moved adapters INTO it, so deletion is design work).

`scripts/ci/test-check-target-tree.py` is the self-test: 17 cases, 13 of them
sabotage — a crate in the wrong family, a crate §5 never drew, a crate §5 drew
that nobody built, a package name that stopped matching its directory, an
excluded package that became a member, an excluded package that vanished from
disk, three ways for the exceptions table to go stale, and three ways for the
gate to lose §5 itself (missing heading, unfenced block, truncated tree) — each
of which must *refuse* rather than report a match. Fed a recorded `cargo
metadata` document, so the suite needs no toolchain. Wired into Code Style's
`Fast deterministic checks`, beside the composition-budget and WASM-freshness
gates, with its self-test in the same job's `Static-check self-tests` list.

* docs(target-arch): WS7 closeout — silk ruling, WS7 rows, Wave-5 milestone

Wave 5 is closed. The target-architecture docs are the single source of truth
for this program, so every finding and correction from both WS7 PRs lands here
rather than only in a PR body.

PROPOSAL:
  * **new §12.13 / D-O** — the `ironclaw_silk_decoder` ruling in full: evidence
    (214 lines, three functional commits ever, zero callers re-measured at HEAD,
    the sole historical caller deleted with the v1 monolith by #6375, WeChat
    marked 🚧/P2 with SILK-to-WAV named in its parity scope, carrying cost zero
    in every denominator), why *wire* and *remove* both lose, where the call was
    close and what the other side is, why the relocation is part of the ruling
    rather than a separate one, the `crate_tree.py` consequence, and the
    successor condition that would flip it.
  * §12 item 10's `silk_decoder` bullet struck with a pointer; §9 row 71 and
    §6.10's `tools/` paragraph updated to the decided disposition.
  * §5's excluded-packages bullet re-derived: three of its four coordinates had
    moved and root `fuzz/` is deleted, not re-pointed.
  * §6.6.1 gains its as-built note — the crate is where the entry wrote it, the
    guests (not the host) paid for the move, and two path-keyed gates that
    would have gone dark were re-keyed with it.
  * §12 item 8's `/wit` clause marked landed, with the freshness-gap guard it
    asked for shown doing its job.

CHECKLIST: all four WS7 rows ticked with dated notes — the two-PR split and why
one crate forced it, the three deliberately-flat crates and their owners, the
`tools/`-unchanged confirmation, the silk ruling, and the verifier's baseline
and sabotage coverage. Two sibling rows corrected by what this PR measured:
WS4's ⚠ "keep the crate flat for this wave" flag is discharged (the Wave-3 bet
paid — `bindings.rs` survived the move unedited), and WS10's `wit/` row point 6
is **refuted on its cost estimate** — the predicted ~2 MB of rebuilt binaries
was zero, because all six rebuilds were byte-identical. That row now also
records why the "codegen-neutral source change" escape hatch it floated should
NOT be built on this evidence: a gate that accepts "this edit cannot matter" on
the author's say-so is the laundering the gate exists to stop, and the case
that would have used it cost 90 seconds of CPU.

PLAN: Wave 5's milestone marked reached, with its two documented exceptions
named as honest residue rather than slippage, and the ordering question this
wave inherited from Wave 3 recorded as resolved the cheap way.

`crates/AGENTS.md` and the root `Cargo.toml`/`crate_tree.py` comments are
repointed at §12.13 D-O so the code and the decision log cite each other.

* fix(ci): classify tools/ironclaw_silk_decoder in the Reborn PR test planner

Found by running the planner over this PR's own diff: `tools/ironclaw_silk_decoder/AGENTS.md`
was an unclassified pull-request path, which fails `Detect Reborn test scope`
closed and skips every downstream `Tests (Reborn)` lane. The crate was reachable
only through the `crates/**` arm before WS7 moved it (§12.13 D-O).

The old answer was worse than missing, which is why this is a new bucket rather
than a restored line. Under `crates/ironclaw_silk_decoder/`, a one-line edit to
`src/main.rs` selected the **entire workspace** — the crate-attribution
fall-through treats a path under `crates/` with no owning workspace member as
broad risk, and the excluded helper is exactly that shape. So the new
`WORKSPACE_EXCLUDED_PREFIXES` bucket says the true thing ("workspace-excluded
project, built by no lane") instead of borrowing
`DEDICATED_WORKFLOW_PREFIXES`'s claim that some workflow owns it — nothing does.

The self-test pins both halves: that the path is classified at all, and that it
selects nothing, so a future "classification" cannot quietly restore the
whole-workspace selection.

* feat(arch): consolidate the source scanners and close four WS10 enforcement rows

WS10 enforcement tail. Five rows moved; all measured, none weakened.

Scanner consolidation (CHECKLIST WS10, the row raised by review on #7003/#7004).
The row's premise was that one helper was duplicated four times. Measured, the
copies were six `strip_cfg_test*` spellings in FIVE distinct bodies spanning TWO
semantic families, and collapsing them onto one body would have changed two
gates' verdicts in the fail-open direction: the byte-scanning family finds the
marker anywhere and brace-balances (sound only on pre-stripped source), while
`reborn_extension_specificity` and `reborn_manifest_reparse_gate` are fed RAW
source on purpose — the first counts a vendor name in a comment as a violation.
So `ratchet_support` gains both, named for what they are, plus
`balanced_angle_close`, `implemented_trait_names`, and `is_rust_identifier` (a
sixth duplicated helper the row did not name, at six byte-identical copies). A
seventh copy of the byte-scanning stripper in `reborn_contracts_vendor_census`
was folded in too. Two gates dropped private `rust_files` walkers for the
existing `production_rust_files`, and the shadowing `strip_comments_and_strings`
in `reborn_deployment_mode_branching_ratchet` is gone. 12 private copies deleted
across 10 gate files.

Verdict preservation, per the row's own rule: full suite before and after,
diffed per binary on test names and pass/fail counts. Before 37 binaries / 263
passed / 0 failed; after 38 / 277 / 0, and the diff is only ADDED tests — every
pre-existing binary is byte-identical.

`reborn_registration_pipeline_boundary::scan_source` is deliberately NOT
normalised onto the sound composition: its ALLOWLIST is empty and it counts
comment hits, so pre-stripping could only ever delete hits. The residual hazard
is pinned at the call site rather than silently traded away.

New `reborn_ratchet_support_scanners` binary carries positive/negative fixtures
for every shared body — required, because two of five sabotage mutations fire
only there (no scanned tree contains the triggering shape today), which is
exactly the regression class this row exists to stop.

§11.2.3 — the missing size-ceiling clause lands as
`reborn_contracts_crates_carry_a_checked_size_ceiling`. The dependency allowlist
cannot see a contracts crate that imports nothing and implements everything;
59,259 production lines across the tier is what that looks like. Ceilinged with
a banked-slack lower bound, so it cannot go inert the way the composition share
ceiling did (#7151).

§11.2.6(b) — the clause names four drivers and only `deadpool-postgres` was
gated, so three driver cones could spread unseen behind a gate whose name read
as if it covered the rule. `libsql` (9 crates), `tokio-postgres` (5) and
`deadpool` (2) now carry exact-match allowlists. Half (a), the admission
singularity, still does not exist and is recorded as such.

§11.3 — the `legacy` layer variant is deleted: zero of 66 packages declare it,
so its match arm and a whole second dependency pass were unreachable. The ~60
`ironclaw_legacy` entries in `boundary_rules()` are reintroduction pins for a
retired CRATE and deliberately stay. `publish = false` was missing from three
manifests, not the one both docs claim; all three fixed and the claim is now a
gate rather than prose.

§11.2.7 — cannot flip: 17 cross-crate reach-ins remain, none a mechanical
repoint. Armed the cross-crate half as an equality ratchet at 17 so warn mode
stops being an open door.

Regression tests: `reborn_ratchet_support_scanners` (11), the size-ceiling and
publish gates in `reborn_dependency_boundaries`, the driver allowlist in
`reborn_persistence_driver_boundary`, and the include-scan ratchet — each
sabotage-verified to fire.

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

* refactor(events): retire the unconstructible TurnEventRebaseRequired projection error

CHECKLIST WS8's `event_projections -> turns` row. Two of its three
deliverables were already discharged when the row was reached, and the
row is corrected rather than reported as done-by-someone-else:

- The dep edge does not exist. `ironclaw_event_projections/Cargo.toml`
  names no `ironclaw_turns`; the cursor type is
  `ironclaw_host_api::turn::EventCursor`, moved there by a Waves 0-4
  contracts extraction that retired the edge as a side effect.
- The `LAYER_MATRIX_EXCEPTION` does not exist either — the list is
  `&[]` at baseline 0.

What remained is exactly the residue the row describes: a variant with
no construction site outside test fakes, and a live production match arm
in `event_streams::map_projection_error`. Both go, with the five test
sites: two clone-helper arms in the contract suite's fakes and one row
from each of the two table-driven error-mapping tables. No assertion is
weakened — both tables keep every other row, and the row they lose
pinned a mapping unreachable in production.

Regression coverage: the surviving rows of
`snapshot_maps_projection_errors` / `subscribe_resume_maps_projection_update_errors`
still drive every constructible `ProjectionError` through the real
manager, so a mapping arm deleted by mistake is red.

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

* security(contracts): delete the five zero-caller protocol-auth mint functions

CHECKLIST WS8's WS1.5 deletion-candidate row. Every reachable mint entry
point is a forgeable one, so an unused one is pure liability.

Re-verified on this tree before deleting (whole-word census over every
.rs/.toml/.md/.py/.sh/.yml, cross-checked against the two sealed-evidence
gates' own scans): `mark_bearer_token_verified`, `mark_session_verified`,
`mark_session_verified_for_tenant`,
`mark_request_signature_verified_for_tenant` and
`mark_shared_secret_header_verified_for_tenant` have no reference outside
their own definitions, the ratchet's frozen tables, and the two
crate-local seal tests. The three survivors are exactly the three with
production callers: `mark_bearer_token_verified_for_tenant` (webui's auth
middleware) and `mark_request_signature_verified` /
`mark_shared_secret_header_verified` (extension_host's ingress verifier).

The row warned that a deletion must not silently narrow the governed
set, and that is the load-bearing part. Dropping a name from
CHANNEL_MINT_FNS/HOST_MINT_FNS would stop
`mint_functions_are_named_only_by_their_owners_and_sanctioned_minters`
from looking for it, handing a future author an un-scanned mint seam. So
the five move to a new frozen `RETIRED_MINT_FNS` denylist with two new
tests: `retired_mint_functions_do_not_return` (defined nowhere in the
workspace) and `live_and_retired_mint_tables_are_disjoint_and_populated`
(the two tables are a partition, neither empty). Reviving one means
moving its name back into the live table with its caller, in one
reviewable diff.

Test deltas, none of them a weakened assertion:
- `session_mint_requires_a_host_authentication_grant` lost both
  subjects; deleted.
- `bearer_mint_requires_a_host_authentication_grant` keeps every
  assertion that still has a subject. It loses only "an unscoped mint
  carries no tenant", which auth.rs's inline
  `verified_can_only_be_constructed_via_host_helper_inside_crate`
  already pins on the crate-private constructor underneath.
- `tenant_scoped_variants_thread_the_host_resolved_tenant_through` is
  deleted; tenant threading survives on
  `mark_bearer_token_verified_for_tenant` and on
  `seal_verified_inbound`'s Some(tenant) path.

Checked before deleting: `AuthRequirement::SessionCookie` stays live —
`ironclaw_extension_contracts::product_adapter_section` constructs it
from manifest sections — so no enum variant is orphaned.

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

* refactor(loops): dissolve ironclaw_first_party_extension_ports into loop_host

CHECKLIST WS8 / PROPOSAL §9 row 55. The row gates on WS3 having removed
the `host_runtime -> first_party_extensions` edge; measured on this tree,
that gate is open and then some. `cargo metadata` reports exactly two
dependents of the crate (assistant, composition), and
`ironclaw_extension_support` no longer depends on `ironclaw_loop_host` at
all — the `first_party_extensions -> loop_host -> host_runtime ->
first_party_extensions` cycle the crate exists to break does not exist,
so the crate had nothing left to be.

All 5,800 lines go to `ironclaw_loop_host` as `src/skill_activation/`,
not to the three owners §9 named, and the correction is recorded rather
than the row half-executed:

- Observer vocab -> `skills` is unreachable. `SkillActivationObservedEvent`
  carries `LoopRunContext` and `SkillActivationRequest`, and the latter
  carries `SkillBundleId`/`SkillSourceKind`; moving them to
  `ironclaw_skills` needs `skills -> loop_host`, a cycle against the live
  `loop_host -> skills`.
- Bundle asset reader -> package is unreachable for the same reason plus
  a layer one: `ironclaw_extension_support` is `runtimes`, below `loops`.

The fold adds zero dependency edges — every one of the crate's five
workspace deps (host_api, loop_contracts, filesystem, skills, turns) and
all five third-party ones were already loop_host's, and both crates
declared layer `loops`.

Un-masking: what the deleted layer was backstopping is a *boundary*, not
a behavior. The crate carried a `BoundaryRule` forbidding 24 crates, nine
of which loop_host legitimately depends on (approvals, capabilities,
host_runtime, llm, memory, outbound, processes, resources, safety), so
folding the module in would have widened its legal imports by nine in
silence. The rule is therefore re-expressed at module granularity as
`dissolved_ports_module_keeps_its_crate_boundary` — an equality over the
`ironclaw_*` crates the module names in code (comments and string
literals stripped), frozen at the dissolved crate's own five, with a
sabotage self-test (`dissolved_ports_module_scanner_reads_code_not_comments`).
The crate's name stays in every `forbidden` list that carried it; those
are reintroduction pins now.

Gate deltas:
- SAME_LAYER_EDGE_BASELINE 72 -> 71 (the `loops` pair the two formed)
- CRATE_LAYER_ORIGINS drops its row, per that table's own stale-row rule
- the `ironclaw_skills` DowngradePin drops a consumer that merged into
  one already listed
- check-target-tree.py exceptions 2 -> 1 rows; the gate now reports
  65 members / 64 documented / 1 exclusion / 1 exception
- untrusted-ingress scan narrows to the receiving module rather than
  dropping the tree
- CI bucket map, sccache case, and the changed-coverage exemption path
  all repointed; every touched literal re-resolved on disk

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

* refactor(approvals): collapse ToolPermissionOverrideStorePort onto the capability trait

CHECKLIST WS8 / PROPOSAL §6.5.3 ("still to do: delete
ToolPermissionOverrideStorePort"). The decision was never open; only the
scheduling was, and the deferral's reason — "a mechanical rename, not a
dead-code deletion" — is an argument about which PR carries the churn.
Carrying it with the WS8 sweep is the cheaper of the two: unlike the
seven `pub type ToolPermission* = CapabilityPermission*` aliases beside
it, this one was a *trait*, so it minted a second `dyn` vtable identity
for one behavior and every `Arc<dyn ...>` site had to pick a spelling.

44 sites in 18 files (the §2.6 figure of 39 counted `dyn` sites only; the
rest are imports) across composition, assistant, extension_manager and
the root integration harness, plus the blanket impl/trait pair in
`ironclaw_approvals/src/lib.rs`. One site needed a hand edit rather than
the mechanical pass: `approval_interaction_contract.rs` already imported
the capability trait, so the rename produced a duplicate import (E0252)
— which is exactly the "two spellings for one trait" the collapse
removes.

Out of scope, left to the deliberate `approvals` narrowing: the seven
sibling `pub type` aliases (~170 references). They are plain type
aliases — one type, one identity — a different class from the trait, and
neither §6.5.3 nor the WS8 row names them.

Regression coverage: the collapse is type-level, so the compiler is the
regression test — every `Arc<dyn ...>` site, every blanket-impl consumer,
and both named `CapabilityPermissionOverrideStorePort` impls
(extension_manager's ClearFailsOverrideStore, its
WriteFailsOverrideStore, composition's two test stores) still bind, and
the approval/operator-config contract suites that drive them run
unchanged.

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

* docs(target-arch): record the WS8 deletions sweep, with three corrections

Dated notes for the four CHECKLIST WS8 rows this slice discharged, plus
the sibling rows their measurements falsify. Corrections recorded rather
than silently fixed, per the target-arch docs-are-the-source-of-truth
rule:

1. §9 row 55's three-way split for `first_party_extension_ports` is not
   executable: observer vocab -> `skills` is a cycle, asset reader ->
   package is an upward layer reach. All of it went to `loop_host`.
   §6.4.7's "Gains: SkillActivationObserver + observed-event type" is
   withdrawn for the same reason, with the condition that would revive
   it (move the bundle vocabulary down first).
2. The `event_projections -> turns` row describes an edge and an
   exception that no longer exist — the edge was retired as a
   side effect of a Waves 0-4 contracts extraction, and
   LAYER_MATRIX_EXCEPTIONS is empty. Only the unconstructible variant
   remained; the finding on §2.6's list is marked superseded.
3. WS6's project-create-capability finding recommends a destination
   crate that this slice deleted; repointed to `loop_host` itself.

Counts re-derived by measurement, not by hand: 66 -> 65 workspace
members (63 under `crates/`), check-target-tree exceptions 2 -> 1,
same-layer baseline 72 -> 71. WS7's "three crates deliberately still
flat" and PLAN's Wave-5 residue both drop to one, `ironclaw_projects`.

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

* refactor(identity): merge ironclaw_projects in as the `projects` module

Executes the §12.10 consolidation audit's single clear merge verdict (owner
rule 2026-07-30; CHECKLIST WS10). W5-PR1 measured this merge and deferred it
rather than smuggling a source merge with gate-baseline cost into a 56-crate
`git mv`; this is that row.

The record half moves and nothing else. `crates/ironclaw_projects` (883 lines)
is now `crates/domains/ironclaw_identity/src/projects{.rs,/store.rs}` with its
contract suite at `tests/project_repository_contract.rs`. The crate, its
manifest, its `CLAUDE.md` and its workspace `members` entry are deleted — no
shim, no `pub use` forward. Its guardrails are rewritten into
`ironclaw_identity/CONTRACT.md`, which is where the W2 "keep standalone"
ruling §6.4.11 overturned had survived as current guidance.

Consumers repointed: exactly the 2 crates / 5 files W5-PR1 measured
(`ironclaw_composition` x2, `ironclaw_assistant` x3 — a capability, a
doc-comment, and the 7-reference `project_service`).

The service half deliberately did NOT travel, and that is what keeps the
allowlist claim true. `trait ProjectService` is a `products` declaration and
this crate is `substrates`, so the gating adapter can only follow once the port
is hoisted to `ironclaw_product_contracts` (§6.4.11's 2026-08-04 correction;
the WS5 `product` row owns the hoist). Identity's workspace deps after the fold
are exactly `{ironclaw_host_api, ironclaw_filesystem}` — re-measured, not
assumed — so `reborn_dependency_boundaries.rs`'s pin is untouched.

Gate deltas, each surfaced BY the gate rather than by reading the diff:

* `SAME_LAYER_EDGE_BASELINE` 72 -> 71. The stale-row arm named
  `projects -> filesystem`; identity's own `identity -> filesystem` row absorbs
  it and no row was added. Recounted on the merged tree (65 layered crates /
  387 workspace edges / 71 same-layer), not derived by subtracting one.
* `CRATE_LAYER_ORIGINS` row deleted under that gate's *stated* removal path —
  its `vanished` arm prints "Delete the rows — a stale origin row is a demotion
  detector aimed at nothing", so "append-only" never covered a crate leaving
  the workspace. The doc comment now says so explicitly.
* `check-target-tree.py`'s exceptions row deleted, not edited: the table
  refused it as "a delta that no longer exists". 66 -> 65 workspace members
  against 64 documented, 1 exclusion, 1 owned exception.
* `reborn_extension_specificity.rs` allowlist row repointed 1-for-1 (`github`,
  a `lane-4: doc-str` hit that travelled with the file); count unchanged at 122.

One self-test moved with the deletion: `check-target-tree.py`'s
`test_exception_row_describing_a_different_delta_fails` had hand-named
`ironclaw_projects` and went red. It now doctors whichever rows name a live
workspace member and refuses to pass vacuously — a case keyed to a row that is
*designed* to be deleted has an expiry date.

Test strengthened, not weakened: the moved contract suite stays an external
test target (preserving the tier it had) and now pins "access resolution is
never cached" in BOTH directions — a revoke and a re-grant are each visible on
the very next call, so neither a positive nor a negative decision can be
memoized. Previously only the revoke half was covered.

Also sheds one unused dependency (`tracing`, which the crate declared and never
used) and drops three `pub(crate)` error constructors to module-private, which
is the faithful translation of their old crate-private scope.

Docs: CHECKLIST WS10 merge row ticked with its measurements, WS7's
"three crates still flat" and §5-verify baseline rows amended, PROPOSAL
§6.4.11 / §9 row 27 / §1's package count carry dated notes, and
`crates/AGENTS.md` + `crates/domains/AGENTS.md` reflect the one remaining flat
crate.

* refactor(openai_compat): evict the WS6 route-mount residue from composition

CHECKLIST WS5 (`webui`, `openai_compat`) + WS6 (composition behavior
evictions, the re-scoped OpenAI-compat clause).

WS6 eviction — the residue the row names moves to
`ironclaw_openai_compat::mount`:

- `openai_compat_route_mount(OpenAiCompatRouteMountPorts) -> ProtectedRouteMount`
  owns the router-state assembly: builder order, the shared projection
  streamer, and the rule that a `None` LLM-config service leaves
  `GET /v1/models` fail-closed at 501. Composition fills in the ports.
- `LlmConfigModelCatalog` + `model_entries_from_snapshot` + the
  `LlmConfigServiceError` map, `product_surface_caller_from_openai_scope`,
  `OpenAiCompatRuntimeProjectionStreamer` + `decode_product_outbound_events`
  travel with it.
- The ~1,240 LOC of port-implementing adapters STAY in composition: they name
  `ironclaw_threads` / `ironclaw_turns` / `ironclaw_event_streams`, all on this
  crate's own armed `BoundaryRule` forbidden list, which is the shape that rule
  exists to require.

Gate deltas:

- composition production LOC 40,595 -> 40,405 (-190 net; -199 out, +9 back as the module doc); `loc_ceiling`,
  `loc_observed` and `COMPOSITION_ABSOLUTE_SRC_LOC` all move to 40,405 in this
  commit. The base was already 96 LOC over the old 40,499 ceiling and passing
  on tolerance alone, so the ratchet reclaims that drift too.
- composition `Arc<dyn>` 822 -> 815.
- `DOWNGRADE_PINS[ironclaw_host_ingress].permitted_consumers` 4 -> 5: the
  reviewed fifth the pin's own comment asks for, sanctioned by the WS6 row,
  which names `product_contracts` + `host_ingress` as exactly the two crates
  the residue is reachable with. `SAME_LAYER_EDGE_BASELINE` unchanged
  (products -> substrates is downward).
- tests: `ironclaw_openai_compat` lib 37 -> 40 (2 moved + 1 new
  `llm_config_errors_map_onto_the_openai_error_envelope` guard tabling all four
  error arms against status/retryability); `ironclaw_composition` lib 503 ->
  501 (the 2 moved).

WS5 rows:

- `webui` box CLOSED. Re-measured: the `ironclaw_assistant` residue is exactly
  100 symbols across exactly 4 production files, asserted set-equal in both
  directions; the bearer-evidence mint is verified at
  `ironclaw_host_api::product_adapter::auth` with both sealed-evidence ratchets
  run and passing unmodified. `families/product.md`'s stale "never depends on
  hosting crates" clause (the row's gap (b)) is corrected.
- `openai_compat` dep flip closed AS REFUSED, correcting D-B: the three
  surviving constants cannot move to contracts at all — `SUBMIT_TURN_COMMAND`
  is named by `the_frozen_operation_inventory_stays_in_product`'s armed sample
  — so `RebornCreateThreadResponse` is the second blocker, not "the entire"
  one. The flip that WAS available is taken: `ironclaw_attachments` was a
  normal dependency with zero references anywhere in the crate; deleted.
- A fourth survivor of the struck "stale feature guidance" clause corrected:
  `src/lib.rs` still said `refs_storage` was "gated behind `storage`"; the
  crate has had no `[features]` table since the ref-store collapse.

Reported, not fixed (recorded on the webui row): §12.11 D-H's prescribed
mechanism for gap (a) is refuted — `collect_forbidden_uses` is shared by four
production rules including a trusted-inbound security gate, so rerouting it
through `source_without_cfg_test_modules` would narrow three armed rules to
close one gap. It needs a per-root opt-in.

* union(batch): both dissolutions land — 64/64 §5 steady state, exceptions empty, same-layer 70

* union(batch): re-seed composition pair at the measured union figure (40,406)

* docs(target-arch): record three measured stops — [google] retirement, the CLI Google shed, and the hooks-projection carve

None of the three tail-batch rows in this slice can be executed as written.
All three are measured rather than attempted, and each measurement lands as a
dated amendment on its own CHECKLIST row and PROPOSAL clause.

1. `config` `[google]` retirement (§6.10.3) — BLOCKED, and the block is the one
   the row warned about. Unlike `[slack]`/`[telegram]`, `[google]` has live
   readers: `ironclaw_cli::runtime::resolve_google_oauth_config_from_env`
   (boot) and `resolve_google_oauth_config_state_from_env` (`ironclaw status`),
   plus two writers (`update_google_oauth_config` and `GoogleOauthSecretStore`).
   A `retired_sections.rs` row before the reader is gone would silently disable
   a configured operator's Google product-auth.

2. CLI sheds the Google-OAuth resolution (§6.10.2 / #7153 item 2) — STOPPED on
   three missing seams. The *receiving* seam is in better shape than #7153
   records: `EngineClientCredentialsSource` already resolves handles from
   `[admin_configuration]` as well as from `with_vendor_oauth_client`, all six
   Google packages already declare `group_id = "vendor.google"` with the
   handles their `[auth.google]` recipes name, and both the resolver slot
   (`production_backend_assembly.rs:988`) and the available-manifest catalog
   (`:985`) are wired in production. What does not exist is the operator-facing
   half: `crates/app/ironclaw_cli/src` holds zero admin-configuration
   references and `extension_host`/`extension_manager` hold zero `env::var`
   reads, so the WebUI `PUT` route is the only writer. Shedding today deletes
   the sole headless path documented by `deploy-reborn-cli-docker.md`,
   `.env.example`, `docs/extensions/google/oauth-setup.md` and used by
   `live-canary.yml`. A third obstacle is behavioural: `google_oauth_configured`
   (`factory.rs:548`) is a composition-time boolean that `GsuiteFirstPartyHandler`
   uses to short-circuit dispatch, and per-request resolution makes it
   permanently false.

3. Hooks-projection carve (§6.10.1 corrected miscount) — REFUSED by an armed
   gate. `composition/src/observability/hooks/projection.rs` names six
   workspace crates in production; three of them —
   `ironclaw_extension_registry`, `ironclaw_filesystem`, `ironclaw_host_runtime`
   — are named verbatim on `ironclaw_hooks`' own `BoundaryRule` forbidden list
   (`reborn_dependency_boundaries.rs:4490`), whose stated purpose is to keep
   runtime adapters and their authority out of the hooks framework.
   Filesystem-rooted package discovery is exactly that authority. Behind it:
   `RebornBuildError` makes the module name an `app` crate (`loops -> app` is
   matrix-illegal) and `ironclaw_extension_host` is `loops` (a new same-layer
   edge). The movable residue under the rule is ~30 LOC of pure `VirtualPath`
   work. The reachable owner, recorded as a destination decision rather than
   taken, is `ironclaw_extension_host` — already holds all three
   forbidden-for-hooks crates, so zero new edges. No eviction, so the
   composition LOC ceiling is unchanged.

Docs-truth correction found while measuring (1): `GoogleSection::hosted_domain_hint`
and its env forms are inert. The value resolves into
`OAuthClientConfig::hosted_domain_hint`, which no production code reads — the
auth engine builds authorization parameters from the recipe's manifest
`extra_authorize_params`, and the Google recipes declare no `hd`. Two documents
claimed otherwise and are corrected: `docs/reborn/contracts/auth-product.md`
and `.env.example`. The live `hd` enforcement is `ironclaw_webui::auth::google`,
fed by the separate `IRONCLAW_REBORN_WEBUI_GOOGLE_ALLOWED_HD`. This is the
`slack.enabled` pattern again and is exactly what PROPOSAL §12.2's "grep the
docs, not just the code" lesson asks for.

No code, gate, baseline or ledger changed. Composition budget unchanged.

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

* refactor(product): dissolve the foreign re-export facade and charter reborn_services

WS5 `product` narrows — the four remaining clauses, two executed and two
refuted with the evidence written into code.

**Facade dissolved (148 symbols → 0).** `ironclaw_assistant/src/lib.rs`
re-exported 19 `host_api::product_adapter` + 42 `extension_contracts` + 87
`product_contracts` names: the same population PROPOSAL §6.9.1 calls "its
~120-symbol re-export facade over `host_api::product_adapter`", which kept its
size and changed its spelling when WS1.3/1.4/1.5 split that module across three
crates. Only 28 of the 144 distinct names had an out-of-crate consumer — the
other 116 re-exports were dead — so the sweep was 32 files, not the "~13 crates"
that justified deferring it since WS1.4. Consumers now import each name from its
owner. Four names had been re-exported twice from the same file (crate root and
`ironclaw_assistant::auth`), a §11.2.4 violation the trait-only import-path half
could not see. Pinned by `product_declares_no_foreign_re_export_facade`; the
193 re-exports in private modules stay by §12.11 D-B and the scan says so.

**`reborn_services` module-charter map.** 517 items, 19 sub-owners, zero source
lines changed, enforced by `tests/reborn_services_module_charter.rs` in the
shape §6.9.4's `handlers.rs` map established. The method-freeze ratchet is a
different gate and is untouched.

**Token heuristics → packages: refuted.** `looks_like_inline_secret` is a safety
denylist whose eleven shapes include five belonging to no package (`sk-`, AWS,
JWT, PEM, URI userinfo), so sourcing it from the inventory would weaken it; and
`registry` (substrates) → packages (products) is matrix-illegal, with inversion
worse because the guard parses manifests before any package loads. The three
specificity rows move from ALLOWLIST to PATH_TERM_COLLISIONS with that reason
(baseline 122 → 119). Closes a real hole: the existing test tripped the
key-name rule, so the value-shape half had no coverage at all.

**`external_tool_catalog` → product: refuted.** `ironclaw_assistant` names zero
of its symbols; the production readers are `ironclaw_loop_host` (loops) and
composition, and `loops → products` needs a layer exception the ratchet forbids.
Measurements recorded in `crates/ironclaw_turns/AGENTS.md`.

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

* refactor(product): split reborn_services/types.rs per concern in the charter map

The submodule-file rule put all twelve response DTOs in `dispatch`, which is
the bucketing the map's own "Never contains" column forbids — `dispatch` holds
an item only when more than one sub-owner calls it. `types.rs` is a shared
*file*, not a shared *concern*: its rows are thread, run, gate, extension and
command responses. Assigning them individually drops `dispatch` from 33 items
to 21 and moves 12 into the concerns that own them. Also corrects the
CHECKLIST residue breakdown, which double-counted the `ironclaw_auth` entry.

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

* docs(target-arch): say why the product narrows box stays open

Every clause the row's own text names is discharged. What keeps the box open
is an obligation the text never named: §12.11 D-F assigns this row the
RunCost/price_usage pricer port, which is behavior — it turns production budget
enforcement back on — and needs its own row. Ticking the box would retire the
only place that is currently written down.

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

* refactor(contracts): hoist ProjectService and land its adapter in identity

Two delegated rulings (PROPOSAL §12.13 D-P, D-Q) executed together, plus a
measure-only discharge of the WS1 failure-summary row.

D-P: `trait ProjectService` + `ProjectServiceError` move from
`ironclaw_assistant/src/reborn_services/projects.rs` to
`ironclaw_product_contracts::project_service`. The row scoped this as "the port
+ its ~18 DTOs"; measured first, WS1.4 had already moved every DTO to
`product_contracts::workspace_views` and left the port behind, so the hoist is
two declarations (96 lines) and the old file is now a 23-line DTO shim. The
port had to leave `products` because its implementor sits *below* product —
that is the dependency-edge argument `workspace_views.rs`'s own doc looked for
and did not find.

`reborn_product_contract_location_scan.rs` enrolls the trait by discovery and
its import-path half then forbids any re-export, so the hoist and the
re-export deletion are one change: 9 call sites repoint to the owner
(2 assistant, 5 composition, 3 integration harness).

D-Q: `ironclaw_identity`'s armed allowlist widens by exactly
`ironclaw_product_contracts` — contracts-layer, downward, so the crate's
"never reach upstream" guarantee is unchanged in kind. The gate comment cites
D-Q and states the constraint rather than the precedent.

With both, §6.4.11's second hop lands: `ironclaw_assistant/src/project_service.rs`
(743 lines, 6 tests) is `ironclaw_identity/src/projects/service.rs`, beside the
records it gates. Identity re-declares `tracing`, which the record merge shed,
because the adapter logs backend causes behind sanitized errors.

STOPPED, with the blockers recorded at §6.10.1 rather than worked around:
`composition/support/fs/mount_filesystem_reader.rs` (505 LOC) cannot follow.
It needs `ironclaw_assistant` (the un-hoisted `FilesystemBrowseReader` plus
five mapping helpers), `ironclaw_attachments` and `ironclaw_safety` (each a new
same-layer edge), and `ironclaw_composition::runtime_mounts` aliases — `app`,
upward, matrix-illegal at any allowlist. That last one is decisive and is not a
placement question: the aliases are which mounts a deployment serves.
`project_create_capability.rs` also does not move; its `ironclaw_loop_host`
blocker is unchanged and was never the port.

WS1 failure-summary row: dispatched to execute, measured already done, so
nothing moved and the measurement is the deliverable. `cargo metadata` (not a
manifest grep) shows the one `assistant -> turn_runner` edge resolving with
`kind: dev`; `assistant/src` names `turn_runner` zero times; the tables are in
`host_api::failure::{categories,summary}` and product imports all four names
from there. The `product -> loop_host` clause stays open and grew again:
7 production files across the same 3 seams (`channel_workflow.rs:38` is new).

Un-masking: identity lib 36 -> 42, assistant lib 381 -> 375 — the same 6 tests,
none lost, no assertion edited.

Both touched guards sabotage-tested and confirmed live, then reverted: adding
`ironclaw_threads` to identity fails `reborn_crate_dependency_boundaries_hold`;
re-adding the `pub use` fails `no_crate_re_exports_a_product_contract_it_does_not_own`.

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

* audit(batch): close all five findings — fold slice 6 for real, re-capture contracts ceilings, empty-table self-test arm, harness lockfile, composition re-seed at the true tip (40,419), CHECKLIST row dedupe

* charter(assistant): drop the two rows for items the D-P hoist moved to product_contracts — the map charts this crate only

* review(7258): CodeRabbit round-1 fixes — table-scoped publish check (sabotage-verified), mod coverage in the charter gate (26 modules chartered, cfg-test mods exempt), doc-truth alignments

* docs(batch): remove committed diff3 markers from the slice-6 fold; de-triplicate PROPOSAL §6.10 with the D-P/D-Q note grafted into the canonical copy (audit pass-2 finding)

* docs: dedupe the repeated self-test segment inside the WS7 verify row (audit cosmetic note)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:21:14 +00:00
firat.sertgoz
967f33aa43 fix(tools): support bounded JSON file queries (#7133)
* fix(tools): support bounded JSON file queries

* test: refresh builtin JSON surface snapshots

* ci: map golden payload snapshots to their test lane

* test(json): address coderabbit review feedback (#7133)

* fix(json): add root paths and actionable errors

* test(json): refresh capability surface snapshots

* test(json): replay sanitized thread failures

* fix(ci): use declared lock-free completion timeout

* fix(json): raise scoped query file limit to 8 MiB

* test(reborn): refresh JSON capability surface snapshots
2026-08-05 20:18:43 +00:00
Benjamin Kurrek
d3791e0f85 WS7 (2/2): wasm lane move + Wave-5 closeout (#7212)
* 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>

* refactor(triggers,conversations): scan trusted trigger prompts at the mint (WS6)

PROPOSAL §6.4.2 asked for the trusted-trigger prompt safety scan to move
"behind the triggers/kernel seam it guards". It was not a module: it was
three lines inside `ConversationTrustedTriggerSubmitter::submit_trusted_trigger_fire`
— one of the two implementations of `ironclaw_triggers::TrustedTriggerFireSubmitter`
— holding its own `Arc<dyn InjectionScanner>` from `Sanitizer::new()`.

That placement is a fail-open: a guard that lives inside one implementation
of a port is lost the moment a second implementation exists, and nothing in
the tree forced a new submitter to re-run it.

The seam is `TrustedTriggerFireSubmitter`, whose only input is the sealed
`TrustedTriggerSubmitRequest`, which `ironclaw_triggers` is the sole minter
of. So the scan moved to the mint: `TrustedTriggerSubmitRequest::new` is now
fallible and calls the new `ironclaw_triggers::prompt_safety` first, making
"this prompt passed the trusted-prompt scan" an invariant of the type rather
than a step some submitter performs. `new_for_test` delegates to `new`, so
the test-support seal bypasses visibility only, never the scan.

Behaviour at the fire level is unchanged — same rejection point, same
`TriggerError::InvalidMaterialization`, same permanent disposition — and
composition's pre-materialization scan is untouched, so defence in depth
survives with the second scan relocated and now covering every submitter.

`ironclaw_conversations` drops `ironclaw_safety` entirely (the scan was its
only use). Enforcement: triggers' boundary rule stops forbidding
`ironclaw_safety` (a same-layer, I/O-free `substrates` leaf — a peer edge,
not a reach upward), and a NEW `BoundaryRule` for `ironclaw_conversations`
forbids it, plus `ironclaw_threads` (§6.4.2's "Never: transcript content"),
a crate that was unruled until now.

Regression coverage at the caller tier, not on the helper:
`tick_rejects_injection_prompt_before_any_trusted_submitter_is_reached`
drives the real `TriggerPollerWorker::tick_once` with a materializer that
does NOT scan and a submitter configured to accept, and asserts the
submitter is never reached. A companion pins that a medium-severity-only
prompt still submits, so the mint cannot drift into a blanket filter.

Tests: conversations 97 -> 97 (name-identical), triggers 169 -> 173
(+2 worker, +2 prompt_safety unit), architecture 206 -> 206.
LAYER_MATRIX_EXCEPTIONS unchanged at 10.

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.

* refactor(traces): drop the boundary-laundering re-export modules (WS6)

PROPOSAL §6.4.14: "drop the boundary-laundering re-export modules
(`recording`, `paths`) — consumers import the owners".

`ironclaw_reborn_traces::{recording, paths}` were two `pub use <other
crate>::*` passthroughs whose own doc comments stated their purpose
plainly: "so reborn-cli does not need a direct `ironclaw_llm`
dependency, preserving the architectural boundary". They preserved
nothing — the edge existed either way; the wildcard only hid which crate
owned the type, so the dependency graph read as a lie.

All three call sites were in `ironclaw_reborn_cli`. Note the literal
reading of "consumers import the owners" is not available here: the CLI's
dependency allowlist (`reborn_cli_binary_crate_stays_separate_from_v1_root`)
deliberately excludes `ironclaw_llm`, so importing the owner would have
traded a laundered re-export for a breached, tested boundary. Satisfied
instead by giving the owning crate the operation, which is what the
laundering was standing in for:

- `onboarding::onboard_instance(invite, consents)` — resolves the
  contribution root itself. Path layout under the base dir is this
  crate's own knowledge; the CLI no longer needs base-dir vocabulary.
- `TraceClientHost::build_envelope_from_recorded_trace_json(json, opts)`
  — parses `ironclaw_llm::recording::TraceFile` inside the crate that
  already depends on `ironclaw_llm`. The CLI hands over raw JSON.
- the CLI's private `trace_contribution_dir()` now delegates to
  `contribution::trace_contribution_dir_for_scope(None)` instead of
  re-deriving `<base>/trace_contributions`. Verified byte-identical:
  `trace_contribution_dir_for_scope(None)` is
  `trace_contribution_dir_for_scope_at(&ironclaw_base_dir(), None)`,
  whose `None` arm returns `base.join("trace_contributions")`.

No dependency was added to any crate. Semantics unchanged.

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

* refactor(llm): make providers.json a crate asset with a boundary rule (WS6)

CHECKLIST WS6: "`llm` `providers.json` becomes a crate asset/composition
input + boundary rule added".

The provider catalog sat at the **repository root**. A root-level data
file has no owning crate, so no boundary rule could govern who edits it,
and every consumer compiled it in behind Cargo's back with an escaping
`include_str!` — the "repo-root asset reach-in" shape §11.2.7's scanner
inventories. `git mv`'d to `crates/ironclaw_llm/assets/providers.json`
and the 20 `include_str!("../../../providers.json")` sites in
`registry.rs` become in-crate `../assets/providers.json`.

⚠ Correcting the row's inherited premise: a prior lane recorded the
"load-bearing include site is in `ironclaw_reborn_cli`" and judged the
item "needs a new mechanism, not a new path". Measured on main: the
load-bearing site is `crates/ironclaw_llm/src/registry.rs:383`
(`builtin_provider_definitions`), inside the owning crate. No new
mechanism was needed — only the path.

**Path-keyed gates rewritten in the same commit** (WS10: these fail
*silently* under a move):
- `Dockerfile` — both `COPY providers.json providers.json` lines deleted;
  `COPY crates/ crates/` already covers the new location in both stages.
  Verified by `scripts/ci/check-include-str-paths.sh` (OK, 119 refs).
- `.github/workflows/reborn-e2e.yml` — the literal `providers.json` path
  filter and its regex alternative removed; the depth-independent
  `crates/**` entry already matches. `ws12_workflow_contracts.py` passes.
- `scripts/ci/classify-test-scope.sh` — kept at its **shared** (both
  lanes) classification under the new path rather than letting it fall
  through to crate scope, so CI breadth does not silently narrow; the
  now-redundant entry in the reborn-only branch is dropped.

**The one consumer that could not simply be repointed.** The CLI's
`default_llm_consts_match_the_real_providers_json_nearai_entry` embedded
the catalog from five directories up to check its mirrored `DEFAULT_LLM_*`
constants. Repointing it would have turned a repo-root reach-in into a
*cross-crate* reach-in — the category §11.2.7 turns into a hard failure —
and the CLI may not depend on `ironclaw_llm`. A cross-crate consistency
rule belongs in the cross-crate suite, so the assertions moved into
`ironclaw_architecture` and read both files from disk at runtime, needing
no compile-time coupling at all.

Test accounting: `ironclaw_reborn_cli` config-init tests 2 -> 1; the
removed one is reborn as `reborn_provider_catalog_is_owned_by_its_crate`
in `reborn_dependency_boundaries.rs`, strictly stronger (it also pins the
asset's location, the repo root's emptiness, and single-embedder
ownership). Net test count +0.

**The new rule is sabotage-tested** — five cases, each red with the right
message, each restored to green:
1. catalog copied back to the repo root -> "must not sit at the
   repository root"
2. a foreign crate `include_str!`s it -> names the offending file
3. catalog `default_model` drifts from the CLI mirror -> names the const,
   the field and both files
4. walker pointed at a non-existent dir -> "walked only 0 Rust files ...
   would pass no matter what the tree contained" (reachability)
5. mirrored const renamed -> "no longer declared as a plain const ...
   update the extraction rather than deleting the drift check"

Case 2 caught a real false positive in the first draft of the guard: a
file-level `include_str!` AND `providers.json` conjunction flagged
`cli/tests/smoke.rs`, which names the *runtime*
`$IRONCLAW_REBORN_HOME/providers.json` and separately embeds something
else. The matcher now inspects the macro argument, not the file.

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

* 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.

* docs(skills): rewrite the stale v1 lib.rs charter note (WS6)

CHECKLIST WS6 domain-internal cleanups: "`skills` stale v1 lib.rs doc
rewritten".

The crate doc claimed "In v1, trust-based tool filtering happens via
`src/skills/attenuation.rs`. In v2, the Python orchestrator handles trust
labels and the policy engine controls tool access via capability leases."
Both halves are dead vocabulary: there is no `src/` monolith on this tree
and no Python orchestrator anywhere in Reborn.

Replaced with what is true and checkable — this crate owns the trust
*label* and none of its enforcement; the ceiling is applied at the
capability tier (`host_api` capability/invocation attenuation via
`first_party_extension_ports`' activation and execution paths) and the
decision belongs to `ironclaw_authorization`. Also points at the existing
`SkillTrust` `Ord` safety note, which the old text left unconnected.

Doc-only; no code change.

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

* 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.

* refactor(crates): execute the WS6 crate renames, no shims (WS6)

Three CHECKLIST WS6 rename rows, executed together as one pure rename.
No compatibility re-export shims (WS6 discipline); every consumer, doc,
CI script and snapshot repointed in this commit.

**Row 1 — stutter kills (decided 2026-07-29):**
- `ironclaw_events`             -> `ironclaw_event_log`
- `ironclaw_extensions`         -> `ironclaw_extension_registry`
- `ironclaw_product`            -> `ironclaw_assistant`

**Row 2 — naming audit (decided 2026-07-30):**
- `ironclaw_architecture`       -> `ironclaw_architecture_tests`
- `ironclaw_runner`             -> `ironclaw_turn_runner`
(`ironclaw_first_party_extensions` -> `ironclaw_extension_support` landed
early with WS2.6 and is already ticked.)

**Row 3 — the `reborn_` batch (decided 2026-07-30):**
- `ironclaw_reborn_composition`   -> `ironclaw_composition`
- `ironclaw_reborn_config`        -> `ironclaw_config`
- `ironclaw_reborn_event_store`   -> `ironclaw_event_store`
- `ironclaw_reborn_identity`      -> `ironclaw_identity`
- `ironclaw_reborn_openai_compat` -> `ironclaw_openai_compat`
- `ironclaw_reborn_traces`        -> `ironclaw_trace_commons` (§6.4.14:
  the crate is the Trace Commons client, not trace machinery)
- root package `ironclaw_reborn_integration_tests` -> `ironclaw_integration_tests`

4,806 occurrences rewritten across 901 files, plus 11 `git mv`'d crate
directories (`git diff -M` reports them as renames). Replacement used
word-boundary matching, which is what keeps `ironclaw_product` from
touching `ironclaw_product_contracts` and `ironclaw_extensions` from
touching the four `ironclaw_extension_*` siblings.

**Semantics: none.** No type was renamed, no module moved, no signature
changed. `cargo check --workspace --all-targets` is clean.

**Path-keyed gates rewritten in the same commit** — WS10 lists these as
the ones that fail *silently* under a rename, and each was re-run to
prove it still scans a non-zero tree rather than merely passing:
- `scripts/no_panics_reborn_baseline.txt` — 3 entries repointed, 0 stale
  names left; `--reborn-baseline` reports "OK ... (1203 files, 51
  reviewed invariant(s))" and `--self-test` passes 34 tests.
- `docs/plans/composition-pubuse.snapshot` — 5 entries. This one is not
  documentation despite its path: `composition_public_pub_use_surface_matches_snapshot`
  compares against it byte-for-byte, and it failed loudly when the rename
  first landed without it. Caught by running the suite, not by inspection.
- `scripts/ci/classify-test-scope.sh`, `scripts/ci/reborn-crate-test-buckets.sh`
  (+ its self-test), `scripts/ci/discover-reborn-package-crates.sh`,
  `scripts/ci/package-feature-flags.sh`,
  `scripts/ci/check-generic-without-concrete.sh`,
  `scripts/ci/ws12_workflow_contracts.py`, `scripts/dev_metrics.py`,
  `scripts/reborn-e2e-rust.sh`, `scripts/pre-commit-safety.sh`.
- **CI lane names**, which the `ironclaw_architecture` row calls out
  explicitly: `.github/workflows/code_style.yml`'s `cargo test -p
  ironclaw_architecture reborn` step and its changed-paths regex.

Verification: `cargo check --workspace --all-targets` clean;
`ironclaw_architecture_tests` 32/32 suites green; `ws12_workflow_contracts.py`,
`test-classify-test-scope.sh`, `test-reborn-crate-test-buckets.sh`,
`check-include-str-paths.sh` all pass. `LAYER_MATRIX_EXCEPTIONS` counted
with Python between the const and its `];` — **6**, unchanged.

Deliberately not rewritten: `docs/reborn/subagent-spawn/diagrams/*.{d2,svg}`
and the historical prose in `docs/`. Those describe an unlanded design
authored against the old tree; renaming inside them would misrepresent
what was designed, and the `.svg`s are generated artifacts.

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

* 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>

* refactor(cli): move the binary crate to crates/app/ironclaw_cli (WS6)

Last clause of the WS6 `reborn_` rename row: "cli directory ->
`app/ironclaw_cli`". Package name stays `ironclaw` (unchanged, as the row
requires); this is a directory move plus the crate-directory rename.

82 path references rewritten across 39 files, plus the crate's own 18
`path = "../X"` dependencies re-based to `../../X` now that it sits one
level deeper. `cargo check --workspace --all-targets` clean.

This is the first crate to live at a nested family path, which is exactly
the shape WS10 warns about: a gate keyed to the flat `crates/<name>/`
layout stops matching and goes green having scanned nothing. Two gates
were found by running them, not by reading them:

1. **`scripts/ci/ws12_workflow_contracts.py` failed loudly and correctly** —
   `.github/workflows/code_style.yml`'s `has_reborn_cli` filter named the
   crate `ironclaw_reborn_cli`, which the crate inventory could no longer
   resolve: "expected exactly one crate directory named
   'ironclaw_reborn_cli' under crates/, found 0 ... repoint the gate that
   names it rather than letting it measure an empty tree." Repointed
   there, in ws12's own probe table, and in
   `check-generic-without-concrete.sh`. The workflow regex already used
   the depth-independent `crates/([^/]+/)*` form, so the nesting itself
   was safe — only the crate *name* needed repointing.

2. **`docs/plans/composition-pubuse.snapshot` regenerated after `cargo
   fmt`**, not before. The rename lengthened a `pub use` line past the
   width limit, so fmt rewrapped it and the snapshot went stale a second
   time. Diff is exactly one alphabetical re-sort
   (`ironclaw_product`->`ironclaw_assistant`) and one rewrap; no symbol
   added or removed.

**Pre-existing bug fixed in passing, with evidence it predates this PR.**
`check-generic-without-concrete.sh` listed `"ironclaw_reborn_cli"` among
its sanctioned assemblers, but that set is matched against cargo
*package* names and the CLI package is `ironclaw`. The exemption
therefore matched nothing and the gate was **already red on clean
`origin/main` @ 283e1f6b7c**, reporting the two concrete extension crates
DEL-7 explicitly allows the binary to link:

    ironclaw: dependency graph contains concrete extension crate ironclaw_slack_extension
    ironclaw: dependency graph contains concrete extension crate ironclaw_telegram_extension

Reproduced on a clean checkout before assuming this PR caused it. Fixed
by naming the package, with a comment recording that these are package
names — the same directory-vs-package confusion that
`boundary_rule_names_are_package_names_not_crate_directories` exists to
catch on the dependency-boundary rules.

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>

* docs(target-arch): tick the three WS6 rename rows, amend four others (WS6)

Dated amendments, each quoting or naming the text it replaces.

**Ticked (condition verified on the merged tree):**
- the three `Renames executed` rows — stutter kills, naming audit, and
  the `reborn_` batch. All 14 clauses across them are done.

**Amended without ticking, because a clause is genuinely unmet:**
- `Domain-internal cleanups` — three of six clauses done (traces
  re-export modules, `llm providers.json`, `skills` lib.rs doc), one
  refuted (`identity` absorbing `host_api::user_identity`), two open
  (`triggers` SQL ADR, `projects` composition adapter).
- `Retire the local_dev misnomer` — the row stays ticked; its *residue
  clause* is re-scoped with measurements.

**Two row texts were wrong and are corrected rather than executed:**
1. The `traces` `ScopedFilesystem` clause says the type is "dropped".
   §6.4.14 says the crate should *take* one. §6.4.14 is right and the
   row is the error — the type exists (`ironclaw_filesystem::ScopedFilesystem`)
   and is absent from the traces crate, so this is adoption, not removal.
   Also corrects "~91 raw `fs` call sites" (that counted test code; the
   production surface is 11 in `contribution.rs` plus ~7 in
   `device_key.rs`).
2. The `local_dev` residue said "the local variable at
   `composition/src/runtime.rs:3016`". It is not one variable — it is 14
   distinct identifiers; #7098's "public type" claim is wrong
   (`RebornLocalRuntimeIdentity` is `pub(crate)`); and #7098's
   explanation for why the ratchet missed it is wrong, because a
   *second* ratchet (`reborn_deployment_mode_typename_ratchet`) already
   inventories the name and records that the sanctioned exit is Slice B,
   not a rename. Every obvious rename target is also already taken by a
   different concept.

**One clause refuted with measurements (delegated authority).** "`identity`
absorbs `host_api::user_identity` ports" would move a ports module out of
the neutral contracts crate into a crate that neither implements nor
consumes it — the sole production implementor is
`extension_host::channel_identity_store::FilesystemChannelIdentityStore`
— and, because `ironclaw_identity` depends on `ironclaw_host_api` and not
the reverse, would force `extension_host` to take a new dependency to
name a port it implements. The ports stay in `host_api`. The dual
binding-store ambiguity is resolved as nominal, not structural: principal
identity (`ironclaw_identity::identity_store`) and post-OAuth channel
binding (`extension_host::channel_identity_store`) are distinct concerns
and neither subsumes the other.

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>

* fix(ci): repoint the release-cut scripts at the moved CLI manifest

`origin/main` added `scripts/ci/cut_ironclaw_release.py` and its
self-test while this branch was in flight; both locate the version to cut
via `crates/ironclaw_reborn_cli/Cargo.toml`, which this PR moved to
`crates/app/ironclaw_cli/Cargo.toml`.

Caught by re-scanning the merge for reintroduced old crate names rather
than trusting a clean `git merge` — the merge was conflict-free precisely
because these files are new on main and touch nothing this branch edited,
which is the shape that reintroduces a stale path silently.

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>

* fix(ci): classify the Dockerfile in the Reborn PR test planner

`Detect Reborn test scope` failed on this PR with:

    Reborn PR test planner failed: unclassified pull-request path: Dockerfile

and took `Tests (Reborn)` down with it ("changes failed: failure").

`scripts/ci/reborn_pr_test_plan.py` classifies every changed path and its
fail-closed arm raises on anything no rule claims. `PR_STATIC_CONTROL_PATHS`
held `Cargo.toml`, the toolchain files and the coverage manifests, but not
`Dockerfile` — so **any** PR editing the container build context aborted
the planner. This PR is simply the first to do so: moving `providers.json`
into its owning crate made the two `COPY providers.json` lines redundant.

The Dockerfile is owned by the `Docker` workflow (its own trigger on this
path) and its COPY coverage by `check-include-str-paths.sh` under Code
Style. No Reborn test lane reads it, so it belongs with the other
de-escalating static-control paths: `mode: none`, `coverage_mode: none`,
no buckets selected.

The existing `test_unclassified_build_input_fails_fast` used `Dockerfile`
as its *example* of an unclassified path. The invariant it protects is the
fail-closed arm, not the filename, so it keeps that arm with a genuinely
unowned fixture (`unowned-root-input.mk`, fictional and never touched on
disk — same convention as `test_unmapped_crate_path_fails_fast`), and a new
`test_dockerfile_is_static_control_not_a_planner_abort` pins the new
decision by asserting the mode, the coverage mode, the empty bucket list
and the reason string.

Sabotage-tested: removing `"Dockerfile"` from the set turns the new test
red; restoring it returns 44/44 green.

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>

* refactor(cli): keep the rename flat; sever the app/ relocation to WS7 (WS6)

**Reverts the `crates/app/` family directory this branch created.** The
crate keeps its WS6 **rename** — `ironclaw_reborn_cli` -> `ironclaw_cli`,
package name `ironclaw` unchanged — at the flat path
`crates/ironclaw_cli`.

The defect was in the row, not in executing it. CHECKLIST WS6's CLI row
names `app/ironclaw_cli` as its rename target, and PROPOSAL §5's tree
confirms that destination — but family directories are WS7 (Wave 5), so a
Wave-4 row named a Wave-5 path. The row's own `[decision — severable]`
tag shows the authors knew a call was owed; it was never made, so
following the row literally does both halves at once. **Owner ruling
2026-08-04: Waves 0–4 close before anything touches Wave 5.** Severed.

This matters beyond tidiness: PLAN marks the WS10 nested-tree-safe gate
rewrites a hard prerequisite *before the first family `git mv`*, because
path-keyed gates fail **silently** under family directories rather than
loudly — #7083 (a coverage regex that blinded 11 crates the moment
`crates/extensions/` appeared) is the worked example. WS10 still has open
rows.

`crates/app/` was the **only** family directory this branch created;
`crates/extensions/` pre-exists on `main`.

**Recorded as a class, not an instance** (docs commit alongside): any
pre-WS7 row quoting PROPOSAL §5 inherits the same collision. The
established precedent is to land flat — WS1's three `contracts/ironclaw_*`
rows all say `contracts/` and all landed at `crates/ironclaw_*`; there is
no `crates/contracts/` directory. Two sibling rows carry the same defect
and are now flagged not-to-execute-as-written: WS3's
`lanes/ironclaw_sandbox` and WS4's `crates/lanes/wit/`.

**Also: the Reborn PR test planner could not classify a rename PR at all.**
`Detect Reborn test scope` failed the whole run — first on `Dockerfile`,
then on `clippy.toml` — and each fix surfaced the next, because
`reborn_pr_test_plan.py` fails closed on any unclassified path and had
never seen a diff of this shape. Fixed as a class:
- root workspace policy files decided: `clippy.toml`, `deny.toml`,
  `release-plz.toml` (beside the already-classified `Cargo.toml`);
- root scripts decided per-file as that set requires:
  `check_no_panics.py`, `dev_metrics.py`, `pre-commit-safety.sh`,
  `test-mutation-audit.sh`;
- prose/standalone trees ignored: `openwiki/` (generated wiki),
  `test-tools/`, `harness/` (standalone cargo project, own Cargo.lock);
- **`scripts/live_canary/`** added to the QA harness prefixes — the set
  listed only `scripts/live-canary/` and **both directories exist**,
  differing by hyphen-vs-underscore, so the underscore one fell through;
- files sitting directly in `crates/` (`crates/AGENTS.md`) classified as
  tree-wide prose — they belong to no package, so the crate arm raised;
- **paths removed by the diff** classified instead of fatal. This is the
  one that matters for the programme: renaming 11 crates puts ~600 deleted
  paths in the diff, none of which map to a package. Without it every WS6
  rename PR and every WS7 family move fails closed here.
- the shared-E2E-harness wall is kept but made *satisfiable*: a
  `DECIDED_E2E_HARNESS_PATHS` set records a decision. The guard's purpose
  is "changing a shared fixture must be deliberate"; as written it had no
  way to record a decision, so it blocked even a mechanical rename with no
  route forward. `tests/e2e/reborn_webui_harness.py` is decided (the E2E
  workflow owns it); everything else still raises, on both fail-closed
  arms.

Its self-test goes 43 -> 49. Two existing tests used as their *example* a
path this commit classifies; both keep their invariant with an undecided
fixture instead. **Sabotage-tested each new arm**: disabling the
removed-path arm, emptying the decided set, and disabling the `crates/`
prose arm each turn the suite red; restoring returns green. The prose arm
initially passed while sabotaged — it had no test — which is precisely the
green-while-checking-nothing shape, so a test was added and the sabotage
re-run to confirm it now fails.

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

* fix: repoint crate names reintroduced by the merge-down from main

`git merge origin/main` (fb776f3c62) was conflict-free — main's new work
touches files this branch had not edited — which is exactly the shape that
reintroduces stale crate names silently. 77 occurrences across 33 files,
found by re-scanning for every old name after the merge rather than
trusting the clean merge.

Dated historical prose under `docs/reborn/target-architecture/` is
deliberately excluded: those rows record what was true when they were
written, and rewriting them would misrepresent the record.

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>

* ci(test-plan): classify the whole repo-root metadata class, not one file per red run

`.gitattributes` is touched by this PR (the rename left its `wix/main.wxs`
rule pointing at `crates/ironclaw_reborn_cli/`, a path that no longer
exists), and the planner fails closed on unclassified paths — so it aborted
`Tests (Reborn)` with "unclassified pull-request path: .gitattributes".

Every entry already in this set was added the same way: a rename-shaped diff
touches root files a feature PR never touches, the planner dies on the first
one, and the next only appears after that one is fixed — Dockerfile, then
clippy.toml, then six more. Rather than add a ninth, this enumerates the
remaining class: all 19 unclassified root paths were found by driving the
planner over every tracked root file, and 17 are listed.

The two that are NOT listed are the point. Membership requires that no
Reborn test lane reads the file, checked per file against `crates/**/*.rs`
and `tests/**`. That check found real readers for `.dockerignore`
(`tests/dockerfile_runtime_home.rs`) and `.env.example` (`ironclaw_cli`,
`ironclaw_host_runtime`), so both stay fail-closed. Classifying a file a
test depends on would silently skip that test — worse than an aborted
planner.

Verified: planner self-test 48/48; every tracked root file except those two
now classifies; the full PR diff plans without error.

* fix(ci): repoint the test-scope classifier off the dead `ironclaw_reborn_*` glob

`Fast deterministic checks` failed on `test-classify-test-scope.sh`:

    FAIL reborn binary crate
    Expected: has_legacy_tests=false has_reborn_tests=true
    Actual:   has_legacy_tests=true  has_reborn_tests=false

`is_reborn_test_path` matched the CLI through `crates/ironclaw_reborn_*/*`.
The WS6 renames dropped that prefix from all seven crates that carried it, so
the glob now matches **nothing** and every one of them silently reclassified
as legacy. Enumerated the seven new names instead of re-globbing: they share
no prefix, and this is the second time a prefix glob has rotted here.

Fixed the classifier, not the fixture. The self-test's expectations describe
the intended behaviour; flipping them to match the break is how a gate goes
quiet.

**This class fails OPEN**, which is why only one crate's assertion caught it —
the classifier keeps answering, just wrongly. Added a guard asserting every
`crates/…` pattern in the classifier matches at least one real path, the same
shape as `sanctioned_paths_all_match_real_files`: an exemption may not outlive
the code it exempts. Two pre-existing dead arms
(`crates/ironclaw_extension_support/`, `crates/ironclaw_oauth/`) are listed
known-dead and shrink-only rather than repointed — both match nothing today,
so neither is load-bearing, and repointing them would change which tests those
crates select. That is a behaviour change, not this PR's business.

Swept the siblings: every `crates/<name>` literal and glob stem across
`scripts/`, `.github/`, and the architecture tests was checked against the
real tree. The only dead reference attributable to the 13 WS6 renames is the
one fixed here; the rest are synthetic self-test fixtures or crates deleted
long before this branch.

Sabotage-tested both, confirming red with the RIGHT message and green after
restore: (1) restoring the dead glob reproduces `FAIL reborn binary crate`;
(2) adding `crates/ironclaw_totally_invented/*` trips the new guard with
`classifier pattern matches no real path`.

Also recorded the ALLOWLIST union recount in the constant's own doc comment:
this branch carried 129, `main` 125, and the merge inherited 125 without
measuring. Recounted off the compiler (constant → 0, read `ALLOWLIST grew to
125 entries`): 125 is the live count with zero slack (#7147).

* 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>

* fix(ci): restore the composition-budget negative case the rename collapsed

T4 asserts the budget gate fails LOUDLY when the composition crate is absent.
It builds a fixture under the crate's real name and renames it away so the
gate cannot find it. The destination was hard-coded `ironclaw_composition` —
which is exactly what the WS6 rename turned the crate's real name into, so
both sides of the `mv` became the same path.

`mv X X` does not rename; it tries to nest a directory inside itself and dies
with "Invalid argument". The negative case stopped running.

Renamed the destination to `composition_renamed_away` — deliberately
synthetic, so no future crate rename can collide with it again — and wrote the
reason into the test.

Found by running the nine `Static-check self-tests` scripts that CI never
reached: that step stops at the first failure, so fixing the classifier only
uncovered what was behind it. Ran all of them, plus the nine skipped steps
after it, rather than discovering them one CI cycle at a time. This was the
only other failure; the other seventeen checks pass.

Sabotage-tested: skipping the rename (so the crate is present) makes T4 fail
with `expected exit 1, got 0` and the missing-message assertion — 49 passed,
2 failed. Restored: 51 passed, 0 failed. The case genuinely exercises the
absence again rather than passing because it never ran.

* 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>

* style: cargo fmt after the #7155/#7062 merge

`Check formatting` (step 6 of Fast deterministic checks) went red on
cca5884b47: the merge was pushed under time pressure without running fmt.

Only the two files whose crate references I rewrote by hand are affected —
`ironclaw_reborn_composition` -> `ironclaw_composition` is 9 characters
shorter, so call sites that were wrapped at the old width now fit on one line.
No semantic change.

* 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>

* WS2: clear the extension_host->product vocabulary residue (ports 4->1, ledger 9->5)

Three of the four frozen ports and four of the nine reference-ledger rows fall
by one move: the port-facing vocabulary is declared where it already lives, and
product maps at its boundary.

- `ExternalActorBindingEpoch` moves `ironclaw_conversations` ->
  `ironclaw_extension_contracts::external`, beside the `ExternalActorRef` whose
  binding it versions. Zero new crate edges (conversations already depends on
  extension_contracts). Its constructor error becomes
  `ProductAdapterError::InvalidIdentifier`, matching its siblings in that module
  byte-for-byte on the three validation rules.
- `ProductActorUserResolver` + `ProductActorUserResolutionRequest` +
  `ResolvedProductActorUser` invert into
  `ironclaw_product_contracts::actor_identity`, error swapped to
  `ProductOperationFailure` (product absorbs it with the existing total `From`,
  discriminants preserved).
- `AuthChallengeProvider`, `BlockedAuthFlowCanceller`, `AuthChallengeView`,
  `PairingAuthChallengeView` and `auth_prompt_view_for_blocked_auth` move to
  `ironclaw_auth::product_prompt`; `ChannelConnectionService` and
  `ChannelAuthAccountState` to `ironclaw_auth::channel_connection`, beside
  `project_auth_account_state` whose argument pair the latter is. Zero
  vocabulary narrowing. `ironclaw_auth` gains a `product_contracts` dependency
  (substrates -> contracts, the same downward edge and rationale
  `ironclaw_attachments` already carries).
- `ExtensionAccountSetupRegistry` stays product-owned state; extension_host now
  holds the two-method read port `ExtensionAccountSetupReader` declared in
  `product_contracts::account_setup`. `None` == empty registry.
- The approval-prompt projection, gate-ref parse and lookup scope move to
  `ironclaw_product_contracts::approval_prompt`, collapsing product's two copies
  and letting the extension host read the approval store itself instead of
  reaching up into `ironclaw_product::projection`. The scope derivation's
  equivalence with `ApprovalInteractionScope` is pinned in product.

Gate updated in the same change: residue 4 -> 1, baseline 4 -> 1, ledger 9 -> 5,
workflow-error residue 2 -> 1, `ProductActorUserResolver` added to
`INVERTED_PORT_IMPLEMENTORS`.

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

* WS2.5: gate + CHECKLIST reconciliation, and two pre-existing clippy reds

- `reborn_extension_host_port_inversion.rs`: `channel_host.rs`'s ledger reason
  loses its stale `ProductActorUserResolver` half (that port is inverted now).
- `reborn_extension_specificity.rs`: the moved `ChannelConnectionService` doc
  carried a `slack` example into `ironclaw_auth`. Reworded generically rather
  than carved, which also made the product entry stale — deleted, allowlist
  baseline 123 -> 122. The gate reported both directions; neither was allowlisted.
- Two clippy reds that pre-exist on this base and bite a `-D warnings` bar: an
  empty line splitting a doc-comment run in the specificity gate, and a
  never-used negative-control fixture in `ironclaw_extension_support`. The
  fixture is `#[allow(dead_code)]`-ed rather than deleted, with the reason.
- CHECKLIST WS2 re-layer row, blockers half: dated and measured annotation of
  what fell, why the "narrow the vocabulary out" framing was only half right,
  and that §12.11 D-A's factory port is unstarted.

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

* WS2: invert channel_host's product-stack construction behind the D-A factory port

§12.11 D-A's factory port, built. `ChannelWorkflowFactory` is declared in
`ironclaw_product_contracts::channel_workflow`, implemented by
`ironclaw_product::RebornChannelWorkflowFactory`, and injected through the
`GenericChannelHostDeps` bundle composition already builds — so
`channel_host.rs` states the shape of the per-extension product cone and
consumes the result instead of inline-constructing product's concrete stack.

`channel_triggered_delivery.rs` sheds through the same seam, but its port
could not live in contracts: it drives the driver with
`TriggerCommunicationContext`, which `ironclaw_outbound` owns and a contracts
crate may not name. So `TriggeredRunDelivery` and `TriggeredRunDeliveryRequest`
are declared in `ironclaw_outbound` beside that vocabulary — the same placement
rule WS2.5 applied to the auth ports, and zero new crate edges. Composition
builds one driver per codec-bearing binding through the same factory; routing
policy stays in the host.

The conversations wrinkle resolved as sanctioned, with no mirror type.
`RebornFilesystemConversationServices` is constructed, consumed and dropped
inside product's factory. What crosses the port is `ChannelWorkflowStorageRoots`
(a `VirtualPath` pair — placement is host policy) in, and the surface, the
binding resolver and the run-delivery observer out.

The last residue port had to be renamed, not just moved:
`ConversationBindingService` is now `ironclaw_product_contracts::binding::
ProductBindingResolver`, because `ironclaw_conversations` already defines a
trait by the old name and §11.2.4's one-home rule refuses two definitions of a
contracts name. The boundary error grew `BindingRequired`,
`UnknownInstallation` and `TurnSubmissionRejected` rather than weakening: all
three are constructed by the port's implementor, `BindingRequired` is what an
unpaired external actor is told, and every one carries `String`/nothing so the
contracts ceiling is untouched.

Gates:
  EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT  5 -> 3
  EXTENSION_HOST_PRODUCT_REFERENCE_FILE_BASELINE        5 -> 3
  PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS 1 -> 0
  WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE            1 -> 0
  EXTENSION_HOST_FILES_STILL_NAMING_THE_WORKFLOW_ERROR  1 -> 0

`the_extension_host_manifest_names_product_only_while_a_residue_needs_it` is
re-keyed on the trait residue OR the reference ledger. That is a correction,
not a relaxation: keyed on the trait list alone it would now demand the
manifest edge be deleted while three adapter-registry rows still name the
crate — failing a correct tree and passing an impossible one. Both directions
stay enforced against the union.

Regression coverage: the ingress/delivery/trigger integration suites are
unchanged in behaviour and green; the only edits to them are import repoints
for the renamed port. `unknown_manifest_command_fails_generic_graph_assembly`
still pins that an undeclarable command fails the whole graph build.

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

* WS2 flip: extension_host products -> loops — manifest edge deleted, ledger/residue 0/0, DowngradePin armed

The batch-2 union (via #7181) and the D-A factory port each discharged
exactly the rows the other left, so the port-inversion biconditional
demanded the flip: layer line + manifest edge in one change. Same-layer
inventory 74 -> 72 net (+1 loops edge extension_host->loop_host, -3
products rows), pin frozen at the four normal-dep consumers. Two typed
ExtensionId seams reconciled between batch-2 and the D-A branch.

* fix(arch): equality-assert the zeroed reference ledger; fmt

* review(7181): architecture-gate hardening from CodeRabbit round 1

Three armed gates were reporting on shapes they could not actually see.

- `reborn_composition_boundaries.rs`: the consumer-annotation scan walked
  back over the attribute block by line prefix, so a multiline
  `#[cfg(any(...))]` between the annotation and the `pub use` stopped the
  walk and rejected a correctly annotated re-export. The walk is now
  bracket-aware and extracted into `pub_use_consumer_annotations` so it is
  testable on synthetic input; the new fixture covers the multiline shape,
  the single-line shape, a bracketed comment, and the unannotated
  sabotage case.
- `reborn_dependency_boundaries.rs`: the MCP/sandbox lane-existence probes
  searched raw concatenated source, so a comment, doc example, string
  literal, or `#[cfg(test)]` fixture naming `McpRuntime<C>` would have kept
  them green after the production runtime was gone. They now scan
  production tokens only (`production_rust_files` +
  `strip_comments_and_strings`), with a regression fixture that plants the
  marker in each of those non-production forms.
- `ironclaw_webui/tests/handlers_module_charter.rs`: `top_level_items`
  stripped only `pub `/`pub(crate) `, so a `pub(super)`/`pub(in ...)` item
  was silently excluded from `charted_surface()` and therefore never
  registered as unassigned. `strip_visibility` now handles every
  visibility form.
- `ironclaw_auth/tests/module_charter.rs`: the two-engine severance scan
  dropped only lines beginning `//`, so a block comment, a trailing
  comment, or a string literal naming the other engine reached the probes
  and a documentation edit could fail the charter gate. A lexical stripper
  replaces the prefix filter, with fixtures for each shape plus a
  must-still-be-seen `use` case.
- `reborn_extension_host_port_inversion.rs`: the reference-ledger history
  still described a 9 -> 5 reduction with five survivors; the live ledger
  has two rows and the baseline is 2. Corrected to the actual 9 -> 5 -> 2.

Every strengthened scanner was sabotage-tested (broken, watched fail,
restored). `cargo test -p ironclaw_architecture` is green across all 37
binaries with no new violations surfaced.

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

* review(7181): MCP lane — arm the charter's failure-string rule, close its exceptions

The crate charter's load-bearing clause — "no module builds a failure
string of its own" — was stated in three files and enforced in none, and
the crate carried live exceptions.

- `egress.rs` minted `"runtime_http_egress_panicked"` inline and forwarded
  `stable_runtime_reason()` verbatim into `McpClientError`. Both are now
  `diagnostics::McpEgressCause` variants named through `egress_failure`.
- `impl From<String> for McpClientError` was the implicit bypass: any `?`
  in the crate could turn an arbitrary String into a model-visible
  reason. It had exactly one user (`client.rs`'s credential-injection
  check, whose reason already came from `diagnostics`), now an explicit
  `map_err(McpClientError::client)`. The impl is deleted.
- `diagnostics.rs` claimed "every reason is capped here" but appended the
  server-supplied `JsonRpcError.message` verbatim. The only production
  producer bounds it upstream, but the cap is this module's invariant,
  not the caller's, so it now goes through `bound_mcp_reason_detail`.
- New `tests/module_charter.rs` arms the rule: a new `reason: "..."` /
  `reason: format!(...)` outside `diagnostics.rs` fails, a re-added
  `From<String>` fails, and the charter text in `lib.rs` + `CLAUDE.md`
  must keep naming the rule and its gate. The rule's one remaining
  carve-out — `runtime.rs`'s two `McpError` descriptor/invocation reasons,
  which echo manifest ids rather than classify a failure — is an
  enumerated list, not a wildcard, and both docs now say so.

Also in `runtime.rs`: the `transport == "stdio"` process-count branch is
unreachable (`prepare_client_request` rejects stdio and everything that
is not http/sse before it), so it is replaced by a comment saying why no
process accounting happens here; and `release_after_failure`'s discarded
`Result` gets the required `// silent-ok:` annotation plus a `debug!` so a
leaked reservation leaves a trace without masking the caller-facing error.

Sabotage-tested: re-inlining the egress reason makes the new gate fail
with 3 inline reasons instead of the 2 grandfathered rows.

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

* review(7181): type the channel-connection port, and fix the trace-prune lock

Two Major findings with real failure modes.

**Typed channel identifier at the `ChannelConnectionService` boundary.**
The port exchanged channel package ids as `String` map keys and a `&str`
disconnect argument, so a malformed or non-canonical id could become a
key no lookup would ever match — a channel that silently reads as "not
connected" instead of failing. The sibling map on the very same product
call (`installed_activation_errors`) was already keyed by `ExtensionId`,
so the untyped half was the odd one out. All three signatures now use
`ironclaw_host_api::ids::ExtensionId`; the generic service applies the
same skip-invalid-vocabulary rule its own discovery walk already used,
and `extension_info` resolves the id once for all three lookups.

**`std::sync::Mutex` held across filesystem I/O in the trace prune step.**
`trace_scope_has_pending_queue` is a synchronous `read_dir` per scope and
was called from inside `observed_scopes.retain`, under the guard, on the
runtime worker thread — while `record_observed_scope` takes the same lock
from the capture path, so a stalled filesystem blocked capture-time scope
recording. The probe now runs on the blocking pool against a snapshot and
the guard is re-acquired only to apply the result, which also leaves
scopes recorded mid-probe alone. (This pattern predates the WS6 move —
it was introduced 2026-06-15 in 410db7720 and relocated verbatim by this
batch — but it is contained enough to fix here.)

**Fire-access unavailable-precedence coverage.** New WS6 policy code
decided what a transient backend fault becomes (retryable `Err` when the
final answer is a denial, but never over a grant) with no test driving a
failing checker at all. Added, test-first: breaking the precedence branch
makes it fail with `Denied` where `Unavailable` is required. Also pins the
last-position fault, which the other two cases never reach.

**Product-adapter section invariants.** `DuplicateCredentialHandle`,
`DuplicateEgressTarget`, and the RFC 7230 token rule (including
`auth.timestamp_header_name`, the optional field a rename could quietly
drop from validation) came over from `ironclaw_product::adapter_registry`
with WS5 and had no assertion anywhere. Covered through the real
deserialize + resolve + validate path.

**Smaller items.** The relocated trigger-fire contract no longer keeps a
second import path through composition (`runtime_input`'s `pub use` and
the four names in the lib.rs surface are gone, consumers repointed at
`ironclaw_triggers`, snapshot recaptured); `repository_contract.rs` uses
`var_os` for presence so a non-UTF-8 `IRONCLAW_REQUIRE_POSTGRES` cannot
silently disarm the parity guard, with a regression fixture;
`ironclaw_reborn_identity`'s stale `Self::bind` rustdoc link, the
`ironclaw_auth` AGENTS.md `loopback_oauth` contradiction, and the
`ironclaw_extension_contracts` charter row missing
`ExternalActorBindingEpoch` are corrected.

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

* Re-arm the union's ratchets: recount, swap one same-layer edge, repoint a coverage exemption

Three gates fired on the merged tree; each is fixed by measurement, not by
lowering a bar.

**Extension-specificity baseline: 125 (ours) / 122 (batch) -> 122.** Neither
side's number is evidence for the union, so the constant was set to `0` and the
true length read out of the ratchet's own panic. The batch's three vendor-pair
removals are the only entries either side removed and this branch's renames
repoint entries in place without adding any, so the union is the batch's number.

**SAME_LAYER_EDGE_BASELINE stays 72 -- one row moved, the count did not.** The
gate found both halves by itself: `triggers -> safety` tripped the
not-inventoried arm and `conversations -> safety` tripped the stale-row arm.
They are the two sides of one swap -- the trusted-trigger prompt scan moved
behind the seam into `TrustedTriggerSubmitRequest::new`, so the edge changed
crate rather than appeared. This merge is the first tree where both halves
exist, which is why nothing had inventoried it before. The equality is what made
the second half loud: under a `<=` ratchet the stale row would have sat green as
one entry of slack.

**changed-coverage exemption #113 repointed 1276 -> 975.** Inherited red, not
caused here: reproduced on a pristine `git archive` of `ws2/da-factory-port`
with the same message. The extension_host products -> loops flip shrank
`channel_host.rs` from 1405 to 1098 lines and left the exemption past EOF.
Repointed to the same construct rather than deleted -- `observe_error`'s `error`
parameter is the only `product_adapter_error::ProductAdapterError` in the file,
so the exemption still names exactly what it always named.

* ci(test-plan): classify the two path classes a rename PR reaches and the planner did not

`Detect Reborn test scope` aborts on the first path no rule claims, and the
nine steps after it are then skipped — so the set gets discovered one CI red at
a time. Both gaps below are the shape #7152 already records for `Dockerfile`
and `clippy.toml`: fail-closed with no rule, surfaced only because a rename
diff touches files a feature PR never touches.

Found as a class rather than one-per-red-run: `build_plan` was driven over all
1,250 paths in this PR's diff with `cargo metadata` resolved once. Two came
back unclassified; after the fix the sweep reports **0**, and the planner
produces a real plan for the actual diff.

- **`openwiki/**`** — the auto-generated wiki, regenerated by
  `openwiki-update.yml` and explicitly not hand-edited. No build or test
  surface, so it joins `docs/` in `IGNORED_PREFIXES`. A crate rename touches it
  by construction: its prose names crate directories.
- **`scripts/live_canary/**`** (UNDERSCORE) — a *second* real directory beside
  the already-classified `scripts/live-canary/` (hyphen), differing only by
  that character. It is the canary's importable Python package; the rename
  reaches it through a `RUST_LOG` string naming a crate. The ⚠ note about the
  two directories is restored beside the constant.

Both are pinned: the wiki test asserts the plan is *equal* to a `docs/` plan
(so a later change that escalates it to a lane fails here too) and that a real
change riding along still selects its lane; the canary paths join the existing
QA-harness subTest list.

* fix(ci): repoint changed-coverage exemption #113 past the flip's channel_host shrink (1276 -> 975)

* test(triggers): hold the workspace env mutex across the non-UTF-8 presence fixture

The hermetic env-mutation guard rejects raw set_var/remove_var without
lock_env(); the fixture now holds the guard across both mutations.

* refactor(crates): move every crate into its §5 family directory (text-only)

Wave 5 / WS7, PR 1 of 2. Creates the ten family directories PROPOSAL §5
specifies — contracts/ substrates/ events/ domains/ kernel/ lanes/ loop/
extensions/ product/ app/ — and `git mv`s 56 crates into them. No crate is
renamed, no code moves between crates, no behavior changes: every diff outside
a manifest, a path literal, or a gate's path resolution is a pure rename.

What moved, and what deliberately did not:

  * 56 of the 58 §5 rows. `ironclaw_extension_support` was already at
    `crates/extensions/`; `ironclaw_wasm` is WS7 2/2's (its `wit/` travels with
    it and forces the guest components' `wit-bindgen` paths plus a rebuild of
    the committed `.wasm` binaries — a binary change that does not belong in a
    text-only move).
  * `tools/` is untouched per the owner ruling, so `ironclaw_stress` stays at
    `tools/ironclaw_stress`.
  * `ironclaw_projects`, `ironclaw_first_party_extension_ports` and the
    workspace-excluded `ironclaw_silk_decoder` stay flat under `crates/`; each
    has an open disposition of its own and is listed in the PR's exceptions
    table.

Fail-open gates hardened BEFORE the first `git mv` (all three were already
inventory-resolved by WS10; each was sabotage-tested here to prove it goes red
rather than silently passing on an unresolvable tree):

  * `reborn_boundary_rules_active_crates_are_workspace_members` — forcing
    resolution to fail drops `checked` to 1 and the `>= 30` floor fires.
  * `boundary_rule_names_are_package_names_not_crate_directories` — adding
    `"ironclaw_cli"` (a directory, package `ironclaw`) to a forbidden list
    produces the directory-vs-package violation.
  * `concrete_extension_crates_link_only_from_the_binary_and_tests` — a fake
    `CONCRETE_EXTENSION_CRATES` resolves nothing and the non-vacuity assert
    fires.

Loud path-inventory repoints (every one of these FAILED first and was fixed by
resolving through the crate inventory — no gate was weakened, no scope
narrowed):

  * 12 architecture gates: composition-boundaries walk root; conversations /
    extension-manager-split / operator-port-inversion scan roots; the three
    contract-location scans' owner attribution (first-path-component under
    `crates/` now answers the FAMILY name, so it moved to
    `ratchet_support::owning_crate_name`); the persistence-driver walk; the
    vendor-census CENSUS/carve-out keys and its sanctioned module; the
    manifest-reparse ALLOWLIST keys; the service-method-freeze sources; the
    provider-catalog ownership test.
  * `scripts/ci/ws12_workflow_contracts.py`: the WebUI lockfile's "one level
    deeper" cache-dependency-path sibling is now `crates/*/*/…`, because the
    single-`*` form matches the crate's real location post-move and the gate
    correctly rejects a probe that is broad rather than depth-tolerant.
  * `scripts/ci/test-classify-test-scope.sh`: the "every arm names a real
    crate" check now resolves through the inventory instead of globbing the
    filesystem — the arms are keyed to the classifier's NORMALIZED
    `crates/<crate>/…` identity, which is not a path on disk.
  * `tests/integration/changed-coverage-exemptions.toml` and the changed-
    coverage self-test fixtures.
  * `Dockerfile`, `.dockerignore`, `.gitattributes`, `.coderabbit.yaml`, the
    seven workflows carrying WebUI/stress path literals, and `README.md`'s
    `cargo install --path`.
  * 39 cross-crate `include_str!`/`include_bytes!` literals and eight
    `CARGO_MANIFEST_DIR`-relative test helpers; the four that resolved the repo
    root by counted `..` hops now search upward for the nearest ancestor
    holding both `crates/` and `Cargo.toml`, because their wrong answer
    (`crates/`) is a directory that exists.

Guidance: one `AGENTS.md` per family directory (charter, member list with each
crate's enforced layer, link to `families/<name>.md`), plus a family index in
`crates/AGENTS.md`. Live agent-facing docs were repointed; generated
(`openwiki/`) and historical (`docs/plans/`, `docs/superpowers/`, `docs/adr/`,
`CHANGELOG.md`, the target-architecture docs) were not.

Measurements unchanged by the move, which is the evidence it is text-only:
composition budget 40582 / 691597 LOC (identical to the base branch — the
ratchet followed the crate by name), specificity allowlist 122, same-layer edge
inventory 72, architecture suite 36 test binaries / 261 tests green.

The projects→identity merge (§12.10) was measured and SKIPPED — see the PR
body's finding: its consumers are two crates and five files, not the single
wiring site the audit counted.

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

* fix(ci): repoint the four path-keyed baselines onto the family tree

Four data files key their entries on a repository path rather than on a crate
name, so the family move leaves every row naming a directory that no longer
exists. Each fails loudly, which is how they were found:

  * `scripts/no_panics_reborn_baseline.txt` — `check_no_panics.py
    --reborn-baseline` reported all 50 audited invariants as *stale* and the
    same 50 as *new*, because the fingerprint's first field is the file path.
  * `tests/integration/coverage-exemptions.toml`,
    `tests/integration/coverage-floor.toml`,
    `tests/integration/critical-mutation-functions.toml` — same shape; the
    critical-mutation manifest validator resolves each row against the live
    crate tree and refuses a row it cannot attribute.

Paths only: no entry added, removed, or re-justified, so every floor,
exemption and reviewed invariant keeps exactly the scope it had.

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

* style(arch): drop two needless borrows the family-move repoint introduced

`crate_dir(root, OWNER)` inside the two `fn …(root: &Path)` helpers took
`&root`, which clippy's `needless_borrow` rejects under `-D warnings`. Found by
the workspace clippy lane, not by review.

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

* docs(crates): sharpen three family AGENTS.md entries

`extensions/` now says what `packages/` holds beyond its four crates (the
data-only packages) and the rule for when a package earns a crate. `app/` names
the one directory whose name and package name differ, and replaces two
descriptions that read as tautologies. `domains/` records why
`ironclaw_projects` is not in the table.

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

* docs(ci): correct the cache-dependency-path comment for the landed move

The comment described the pair as "flat line + one family directory down".
Post-WS7 the first line IS the family path, and the spare is one level below
that — so the comment now says which is which, and names the rule that forces
them not to overlap (`ws12_workflow_contracts.py` rejects a spare that already
matches the real location).

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

* fix(ci): repoint two path-keyed self-tests the family move took dark

Both fired in CI, not in review.

`crates/app/ironclaw_cli/tests/smoke.rs` asserts against *repository paths*
written into the Dockerfile and the release workflows, and those carry the
crate's family. Three assertions read a flat `crates/ironclaw_*` path: the
Dockerfile's WebUI frontend install, and two reads of the CLI manifest / WiX
manifest — the latter two failed with `NotFound`, the first with a
"Dockerfile must install WebUI frontend dependencies" message that pointed at
the Dockerfile rather than at the test. They now derive the crate directory by
walking `crates/` for the outermost directory owning a `Cargo.toml`, and panic
on absent-or-ambiguous rather than answering an empty path.

`scripts/check_no_panics.py`'s `test_test_only_path_detection` names a REAL
repository file, because that branch of `is_test_only_path` reads the file to
confirm its `#[cfg(test)] mod` declaration. Pointed at the crate's family path
so the assertion measures the file it claims to.

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

* refactor(lanes): move ironclaw_wasm into crates/lanes/

The last WS7 family move. `wit/` lives inside the crate (PROPOSAL §6.6.1), so
the ABI travels with it — which is the whole point of putting it there, and
also the reason this move could not ride the text-only batch in WS7 1/2: nine
`wit-bindgen` guests reach the ABI by relative path, and six of them commit a
`.wasm` keyed to a digest of their whole `wasm-src/` tree.

Repointed:
  * root `members` + the `ironclaw_wasm` workspace dep, and
    `ironclaw_host_runtime`'s `path = "../../lanes/ironclaw_wasm"`;
  * the moved manifest's own eight `path =` deps, one level deeper — except
    `ironclaw_wasm_limiter`, which became a plain sibling hop now that both
    lane crates share a family directory;
  * nine guests: six `crates/extensions/packages/*/wasm-src/src/lib.rs`
    (`../../../../lanes/ironclaw_wasm/wit/tool.wit`) and three
    `test-tools/*/wasm-src/src/lib.rs`. The host's own `bindings.rs` is
    crate-relative (`path: "wit/tool.wit"`) and needed no edit — the tenth
    site the WS10 row counted is free by construction.

Two path-keyed gates were silently darkable by this move and are now keyed to
the crate rather than to a literal:

  * `scripts/check-version-bumps.sh` resolved the WIT *constant* through the
    crate inventory but still triggered on the literal
    `crates/ironclaw_wasm/wit/tool.wit`. After the move that grep matches
    nothing, so the whole WIT version-parity gate would have passed vacuously
    on every future ABI change. The trigger paths are now derived from
    `crate-dir.sh ironclaw_wasm` alongside the constant, and resolution failure
    exits non-zero.
  * `.githooks/pre-commit`'s selector is depth-agnostic
    (`^crates/([^/]+/)*ironclaw_wasm/wit/`), matching the precedent already set
    by `platform-and-compat.yml`'s `has_direct_wasm_abi_risk` filter. Its
    self-test now resolves the gated prefix from the inventory instead of
    holding a second copy of the literal, so a *rename* — which no regex can
    follow — still fails loudly.

Arch-test path literals needed no change: they already spell logical
`crates/<crate>/…` and resolve through `crate_path`.

* chore(wasm): re-record the six guest source digests after rebuilding

`check-wasm-artifact-freshness.py` keys each committed `wasm/<name>.wasm` to a
digest of its whole `wasm-src/` tree, and its contract forbids re-recording
without rebuilding ("the digest asserts a claim about the artifact, and
updating it without rebuilding launders a stale one"). The nine `path:` edits
in the previous commit invalidated all six digests, so all six were rebuilt
with `./scripts/build-wasm-extensions.sh --first-party` before this re-record.

Measured refutation of the planning estimate: CHECKLIST WS10's `wit/` row point
6 and PLAN's Wave-3 note both budgeted "six rebuilt `.wasm` artifacts (~2 MB,
in their own commit) whose byte deltas are mostly fresh `Cargo.lock`
resolution". All six rebuilds were **byte-identical** to what is committed —
`git status` over `crates/extensions/packages/*/wasm/*.wasm` is empty after a
full rebuild — so this commit ships 6 changed digest lines and zero artifact
bytes. `wit-bindgen` embeds the WIT *contents*, not the path it read them from,
and the guests re-resolved to the same dependency versions, so a `path:` literal
that resolves to byte-identical WIT is codegen-neutral in fact as well as in
principle. That is the motivating case the WS10 row named for a future
"source change provably cannot affect codegen" escape hatch; this run is
evidence the escape hatch would have been sound here, not a reason to add it
untested.

* refactor(tools): relocate ironclaw_silk_decoder to tools/ (retain-excluded)

Discharges CHECKLIST WS7's `wire or remove [decision]` row and PROPOSAL
§12.10's `silk_decoder wiring-or-removal` item under delegated authority. The
ruling, its alternatives, and the evidence are written up as §12.13 D-P in the
next commit; the short form:

  * **Wire — loses.** There is nothing to wire it to. Its sole historical
    caller was `src/channels/wasm/attachment_hydration.rs`, deleted with the v1
    monolith (#6375), and Reborn has no WeChat channel package and no audio
    path in `ironclaw_extractors`. FEATURE_PARITY row 89 marks WeChat Reborn-
    side 🚧/P2. Wiring means building that channel first; that work owns the
    wiring, not WS7.
  * **Remove — loses.** It is the only SILK v3 → WAV implementation in the
    repository and the same P2 parity row names "SILK-to-WAV voice fallback"
    as in scope. Its carrying cost is measurably zero: `[workspace]`-rooted and
    `exclude`d, so it is in no inventory, no coverage denominator, no
    composition budget and no CI build.
  * **Retain excluded — wins**, which is §5's default and what PROPOSAL's
    `tools/` paragraph already says.

Retaining it properly means putting it where §5 draws it. Two doc sites agree
on `tools/` — the §5 tree and the `tools/` paragraph — and after WS7 `crates/`
holds exactly the ten family directories, so a flat crate there is the stray
top-level entry §11.2.1's check exists to reject. The functional change surface
is one line: the root `exclude` path. Everything else was comments.

One inventory consequence, recorded rather than left to be discovered: the
crate was the only non-`wasm-src` member of `crate_tree.py`'s
"declares its own `[workspace]`" rule, so `workspace_root_directories()` now
returns 6 instead of 7 and the crate inventory is 64 instead of 65. The rule
stays — it is what keeps a future `[workspace]`-rooted directory under
`crates/` out of the denominators — but the silk decoder is now excluded by
*scope* rather than by that rule, and every comment that said otherwise is
corrected here. Both floors are 20, so nothing binds.

* feat(ci): add check-target-tree.py — the §5 tree verifier

Closes CHECKLIST WS7's last row ("Verify after the last move: tree matches
PROPOSAL §5 exactly (a script comparing `cargo metadata` paths to the
documented tree)").

Why this gate cannot be one of the existing ones. Every path gate in this
repository answers "where is crate X?" by *discovery* through
`scripts/ci/lib/crate_tree.py`. That is exactly right for a gate that must
survive a family move, and exactly why not one of them can tell you the move
went where the design said: a crate landing in `substrates/` instead of
`domains/` is invisible to all of them, and shows up only when a reader trusts
§5 and finds it wrong. So this one compares the two directly — `cargo metadata
--no-deps` against the fenced tree under PROPOSAL §5, which is the only copy.
The script deliberately embeds no second copy of the tree; a table it held
itself would drift from the document it claims to enforce.

Three claims: placement (every member at its §5 path, and §5 draws no crate
that does not exist), naming (§5.1's directory rule, with its two *written*
exceptions read out of the tree — `app/ironclaw_cli` holds `ironclaw` per its
own annotation, and package directories under `extensions/packages/` write
their crate name beside the marker), and exclusions (a `◇` package must exist
on disk and must not be a workspace member, which is what now pins the silk
decoder's new home).

The exceptions table is **shrink-only in both directions**: an uncovered delta
fails, *and* a row that no longer describes a real delta fails. Closing a
disposition therefore means deleting its row, and no row can outlive the thing
it excuses. Two rows today, each naming the row that closes it —
`ironclaw_projects` (its §12.10 merge into `identity` is decided; WS7 measured
it as a source merge across 2 consumer crates / 5 files with an
equality-baseline and origins-row cost, not a `git mv`) and
`ironclaw_first_party_extension_ports` (deletion owned by its own §9 row; waves
moved adapters INTO it, so deletion is design work).

`scripts/ci/test-check-target-tree.py` is the self-test: 17 cases, 13 of them
sabotage — a crate in the wrong family, a crate §5 never drew, a crate §5 drew
that nobody built, a package name that stopped matching its directory, an
excluded package that became a member, an excluded package that vanished from
disk, three ways for the exceptions table to go stale, and three ways for the
gate to lose §5 itself (missing heading, unfenced block, truncated tree) — each
of which must *refuse* rather than report a match. Fed a recorded `cargo
metadata` document, so the suite needs no toolchain. Wired into Code Style's
`Fast deterministic checks`, beside the composition-budget and WASM-freshness
gates, with its self-test in the same job's `Static-check self-tests` list.

* docs(target-arch): WS7 closeout — silk ruling, WS7 rows, Wave-5 milestone

Wave 5 is closed. The target-architecture docs are the single source of truth
for this program, so every finding and correction from both WS7 PRs lands here
rather than only in a PR body.

PROPOSAL:
  * **new §12.13 / D-O** — the `ironclaw_silk_decoder` ruling in full: evidence
    (214 lines, three functional commits ever, zero callers re-measured at HEAD,
    the sole historical caller deleted with the v1 monolith by #6375, WeChat
    marked 🚧/P2 with SILK-to-WAV named in its parity scope, carrying cost zero
    in every denominator), why *wire* and *remove* both lose, where the call was
    close and what the other side is, why the relocation is part of the ruling
    rather than a separate one, the `crate_tree.py` consequence, and the
    successor condition that would flip it.
  * §12 item 10's `silk_decoder` bullet struck with a pointer; §9 row 71 and
    §6.10's `tools/` paragraph updated to the decided disposition.
  * §5's excluded-packages bullet re-derived: three of its four coordinates had
    moved and root `fuzz/` is deleted, not re-pointed.
  * §6.6.1 gains its as-built note — the crate is where the entry wrote it, the
    guests (not the host) paid for the move, and two path-keyed gates that
    would have gone dark were re-keyed with it.
  * §12 item 8's `/wit` clause marked landed, with the freshness-gap guard it
    asked for shown doing its job.

CHECKLIST: all four WS7 rows ticked with dated notes — the two-PR split and why
one crate forced it, the three deliberately-flat crates and their owners, the
`tools/`-unchanged confirmation, the silk ruling, and the verifier's baseline
and sabotage coverage. Two sibling rows corrected by what this PR measured:
WS4's ⚠ "keep the crate flat for this wave" flag is discharged (the Wave-3 bet
paid — `bindings.rs` survived the move unedited), and WS10's `wit/` row point 6
is **refuted on its cost estimate** — the predicted ~2 MB of rebuilt binaries
was zero, because all six rebuilds were byte-identical. That row now also
records why the "codegen-neutral source change" escape hatch it floated should
NOT be built on this evidence: a gate that accepts "this edit cannot matter" on
the author's say-so is the laundering the gate exists to stop, and the case
that would have used it cost 90 seconds of CPU.

PLAN: Wave 5's milestone marked reached, with its two documented exceptions
named as honest residue rather than slippage, and the ordering question this
wave inherited from Wave 3 recorded as resolved the cheap way.

`crates/AGENTS.md` and the root `Cargo.toml`/`crate_tree.py` comments are
repointed at §12.13 D-O so the code and the decision log cite each other.

* fix(ci): classify tools/ironclaw_silk_decoder in the Reborn PR test planner

Found by running the planner over this PR's own diff: `tools/ironclaw_silk_decoder/AGENTS.md`
was an unclassified pull-request path, which fails `Detect Reborn test scope`
closed and skips every downstream `Tests (Reborn)` lane. The crate was reachable
only through the `crates/**` arm before WS7 moved it (§12.13 D-O).

The old answer was worse than missing, which is why this is a new bucket rather
than a restored line. Under `crates/ironclaw_silk_decoder/`, a one-line edit to
`src/main.rs` selected the **entire workspace** — the crate-attribution
fall-through treats a path under `crates/` with no owning workspace member as
broad risk, and the excluded helper is exactly that shape. So the new
`WORKSPACE_EXCLUDED_PREFIXES` bucket says the true thing ("workspace-excluded
project, built by no lane") instead of borrowing
`DEDICATED_WORKFLOW_PREFIXES`'s claim that some workflow owns it — nothing does.

The self-test pins both halves: that the path is classified at all, and that it
selects nothing, so a future "classification" cannot quietly restore the
whole-workspace selection.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:54:17 +00:00
Benjamin Kurrek
80af97390f WS7 (1/2): family directory moves — text-only (#7206)
* 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>

* refactor(triggers,conversations): scan trusted trigger prompts at the mint (WS6)

PROPOSAL §6.4.2 asked for the trusted-trigger prompt safety scan to move
"behind the triggers/kernel seam it guards". It was not a module: it was
three lines inside `ConversationTrustedTriggerSubmitter::submit_trusted_trigger_fire`
— one of the two implementations of `ironclaw_triggers::TrustedTriggerFireSubmitter`
— holding its own `Arc<dyn InjectionScanner>` from `Sanitizer::new()`.

That placement is a fail-open: a guard that lives inside one implementation
of a port is lost the moment a second implementation exists, and nothing in
the tree forced a new submitter to re-run it.

The seam is `TrustedTriggerFireSubmitter`, whose only input is the sealed
`TrustedTriggerSubmitRequest`, which `ironclaw_triggers` is the sole minter
of. So the scan moved to the mint: `TrustedTriggerSubmitRequest::new` is now
fallible and calls the new `ironclaw_triggers::prompt_safety` first, making
"this prompt passed the trusted-prompt scan" an invariant of the type rather
than a step some submitter performs. `new_for_test` delegates to `new`, so
the test-support seal bypasses visibility only, never the scan.

Behaviour at the fire level is unchanged — same rejection point, same
`TriggerError::InvalidMaterialization`, same permanent disposition — and
composition's pre-materialization scan is untouched, so defence in depth
survives with the second scan relocated and now covering every submitter.

`ironclaw_conversations` drops `ironclaw_safety` entirely (the scan was its
only use). Enforcement: triggers' boundary rule stops forbidding
`ironclaw_safety` (a same-layer, I/O-free `substrates` leaf — a peer edge,
not a reach upward), and a NEW `BoundaryRule` for `ironclaw_conversations`
forbids it, plus `ironclaw_threads` (§6.4.2's "Never: transcript content"),
a crate that was unruled until now.

Regression coverage at the caller tier, not on the helper:
`tick_rejects_injection_prompt_before_any_trusted_submitter_is_reached`
drives the real `TriggerPollerWorker::tick_once` with a materializer that
does NOT scan and a submitter configured to accept, and asserts the
submitter is never reached. A companion pins that a medium-severity-only
prompt still submits, so the mint cannot drift into a blanket filter.

Tests: conversations 97 -> 97 (name-identical), triggers 169 -> 173
(+2 worker, +2 prompt_safety unit), architecture 206 -> 206.
LAYER_MATRIX_EXCEPTIONS unchanged at 10.

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.

* refactor(traces): drop the boundary-laundering re-export modules (WS6)

PROPOSAL §6.4.14: "drop the boundary-laundering re-export modules
(`recording`, `paths`) — consumers import the owners".

`ironclaw_reborn_traces::{recording, paths}` were two `pub use <other
crate>::*` passthroughs whose own doc comments stated their purpose
plainly: "so reborn-cli does not need a direct `ironclaw_llm`
dependency, preserving the architectural boundary". They preserved
nothing — the edge existed either way; the wildcard only hid which crate
owned the type, so the dependency graph read as a lie.

All three call sites were in `ironclaw_reborn_cli`. Note the literal
reading of "consumers import the owners" is not available here: the CLI's
dependency allowlist (`reborn_cli_binary_crate_stays_separate_from_v1_root`)
deliberately excludes `ironclaw_llm`, so importing the owner would have
traded a laundered re-export for a breached, tested boundary. Satisfied
instead by giving the owning crate the operation, which is what the
laundering was standing in for:

- `onboarding::onboard_instance(invite, consents)` — resolves the
  contribution root itself. Path layout under the base dir is this
  crate's own knowledge; the CLI no longer needs base-dir vocabulary.
- `TraceClientHost::build_envelope_from_recorded_trace_json(json, opts)`
  — parses `ironclaw_llm::recording::TraceFile` inside the crate that
  already depends on `ironclaw_llm`. The CLI hands over raw JSON.
- the CLI's private `trace_contribution_dir()` now delegates to
  `contribution::trace_contribution_dir_for_scope(None)` instead of
  re-deriving `<base>/trace_contributions`. Verified byte-identical:
  `trace_contribution_dir_for_scope(None)` is
  `trace_contribution_dir_for_scope_at(&ironclaw_base_dir(), None)`,
  whose `None` arm returns `base.join("trace_contributions")`.

No dependency was added to any crate. Semantics unchanged.

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

* refactor(llm): make providers.json a crate asset with a boundary rule (WS6)

CHECKLIST WS6: "`llm` `providers.json` becomes a crate asset/composition
input + boundary rule added".

The provider catalog sat at the **repository root**. A root-level data
file has no owning crate, so no boundary rule could govern who edits it,
and every consumer compiled it in behind Cargo's back with an escaping
`include_str!` — the "repo-root asset reach-in" shape §11.2.7's scanner
inventories. `git mv`'d to `crates/ironclaw_llm/assets/providers.json`
and the 20 `include_str!("../../../providers.json")` sites in
`registry.rs` become in-crate `../assets/providers.json`.

⚠ Correcting the row's inherited premise: a prior lane recorded the
"load-bearing include site is in `ironclaw_reborn_cli`" and judged the
item "needs a new mechanism, not a new path". Measured on main: the
load-bearing site is `crates/ironclaw_llm/src/registry.rs:383`
(`builtin_provider_definitions`), inside the owning crate. No new
mechanism was needed — only the path.

**Path-keyed gates rewritten in the same commit** (WS10: these fail
*silently* under a move):
- `Dockerfile` — both `COPY providers.json providers.json` lines deleted;
  `COPY crates/ crates/` already covers the new location in both stages.
  Verified by `scripts/ci/check-include-str-paths.sh` (OK, 119 refs).
- `.github/workflows/reborn-e2e.yml` — the literal `providers.json` path
  filter and its regex alternative removed; the depth-independent
  `crates/**` entry already matches. `ws12_workflow_contracts.py` passes.
- `scripts/ci/classify-test-scope.sh` — kept at its **shared** (both
  lanes) classification under the new path rather than letting it fall
  through to crate scope, so CI breadth does not silently narrow; the
  now-redundant entry in the reborn-only branch is dropped.

**The one consumer that could not simply be repointed.** The CLI's
`default_llm_consts_match_the_real_providers_json_nearai_entry` embedded
the catalog from five directories up to check its mirrored `DEFAULT_LLM_*`
constants. Repointing it would have turned a repo-root reach-in into a
*cross-crate* reach-in — the category §11.2.7 turns into a hard failure —
and the CLI may not depend on `ironclaw_llm`. A cross-crate consistency
rule belongs in the cross-crate suite, so the assertions moved into
`ironclaw_architecture` and read both files from disk at runtime, needing
no compile-time coupling at all.

Test accounting: `ironclaw_reborn_cli` config-init tests 2 -> 1; the
removed one is reborn as `reborn_provider_catalog_is_owned_by_its_crate`
in `reborn_dependency_boundaries.rs`, strictly stronger (it also pins the
asset's location, the repo root's emptiness, and single-embedder
ownership). Net test count +0.

**The new rule is sabotage-tested** — five cases, each red with the right
message, each restored to green:
1. catalog copied back to the repo root -> "must not sit at the
   repository root"
2. a foreign crate `include_str!`s it -> names the offending file
3. catalog `default_model` drifts from the CLI mirror -> names the const,
   the field and both files
4. walker pointed at a non-existent dir -> "walked only 0 Rust files ...
   would pass no matter what the tree contained" (reachability)
5. mirrored const renamed -> "no longer declared as a plain const ...
   update the extraction rather than deleting the drift check"

Case 2 caught a real false positive in the first draft of the guard: a
file-level `include_str!` AND `providers.json` conjunction flagged
`cli/tests/smoke.rs`, which names the *runtime*
`$IRONCLAW_REBORN_HOME/providers.json` and separately embeds something
else. The matcher now inspects the macro argument, not the file.

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

* 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.

* docs(skills): rewrite the stale v1 lib.rs charter note (WS6)

CHECKLIST WS6 domain-internal cleanups: "`skills` stale v1 lib.rs doc
rewritten".

The crate doc claimed "In v1, trust-based tool filtering happens via
`src/skills/attenuation.rs`. In v2, the Python orchestrator handles trust
labels and the policy engine controls tool access via capability leases."
Both halves are dead vocabulary: there is no `src/` monolith on this tree
and no Python orchestrator anywhere in Reborn.

Replaced with what is true and checkable — this crate owns the trust
*label* and none of its enforcement; the ceiling is applied at the
capability tier (`host_api` capability/invocation attenuation via
`first_party_extension_ports`' activation and execution paths) and the
decision belongs to `ironclaw_authorization`. Also points at the existing
`SkillTrust` `Ord` safety note, which the old text left unconnected.

Doc-only; no code change.

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

* 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.

* refactor(crates): execute the WS6 crate renames, no shims (WS6)

Three CHECKLIST WS6 rename rows, executed together as one pure rename.
No compatibility re-export shims (WS6 discipline); every consumer, doc,
CI script and snapshot repointed in this commit.

**Row 1 — stutter kills (decided 2026-07-29):**
- `ironclaw_events`             -> `ironclaw_event_log`
- `ironclaw_extensions`         -> `ironclaw_extension_registry`
- `ironclaw_product`            -> `ironclaw_assistant`

**Row 2 — naming audit (decided 2026-07-30):**
- `ironclaw_architecture`       -> `ironclaw_architecture_tests`
- `ironclaw_runner`             -> `ironclaw_turn_runner`
(`ironclaw_first_party_extensions` -> `ironclaw_extension_support` landed
early with WS2.6 and is already ticked.)

**Row 3 — the `reborn_` batch (decided 2026-07-30):**
- `ironclaw_reborn_composition`   -> `ironclaw_composition`
- `ironclaw_reborn_config`        -> `ironclaw_config`
- `ironclaw_reborn_event_store`   -> `ironclaw_event_store`
- `ironclaw_reborn_identity`      -> `ironclaw_identity`
- `ironclaw_reborn_openai_compat` -> `ironclaw_openai_compat`
- `ironclaw_reborn_traces`        -> `ironclaw_trace_commons` (§6.4.14:
  the crate is the Trace Commons client, not trace machinery)
- root package `ironclaw_reborn_integration_tests` -> `ironclaw_integration_tests`

4,806 occurrences rewritten across 901 files, plus 11 `git mv`'d crate
directories (`git diff -M` reports them as renames). Replacement used
word-boundary matching, which is what keeps `ironclaw_product` from
touching `ironclaw_product_contracts` and `ironclaw_extensions` from
touching the four `ironclaw_extension_*` siblings.

**Semantics: none.** No type was renamed, no module moved, no signature
changed. `cargo check --workspace --all-targets` is clean.

**Path-keyed gates rewritten in the same commit** — WS10 lists these as
the ones that fail *silently* under a rename, and each was re-run to
prove it still scans a non-zero tree rather than merely passing:
- `scripts/no_panics_reborn_baseline.txt` — 3 entries repointed, 0 stale
  names left; `--reborn-baseline` reports "OK ... (1203 files, 51
  reviewed invariant(s))" and `--self-test` passes 34 tests.
- `docs/plans/composition-pubuse.snapshot` — 5 entries. This one is not
  documentation despite its path: `composition_public_pub_use_surface_matches_snapshot`
  compares against it byte-for-byte, and it failed loudly when the rename
  first landed without it. Caught by running the suite, not by inspection.
- `scripts/ci/classify-test-scope.sh`, `scripts/ci/reborn-crate-test-buckets.sh`
  (+ its self-test), `scripts/ci/discover-reborn-package-crates.sh`,
  `scripts/ci/package-feature-flags.sh`,
  `scripts/ci/check-generic-without-concrete.sh`,
  `scripts/ci/ws12_workflow_contracts.py`, `scripts/dev_metrics.py`,
  `scripts/reborn-e2e-rust.sh`, `scripts/pre-commit-safety.sh`.
- **CI lane names**, which the `ironclaw_architecture` row calls out
  explicitly: `.github/workflows/code_style.yml`'s `cargo test -p
  ironclaw_architecture reborn` step and its changed-paths regex.

Verification: `cargo check --workspace --all-targets` clean;
`ironclaw_architecture_tests` 32/32 suites green; `ws12_workflow_contracts.py`,
`test-classify-test-scope.sh`, `test-reborn-crate-test-buckets.sh`,
`check-include-str-paths.sh` all pass. `LAYER_MATRIX_EXCEPTIONS` counted
with Python between the const and its `];` — **6**, unchanged.

Deliberately not rewritten: `docs/reborn/subagent-spawn/diagrams/*.{d2,svg}`
and the historical prose in `docs/`. Those describe an unlanded design
authored against the old tree; renaming inside them would misrepresent
what was designed, and the `.svg`s are generated artifacts.

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

* 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>

* refactor(cli): move the binary crate to crates/app/ironclaw_cli (WS6)

Last clause of the WS6 `reborn_` rename row: "cli directory ->
`app/ironclaw_cli`". Package name stays `ironclaw` (unchanged, as the row
requires); this is a directory move plus the crate-directory rename.

82 path references rewritten across 39 files, plus the crate's own 18
`path = "../X"` dependencies re-based to `../../X` now that it sits one
level deeper. `cargo check --workspace --all-targets` clean.

This is the first crate to live at a nested family path, which is exactly
the shape WS10 warns about: a gate keyed to the flat `crates/<name>/`
layout stops matching and goes green having scanned nothing. Two gates
were found by running them, not by reading them:

1. **`scripts/ci/ws12_workflow_contracts.py` failed loudly and correctly** —
   `.github/workflows/code_style.yml`'s `has_reborn_cli` filter named the
   crate `ironclaw_reborn_cli`, which the crate inventory could no longer
   resolve: "expected exactly one crate directory named
   'ironclaw_reborn_cli' under crates/, found 0 ... repoint the gate that
   names it rather than letting it measure an empty tree." Repointed
   there, in ws12's own probe table, and in
   `check-generic-without-concrete.sh`. The workflow regex already used
   the depth-independent `crates/([^/]+/)*` form, so the nesting itself
   was safe — only the crate *name* needed repointing.

2. **`docs/plans/composition-pubuse.snapshot` regenerated after `cargo
   fmt`**, not before. The rename lengthened a `pub use` line past the
   width limit, so fmt rewrapped it and the snapshot went stale a second
   time. Diff is exactly one alphabetical re-sort
   (`ironclaw_product`->`ironclaw_assistant`) and one rewrap; no symbol
   added or removed.

**Pre-existing bug fixed in passing, with evidence it predates this PR.**
`check-generic-without-concrete.sh` listed `"ironclaw_reborn_cli"` among
its sanctioned assemblers, but that set is matched against cargo
*package* names and the CLI package is `ironclaw`. The exemption
therefore matched nothing and the gate was **already red on clean
`origin/main` @ 283e1f6b7c**, reporting the two concrete extension crates
DEL-7 explicitly allows the binary to link:

    ironclaw: dependency graph contains concrete extension crate ironclaw_slack_extension
    ironclaw: dependency graph contains concrete extension crate ironclaw_telegram_extension

Reproduced on a clean checkout before assuming this PR caused it. Fixed
by naming the package, with a comment recording that these are package
names — the same directory-vs-package confusion that
`boundary_rule_names_are_package_names_not_crate_directories` exists to
catch on the dependency-boundary rules.

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>

* docs(target-arch): tick the three WS6 rename rows, amend four others (WS6)

Dated amendments, each quoting or naming the text it replaces.

**Ticked (condition verified on the merged tree):**
- the three `Renames executed` rows — stutter kills, naming audit, and
  the `reborn_` batch. All 14 clauses across them are done.

**Amended without ticking, because a clause is genuinely unmet:**
- `Domain-internal cleanups` — three of six clauses done (traces
  re-export modules, `llm providers.json`, `skills` lib.rs doc), one
  refuted (`identity` absorbing `host_api::user_identity`), two open
  (`triggers` SQL ADR, `projects` composition adapter).
- `Retire the local_dev misnomer` — the row stays ticked; its *residue
  clause* is re-scoped with measurements.

**Two row texts were wrong and are corrected rather than executed:**
1. The `traces` `ScopedFilesystem` clause says the type is "dropped".
   §6.4.14 says the crate should *take* one. §6.4.14 is right and the
   row is the error — the type exists (`ironclaw_filesystem::ScopedFilesystem`)
   and is absent from the traces crate, so this is adoption, not removal.
   Also corrects "~91 raw `fs` call sites" (that counted test code; the
   production surface is 11 in `contribution.rs` plus ~7 in
   `device_key.rs`).
2. The `local_dev` residue said "the local variable at
   `composition/src/runtime.rs:3016`". It is not one variable — it is 14
   distinct identifiers; #7098's "public type" claim is wrong
   (`RebornLocalRuntimeIdentity` is `pub(crate)`); and #7098's
   explanation for why the ratchet missed it is wrong, because a
   *second* ratchet (`reborn_deployment_mode_typename_ratchet`) already
   inventories the name and records that the sanctioned exit is Slice B,
   not a rename. Every obvious rename target is also already taken by a
   different concept.

**One clause refuted with measurements (delegated authority).** "`identity`
absorbs `host_api::user_identity` ports" would move a ports module out of
the neutral contracts crate into a crate that neither implements nor
consumes it — the sole production implementor is
`extension_host::channel_identity_store::FilesystemChannelIdentityStore`
— and, because `ironclaw_identity` depends on `ironclaw_host_api` and not
the reverse, would force `extension_host` to take a new dependency to
name a port it implements. The ports stay in `host_api`. The dual
binding-store ambiguity is resolved as nominal, not structural: principal
identity (`ironclaw_identity::identity_store`) and post-OAuth channel
binding (`extension_host::channel_identity_store`) are distinct concerns
and neither subsumes the other.

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>

* fix(ci): repoint the release-cut scripts at the moved CLI manifest

`origin/main` added `scripts/ci/cut_ironclaw_release.py` and its
self-test while this branch was in flight; both locate the version to cut
via `crates/ironclaw_reborn_cli/Cargo.toml`, which this PR moved to
`crates/app/ironclaw_cli/Cargo.toml`.

Caught by re-scanning the merge for reintroduced old crate names rather
than trusting a clean `git merge` — the merge was conflict-free precisely
because these files are new on main and touch nothing this branch edited,
which is the shape that reintroduces a stale path silently.

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>

* fix(ci): classify the Dockerfile in the Reborn PR test planner

`Detect Reborn test scope` failed on this PR with:

    Reborn PR test planner failed: unclassified pull-request path: Dockerfile

and took `Tests (Reborn)` down with it ("changes failed: failure").

`scripts/ci/reborn_pr_test_plan.py` classifies every changed path and its
fail-closed arm raises on anything no rule claims. `PR_STATIC_CONTROL_PATHS`
held `Cargo.toml`, the toolchain files and the coverage manifests, but not
`Dockerfile` — so **any** PR editing the container build context aborted
the planner. This PR is simply the first to do so: moving `providers.json`
into its owning crate made the two `COPY providers.json` lines redundant.

The Dockerfile is owned by the `Docker` workflow (its own trigger on this
path) and its COPY coverage by `check-include-str-paths.sh` under Code
Style. No Reborn test lane reads it, so it belongs with the other
de-escalating static-control paths: `mode: none`, `coverage_mode: none`,
no buckets selected.

The existing `test_unclassified_build_input_fails_fast` used `Dockerfile`
as its *example* of an unclassified path. The invariant it protects is the
fail-closed arm, not the filename, so it keeps that arm with a genuinely
unowned fixture (`unowned-root-input.mk`, fictional and never touched on
disk — same convention as `test_unmapped_crate_path_fails_fast`), and a new
`test_dockerfile_is_static_control_not_a_planner_abort` pins the new
decision by asserting the mode, the coverage mode, the empty bucket list
and the reason string.

Sabotage-tested: removing `"Dockerfile"` from the set turns the new test
red; restoring it returns 44/44 green.

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>

* refactor(cli): keep the rename flat; sever the app/ relocation to WS7 (WS6)

**Reverts the `crates/app/` family directory this branch created.** The
crate keeps its WS6 **rename** — `ironclaw_reborn_cli` -> `ironclaw_cli`,
package name `ironclaw` unchanged — at the flat path
`crates/ironclaw_cli`.

The defect was in the row, not in executing it. CHECKLIST WS6's CLI row
names `app/ironclaw_cli` as its rename target, and PROPOSAL §5's tree
confirms that destination — but family directories are WS7 (Wave 5), so a
Wave-4 row named a Wave-5 path. The row's own `[decision — severable]`
tag shows the authors knew a call was owed; it was never made, so
following the row literally does both halves at once. **Owner ruling
2026-08-04: Waves 0–4 close before anything touches Wave 5.** Severed.

This matters beyond tidiness: PLAN marks the WS10 nested-tree-safe gate
rewrites a hard prerequisite *before the first family `git mv`*, because
path-keyed gates fail **silently** under family directories rather than
loudly — #7083 (a coverage regex that blinded 11 crates the moment
`crates/extensions/` appeared) is the worked example. WS10 still has open
rows.

`crates/app/` was the **only** family directory this branch created;
`crates/extensions/` pre-exists on `main`.

**Recorded as a class, not an instance** (docs commit alongside): any
pre-WS7 row quoting PROPOSAL §5 inherits the same collision. The
established precedent is to land flat — WS1's three `contracts/ironclaw_*`
rows all say `contracts/` and all landed at `crates/ironclaw_*`; there is
no `crates/contracts/` directory. Two sibling rows carry the same defect
and are now flagged not-to-execute-as-written: WS3's
`lanes/ironclaw_sandbox` and WS4's `crates/lanes/wit/`.

**Also: the Reborn PR test planner could not classify a rename PR at all.**
`Detect Reborn test scope` failed the whole run — first on `Dockerfile`,
then on `clippy.toml` — and each fix surfaced the next, because
`reborn_pr_test_plan.py` fails closed on any unclassified path and had
never seen a diff of this shape. Fixed as a class:
- root workspace policy files decided: `clippy.toml`, `deny.toml`,
  `release-plz.toml` (beside the already-classified `Cargo.toml`);
- root scripts decided per-file as that set requires:
  `check_no_panics.py`, `dev_metrics.py`, `pre-commit-safety.sh`,
  `test-mutation-audit.sh`;
- prose/standalone trees ignored: `openwiki/` (generated wiki),
  `test-tools/`, `harness/` (standalone cargo project, own Cargo.lock);
- **`scripts/live_canary/`** added to the QA harness prefixes — the set
  listed only `scripts/live-canary/` and **both directories exist**,
  differing by hyphen-vs-underscore, so the underscore one fell through;
- files sitting directly in `crates/` (`crates/AGENTS.md`) classified as
  tree-wide prose — they belong to no package, so the crate arm raised;
- **paths removed by the diff** classified instead of fatal. This is the
  one that matters for the programme: renaming 11 crates puts ~600 deleted
  paths in the diff, none of which map to a package. Without it every WS6
  rename PR and every WS7 family move fails closed here.
- the shared-E2E-harness wall is kept but made *satisfiable*: a
  `DECIDED_E2E_HARNESS_PATHS` set records a decision. The guard's purpose
  is "changing a shared fixture must be deliberate"; as written it had no
  way to record a decision, so it blocked even a mechanical rename with no
  route forward. `tests/e2e/reborn_webui_harness.py` is decided (the E2E
  workflow owns it); everything else still raises, on both fail-closed
  arms.

Its self-test goes 43 -> 49. Two existing tests used as their *example* a
path this commit classifies; both keep their invariant with an undecided
fixture instead. **Sabotage-tested each new arm**: disabling the
removed-path arm, emptying the decided set, and disabling the `crates/`
prose arm each turn the suite red; restoring returns green. The prose arm
initially passed while sabotaged — it had no test — which is precisely the
green-while-checking-nothing shape, so a test was added and the sabotage
re-run to confirm it now fails.

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

* fix: repoint crate names reintroduced by the merge-down from main

`git merge origin/main` (fb776f3c62) was conflict-free — main's new work
touches files this branch had not edited — which is exactly the shape that
reintroduces stale crate names silently. 77 occurrences across 33 files,
found by re-scanning for every old name after the merge rather than
trusting the clean merge.

Dated historical prose under `docs/reborn/target-architecture/` is
deliberately excluded: those rows record what was true when they were
written, and rewriting them would misrepresent the record.

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>

* ci(test-plan): classify the whole repo-root metadata class, not one file per red run

`.gitattributes` is touched by this PR (the rename left its `wix/main.wxs`
rule pointing at `crates/ironclaw_reborn_cli/`, a path that no longer
exists), and the planner fails closed on unclassified paths — so it aborted
`Tests (Reborn)` with "unclassified pull-request path: .gitattributes".

Every entry already in this set was added the same way: a rename-shaped diff
touches root files a feature PR never touches, the planner dies on the first
one, and the next only appears after that one is fixed — Dockerfile, then
clippy.toml, then six more. Rather than add a ninth, this enumerates the
remaining class: all 19 unclassified root paths were found by driving the
planner over every tracked root file, and 17 are listed.

The two that are NOT listed are the point. Membership requires that no
Reborn test lane reads the file, checked per file against `crates/**/*.rs`
and `tests/**`. That check found real readers for `.dockerignore`
(`tests/dockerfile_runtime_home.rs`) and `.env.example` (`ironclaw_cli`,
`ironclaw_host_runtime`), so both stay fail-closed. Classifying a file a
test depends on would silently skip that test — worse than an aborted
planner.

Verified: planner self-test 48/48; every tracked root file except those two
now classifies; the full PR diff plans without error.

* fix(ci): repoint the test-scope classifier off the dead `ironclaw_reborn_*` glob

`Fast deterministic checks` failed on `test-classify-test-scope.sh`:

    FAIL reborn binary crate
    Expected: has_legacy_tests=false has_reborn_tests=true
    Actual:   has_legacy_tests=true  has_reborn_tests=false

`is_reborn_test_path` matched the CLI through `crates/ironclaw_reborn_*/*`.
The WS6 renames dropped that prefix from all seven crates that carried it, so
the glob now matches **nothing** and every one of them silently reclassified
as legacy. Enumerated the seven new names instead of re-globbing: they share
no prefix, and this is the second time a prefix glob has rotted here.

Fixed the classifier, not the fixture. The self-test's expectations describe
the intended behaviour; flipping them to match the break is how a gate goes
quiet.

**This class fails OPEN**, which is why only one crate's assertion caught it —
the classifier keeps answering, just wrongly. Added a guard asserting every
`crates/…` pattern in the classifier matches at least one real path, the same
shape as `sanctioned_paths_all_match_real_files`: an exemption may not outlive
the code it exempts. Two pre-existing dead arms
(`crates/ironclaw_extension_support/`, `crates/ironclaw_oauth/`) are listed
known-dead and shrink-only rather than repointed — both match nothing today,
so neither is load-bearing, and repointing them would change which tests those
crates select. That is a behaviour change, not this PR's business.

Swept the siblings: every `crates/<name>` literal and glob stem across
`scripts/`, `.github/`, and the architecture tests was checked against the
real tree. The only dead reference attributable to the 13 WS6 renames is the
one fixed here; the rest are synthetic self-test fixtures or crates deleted
long before this branch.

Sabotage-tested both, confirming red with the RIGHT message and green after
restore: (1) restoring the dead glob reproduces `FAIL reborn binary crate`;
(2) adding `crates/ironclaw_totally_invented/*` trips the new guard with
`classifier pattern matches no real path`.

Also recorded the ALLOWLIST union recount in the constant's own doc comment:
this branch carried 129, `main` 125, and the merge inherited 125 without
measuring. Recounted off the compiler (constant → 0, read `ALLOWLIST grew to
125 entries`): 125 is the live count with zero slack (#7147).

* 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>

* fix(ci): restore the composition-budget negative case the rename collapsed

T4 asserts the budget gate fails LOUDLY when the composition crate is absent.
It builds a fixture under the crate's real name and renames it away so the
gate cannot find it. The destination was hard-coded `ironclaw_composition` —
which is exactly what the WS6 rename turned the crate's real name into, so
both sides of the `mv` became the same path.

`mv X X` does not rename; it tries to nest a directory inside itself and dies
with "Invalid argument". The negative case stopped running.

Renamed the destination to `composition_renamed_away` — deliberately
synthetic, so no future crate rename can collide with it again — and wrote the
reason into the test.

Found by running the nine `Static-check self-tests` scripts that CI never
reached: that step stops at the first failure, so fixing the classifier only
uncovered what was behind it. Ran all of them, plus the nine skipped steps
after it, rather than discovering them one CI cycle at a time. This was the
only other failure; the other seventeen checks pass.

Sabotage-tested: skipping the rename (so the crate is present) makes T4 fail
with `expected exit 1, got 0` and the missing-message assertion — 49 passed,
2 failed. Restored: 51 passed, 0 failed. The case genuinely exercises the
absence again rather than passing because it never ran.

* 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>

* style: cargo fmt after the #7155/#7062 merge

`Check formatting` (step 6 of Fast deterministic checks) went red on
cca5884b47: the merge was pushed under time pressure without running fmt.

Only the two files whose crate references I rewrote by hand are affected —
`ironclaw_reborn_composition` -> `ironclaw_composition` is 9 characters
shorter, so call sites that were wrapped at the old width now fit on one line.
No semantic change.

* 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>

* WS2: clear the extension_host->product vocabulary residue (ports 4->1, ledger 9->5)

Three of the four frozen ports and four of the nine reference-ledger rows fall
by one move: the port-facing vocabulary is declared where it already lives, and
product maps at its boundary.

- `ExternalActorBindingEpoch` moves `ironclaw_conversations` ->
  `ironclaw_extension_contracts::external`, beside the `ExternalActorRef` whose
  binding it versions. Zero new crate edges (conversations already depends on
  extension_contracts). Its constructor error becomes
  `ProductAdapterError::InvalidIdentifier`, matching its siblings in that module
  byte-for-byte on the three validation rules.
- `ProductActorUserResolver` + `ProductActorUserResolutionRequest` +
  `ResolvedProductActorUser` invert into
  `ironclaw_product_contracts::actor_identity`, error swapped to
  `ProductOperationFailure` (product absorbs it with the existing total `From`,
  discriminants preserved).
- `AuthChallengeProvider`, `BlockedAuthFlowCanceller`, `AuthChallengeView`,
  `PairingAuthChallengeView` and `auth_prompt_view_for_blocked_auth` move to
  `ironclaw_auth::product_prompt`; `ChannelConnectionService` and
  `ChannelAuthAccountState` to `ironclaw_auth::channel_connection`, beside
  `project_auth_account_state` whose argument pair the latter is. Zero
  vocabulary narrowing. `ironclaw_auth` gains a `product_contracts` dependency
  (substrates -> contracts, the same downward edge and rationale
  `ironclaw_attachments` already carries).
- `ExtensionAccountSetupRegistry` stays product-owned state; extension_host now
  holds the two-method read port `ExtensionAccountSetupReader` declared in
  `product_contracts::account_setup`. `None` == empty registry.
- The approval-prompt projection, gate-ref parse and lookup scope move to
  `ironclaw_product_contracts::approval_prompt`, collapsing product's two copies
  and letting the extension host read the approval store itself instead of
  reaching up into `ironclaw_product::projection`. The scope derivation's
  equivalence with `ApprovalInteractionScope` is pinned in product.

Gate updated in the same change: residue 4 -> 1, baseline 4 -> 1, ledger 9 -> 5,
workflow-error residue 2 -> 1, `ProductActorUserResolver` added to
`INVERTED_PORT_IMPLEMENTORS`.

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

* WS2.5: gate + CHECKLIST reconciliation, and two pre-existing clippy reds

- `reborn_extension_host_port_inversion.rs`: `channel_host.rs`'s ledger reason
  loses its stale `ProductActorUserResolver` half (that port is inverted now).
- `reborn_extension_specificity.rs`: the moved `ChannelConnectionService` doc
  carried a `slack` example into `ironclaw_auth`. Reworded generically rather
  than carved, which also made the product entry stale — deleted, allowlist
  baseline 123 -> 122. The gate reported both directions; neither was allowlisted.
- Two clippy reds that pre-exist on this base and bite a `-D warnings` bar: an
  empty line splitting a doc-comment run in the specificity gate, and a
  never-used negative-control fixture in `ironclaw_extension_support`. The
  fixture is `#[allow(dead_code)]`-ed rather than deleted, with the reason.
- CHECKLIST WS2 re-layer row, blockers half: dated and measured annotation of
  what fell, why the "narrow the vocabulary out" framing was only half right,
  and that §12.11 D-A's factory port is unstarted.

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

* WS2: invert channel_host's product-stack construction behind the D-A factory port

§12.11 D-A's factory port, built. `ChannelWorkflowFactory` is declared in
`ironclaw_product_contracts::channel_workflow`, implemented by
`ironclaw_product::RebornChannelWorkflowFactory`, and injected through the
`GenericChannelHostDeps` bundle composition already builds — so
`channel_host.rs` states the shape of the per-extension product cone and
consumes the result instead of inline-constructing product's concrete stack.

`channel_triggered_delivery.rs` sheds through the same seam, but its port
could not live in contracts: it drives the driver with
`TriggerCommunicationContext`, which `ironclaw_outbound` owns and a contracts
crate may not name. So `TriggeredRunDelivery` and `TriggeredRunDeliveryRequest`
are declared in `ironclaw_outbound` beside that vocabulary — the same placement
rule WS2.5 applied to the auth ports, and zero new crate edges. Composition
builds one driver per codec-bearing binding through the same factory; routing
policy stays in the host.

The conversations wrinkle resolved as sanctioned, with no mirror type.
`RebornFilesystemConversationServices` is constructed, consumed and dropped
inside product's factory. What crosses the port is `ChannelWorkflowStorageRoots`
(a `VirtualPath` pair — placement is host policy) in, and the surface, the
binding resolver and the run-delivery observer out.

The last residue port had to be renamed, not just moved:
`ConversationBindingService` is now `ironclaw_product_contracts::binding::
ProductBindingResolver`, because `ironclaw_conversations` already defines a
trait by the old name and §11.2.4's one-home rule refuses two definitions of a
contracts name. The boundary error grew `BindingRequired`,
`UnknownInstallation` and `TurnSubmissionRejected` rather than weakening: all
three are constructed by the port's implementor, `BindingRequired` is what an
unpaired external actor is told, and every one carries `String`/nothing so the
contracts ceiling is untouched.

Gates:
  EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT  5 -> 3
  EXTENSION_HOST_PRODUCT_REFERENCE_FILE_BASELINE        5 -> 3
  PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS 1 -> 0
  WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE            1 -> 0
  EXTENSION_HOST_FILES_STILL_NAMING_THE_WORKFLOW_ERROR  1 -> 0

`the_extension_host_manifest_names_product_only_while_a_residue_needs_it` is
re-keyed on the trait residue OR the reference ledger. That is a correction,
not a relaxation: keyed on the trait list alone it would now demand the
manifest edge be deleted while three adapter-registry rows still name the
crate — failing a correct tree and passing an impossible one. Both directions
stay enforced against the union.

Regression coverage: the ingress/delivery/trigger integration suites are
unchanged in behaviour and green; the only edits to them are import repoints
for the renamed port. `unknown_manifest_command_fails_generic_graph_assembly`
still pins that an undeclarable command fails the whole graph build.

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

* WS2 flip: extension_host products -> loops — manifest edge deleted, ledger/residue 0/0, DowngradePin armed

The batch-2 union (via #7181) and the D-A factory port each discharged
exactly the rows the other left, so the port-inversion biconditional
demanded the flip: layer line + manifest edge in one change. Same-layer
inventory 74 -> 72 net (+1 loops edge extension_host->loop_host, -3
products rows), pin frozen at the four normal-dep consumers. Two typed
ExtensionId seams reconciled between batch-2 and the D-A branch.

* fix(arch): equality-assert the zeroed reference ledger; fmt

* review(7181): architecture-gate hardening from CodeRabbit round 1

Three armed gates were reporting on shapes they could not actually see.

- `reborn_composition_boundaries.rs`: the consumer-annotation scan walked
  back over the attribute block by line prefix, so a multiline
  `#[cfg(any(...))]` between the annotation and the `pub use` stopped the
  walk and rejected a correctly annotated re-export. The walk is now
  bracket-aware and extracted into `pub_use_consumer_annotations` so it is
  testable on synthetic input; the new fixture covers the multiline shape,
  the single-line shape, a bracketed comment, and the unannotated
  sabotage case.
- `reborn_dependency_boundaries.rs`: the MCP/sandbox lane-existence probes
  searched raw concatenated source, so a comment, doc example, string
  literal, or `#[cfg(test)]` fixture naming `McpRuntime<C>` would have kept
  them green after the production runtime was gone. They now scan
  production tokens only (`production_rust_files` +
  `strip_comments_and_strings`), with a regression fixture that plants the
  marker in each of those non-production forms.
- `ironclaw_webui/tests/handlers_module_charter.rs`: `top_level_items`
  stripped only `pub `/`pub(crate) `, so a `pub(super)`/`pub(in ...)` item
  was silently excluded from `charted_surface()` and therefore never
  registered as unassigned. `strip_visibility` now handles every
  visibility form.
- `ironclaw_auth/tests/module_charter.rs`: the two-engine severance scan
  dropped only lines beginning `//`, so a block comment, a trailing
  comment, or a string literal naming the other engine reached the probes
  and a documentation edit could fail the charter gate. A lexical stripper
  replaces the prefix filter, with fixtures for each shape plus a
  must-still-be-seen `use` case.
- `reborn_extension_host_port_inversion.rs`: the reference-ledger history
  still described a 9 -> 5 reduction with five survivors; the live ledger
  has two rows and the baseline is 2. Corrected to the actual 9 -> 5 -> 2.

Every strengthened scanner was sabotage-tested (broken, watched fail,
restored). `cargo test -p ironclaw_architecture` is green across all 37
binaries with no new violations surfaced.

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

* review(7181): MCP lane — arm the charter's failure-string rule, close its exceptions

The crate charter's load-bearing clause — "no module builds a failure
string of its own" — was stated in three files and enforced in none, and
the crate carried live exceptions.

- `egress.rs` minted `"runtime_http_egress_panicked"` inline and forwarded
  `stable_runtime_reason()` verbatim into `McpClientError`. Both are now
  `diagnostics::McpEgressCause` variants named through `egress_failure`.
- `impl From<String> for McpClientError` was the implicit bypass: any `?`
  in the crate could turn an arbitrary String into a model-visible
  reason. It had exactly one user (`client.rs`'s credential-injection
  check, whose reason already came from `diagnostics`), now an explicit
  `map_err(McpClientError::client)`. The impl is deleted.
- `diagnostics.rs` claimed "every reason is capped here" but appended the
  server-supplied `JsonRpcError.message` verbatim. The only production
  producer bounds it upstream, but the cap is this module's invariant,
  not the caller's, so it now goes through `bound_mcp_reason_detail`.
- New `tests/module_charter.rs` arms the rule: a new `reason: "..."` /
  `reason: format!(...)` outside `diagnostics.rs` fails, a re-added
  `From<String>` fails, and the charter text in `lib.rs` + `CLAUDE.md`
  must keep naming the rule and its gate. The rule's one remaining
  carve-out — `runtime.rs`'s two `McpError` descriptor/invocation reasons,
  which echo manifest ids rather than classify a failure — is an
  enumerated list, not a wildcard, and both docs now say so.

Also in `runtime.rs`: the `transport == "stdio"` process-count branch is
unreachable (`prepare_client_request` rejects stdio and everything that
is not http/sse before it), so it is replaced by a comment saying why no
process accounting happens here; and `release_after_failure`'s discarded
`Result` gets the required `// silent-ok:` annotation plus a `debug!` so a
leaked reservation leaves a trace without masking the caller-facing error.

Sabotage-tested: re-inlining the egress reason makes the new gate fail
with 3 inline reasons instead of the 2 grandfathered rows.

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

* review(7181): type the channel-connection port, and fix the trace-prune lock

Two Major findings with real failure modes.

**Typed channel identifier at the `ChannelConnectionService` boundary.**
The port exchanged channel package ids as `String` map keys and a `&str`
disconnect argument, so a malformed or non-canonical id could become a
key no lookup would ever match — a channel that silently reads as "not
connected" instead of failing. The sibling map on the very same product
call (`installed_activation_errors`) was already keyed by `ExtensionId`,
so the untyped half was the odd one out. All three signatures now use
`ironclaw_host_api::ids::ExtensionId`; the generic service applies the
same skip-invalid-vocabulary rule its own discovery walk already used,
and `extension_info` resolves the id once for all three lookups.

**`std::sync::Mutex` held across filesystem I/O in the trace prune step.**
`trace_scope_has_pending_queue` is a synchronous `read_dir` per scope and
was called from inside `observed_scopes.retain`, under the guard, on the
runtime worker thread — while `record_observed_scope` takes the same lock
from the capture path, so a stalled filesystem blocked capture-time scope
recording. The probe now runs on the blocking pool against a snapshot and
the guard is re-acquired only to apply the result, which also leaves
scopes recorded mid-probe alone. (This pattern predates the WS6 move —
it was introduced 2026-06-15 in 410db7720 and relocated verbatim by this
batch — but it is contained enough to fix here.)

**Fire-access unavailable-precedence coverage.** New WS6 policy code
decided what a transient backend fault becomes (retryable `Err` when the
final answer is a denial, but never over a grant) with no test driving a
failing checker at all. Added, test-first: breaking the precedence branch
makes it fail with `Denied` where `Unavailable` is required. Also pins the
last-position fault, which the other two cases never reach.

**Product-adapter section invariants.** `DuplicateCredentialHandle`,
`DuplicateEgressTarget`, and the RFC 7230 token rule (including
`auth.timestamp_header_name`, the optional field a rename could quietly
drop from validation) came over from `ironclaw_product::adapter_registry`
with WS5 and had no assertion anywhere. Covered through the real
deserialize + resolve + validate path.

**Smaller items.** The relocated trigger-fire contract no longer keeps a
second import path through composition (`runtime_input`'s `pub use` and
the four names in the lib.rs surface are gone, consumers repointed at
`ironclaw_triggers`, snapshot recaptured); `repository_contract.rs` uses
`var_os` for presence so a non-UTF-8 `IRONCLAW_REQUIRE_POSTGRES` cannot
silently disarm the parity guard, with a regression fixture;
`ironclaw_reborn_identity`'s stale `Self::bind` rustdoc link, the
`ironclaw_auth` AGENTS.md `loopback_oauth` contradiction, and the
`ironclaw_extension_contracts` charter row missing
`ExternalActorBindingEpoch` are corrected.

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

* Re-arm the union's ratchets: recount, swap one same-layer edge, repoint a coverage exemption

Three gates fired on the merged tree; each is fixed by measurement, not by
lowering a bar.

**Extension-specificity baseline: 125 (ours) / 122 (batch) -> 122.** Neither
side's number is evidence for the union, so the constant was set to `0` and the
true length read out of the ratchet's own panic. The batch's three vendor-pair
removals are the only entries either side removed and this branch's renames
repoint entries in place without adding any, so the union is the batch's number.

**SAME_LAYER_EDGE_BASELINE stays 72 -- one row moved, the count did not.** The
gate found both halves by itself: `triggers -> safety` tripped the
not-inventoried arm and `conversations -> safety` tripped the stale-row arm.
They are the two sides of one swap -- the trusted-trigger prompt scan moved
behind the seam into `TrustedTriggerSubmitRequest::new`, so the edge changed
crate rather than appeared. This merge is the first tree where both halves
exist, which is why nothing had inventoried it before. The equality is what made
the second half loud: under a `<=` ratchet the stale row would have sat green as
one entry of slack.

**changed-coverage exemption #113 repointed 1276 -> 975.** Inherited red, not
caused here: reproduced on a pristine `git archive` of `ws2/da-factory-port`
with the same message. The extension_host products -> loops flip shrank
`channel_host.rs` from 1405 to 1098 lines and left the exemption past EOF.
Repointed to the same construct rather than deleted -- `observe_error`'s `error`
parameter is the only `product_adapter_error::ProductAdapterError` in the file,
so the exemption still names exactly what it always named.

* ci(test-plan): classify the two path classes a rename PR reaches and the planner did not

`Detect Reborn test scope` aborts on the first path no rule claims, and the
nine steps after it are then skipped — so the set gets discovered one CI red at
a time. Both gaps below are the shape #7152 already records for `Dockerfile`
and `clippy.toml`: fail-closed with no rule, surfaced only because a rename
diff touches files a feature PR never touches.

Found as a class rather than one-per-red-run: `build_plan` was driven over all
1,250 paths in this PR's diff with `cargo metadata` resolved once. Two came
back unclassified; after the fix the sweep reports **0**, and the planner
produces a real plan for the actual diff.

- **`openwiki/**`** — the auto-generated wiki, regenerated by
  `openwiki-update.yml` and explicitly not hand-edited. No build or test
  surface, so it joins `docs/` in `IGNORED_PREFIXES`. A crate rename touches it
  by construction: its prose names crate directories.
- **`scripts/live_canary/**`** (UNDERSCORE) — a *second* real directory beside
  the already-classified `scripts/live-canary/` (hyphen), differing only by
  that character. It is the canary's importable Python package; the rename
  reaches it through a `RUST_LOG` string naming a crate. The ⚠ note about the
  two directories is restored beside the constant.

Both are pinned: the wiki test asserts the plan is *equal* to a `docs/` plan
(so a later change that escalates it to a lane fails here too) and that a real
change riding along still selects its lane; the canary paths join the existing
QA-harness subTest list.

* fix(ci): repoint changed-coverage exemption #113 past the flip's channel_host shrink (1276 -> 975)

* test(triggers): hold the workspace env mutex across the non-UTF-8 presence fixture

The hermetic env-mutation guard rejects raw set_var/remove_var without
lock_env(); the fixture now holds the guard across both mutations.

* refactor(crates): move every crate into its §5 family directory (text-only)

Wave 5 / WS7, PR 1 of 2. Creates the ten family directories PROPOSAL §5
specifies — contracts/ substrates/ events/ domains/ kernel/ lanes/ loop/
extensions/ product/ app/ — and `git mv`s 56 crates into them. No crate is
renamed, no code moves between crates, no behavior changes: every diff outside
a manifest, a path literal, or a gate's path resolution is a pure rename.

What moved, and what deliberately did not:

  * 56 of the 58 §5 rows. `ironclaw_extension_support` was already at
    `crates/extensions/`; `ironclaw_wasm` is WS7 2/2's (its `wit/` travels with
    it and forces the guest components' `wit-bindgen` paths plus a rebuild of
    the committed `.wasm` binaries — a binary change that does not belong in a
    text-only move).
  * `tools/` is untouched per the owner ruling, so `ironclaw_stress` stays at
    `tools/ironclaw_stress`.
  * `ironclaw_projects`, `ironclaw_first_party_extension_ports` and the
    workspace-excluded `ironclaw_silk_decoder` stay flat under `crates/`; each
    has an open disposition of its own and is listed in the PR's exceptions
    table.

Fail-open gates hardened BEFORE the first `git mv` (all three were already
inventory-resolved by WS10; each was sabotage-tested here to prove it goes red
rather than silently passing on an unresolvable tree):

  * `reborn_boundary_rules_active_crates_are_workspace_members` — forcing
    resolution to fail drops `checked` to 1 and the `>= 30` floor fires.
  * `boundary_rule_names_are_package_names_not_crate_directories` — adding
    `"ironclaw_cli"` (a directory, package `ironclaw`) to a forbidden list
    produces the directory-vs-package violation.
  * `concrete_extension_crates_link_only_from_the_binary_and_tests` — a fake
    `CONCRETE_EXTENSION_CRATES` resolves nothing and the non-vacuity assert
    fires.

Loud path-inventory repoints (every one of these FAILED first and was fixed by
resolving through the crate inventory — no gate was weakened, no scope
narrowed):

  * 12 architecture gates: composition-boundaries walk root; conversations /
    extension-manager-split / operator-port-inversion scan roots; the three
    contract-location scans' owner attribution (first-path-component under
    `crates/` now answers the FAMILY name, so it moved to
    `ratchet_support::owning_crate_name`); the persistence-driver walk; the
    vendor-census CENSUS/carve-out keys and its sanctioned module; the
    manifest-reparse ALLOWLIST keys; the service-method-freeze sources; the
    provider-catalog ownership test.
  * `scripts/ci/ws12_workflow_contracts.py`: the WebUI lockfile's "one level
    deeper" cache-dependency-path sibling is now `crates/*/*/…`, because the
    single-`*` form matches the crate's real location post-move and the gate
    correctly rejects a probe that is broad rather than depth-tolerant.
  * `scripts/ci/test-classify-test-scope.sh`: the "every arm names a real
    crate" check now resolves through the inventory instead of globbing the
    filesystem — the arms are keyed to the classifier's NORMALIZED
    `crates/<crate>/…` identity, which is not a path on disk.
  * `tests/integration/changed-coverage-exemptions.toml` and the changed-
    coverage self-test fixtures.
  * `Dockerfile`, `.dockerignore`, `.gitattributes`, `.coderabbit.yaml`, the
    seven workflows carrying WebUI/stress path literals, and `README.md`'s
    `cargo install --path`.
  * 39 cross-crate `include_str!`/`include_bytes!` literals and eight
    `CARGO_MANIFEST_DIR`-relative test helpers; the four that resolved the repo
    root by counted `..` hops now search upward for the nearest ancestor
    holding both `crates/` and `Cargo.toml`, because their wrong answer
    (`crates/`) is a directory that exists.

Guidance: one `AGENTS.md` per family directory (charter, member list with each
crate's enforced layer, link to `families/<name>.md`), plus a family index in
`crates/AGENTS.md`. Live agent-facing docs were repointed; generated
(`openwiki/`) and historical (`docs/plans/`, `docs/superpowers/`, `docs/adr/`,
`CHANGELOG.md`, the target-architecture docs) were not.

Measurements unchanged by the move, which is the evidence it is text-only:
composition budget 40582 / 691597 LOC (identical to the base branch — the
ratchet followed the crate by name), specificity allowlist 122, same-layer edge
inventory 72, architecture suite 36 test binaries / 261 tests green.

The projects→identity merge (§12.10) was measured and SKIPPED — see the PR
body's finding: its consumers are two crates and five files, not the single
wiring site the audit counted.

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

* fix(ci): repoint the four path-keyed baselines onto the family tree

Four data files key their entries on a repository path rather than on a crate
name, so the family move leaves every row naming a directory that no longer
exists. Each fails loudly, which is how they were found:

  * `scripts/no_panics_reborn_baseline.txt` — `check_no_panics.py
    --reborn-baseline` reported all 50 audited invariants as *stale* and the
    same 50 as *new*, because the fingerprint's first field is the file path.
  * `tests/integration/coverage-exemptions.toml`,
    `tests/integration/coverage-floor.toml`,
    `tests/integration/critical-mutation-functions.toml` — same shape; the
    critical-mutation manifest validator resolves each row against the live
    crate tree and refuses a row it cannot attribute.

Paths only: no entry added, removed, or re-justified, so every floor,
exemption and reviewed invariant keeps exactly the scope it had.

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

* style(arch): drop two needless borrows the family-move repoint introduced

`crate_dir(root, OWNER)` inside the two `fn …(root: &Path)` helpers took
`&root`, which clippy's `needless_borrow` rejects under `-D warnings`. Found by
the workspace clippy lane, not by review.

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

* docs(crates): sharpen three family AGENTS.md entries

`extensions/` now says what `packages/` holds beyond its four crates (the
data-only packages) and the rule for when a package earns a crate. `app/` names
the one directory whose name and package name differ, and replaces two
descriptions that read as tautologies. `domains/` records why
`ironclaw_projects` is not in the table.

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

* docs(ci): correct the cache-dependency-path comment for the landed move

The comment described the pair as "flat line + one family directory down".
Post-WS7 the first line IS the family path, and the spare is one level below
that — so the comment now says which is which, and names the rule that forces
them not to overlap (`ws12_workflow_contracts.py` rejects a spare that already
matches the real location).

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

* fix(ci): repoint two path-keyed self-tests the family move took dark

Both fired in CI, not in review.

`crates/app/ironclaw_cli/tests/smoke.rs` asserts against *repository paths*
written into the Dockerfile and the release workflows, and those carry the
crate's family. Three assertions read a flat `crates/ironclaw_*` path: the
Dockerfile's WebUI frontend install, and two reads of the CLI manifest / WiX
manifest — the latter two failed with `NotFound`, the first with a
"Dockerfile must install WebUI frontend dependencies" message that pointed at
the Dockerfile rather than at the test. They now derive the crate directory by
walking `crates/` for the outermost directory owning a `Cargo.toml`, and panic
on absent-or-ambiguous rather than answering an empty path.

`scripts/check_no_panics.py`'s `test_test_only_path_detection` names a REAL
repository file, because that branch of `is_test_only_path` reads the file to
confirm its `#[cfg(test)] mod` declaration. Pointed at the crate's family path
so the assertion measures the file it claims to.

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

* ci(classify): attribute family-level guidance files (crates/<family>/AGENTS.md)

The WS7 family dirs each carry an AGENTS.md that sits inside no crate;
the classifier's fail-closed refusal correctly caught the new shape on
CI. Recognized structurally (parent dir is a prefix of a discovered
crate dir), bucketed exactly like crates/AGENTS.md. Fixture red-verified
before the fix.

* test(e2e): repoint journey-evidence and inventory paths to the family tree

The journey-coverage suite asserts each case's evidence file exists;
four e2e data/scenario files still carried flat crates/<crate> paths.
Repointed through the family map; every referenced .rs path verified
resolving on this tree.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:33:28 +00:00
Benjamin Kurrek
8a409780ad refactor(ws6): execute the 13 WS6 renames and close the remaining Wave 4 rows (#7152)
* 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>

* refactor(triggers,conversations): scan trusted trigger prompts at the mint (WS6)

PROPOSAL §6.4.2 asked for the trusted-trigger prompt safety scan to move
"behind the triggers/kernel seam it guards". It was not a module: it was
three lines inside `ConversationTrustedTriggerSubmitter::submit_trusted_trigger_fire`
— one of the two implementations of `ironclaw_triggers::TrustedTriggerFireSubmitter`
— holding its own `Arc<dyn InjectionScanner>` from `Sanitizer::new()`.

That placement is a fail-open: a guard that lives inside one implementation
of a port is lost the moment a second implementation exists, and nothing in
the tree forced a new submitter to re-run it.

The seam is `TrustedTriggerFireSubmitter`, whose only input is the sealed
`TrustedTriggerSubmitRequest`, which `ironclaw_triggers` is the sole minter
of. So the scan moved to the mint: `TrustedTriggerSubmitRequest::new` is now
fallible and calls the new `ironclaw_triggers::prompt_safety` first, making
"this prompt passed the trusted-prompt scan" an invariant of the type rather
than a step some submitter performs. `new_for_test` delegates to `new`, so
the test-support seal bypasses visibility only, never the scan.

Behaviour at the fire level is unchanged — same rejection point, same
`TriggerError::InvalidMaterialization`, same permanent disposition — and
composition's pre-materialization scan is untouched, so defence in depth
survives with the second scan relocated and now covering every submitter.

`ironclaw_conversations` drops `ironclaw_safety` entirely (the scan was its
only use). Enforcement: triggers' boundary rule stops forbidding
`ironclaw_safety` (a same-layer, I/O-free `substrates` leaf — a peer edge,
not a reach upward), and a NEW `BoundaryRule` for `ironclaw_conversations`
forbids it, plus `ironclaw_threads` (§6.4.2's "Never: transcript content"),
a crate that was unruled until now.

Regression coverage at the caller tier, not on the helper:
`tick_rejects_injection_prompt_before_any_trusted_submitter_is_reached`
drives the real `TriggerPollerWorker::tick_once` with a materializer that
does NOT scan and a submitter configured to accept, and asserts the
submitter is never reached. A companion pins that a medium-severity-only
prompt still submits, so the mint cannot drift into a blanket filter.

Tests: conversations 97 -> 97 (name-identical), triggers 169 -> 173
(+2 worker, +2 prompt_safety unit), architecture 206 -> 206.
LAYER_MATRIX_EXCEPTIONS unchanged at 10.

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.

* refactor(traces): drop the boundary-laundering re-export modules (WS6)

PROPOSAL §6.4.14: "drop the boundary-laundering re-export modules
(`recording`, `paths`) — consumers import the owners".

`ironclaw_reborn_traces::{recording, paths}` were two `pub use <other
crate>::*` passthroughs whose own doc comments stated their purpose
plainly: "so reborn-cli does not need a direct `ironclaw_llm`
dependency, preserving the architectural boundary". They preserved
nothing — the edge existed either way; the wildcard only hid which crate
owned the type, so the dependency graph read as a lie.

All three call sites were in `ironclaw_reborn_cli`. Note the literal
reading of "consumers import the owners" is not available here: the CLI's
dependency allowlist (`reborn_cli_binary_crate_stays_separate_from_v1_root`)
deliberately excludes `ironclaw_llm`, so importing the owner would have
traded a laundered re-export for a breached, tested boundary. Satisfied
instead by giving the owning crate the operation, which is what the
laundering was standing in for:

- `onboarding::onboard_instance(invite, consents)` — resolves the
  contribution root itself. Path layout under the base dir is this
  crate's own knowledge; the CLI no longer needs base-dir vocabulary.
- `TraceClientHost::build_envelope_from_recorded_trace_json(json, opts)`
  — parses `ironclaw_llm::recording::TraceFile` inside the crate that
  already depends on `ironclaw_llm`. The CLI hands over raw JSON.
- the CLI's private `trace_contribution_dir()` now delegates to
  `contribution::trace_contribution_dir_for_scope(None)` instead of
  re-deriving `<base>/trace_contributions`. Verified byte-identical:
  `trace_contribution_dir_for_scope(None)` is
  `trace_contribution_dir_for_scope_at(&ironclaw_base_dir(), None)`,
  whose `None` arm returns `base.join("trace_contributions")`.

No dependency was added to any crate. Semantics unchanged.

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

* refactor(llm): make providers.json a crate asset with a boundary rule (WS6)

CHECKLIST WS6: "`llm` `providers.json` becomes a crate asset/composition
input + boundary rule added".

The provider catalog sat at the **repository root**. A root-level data
file has no owning crate, so no boundary rule could govern who edits it,
and every consumer compiled it in behind Cargo's back with an escaping
`include_str!` — the "repo-root asset reach-in" shape §11.2.7's scanner
inventories. `git mv`'d to `crates/ironclaw_llm/assets/providers.json`
and the 20 `include_str!("../../../providers.json")` sites in
`registry.rs` become in-crate `../assets/providers.json`.

⚠ Correcting the row's inherited premise: a prior lane recorded the
"load-bearing include site is in `ironclaw_reborn_cli`" and judged the
item "needs a new mechanism, not a new path". Measured on main: the
load-bearing site is `crates/ironclaw_llm/src/registry.rs:383`
(`builtin_provider_definitions`), inside the owning crate. No new
mechanism was needed — only the path.

**Path-keyed gates rewritten in the same commit** (WS10: these fail
*silently* under a move):
- `Dockerfile` — both `COPY providers.json providers.json` lines deleted;
  `COPY crates/ crates/` already covers the new location in both stages.
  Verified by `scripts/ci/check-include-str-paths.sh` (OK, 119 refs).
- `.github/workflows/reborn-e2e.yml` — the literal `providers.json` path
  filter and its regex alternative removed; the depth-independent
  `crates/**` entry already matches. `ws12_workflow_contracts.py` passes.
- `scripts/ci/classify-test-scope.sh` — kept at its **shared** (both
  lanes) classification under the new path rather than letting it fall
  through to crate scope, so CI breadth does not silently narrow; the
  now-redundant entry in the reborn-only branch is dropped.

**The one consumer that could not simply be repointed.** The CLI's
`default_llm_consts_match_the_real_providers_json_nearai_entry` embedded
the catalog from five directories up to check its mirrored `DEFAULT_LLM_*`
constants. Repointing it would have turned a repo-root reach-in into a
*cross-crate* reach-in — the category §11.2.7 turns into a hard failure —
and the CLI may not depend on `ironclaw_llm`. A cross-crate consistency
rule belongs in the cross-crate suite, so the assertions moved into
`ironclaw_architecture` and read both files from disk at runtime, needing
no compile-time coupling at all.

Test accounting: `ironclaw_reborn_cli` config-init tests 2 -> 1; the
removed one is reborn as `reborn_provider_catalog_is_owned_by_its_crate`
in `reborn_dependency_boundaries.rs`, strictly stronger (it also pins the
asset's location, the repo root's emptiness, and single-embedder
ownership). Net test count +0.

**The new rule is sabotage-tested** — five cases, each red with the right
message, each restored to green:
1. catalog copied back to the repo root -> "must not sit at the
   repository root"
2. a foreign crate `include_str!`s it -> names the offending file
3. catalog `default_model` drifts from the CLI mirror -> names the const,
   the field and both files
4. walker pointed at a non-existent dir -> "walked only 0 Rust files ...
   would pass no matter what the tree contained" (reachability)
5. mirrored const renamed -> "no longer declared as a plain const ...
   update the extraction rather than deleting the drift check"

Case 2 caught a real false positive in the first draft of the guard: a
file-level `include_str!` AND `providers.json` conjunction flagged
`cli/tests/smoke.rs`, which names the *runtime*
`$IRONCLAW_REBORN_HOME/providers.json` and separately embeds something
else. The matcher now inspects the macro argument, not the file.

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

* 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.

* docs(skills): rewrite the stale v1 lib.rs charter note (WS6)

CHECKLIST WS6 domain-internal cleanups: "`skills` stale v1 lib.rs doc
rewritten".

The crate doc claimed "In v1, trust-based tool filtering happens via
`src/skills/attenuation.rs`. In v2, the Python orchestrator handles trust
labels and the policy engine controls tool access via capability leases."
Both halves are dead vocabulary: there is no `src/` monolith on this tree
and no Python orchestrator anywhere in Reborn.

Replaced with what is true and checkable — this crate owns the trust
*label* and none of its enforcement; the ceiling is applied at the
capability tier (`host_api` capability/invocation attenuation via
`first_party_extension_ports`' activation and execution paths) and the
decision belongs to `ironclaw_authorization`. Also points at the existing
`SkillTrust` `Ord` safety note, which the old text left unconnected.

Doc-only; no code change.

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

* 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.

* refactor(crates): execute the WS6 crate renames, no shims (WS6)

Three CHECKLIST WS6 rename rows, executed together as one pure rename.
No compatibility re-export shims (WS6 discipline); every consumer, doc,
CI script and snapshot repointed in this commit.

**Row 1 — stutter kills (decided 2026-07-29):**
- `ironclaw_events`             -> `ironclaw_event_log`
- `ironclaw_extensions`         -> `ironclaw_extension_registry`
- `ironclaw_product`            -> `ironclaw_assistant`

**Row 2 — naming audit (decided 2026-07-30):**
- `ironclaw_architecture`       -> `ironclaw_architecture_tests`
- `ironclaw_runner`             -> `ironclaw_turn_runner`
(`ironclaw_first_party_extensions` -> `ironclaw_extension_support` landed
early with WS2.6 and is already ticked.)

**Row 3 — the `reborn_` batch (decided 2026-07-30):**
- `ironclaw_reborn_composition`   -> `ironclaw_composition`
- `ironclaw_reborn_config`        -> `ironclaw_config`
- `ironclaw_reborn_event_store`   -> `ironclaw_event_store`
- `ironclaw_reborn_identity`      -> `ironclaw_identity`
- `ironclaw_reborn_openai_compat` -> `ironclaw_openai_compat`
- `ironclaw_reborn_traces`        -> `ironclaw_trace_commons` (§6.4.14:
  the crate is the Trace Commons client, not trace machinery)
- root package `ironclaw_reborn_integration_tests` -> `ironclaw_integration_tests`

4,806 occurrences rewritten across 901 files, plus 11 `git mv`'d crate
directories (`git diff -M` reports them as renames). Replacement used
word-boundary matching, which is what keeps `ironclaw_product` from
touching `ironclaw_product_contracts` and `ironclaw_extensions` from
touching the four `ironclaw_extension_*` siblings.

**Semantics: none.** No type was renamed, no module moved, no signature
changed. `cargo check --workspace --all-targets` is clean.

**Path-keyed gates rewritten in the same commit** — WS10 lists these as
the ones that fail *silently* under a rename, and each was re-run to
prove it still scans a non-zero tree rather than merely passing:
- `scripts/no_panics_reborn_baseline.txt` — 3 entries repointed, 0 stale
  names left; `--reborn-baseline` reports "OK ... (1203 files, 51
  reviewed invariant(s))" and `--self-test` passes 34 tests.
- `docs/plans/composition-pubuse.snapshot` — 5 entries. This one is not
  documentation despite its path: `composition_public_pub_use_surface_matches_snapshot`
  compares against it byte-for-byte, and it failed loudly when the rename
  first landed without it. Caught by running the suite, not by inspection.
- `scripts/ci/classify-test-scope.sh`, `scripts/ci/reborn-crate-test-buckets.sh`
  (+ its self-test), `scripts/ci/discover-reborn-package-crates.sh`,
  `scripts/ci/package-feature-flags.sh`,
  `scripts/ci/check-generic-without-concrete.sh`,
  `scripts/ci/ws12_workflow_contracts.py`, `scripts/dev_metrics.py`,
  `scripts/reborn-e2e-rust.sh`, `scripts/pre-commit-safety.sh`.
- **CI lane names**, which the `ironclaw_architecture` row calls out
  explicitly: `.github/workflows/code_style.yml`'s `cargo test -p
  ironclaw_architecture reborn` step and its changed-paths regex.

Verification: `cargo check --workspace --all-targets` clean;
`ironclaw_architecture_tests` 32/32 suites green; `ws12_workflow_contracts.py`,
`test-classify-test-scope.sh`, `test-reborn-crate-test-buckets.sh`,
`check-include-str-paths.sh` all pass. `LAYER_MATRIX_EXCEPTIONS` counted
with Python between the const and its `];` — **6**, unchanged.

Deliberately not rewritten: `docs/reborn/subagent-spawn/diagrams/*.{d2,svg}`
and the historical prose in `docs/`. Those describe an unlanded design
authored against the old tree; renaming inside them would misrepresent
what was designed, and the `.svg`s are generated artifacts.

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

* 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>

* refactor(cli): move the binary crate to crates/app/ironclaw_cli (WS6)

Last clause of the WS6 `reborn_` rename row: "cli directory ->
`app/ironclaw_cli`". Package name stays `ironclaw` (unchanged, as the row
requires); this is a directory move plus the crate-directory rename.

82 path references rewritten across 39 files, plus the crate's own 18
`path = "../X"` dependencies re-based to `../../X` now that it sits one
level deeper. `cargo check --workspace --all-targets` clean.

This is the first crate to live at a nested family path, which is exactly
the shape WS10 warns about: a gate keyed to the flat `crates/<name>/`
layout stops matching and goes green having scanned nothing. Two gates
were found by running them, not by reading them:

1. **`scripts/ci/ws12_workflow_contracts.py` failed loudly and correctly** —
   `.github/workflows/code_style.yml`'s `has_reborn_cli` filter named the
   crate `ironclaw_reborn_cli`, which the crate inventory could no longer
   resolve: "expected exactly one crate directory named
   'ironclaw_reborn_cli' under crates/, found 0 ... repoint the gate that
   names it rather than letting it measure an empty tree." Repointed
   there, in ws12's own probe table, and in
   `check-generic-without-concrete.sh`. The workflow regex already used
   the depth-independent `crates/([^/]+/)*` form, so the nesting itself
   was safe — only the crate *name* needed repointing.

2. **`docs/plans/composition-pubuse.snapshot` regenerated after `cargo
   fmt`**, not before. The rename lengthened a `pub use` line past the
   width limit, so fmt rewrapped it and the snapshot went stale a second
   time. Diff is exactly one alphabetical re-sort
   (`ironclaw_product`->`ironclaw_assistant`) and one rewrap; no symbol
   added or removed.

**Pre-existing bug fixed in passing, with evidence it predates this PR.**
`check-generic-without-concrete.sh` listed `"ironclaw_reborn_cli"` among
its sanctioned assemblers, but that set is matched against cargo
*package* names and the CLI package is `ironclaw`. The exemption
therefore matched nothing and the gate was **already red on clean
`origin/main` @ 283e1f6b7c**, reporting the two concrete extension crates
DEL-7 explicitly allows the binary to link:

    ironclaw: dependency graph contains concrete extension crate ironclaw_slack_extension
    ironclaw: dependency graph contains concrete extension crate ironclaw_telegram_extension

Reproduced on a clean checkout before assuming this PR caused it. Fixed
by naming the package, with a comment recording that these are package
names — the same directory-vs-package confusion that
`boundary_rule_names_are_package_names_not_crate_directories` exists to
catch on the dependency-boundary rules.

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>

* docs(target-arch): tick the three WS6 rename rows, amend four others (WS6)

Dated amendments, each quoting or naming the text it replaces.

**Ticked (condition verified on the merged tree):**
- the three `Renames executed` rows — stutter kills, naming audit, and
  the `reborn_` batch. All 14 clauses across them are done.

**Amended without ticking, because a clause is genuinely unmet:**
- `Domain-internal cleanups` — three of six clauses done (traces
  re-export modules, `llm providers.json`, `skills` lib.rs doc), one
  refuted (`identity` absorbing `host_api::user_identity`), two open
  (`triggers` SQL ADR, `projects` composition adapter).
- `Retire the local_dev misnomer` — the row stays ticked; its *residue
  clause* is re-scoped with measurements.

**Two row texts were wrong and are corrected rather than executed:**
1. The `traces` `ScopedFilesystem` clause says the type is "dropped".
   §6.4.14 says the crate should *take* one. §6.4.14 is right and the
   row is the error — the type exists (`ironclaw_filesystem::ScopedFilesystem`)
   and is absent from the traces crate, so this is adoption, not removal.
   Also corrects "~91 raw `fs` call sites" (that counted test code; the
   production surface is 11 in `contribution.rs` plus ~7 in
   `device_key.rs`).
2. The `local_dev` residue said "the local variable at
   `composition/src/runtime.rs:3016`". It is not one variable — it is 14
   distinct identifiers; #7098's "public type" claim is wrong
   (`RebornLocalRuntimeIdentity` is `pub(crate)`); and #7098's
   explanation for why the ratchet missed it is wrong, because a
   *second* ratchet (`reborn_deployment_mode_typename_ratchet`) already
   inventories the name and records that the sanctioned exit is Slice B,
   not a rename. Every obvious rename target is also already taken by a
   different concept.

**One clause refuted with measurements (delegated authority).** "`identity`
absorbs `host_api::user_identity` ports" would move a ports module out of
the neutral contracts crate into a crate that neither implements nor
consumes it — the sole production implementor is
`extension_host::channel_identity_store::FilesystemChannelIdentityStore`
— and, because `ironclaw_identity` depends on `ironclaw_host_api` and not
the reverse, would force `extension_host` to take a new dependency to
name a port it implements. The ports stay in `host_api`. The dual
binding-store ambiguity is resolved as nominal, not structural: principal
identity (`ironclaw_identity::identity_store`) and post-OAuth channel
binding (`extension_host::channel_identity_store`) are distinct concerns
and neither subsumes the other.

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>

* fix(ci): repoint the release-cut scripts at the moved CLI manifest

`origin/main` added `scripts/ci/cut_ironclaw_release.py` and its
self-test while this branch was in flight; both locate the version to cut
via `crates/ironclaw_reborn_cli/Cargo.toml`, which this PR moved to
`crates/app/ironclaw_cli/Cargo.toml`.

Caught by re-scanning the merge for reintroduced old crate names rather
than trusting a clean `git merge` — the merge was conflict-free precisely
because these files are new on main and touch nothing this branch edited,
which is the shape that reintroduces a stale path silently.

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>

* fix(ci): classify the Dockerfile in the Reborn PR test planner

`Detect Reborn test scope` failed on this PR with:

    Reborn PR test planner failed: unclassified pull-request path: Dockerfile

and took `Tests (Reborn)` down with it ("changes failed: failure").

`scripts/ci/reborn_pr_test_plan.py` classifies every changed path and its
fail-closed arm raises on anything no rule claims. `PR_STATIC_CONTROL_PATHS`
held `Cargo.toml`, the toolchain files and the coverage manifests, but not
`Dockerfile` — so **any** PR editing the container build context aborted
the planner. This PR is simply the first to do so: moving `providers.json`
into its owning crate made the two `COPY providers.json` lines redundant.

The Dockerfile is owned by the `Docker` workflow (its own trigger on this
path) and its COPY coverage by `check-include-str-paths.sh` under Code
Style. No Reborn test lane reads it, so it belongs with the other
de-escalating static-control paths: `mode: none`, `coverage_mode: none`,
no buckets selected.

The existing `test_unclassified_build_input_fails_fast` used `Dockerfile`
as its *example* of an unclassified path. The invariant it protects is the
fail-closed arm, not the filename, so it keeps that arm with a genuinely
unowned fixture (`unowned-root-input.mk`, fictional and never touched on
disk — same convention as `test_unmapped_crate_path_fails_fast`), and a new
`test_dockerfile_is_static_control_not_a_planner_abort` pins the new
decision by asserting the mode, the coverage mode, the empty bucket list
and the reason string.

Sabotage-tested: removing `"Dockerfile"` from the set turns the new test
red; restoring it returns 44/44 green.

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>

* refactor(cli): keep the rename flat; sever the app/ relocation to WS7 (WS6)

**Reverts the `crates/app/` family directory this branch created.** The
crate keeps its WS6 **rename** — `ironclaw_reborn_cli` -> `ironclaw_cli`,
package name `ironclaw` unchanged — at the flat path
`crates/ironclaw_cli`.

The defect was in the row, not in executing it. CHECKLIST WS6's CLI row
names `app/ironclaw_cli` as its rename target, and PROPOSAL §5's tree
confirms that destination — but family directories are WS7 (Wave 5), so a
Wave-4 row named a Wave-5 path. The row's own `[decision — severable]`
tag shows the authors knew a call was owed; it was never made, so
following the row literally does both halves at once. **Owner ruling
2026-08-04: Waves 0–4 close before anything touches Wave 5.** Severed.

This matters beyond tidiness: PLAN marks the WS10 nested-tree-safe gate
rewrites a hard prerequisite *before the first family `git mv`*, because
path-keyed gates fail **silently** under family directories rather than
loudly — #7083 (a coverage regex that blinded 11 crates the moment
`crates/extensions/` appeared) is the worked example. WS10 still has open
rows.

`crates/app/` was the **only** family directory this branch created;
`crates/extensions/` pre-exists on `main`.

**Recorded as a class, not an instance** (docs commit alongside): any
pre-WS7 row quoting PROPOSAL §5 inherits the same collision. The
established precedent is to land flat — WS1's three `contracts/ironclaw_*`
rows all say `contracts/` and all landed at `crates/ironclaw_*`; there is
no `crates/contracts/` directory. Two sibling rows carry the same defect
and are now flagged not-to-execute-as-written: WS3's
`lanes/ironclaw_sandbox` and WS4's `crates/lanes/wit/`.

**Also: the Reborn PR test planner could not classify a rename PR at all.**
`Detect Reborn test scope` failed the whole run — first on `Dockerfile`,
then on `clippy.toml` — and each fix surfaced the next, because
`reborn_pr_test_plan.py` fails closed on any unclassified path and had
never seen a diff of this shape. Fixed as a class:
- root workspace policy files decided: `clippy.toml`, `deny.toml`,
  `release-plz.toml` (beside the already-classified `Cargo.toml`);
- root scripts decided per-file as that set requires:
  `check_no_panics.py`, `dev_metrics.py`, `pre-commit-safety.sh`,
  `test-mutation-audit.sh`;
- prose/standalone trees ignored: `openwiki/` (generated wiki),
  `test-tools/`, `harness/` (standalone cargo project, own Cargo.lock);
- **`scripts/live_canary/`** added to the QA harness prefixes — the set
  listed only `scripts/live-canary/` and **both directories exist**,
  differing by hyphen-vs-underscore, so the underscore one fell through;
- files sitting directly in `crates/` (`crates/AGENTS.md`) classified as
  tree-wide prose — they belong to no package, so the crate arm raised;
- **paths removed by the diff** classified instead of fatal. This is the
  one that matters for the programme: renaming 11 crates puts ~600 deleted
  paths in the diff, none of which map to a package. Without it every WS6
  rename PR and every WS7 family move fails closed here.
- the shared-E2E-harness wall is kept but made *satisfiable*: a
  `DECIDED_E2E_HARNESS_PATHS` set records a decision. The guard's purpose
  is "changing a shared fixture must be deliberate"; as written it had no
  way to record a decision, so it blocked even a mechanical rename with no
  route forward. `tests/e2e/reborn_webui_harness.py` is decided (the E2E
  workflow owns it); everything else still raises, on both fail-closed
  arms.

Its self-test goes 43 -> 49. Two existing tests used as their *example* a
path this commit classifies; both keep their invariant with an undecided
fixture instead. **Sabotage-tested each new arm**: disabling the
removed-path arm, emptying the decided set, and disabling the `crates/`
prose arm each turn the suite red; restoring returns green. The prose arm
initially passed while sabotaged — it had no test — which is precisely the
green-while-checking-nothing shape, so a test was added and the sabotage
re-run to confirm it now fails.

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

* fix: repoint crate names reintroduced by the merge-down from main

`git merge origin/main` (fb776f3c62) was conflict-free — main's new work
touches files this branch had not edited — which is exactly the shape that
reintroduces stale crate names silently. 77 occurrences across 33 files,
found by re-scanning for every old name after the merge rather than
trusting the clean merge.

Dated historical prose under `docs/reborn/target-architecture/` is
deliberately excluded: those rows record what was true when they were
written, and rewriting them would misrepresent the record.

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>

* ci(test-plan): classify the whole repo-root metadata class, not one file per red run

`.gitattributes` is touched by this PR (the rename left its `wix/main.wxs`
rule pointing at `crates/ironclaw_reborn_cli/`, a path that no longer
exists), and the planner fails closed on unclassified paths — so it aborted
`Tests (Reborn)` with "unclassified pull-request path: .gitattributes".

Every entry already in this set was added the same way: a rename-shaped diff
touches root files a feature PR never touches, the planner dies on the first
one, and the next only appears after that one is fixed — Dockerfile, then
clippy.toml, then six more. Rather than add a ninth, this enumerates the
remaining class: all 19 unclassified root paths were found by driving the
planner over every tracked root file, and 17 are listed.

The two that are NOT listed are the point. Membership requires that no
Reborn test lane reads the file, checked per file against `crates/**/*.rs`
and `tests/**`. That check found real readers for `.dockerignore`
(`tests/dockerfile_runtime_home.rs`) and `.env.example` (`ironclaw_cli`,
`ironclaw_host_runtime`), so both stay fail-closed. Classifying a file a
test depends on would silently skip that test — worse than an aborted
planner.

Verified: planner self-test 48/48; every tracked root file except those two
now classifies; the full PR diff plans without error.

* fix(ci): repoint the test-scope classifier off the dead `ironclaw_reborn_*` glob

`Fast deterministic checks` failed on `test-classify-test-scope.sh`:

    FAIL reborn binary crate
    Expected: has_legacy_tests=false has_reborn_tests=true
    Actual:   has_legacy_tests=true  has_reborn_tests=false

`is_reborn_test_path` matched the CLI through `crates/ironclaw_reborn_*/*`.
The WS6 renames dropped that prefix from all seven crates that carried it, so
the glob now matches **nothing** and every one of them silently reclassified
as legacy. Enumerated the seven new names instead of re-globbing: they share
no prefix, and this is the second time a prefix glob has rotted here.

Fixed the classifier, not the fixture. The self-test's expectations describe
the intended behaviour; flipping them to match the break is how a gate goes
quiet.

**This class fails OPEN**, which is why only one crate's assertion caught it —
the classifier keeps answering, just wrongly. Added a guard asserting every
`crates/…` pattern in the classifier matches at least one real path, the same
shape as `sanctioned_paths_all_match_real_files`: an exemption may not outlive
the code it exempts. Two pre-existing dead arms
(`crates/ironclaw_extension_support/`, `crates/ironclaw_oauth/`) are listed
known-dead and shrink-only rather than repointed — both match nothing today,
so neither is load-bearing, and repointing them would change which tests those
crates select. That is a behaviour change, not this PR's business.

Swept the siblings: every `crates/<name>` literal and glob stem across
`scripts/`, `.github/`, and the architecture tests was checked against the
real tree. The only dead reference attributable to the 13 WS6 renames is the
one fixed here; the rest are synthetic self-test fixtures or crates deleted
long before this branch.

Sabotage-tested both, confirming red with the RIGHT message and green after
restore: (1) restoring the dead glob reproduces `FAIL reborn binary crate`;
(2) adding `crates/ironclaw_totally_invented/*` trips the new guard with
`classifier pattern matches no real path`.

Also recorded the ALLOWLIST union recount in the constant's own doc comment:
this branch carried 129, `main` 125, and the merge inherited 125 without
measuring. Recounted off the compiler (constant → 0, read `ALLOWLIST grew to
125 entries`): 125 is the live count with zero slack (#7147).

* 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>

* fix(ci): restore the composition-budget negative case the rename collapsed

T4 asserts the budget gate fails LOUDLY when the composition crate is absent.
It builds a fixture under the crate's real name and renames it away so the
gate cannot find it. The destination was hard-coded `ironclaw_composition` —
which is exactly what the WS6 rename turned the crate's real name into, so
both sides of the `mv` became the same path.

`mv X X` does not rename; it tries to nest a directory inside itself and dies
with "Invalid argument". The negative case stopped running.

Renamed the destination to `composition_renamed_away` — deliberately
synthetic, so no future crate rename can collide with it again — and wrote the
reason into the test.

Found by running the nine `Static-check self-tests` scripts that CI never
reached: that step stops at the first failure, so fixing the classifier only
uncovered what was behind it. Ran all of them, plus the nine skipped steps
after it, rather than discovering them one CI cycle at a time. This was the
only other failure; the other seventeen checks pass.

Sabotage-tested: skipping the rename (so the crate is present) makes T4 fail
with `expected exit 1, got 0` and the missing-message assertion — 49 passed,
2 failed. Restored: 51 passed, 0 failed. The case genuinely exercises the
absence again rather than passing because it never ran.

* 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>

* style: cargo fmt after the #7155/#7062 merge

`Check formatting` (step 6 of Fast deterministic checks) went red on
cca5884b47: the merge was pushed under time pressure without running fmt.

Only the two files whose crate references I rewrote by hand are affected —
`ironclaw_reborn_composition` -> `ironclaw_composition` is 9 characters
shorter, so call sites that were wrapped at the old width now fit on one line.
No semantic change.

* 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>

* WS2: clear the extension_host->product vocabulary residue (ports 4->1, ledger 9->5)

Three of the four frozen ports and four of the nine reference-ledger rows fall
by one move: the port-facing vocabulary is declared where it already lives, and
product maps at its boundary.

- `ExternalActorBindingEpoch` moves `ironclaw_conversations` ->
  `ironclaw_extension_contracts::external`, beside the `ExternalActorRef` whose
  binding it versions. Zero new crate edges (conversations already depends on
  extension_contracts). Its constructor error becomes
  `ProductAdapterError::InvalidIdentifier`, matching its siblings in that module
  byte-for-byte on the three validation rules.
- `ProductActorUserResolver` + `ProductActorUserResolutionRequest` +
  `ResolvedProductActorUser` invert into
  `ironclaw_product_contracts::actor_identity`, error swapped to
  `ProductOperationFailure` (product absorbs it with the existing total `From`,
  discriminants preserved).
- `AuthChallengeProvider`, `BlockedAuthFlowCanceller`, `AuthChallengeView`,
  `PairingAuthChallengeView` and `auth_prompt_view_for_blocked_auth` move to
  `ironclaw_auth::product_prompt`; `ChannelConnectionService` and
  `ChannelAuthAccountState` to `ironclaw_auth::channel_connection`, beside
  `project_auth_account_state` whose argument pair the latter is. Zero
  vocabulary narrowing. `ironclaw_auth` gains a `product_contracts` dependency
  (substrates -> contracts, the same downward edge and rationale
  `ironclaw_attachments` already carries).
- `ExtensionAccountSetupRegistry` stays product-owned state; extension_host now
  holds the two-method read port `ExtensionAccountSetupReader` declared in
  `product_contracts::account_setup`. `None` == empty registry.
- The approval-prompt projection, gate-ref parse and lookup scope move to
  `ironclaw_product_contracts::approval_prompt`, collapsing product's two copies
  and letting the extension host read the approval store itself instead of
  reaching up into `ironclaw_product::projection`. The scope derivation's
  equivalence with `ApprovalInteractionScope` is pinned in product.

Gate updated in the same change: residue 4 -> 1, baseline 4 -> 1, ledger 9 -> 5,
workflow-error residue 2 -> 1, `ProductActorUserResolver` added to
`INVERTED_PORT_IMPLEMENTORS`.

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

* WS2.5: gate + CHECKLIST reconciliation, and two pre-existing clippy reds

- `reborn_extension_host_port_inversion.rs`: `channel_host.rs`'s ledger reason
  loses its stale `ProductActorUserResolver` half (that port is inverted now).
- `reborn_extension_specificity.rs`: the moved `ChannelConnectionService` doc
  carried a `slack` example into `ironclaw_auth`. Reworded generically rather
  than carved, which also made the product entry stale — deleted, allowlist
  baseline 123 -> 122. The gate reported both directions; neither was allowlisted.
- Two clippy reds that pre-exist on this base and bite a `-D warnings` bar: an
  empty line splitting a doc-comment run in the specificity gate, and a
  never-used negative-control fixture in `ironclaw_extension_support`. The
  fixture is `#[allow(dead_code)]`-ed rather than deleted, with the reason.
- CHECKLIST WS2 re-layer row, blockers half: dated and measured annotation of
  what fell, why the "narrow the vocabulary out" framing was only half right,
  and that §12.11 D-A's factory port is unstarted.

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

* WS2: invert channel_host's product-stack construction behind the D-A factory port

§12.11 D-A's factory port, built. `ChannelWorkflowFactory` is declared in
`ironclaw_product_contracts::channel_workflow`, implemented by
`ironclaw_product::RebornChannelWorkflowFactory`, and injected through the
`GenericChannelHostDeps` bundle composition already builds — so
`channel_host.rs` states the shape of the per-extension product cone and
consumes the result instead of inline-constructing product's concrete stack.

`channel_triggered_delivery.rs` sheds through the same seam, but its port
could not live in contracts: it drives the driver with
`TriggerCommunicationContext`, which `ironclaw_outbound` owns and a contracts
crate may not name. So `TriggeredRunDelivery` and `TriggeredRunDeliveryRequest`
are declared in `ironclaw_outbound` beside that vocabulary — the same placement
rule WS2.5 applied to the auth ports, and zero new crate edges. Composition
builds one driver per codec-bearing binding through the same factory; routing
policy stays in the host.

The conversations wrinkle resolved as sanctioned, with no mirror type.
`RebornFilesystemConversationServices` is constructed, consumed and dropped
inside product's factory. What crosses the port is `ChannelWorkflowStorageRoots`
(a `VirtualPath` pair — placement is host policy) in, and the surface, the
binding resolver and the run-delivery observer out.

The last residue port had to be renamed, not just moved:
`ConversationBindingService` is now `ironclaw_product_contracts::binding::
ProductBindingResolver`, because `ironclaw_conversations` already defines a
trait by the old name and §11.2.4's one-home rule refuses two definitions of a
contracts name. The boundary error grew `BindingRequired`,
`UnknownInstallation` and `TurnSubmissionRejected` rather than weakening: all
three are constructed by the port's implementor, `BindingRequired` is what an
unpaired external actor is told, and every one carries `String`/nothing so the
contracts ceiling is untouched.

Gates:
  EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT  5 -> 3
  EXTENSION_HOST_PRODUCT_REFERENCE_FILE_BASELINE        5 -> 3
  PRODUCT_DEFINED_TRAITS_EXTENSION_HOST_STILL_IMPLEMENTS 1 -> 0
  WS2_PRODUCT_DEFINED_TRAIT_RESIDUE_BASELINE            1 -> 0
  EXTENSION_HOST_FILES_STILL_NAMING_THE_WORKFLOW_ERROR  1 -> 0

`the_extension_host_manifest_names_product_only_while_a_residue_needs_it` is
re-keyed on the trait residue OR the reference ledger. That is a correction,
not a relaxation: keyed on the trait list alone it would now demand the
manifest edge be deleted while three adapter-registry rows still name the
crate — failing a correct tree and passing an impossible one. Both directions
stay enforced against the union.

Regression coverage: the ingress/delivery/trigger integration suites are
unchanged in behaviour and green; the only edits to them are import repoints
for the renamed port. `unknown_manifest_command_fails_generic_graph_assembly`
still pins that an undeclarable command fails the whole graph build.

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

* WS2 flip: extension_host products -> loops — manifest edge deleted, ledger/residue 0/0, DowngradePin armed

The batch-2 union (via #7181) and the D-A factory port each discharged
exactly the rows the other left, so the port-inversion biconditional
demanded the flip: layer line + manifest edge in one change. Same-layer
inventory 74 -> 72 net (+1 loops edge extension_host->loop_host, -3
products rows), pin frozen at the four normal-dep consumers. Two typed
ExtensionId seams reconciled between batch-2 and the D-A branch.

* fix(arch): equality-assert the zeroed reference ledger; fmt

* review(7181): architecture-gate hardening from CodeRabbit round 1

Three armed gates were reporting on shapes they could not actually see.

- `reborn_composition_boundaries.rs`: the consumer-annotation scan walked
  back over the attribute block by line prefix, so a multiline
  `#[cfg(any(...))]` between the annotation and the `pub use` stopped the
  walk and rejected a correctly annotated re-export. The walk is now
  bracket-aware and extracted into `pub_use_consumer_annotations` so it is
  testable on synthetic input; the new fixture covers the multiline shape,
  the single-line shape, a bracketed comment, and the unannotated
  sabotage case.
- `reborn_dependency_boundaries.rs`: the MCP/sandbox lane-existence probes
  searched raw concatenated source, so a comment, doc example, string
  literal, or `#[cfg(test)]` fixture naming `McpRuntime<C>` would have kept
  them green after the production runtime was gone. They now scan
  production tokens only (`production_rust_files` +
  `strip_comments_and_strings`), with a regression fixture that plants the
  marker in each of those non-production forms.
- `ironclaw_webui/tests/handlers_module_charter.rs`: `top_level_items`
  stripped only `pub `/`pub(crate) `, so a `pub(super)`/`pub(in ...)` item
  was silently excluded from `charted_surface()` and therefore never
  registered as unassigned. `strip_visibility` now handles every
  visibility form.
- `ironclaw_auth/tests/module_charter.rs`: the two-engine severance scan
  dropped only lines beginning `//`, so a block comment, a trailing
  comment, or a string literal naming the other engine reached the probes
  and a documentation edit could fail the charter gate. A lexical stripper
  replaces the prefix filter, with fixtures for each shape plus a
  must-still-be-seen `use` case.
- `reborn_extension_host_port_inversion.rs`: the reference-ledger history
  still described a 9 -> 5 reduction with five survivors; the live ledger
  has two rows and the baseline is 2. Corrected to the actual 9 -> 5 -> 2.

Every strengthened scanner was sabotage-tested (broken, watched fail,
restored). `cargo test -p ironclaw_architecture` is green across all 37
binaries with no new violations surfaced.

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

* review(7181): MCP lane — arm the charter's failure-string rule, close its exceptions

The crate charter's load-bearing clause — "no module builds a failure
string of its own" — was stated in three files and enforced in none, and
the crate carried live exceptions.

- `egress.rs` minted `"runtime_http_egress_panicked"` inline and forwarded
  `stable_runtime_reason()` verbatim into `McpClientError`. Both are now
  `diagnostics::McpEgressCause` variants named through `egress_failure`.
- `impl From<String> for McpClientError` was the implicit bypass: any `?`
  in the crate could turn an arbitrary String into a model-visible
  reason. It had exactly one user (`client.rs`'s credential-injection
  check, whose reason already came from `diagnostics`), now an explicit
  `map_err(McpClientError::client)`. The impl is deleted.
- `diagnostics.rs` claimed "every reason is capped here" but appended the
  server-supplied `JsonRpcError.message` verbatim. The only production
  producer bounds it upstream, but the cap is this module's invariant,
  not the caller's, so it now goes through `bound_mcp_reason_detail`.
- New `tests/module_charter.rs` arms the rule: a new `reason: "..."` /
  `reason: format!(...)` outside `diagnostics.rs` fails, a re-added
  `From<String>` fails, and the charter text in `lib.rs` + `CLAUDE.md`
  must keep naming the rule and its gate. The rule's one remaining
  carve-out — `runtime.rs`'s two `McpError` descriptor/invocation reasons,
  which echo manifest ids rather than classify a failure — is an
  enumerated list, not a wildcard, and both docs now say so.

Also in `runtime.rs`: the `transport == "stdio"` process-count branch is
unreachable (`prepare_client_request` rejects stdio and everything that
is not http/sse before it), so it is replaced by a comment saying why no
process accounting happens here; and `release_after_failure`'s discarded
`Result` gets the required `// silent-ok:` annotation plus a `debug!` so a
leaked reservation leaves a trace without masking the caller-facing error.

Sabotage-tested: re-inlining the egress reason makes the new gate fail
with 3 inline reasons instead of the 2 grandfathered rows.

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

* review(7181): type the channel-connection port, and fix the trace-prune lock

Two Major findings with real failure modes.

**Typed channel identifier at the `ChannelConnectionService` boundary.**
The port exchanged channel package ids as `String` map keys and a `&str`
disconnect argument, so a malformed or non-canonical id could become a
key no lookup would ever match — a channel that silently reads as "not
connected" instead of failing. The sibling map on the very same product
call (`installed_activation_errors`) was already keyed by `ExtensionId`,
so the untyped half was the odd one out. All three signatures now use
`ironclaw_host_api::ids::ExtensionId`; the generic service applies the
same skip-invalid-vocabulary rule its own discovery walk already used,
and `extension_info` resolves the id once for all three lookups.

**`std::sync::Mutex` held across filesystem I/O in the trace prune step.**
`trace_scope_has_pending_queue` is a synchronous `read_dir` per scope and
was called from inside `observed_scopes.retain`, under the guard, on the
runtime worker thread — while `record_observed_scope` takes the same lock
from the capture path, so a stalled filesystem blocked capture-time scope
recording. The probe now runs on the blocking pool against a snapshot and
the guard is re-acquired only to apply the result, which also leaves
scopes recorded mid-probe alone. (This pattern predates the WS6 move —
it was introduced 2026-06-15 in 410db7720 and relocated verbatim by this
batch — but it is contained enough to fix here.)

**Fire-access unavailable-precedence coverage.** New WS6 policy code
decided what a transient backend fault becomes (retryable `Err` when the
final answer is a denial, but never over a grant) with no test driving a
failing checker at all. Added, test-first: breaking the precedence branch
makes it fail with `Denied` where `Unavailable` is required. Also pins the
last-position fault, which the other two cases never reach.

**Product-adapter section invariants.** `DuplicateCredentialHandle`,
`DuplicateEgressTarget`, and the RFC 7230 token rule (including
`auth.timestamp_header_name`, the optional field a rename could quietly
drop from validation) came over from `ironclaw_product::adapter_registry`
with WS5 and had no assertion anywhere. Covered through the real
deserialize + resolve + validate path.

**Smaller items.** The relocated trigger-fire contract no longer keeps a
second import path through composition (`runtime_input`'s `pub use` and
the four names in the lib.rs surface are gone, consumers repointed at
`ironclaw_triggers`, snapshot recaptured); `repository_contract.rs` uses
`var_os` for presence so a non-UTF-8 `IRONCLAW_REQUIRE_POSTGRES` cannot
silently disarm the parity guard, with a regression fixture;
`ironclaw_reborn_identity`'s stale `Self::bind` rustdoc link, the
`ironclaw_auth` AGENTS.md `loopback_oauth` contradiction, and the
`ironclaw_extension_contracts` charter row missing
`ExternalActorBindingEpoch` are corrected.

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

* Re-arm the union's ratchets: recount, swap one same-layer edge, repoint a coverage exemption

Three gates fired on the merged tree; each is fixed by measurement, not by
lowering a bar.

**Extension-specificity baseline: 125 (ours) / 122 (batch) -> 122.** Neither
side's number is evidence for the union, so the constant was set to `0` and the
true length read out of the ratchet's own panic. The batch's three vendor-pair
removals are the only entries either side removed and this branch's renames
repoint entries in place without adding any, so the union is the batch's number.

**SAME_LAYER_EDGE_BASELINE stays 72 -- one row moved, the count did not.** The
gate found both halves by itself: `triggers -> safety` tripped the
not-inventoried arm and `conversations -> safety` tripped the stale-row arm.
They are the two sides of one swap -- the trusted-trigger prompt scan moved
behind the seam into `TrustedTriggerSubmitRequest::new`, so the edge changed
crate rather than appeared. This merge is the first tree where both halves
exist, which is why nothing had inventoried it before. The equality is what made
the second half loud: under a `<=` ratchet the stale row would have sat green as
one entry of slack.

**changed-coverage exemption #113 repointed 1276 -> 975.** Inherited red, not
caused here: reproduced on a pristine `git archive` of `ws2/da-factory-port`
with the same message. The extension_host products -> loops flip shrank
`channel_host.rs` from 1405 to 1098 lines and left the exemption past EOF.
Repointed to the same construct rather than deleted -- `observe_error`'s `error`
parameter is the only `product_adapter_error::ProductAdapterError` in the file,
so the exemption still names exactly what it always named.

* ci(test-plan): classify the two path classes a rename PR reaches and the planner did not

`Detect Reborn test scope` aborts on the first path no rule claims, and the
nine steps after it are then skipped — so the set gets discovered one CI red at
a time. Both gaps below are the shape #7152 already records for `Dockerfile`
and `clippy.toml`: fail-closed with no rule, surfaced only because a rename
diff touches files a feature PR never touches.

Found as a class rather than one-per-red-run: `build_plan` was driven over all
1,250 paths in this PR's diff with `cargo metadata` resolved once. Two came
back unclassified; after the fix the sweep reports **0**, and the planner
produces a real plan for the actual diff.

- **`openwiki/**`** — the auto-generated wiki, regenerated by
  `openwiki-update.yml` and explicitly not hand-edited. No build or test
  surface, so it joins `docs/` in `IGNORED_PREFIXES`. A crate rename touches it
  by construction: its prose names crate directories.
- **`scripts/live_canary/**`** (UNDERSCORE) — a *second* real directory beside
  the already-classified `scripts/live-canary/` (hyphen), differing only by
  that character. It is the canary's importable Python package; the rename
  reaches it through a `RUST_LOG` string naming a crate. The ⚠ note about the
  two directories is restored beside the constant.

Both are pinned: the wiki test asserts the plan is *equal* to a `docs/` plan
(so a later change that escalates it to a lane fails here too) and that a real
change riding along still selects its lane; the canary paths join the existing
QA-harness subTest list.

* fix(ci): repoint changed-coverage exemption #113 past the flip's channel_host shrink (1276 -> 975)

* test(triggers): hold the workspace env mutex across the non-UTF-8 presence fixture

The hermetic env-mutation guard rejects raw set_var/remove_var without
lock_env(); the fixture now holds the guard across both mutations.

* ci(test-plan): classify the #7215 knowledge-graph paths the refresh's stricter planner enumerates

Main's #7215 committed .codebase-memory/ and scripts/codebase-graph.sh
with matching planner rules; this branch's evolved planner kept its own
rule set through the merge and lost those two. Ported both, with the
same rationale comments. Self-tests 59/59.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:39:19 +00:00
Illia Polosukhin
a22631b252 ci(gate): run the pre-push test suite with nextest when available (#7169)
* ci(gate): run the pre-push test suite with nextest when it is available

`cargo test` runs each test binary strictly sequentially — it parallelises
within a binary but never across them. This workspace builds well over 400
test binaries and the large majority finish in under a second, so the
pre-push gate spends most of its wall clock starting and tearing down
processes one at a time rather than doing work. `cargo nextest` runs them
in a single parallel pool.

Coverage is unchanged. `--all-targets` expands to
`--lib --bins --tests --benches --examples`, so it has never included
doctests, and nextest does not run them either. This changes scheduling
only, not what executes — worth stating explicitly because swapping a
test runner is exactly the kind of change that silently drops a target
class.

nextest stays optional. `tests/fixtures/llm_traces/README.md` documents
that local development must keep working without it, so an absent binary
falls back to `cargo test` instead of failing. `.config/nextest.toml`
already carries the repository's profiles and per-test slow-timeouts —
CI's insta gate uses them — so the parallel path inherits the timeout
policy that is already tuned here rather than inventing one.

`IRONCLAW_GATE_TEST_RUNNER` selects explicitly: `auto` (default),
`nextest` (require it), or `cargo` (force sequential, for bisecting a
suspected parallelism-order failure).

Scope: `scripts/ci/quality_gate.sh` has exactly one caller, the pre-push
hook. No CI workflow invokes it, so this cannot change what CI runs.

Adds `scripts/ci/test-quality-gate-runner.sh`, which stubs every cargo
invocation and asserts each branch. It replaces PATH rather than
prepending, because a developer's real ~/.cargo/bin/cargo-nextest would
otherwise satisfy the absence case and test the wrong branch. Verified to
fail when the fallback is removed.

* fix(ci): address reviewer feedback — enforce runner tests (#7169)

* test(ci): address coderabbitai review — cover runner matrix (#7169)

---------

Co-authored-by: serrrfirat <f@nuff.tech>
2026-08-05 10:07:32 +00:00
firat.sertgoz
ee2d90f8e1 chore(agents): share codebase knowledge graph (#7215)
* chore(agents): share codebase knowledge graph

* fix(agents): address graph review feedback

* test(ci): assert all graph script lanes stay empty

* ci: refresh codebase graph nightly
2026-08-05 10:03:15 +00:00
Benjamin Kurrek
57c685f6b8 Waves 0–4 batch 2: register to zero, adapter-registry move, ruled decisions (accumulating the fleet) (#7181)
* 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>

* fix(arch): repair the base-inherited clippy break in the specificity ratchet's doc block

Not one of the WS6 module-charter clauses — a base repair this stack needs
before its own gate can run.

`WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE`'s doc block accumulated four
dated recount notes across the Waves 0-4 batch merges, and one of the joins
left a bare blank line between two `///` runs documenting the same constant.
`clippy::empty_line_after_doc_comments` rejects that, so
`cargo clippy --all --tests --examples --all-features -- -D warnings` fails on
`crates/ironclaw_architecture/tests/reborn_extension_specificity.rs`.

Why it is invisible on the batch's own PR checks: `.github/workflows/
code_style.yml` lints `--lib --bins` on `pull_request` and only runs the
`--all --tests --examples` sweep on `push`. Test targets are therefore
unlinted until the merge queue, where this would have gone red for everything
stacked on the batch. Reproduced on the untouched base `89080c5160` by
stashing this branch's work.

The fix is the blank line only — `///` restored so the two runs are one doc
block. Two adjacent merge artifacts in the same block are recorded rather than
edited, because repairing them is editorial rather than mechanical: two
paragraphs end `...was 124).///` and `...optional.///`, where a following
note's `///` marker was glued to the previous line instead of starting one.

Verification: `cargo clippy -p ironclaw_architecture --all-features
--all-targets -- -D warnings` clean (was: 1 error); `cargo test -p
ironclaw_architecture --all-features` 259 passed / 0 failed; `cargo fmt
--check` clean.

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

* docs(arch): execute the three ruled WS4/WS6 decision rows

Two ADRs, one regression guard, one public-API deletion, and the
doc-truth corrections each ruling forced.

1. triggers SQL — ADR 0003, keep. The claim/lease semantics are not
   expressible on the RootFilesystem fabric: a five-predicate
   single-statement CAS inside BEGIN IMMEDIATE, SELECT ... FOR UPDATE,
   and one transaction spanning trigger_records + trigger_run_history
   with per-column ON CONFLICT precedence. Both backends also ship by
   profile, and converging would additionally have to delete
   ironclaw_filesystem from the crate's forbidden boundary list.
   Parity is enforced by a 51-test/31-shared-helper conformance suite —
   but all five Postgres skip paths returned None silently, so the
   Postgres half could skip-pass on a Docker-less runner. The suite now
   honours IRONCLAW_REQUIRE_POSTGRES=1 (sabotage-tested).

2. hooks SQL — ADR 0004, keep, on corrected reasoning. The row's stated
   premise was false: composition hard-codes InMemoryPredicateStateBackend
   and neither durable backend is wired at all. They are kept as staged
   work because in-memory replay dedup is process-local and cannot
   defend multi-host rate caps, the parity matrix proves the backends
   interchangeable, and the swap is one line.

   Closes #6945: poisoned_hook_slot_does_not_leak_into_the_next_run
   extends tests/integration/hooks.rs and pins that a poisoned hook slot
   does not survive into the next run. Verified red-able by pointing
   ironclaw_runner::runtime at the legacy shared-dispatcher adapter
   (1 fire instead of 2); sabotage reverted. Predicate counter state is
   deliberately not asserted isolated — it is tenant-scoped by design.

3. identity — the user_identity absorption is refuted (independently
   first by #7152, re-derived here and in agreement): the ports' sole
   implementor is extension_host, so moving them would add a new edge to
   let a crate name a port it implements. The "dual binding-store" is two
   disjoint concerns — principal identity vs post-OAuth channel binding —
   now stated in both charters. What was genuinely duplicated is deleted:
   ExternalIdentityKey + RebornIdentityResolver::{lookup,bind} had zero
   production callers. Un-masking roster 39 -> 34, exactly the five tests
   that drove the removed methods; three other tests repointed onto
   resolve_or_create rather than dropped.

   Closes #5618. Closes #5615.

Rulings recorded as PROPOSAL §12.12 D-L/D-M/D-N; CHECKLIST rows ticked
with measurements; families/domains.md, explorer.html, hooks CLAUDE.md,
identity CONTRACT.md and the driver-allowlist gate all repointed at the
ADRs.

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

* refactor(mcp): split the single-file lane into seven chartered modules (WS6, §6.6.3)

`crates/ironclaw_mcp/src/lib.rs` was **2,767 lines** — PROPOSAL §6.6.3 records
2,709 (measured 2026-07-31), so the figure had drifted +58 and this is the
third recorded value for one file. WS6's module-charter row and §6.6.3 both
call for splitting it.

It becomes **seven private modules** plus a 61-line `lib.rs` that is the
charter table and the re-export list and nothing else:

| Module | Owns |
|---|---|
| `contract` | The vocabulary a caller names: config, DTOs, `McpClient`/`McpExecutor`, the `McpError`/`McpClientError` taxonomy |
| `runtime` | Reserve -> call -> reconcile/release, descriptor admission, the manifest credential context |
| `client` | The Streamable-HTTP `McpClient`: handshake, per-invocation session lifecycle, the `tools/list` paging loop |
| `jsonrpc` | The JSON-RPC 2.0 codec and response hygiene: framing, id matching, session-id/protocol-version validation, auth challenge, per-method credential routing |
| `discovery` | `tools/list` catalog admission: ceilings, per-tool classification, schema bounds, tool-name grammar |
| `egress` | The `McpHostHttp` port and the host-owned egress plan/planner |
| `diagnostics` | Every stable, bounded failure token the lane surfaces |

Two rules in the charter are load-bearing rather than decorative, because the
code already depended on both and neither was checkable while it was one file:

- **No module builds a failure string of its own.** Every reason comes from
  `diagnostics`' three cause enums, so the model-visible token set stays
  enumerable in one 209-line file. `diagnostics` is now the only module with
  no crate-internal dependency, which is what makes that verifiable.
- **`discovery` owns the catalog rules, `client` owns the paging loop.** The
  three ceilings live in `discovery` and the loop reads them — the
  drift-proofing `MAX_DISCOVERED_MCP_TOOLS`' own doc comment already claimed
  but could not enforce with both enforcement points in one file.

## No API change, no consumer edits

The submodules are private and `lib.rs` glob-free `pub use`s them, so
`ironclaw_mcp::X` remains the single import path for all consumers
(`ironclaw_host_runtime`, `ironclaw_extension_host`, the integration harness).
Zero files changed outside `ironclaw_mcp` except one architecture test and the
two target-architecture docs. Items that newly cross a module line were
widened to `pub(crate)` — never to `pub`.

## The waiver is deleted, not carried forward

`lib.rs:1` carried `// arch-exempt: large_file, ... pending the adapter module
split, plan #4088` — the split this commit is. No replacement was added:
largest file is now 658 lines (`jsonrpc.rs`), clearing the 1,500-line
ARCH-SPRAWL threshold `scripts/pre-commit-safety.sh` enforces with `exit 1`.

## A gate would have gone silently green

`reborn_dependency_boundaries.rs:1124` read `crates/ironclaw_mcp/src/lib.rs`
**as one string** and scanned it for forbidden dispatcher-composition surface.
After the split that file is 61 lines of `pub use`, so the scan would have
found nothing and passed for the wrong reason. Repointed to
`concatenated_crate_sources(crates/ironclaw_mcp/src)` with a non-vacuity
assertion — the identical shape the `ironclaw_sandbox` lane three lines above
already carries, from WS3 hitting this exact trap. Two lanes for two: any gate
naming a single `lib.rs` is a landmine for the crate it guards.

## Two placement calls (delegated authority)

`McpAuthContext` and `PreparedMcpClientRequest` are **not** in `contract`
despite being vocabulary by shape: both are constructed and consumed entirely
inside `runtime` and name no public type, so `contract` would have become the
owner of the runtime's private plumbing. `requires_host_http_egress` is in
`egress`, not `contract`, because it is a transport predicate consumed by both
`client` and `runtime` — charging it to either would have made one depend on
the other.

## Verification (measured, not asserted)

| Check | Result |
|---|---|
| Top-level item roster, name+kind | **105 -> 105**, zero added, zero removed |
| Items declared `pub` | **21 -> 21** (public surface unchanged) |
| Visibility widenings | 28 `priv` -> `pub(crate)`; **0** `priv` -> `pub` |
| Unfiltered `cargo test -p ironclaw_mcp --all-features -- --list` | **75 -> 75**; leaf-name diff empty |
| `cargo test -p ironclaw_mcp --all-features` | 32 lib + 38 + 5 integration pass |
| `cargo test -p ironclaw_architecture --all-features` | 259 passed / 0 failed |
| `cargo test -p ironclaw_host_runtime` | 1097 passed / 0 failed |
| `cargo test -p ironclaw_extension_host` | 386 passed / 0 failed |
| `cargo clippy -p ironclaw_mcp -p ironclaw_architecture --all-features --all-targets -- -D warnings` | clean |
| `cargo fmt --check` | clean |

Test paths moved from `tests::<name>` to `<owner>::tests::<name>`; the **leaf
names are byte-identical** and were diffed as such. The 32 lib tests bucket to
the owner they exercise (discovery 15, jsonrpc 13, diagnostics 2, client 1,
runtime 1). No test helper crossed an owner, so no shared test-support module
was needed.

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

* WS2: clear the extension_host->product vocabulary residue (ports 4->1, ledger 9->5)

Three of the four frozen ports and four of the nine reference-ledger rows fall
by one move: the port-facing vocabulary is declared where it already lives, and
product maps at its boundary.

- `ExternalActorBindingEpoch` moves `ironclaw_conversations` ->
  `ironclaw_extension_contracts::external`, beside the `ExternalActorRef` whose
  binding it versions. Zero new crate edges (conversations already depends on
  extension_contracts). Its constructor error becomes
  `ProductAdapterError::InvalidIdentifier`, matching its siblings in that module
  byte-for-byte on the three validation rules.
- `ProductActorUserResolver` + `ProductActorUserResolutionRequest` +
  `ResolvedProductActorUser` invert into
  `ironclaw_product_contracts::actor_identity`, error swapped to
  `ProductOperationFailure` (product absorbs it with the existing total `From`,
  discriminants preserved).
- `AuthChallengeProvider`, `BlockedAuthFlowCanceller`, `AuthChallengeView`,
  `PairingAuthChallengeView` and `auth_prompt_view_for_blocked_auth` move to
  `ironclaw_auth::product_prompt`; `ChannelConnectionService` and
  `ChannelAuthAccountState` to `ironclaw_auth::channel_connection`, beside
  `project_auth_account_state` whose argument pair the latter is. Zero
  vocabulary narrowing. `ironclaw_auth` gains a `product_contracts` dependency
  (substrates -> contracts, the same downward edge and rationale
  `ironclaw_attachments` already carries).
- `ExtensionAccountSetupRegistry` stays product-owned state; extension_host now
  holds the two-method read port `ExtensionAccountSetupReader` declared in
  `product_contracts::account_setup`. `None` == empty registry.
- The approval-prompt projection, gate-ref parse and lookup scope move to
  `ironclaw_product_contracts::approval_prompt`, collapsing product's two copies
  and letting the extension host read the approval store itself instead of
  reaching up into `ironclaw_product::projection`. The scope derivation's
  equivalence with `ApprovalInteractionScope` is pinned in product.

Gate updated in the same change: residue 4 -> 1, baseline 4 -> 1, ledger 9 -> 5,
workflow-error residue 2 -> 1, `ProductActorUserResolver` added to
`INVERTED_PORT_IMPLEMENTORS`.

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

* refactor(auth): charter the two engines and enforce their severance (WS6, §6.4.8)

§6.4.8 asks for the "internal two-engine split (engine vs product_auth)" to
become "two chartered top-level modules". Measured on the base: both modules
already exist as top-level modules, and **neither names the other — zero
references in both directions**. The split was never structural. What was
missing is the charter, and a severance nobody checks is an observation that
lapses on the next PR.

## Two owners were not enough, measured

Charting only the two engines leaves the crate's **11 shared top-level
modules** unowned. Counted symbol-by-symbol — the right instrument, because
both engines import through the crate root's flat `pub use` list, so counting
`crate::<module>::` paths reads zero and is silently wrong — **6 of the 11 are
named by BOTH engines** (`credential`, `provider`, `oauth`, `scope`, `ids`,
`error`). Charging those to either engine would make one engine the owner of
the other's dependencies.

So the map has **four** owners, not two: `engine`, `product-auth`,
`vocabulary` (what both engines stand on and neither owns), and
`test-support`. This is the same refutation §6.4.13's five-sub-owner claim met
in the `llm` map, arriving independently.

## What landed

- **A charter in each engine's `mod.rs`** — owns / never-contains, plus the
  severance invariant and where the two engines are allowed to meet.
- **`crates/ironclaw_auth/CLAUDE.md` gains an enforced `## Sub-owner map`**
  covering all 43 `src/**/*.rs` files across the four owners, with three
  placement calls stated.
- **`crates/ironclaw_auth/tests/module_charter.rs`** — coverage (every file
  exactly one owner, every charted path exists, no double claims) **and** the
  severance pin (`engine` must not name `product_auth`, and the reverse).

## Three placement calls (delegated authority)

- **`account_state.rs` -> `engine`**, not `vocabulary`, despite sitting at the
  crate root: `AuthAccountState` is named by `engine/` and by zero files in
  `product_auth/`, and `engine/mod.rs`'s doc already claimed the state machine.
- **`cleanup.rs`/`domain.rs`/`flow.rs`/`interaction.rs` -> `product-auth`** on
  the same measured test. They are the four files a later slice could `git mv`
  into `product_auth/`; the map says so, and names the blocker — `domain.rs`
  needs a rename first, because `product_auth/durable/domain.rs` exists.
- **`credential.rs` is the one genuinely two-owner file** (18 of 25 symbols
  `product_auth`-only, 6 named by both, including `CredentialAccountService`
  and `ProviderBackedCredentialAccountService`, which `engine/keepalive.rs`
  drives for the refresh sweep). Charged to `vocabulary` — the shared half is
  what makes it un-movable — with the service split recorded as owed work.

## Three other §6.4.8 clauses were already discharged

Struck in the docs rather than left to be re-attempted: `loopback_oauth` and
its `urlencoding` dep are **gone** (both `CLAUDE.md` and `AGENTS.md` still
described it as a live "temporary exception" — corrected); `fakes.rs` **is**
gated behind `test-support` (`lib.rs:21-22`); and `ironclaw_turns` appears
nowhere in `crates/ironclaw_auth/Cargo.toml`.

## Verification (measured, not asserted)

This clause moved **no production code**, and says so rather than dressing a
charter up as a move:

| Check | Result |
|---|---|
| Top-level item roster | **609 -> 609**, byte-identical **including visibility** (zero widenings) |
| Unfiltered `cargo test -p ironclaw_auth --all-features -- --list` | **288 -> 291**; the +3 are exactly the new gate, no pre-existing test renamed/moved/removed |
| `cargo test -p ironclaw_auth --all-features` | 291 passed / 0 failed |
| `cargo clippy -p ironclaw_auth --all-features --all-targets -- -D warnings` | clean |
| `cargo fmt --check` | clean |

**Sabotage-proved in five directions**, each restored green: drop a file from
the map -> "1 source file(s) have no sub-owner"; add a phantom path -> "no
longer exists"; claim a file twice -> "claimed by more than one";
`use crate::product_auth::...` inside `engine/` -> severance failure naming the
probe; `use crate::engine::...` inside `product_auth/` -> the mirror. The gate
self-guards against going vacuous in four ways (zero parsed rows, implausibly
few walked files, a missing engine directory, a module concatenating to
implausibly little code). The severance scan strips comment lines, because both
charters deliberately name the other engine in prose and a scan counting those
would be unsatisfiable by construction.

## Coordination note

The `ChannelAuthAccountState` family is declared in `ironclaw_product`
(`reborn_services.rs:677`), not in `ironclaw_auth` — this clause touches none
of its files, so there is no collision with the sibling relocating it. If that
relocation lands a new file under `crates/ironclaw_auth/src/`, the coverage
gate fails until it is given a row. That is by design, and the failure message
states the rule to apply.

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

* refactor(ws6): evict the profile approval gate from composition to ironclaw_approvals

WS6's composition-evictions row, "Still owed" clause 1 (approval/authorization
policy -> approvals/authorization). PROPOSAL §6.5.2 names the destination: the
profile-policy authorizer's *policy content* moves to `approvals`, not
`authorization` — §6.5.2 forbids `authorization` from doing "approvals
resolution", which is exactly what this module does.

Two files move by `git mv`, no content edits beyond import repointing and
visibility:

- `composition/src/profile_approval_authorization.rs` (1,844) ->
  `approvals/src/profile_gate.rs`. The class-A/class-B approval composition
  algorithm: origin-gate matrix folding (Forbidden -> hard deny, AskAlways ->
  hard floor, GatedUnlessGranted -> soft gate) against effect gates, with tool
  overrides / leases / auto-approve / always-allow modulating strictly between
  the two tiers. It was a leaf inside composition — zero `crate::` imports in
  production code — so it moved first with no churn.
- `composition/src/runtime_profile_approval_policy.rs` (334) ->
  `approvals/src/profile_gate_policy.rs`. The concrete `ProfileApprovalGatePolicy`
  the TOML data feeds.

What deliberately did NOT move, each for a stated reason:
- `builtin_capability_policy.rs` — `reborn_composition_boundaries.rs`
  hard-asserts `mod builtin_capability_policy;` stays at composition's crate
  root ("runtime-profile policy"), and its `.toml` is config-as-data, which is
  §6.10.1's Keeps list.
- `capability_authorization.rs` — `StoreApprovalSettingsProvider` is an adapter
  over composition-selected durable stores plus a TTL/single-flight cache; it
  reads `builtin_capability_policy`, which cannot leave.
- `runtime/approval.rs` — depends on `product`/`extensions`/`extension_host`;
  genuinely composition-shaped.
- `production_runtime_policy.rs` — a smart constructor over
  `crate::RebornCompositionError` / `RebornRuntimeProcessBinding`.

Cost, stated rather than hidden: `ironclaw_approvals` takes two new same-layer
kernel edges, `-> ironclaw_trust` and `-> ironclaw_runtime_policy`, and
`SAME_LAYER_EDGE_BASELINE` rises 72 -> 74 with both rows inventoried. Neither is
avoidable at the destination — the gate *implements*
`TrustAwareCapabilityDispatchAuthorizer`, whose signature names
`ironclaw_trust::TrustDecision`, and it consumes `MinimalApprovalBypass`, which
§4.4 pins to `ironclaw_runtime_policy` as the one place that classification
lives. The kernel family already carries twelve such edges (`capabilities` and
`host_runtime` hold six each).

Un-masking evidence (full unfiltered suites, both crates):
- composition 957 -> 924 tests, approvals 85 -> 118. The 33 that left composition
  are the 33 that arrived in approvals, leaf names identical, zero unclassified,
  no surviving assertion edited.
- composition lib 564 -> 531 passing (-33), 0 failed across all 35 test binaries;
  approvals 118 passing, 0 failed.
- Two `RunTimeout { timeout: 3s }` failures appeared on one run taken immediately
  after `cargo fmt --all` + a full rebuild; both are wall-clock deadlines, both
  pass on a settled tree, and the base tree under the same full-suite load is
  564/564. Recorded rather than silently re-run.

Full `ironclaw_architecture` package green (37 binaries, 0 failures). Composition
production LOC 45,127 -> 42,943.

Also fixes one pre-existing `-D warnings` clippy break inherited from the batch
base (`reborn_extension_specificity.rs`: a blank line between two doc-comment
blocks on `WS0_EXTENSION_SPECIFICITY_ALLOWLIST_BASELINE`). Untouched by this
eviction, but it made a clean clippy run impossible.

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

* refactor(composition): evict the admin-user directory and blocked-auth resume fan-out to product

Two WS6 §6.10.1 "still owed" evictions with the same destination owner
(`ironclaw_product`) and the same shape — the *adapter* leaves the
composition root, the *deployment* half (which backend, which minter) stays.
They land in one commit because both rewrite the same composition call sites
(`runtime.rs`, `factory.rs`, `runtime_input.rs`, `lib.rs`).

**Admin-user directory (322 LOC).** `RebornAdminUserDirectory` — the sole
production `AdminUserService` implementation — is product workflow (tenant
scoping, role/status transitions, one-time bearer issuance), not assembly.
It moves to `ironclaw_product::admin_user_directory` verbatim.

- `AdminApiTokenMinter` moves to `ironclaw_product_contracts::admin_users`,
  beside the `AdminUserService` port its one caller implements. It is
  dependency-free (`host_api::ids` + `secrecy`), and declaring it in the
  contracts crate is what lets `ironclaw_reborn_cli` implement it and
  `ironclaw_product` call it without either naming composition. The
  composition re-export `pub use admin_token::AdminApiTokenMinter` is
  deleted, not forwarded; the pub-use snapshot loses exactly that line.
- `AdminSecretProvisioner` is declared in `ironclaw_product` beside its one
  caller; it names `ironclaw_secrets` types so it cannot live in the
  contracts crate (allowlist: product_contracts/extension_contracts/host_api).
  Composition keeps `FilesystemAdminSecretProvisioner`, which is the
  deployment half — it mints a per-target-user `SecretStore` from a
  `MountView`, which is what the composition root is for.
- `RejectingAdminApiTokenMinter` (the fail-closed default for role-read-only
  paths) travels with its caller.

**Blocked-auth resume fan-out (574 LOC).** `BlockedAuthResumeFanout`
decorates the continuation dispatcher so one completed OAuth flow resumes
every run the same caller has parked on that provider. That is product-auth
workflow; composition only chooses whether a gate source exists.
`process_gate_turn_view.rs` (56 LOC) travels with it rather than being
duplicated: `turn_scope_from_process_gate` has no other consumer, and
`current_turn_gate_runs` / `first_turn_run_for_gate` are the same projection
read by the auth-interaction services — they are now `pub` from product and
imported by `runtime.rs` / `runtime/auth_interaction.rs`.

`ironclaw_product` gains `ironclaw_reborn_identity`, `ironclaw_secrets` and
`ironclaw_processes` as normal dependencies (`ironclaw_processes` was already
a dev-dependency, now promoted). All three are `substrates`/`kernel` under a
`products` crate — matrix-legal, and none is on `ironclaw_product`'s
`BoundaryRule` forbidden list.

Extension-specificity allowlist: the two `blocked_auth_resume.rs` rows are
re-keyed to the new path, count unchanged at 2 — the vendor tokens are
test-fixture provider ids and they moved with the tests.

Verification:
  cargo test -p ironclaw_product_contracts -p ironclaw_product
    -> 22 suites ok (390 product lib + 141 contracts lib + integration), 0 failed
  cargo test -p ironclaw_architecture --test reborn_composition_boundaries
    -> 21 passed, 0 failed
  cargo check -p ironclaw_reborn_composition --all-targets -> clean
  cargo check -p ironclaw -> clean

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

* refactor(composition): split turn-end trace capture into the traces pipeline and the turn-runner observer

WS6 §6.10.1, "trace capture … → `trace_commons` + the turn-runner observer
seam". The row names two destinations because one file cannot go to either
alone: the capture module is 1,170 LOC of two different things fused, and
the layer matrix decides where the seam falls.

**`ironclaw_reborn_traces::capture` (new, 234 LOC)** takes everything that is
pure Trace Commons — standing-policy resolution, envelope build, the
Submit/Held/Skipped disposition, queue + immediate flush, the
`ObservedTraceScopes` set, and the periodic `spawn_trace_queue_flush_worker`.
Its entry point `capture_conversation_trace(scope, messages, task_failed)` is
keyed on a scope string and this crate's own `ConversationMessage`, so it
names no turn, thread, or runtime type.

**`ironclaw_runner::trace_capture` (moved, 240 LOC smaller)** keeps exactly
what needs turn/thread vocabulary: the `TurnEventSink` implementation, the
`TraceCaptureHistorySource` port and its `load_context_window`-backed
implementation, the terminal-event gate, and the record→`ConversationMessage`
adaptation (including tool-call reconstruction from replay metadata).

Neither half could hold the other. `ironclaw_reborn_traces` is `substrates`
and `ironclaw_turns` is `kernel`, so the sink cannot live in the traces
crate; and the traces crate must not learn thread-record vocabulary to stay
the Trace Commons client. `ironclaw_runner` is `loops` and already depends on
both `ironclaw_turns` and `ironclaw_threads`, which is why the row calls it
the observer seam.

Composition keeps three lines of wiring: seed the observed-scope set with the
runtime owner's tenant-scoped key, subscribe the sink, start the flush
worker. Its `observability/trace_capture.rs` module is gone; the
`test-support` seam `trace_capture_turn_event_sink_for_test` still builds the
identical production sink, now through the runner path.

Test accounting: all 15 tests moved with identical leaf names
(`trace_capture::tests::*`, verified with `--lib -- --list`); runner lib
229 → 243, traces 217 unchanged (the pipeline's coverage rides on the runner
tests that drive it end to end, which is where it already was).

The `provider_tool_names_stay_at_model_protocol_boundaries` allowlist row is
re-keyed to the new path — trace capture rebuilds a provider-shaped tool-call
transcript from stored replay metadata, which is the sanctioned reason the
row existed. Count unchanged.

Verification:
  cargo test -p ironclaw_reborn_traces -p ironclaw_runner
    -> 217 + 243 lib + 5 integration suites, all ok, 0 failed
  cargo test -p ironclaw_runner --lib -- --list | grep '^trace_capture::' | wc -l -> 15
  cargo test -p ironclaw_architecture -> all suites pass, 0 failed
  cargo check -p ironclaw_reborn_composition --all-targets -> clean

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

* WS5: move adapter_registry parsing to its contracts/registry owners

Executes the `adapter_registry` clause of CHECKLIST WS5's `product` narrows
row — a named prerequisite of the `extension_host -> loops` re-layer (#7145).
Behavior-free move.

`ironclaw_product::adapter_registry` splits along the line PROPOSAL §6.1.2 /
§6.8.1 already draw for every other manifest surface:

- `ironclaw_extension_contracts::product_adapter_section` takes the
  `[product_adapter.*]` schema: the id/prefix constants,
  `ProductAdapterSectionDeclaration` (the `Deserialize` wire shape),
  `ProductAdapterSection` (resolved + validated), `HostIngressRoute`, and
  `ProductAdapterSectionError`.
- `ironclaw_extensions::host_api::product_adapter` takes the resolved
  projection: the `HostApiManifestContract`, `parse_product_adapter_manifest_
  record` / `product_adapter_sections`, the raw-TOML inline-secret guard,
  `ProductAdapterHostApiSection`, and `RegistryError`. It sits beside
  `host_api/capability_provider.rs` and is not re-exported from the crate root.

`ironclaw_product` drops its `ironclaw_extensions` dependency — `adapter_registry`
was the sole consumer — resolving the guidance-vs-code contradiction §6.9.1
records: the crate guide forbade the edge, the manifest declared it, and the
enforced `BoundaryRule` never named it. The rule is now enforced.

`EXTENSION_HOST_PRODUCTION_FILES_STILL_NAMING_PRODUCT` loses its three
`adapter-registry` rows (`available_extensions.rs`, `channel_lifecycle.rs`,
`host_api_contracts.rs`) and `EXTENSION_HOST_PRODUCT_REFERENCE_FILE_BASELINE`
goes 9 -> 6.

Also fixes a pre-existing `clippy::empty_line_after_doc_comments` break in
`reborn_extension_specificity.rs` (proved present on the base at line 1620
before this branch shifted it), which blocked the required `-D warnings` gate
on a crate this change touches.

Regression coverage: the moved suites
`crates/ironclaw_extensions/tests/product_adapter_{contract,manifest_ingestion}.rs`
(8 + 11) and the schema's 6-test unit module drive every path through its new
home — all 25 tests survive the move — including the ingestion suite's new
`section()` assertion.

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

* docs(arch): re-ratchet composition mass to 42,938 and reconcile the WS6 eviction row with measurement

**Mass.** `loc_ceiling` / `loc_observed` / `COMPOSITION_ABSOLUTE_SRC_LOC`
45,127 → **42,938**, the count this branch's two evictions leave behind
(−2,189 LOC), measured with `bash scripts/ci/check-composition-budget.sh
--print` and locked in the same branch that earned it. `ceiling_bp` is
deliberately NOT lowered: the WS0 continuity record pins
`WS0_COMPOSITION_SHARE_BP = 658` and the baselines test asserts it stays under
`ceiling_bp + tolerance_bp`, so ratcheting the share to today's 622 bp would
red a gate that is measuring a different tree. The absolute bound is the one
that binds — which this run demonstrates again: 4.9% of composition's mass
left, and the share metric moved 32 bp.

**Row reconciliation (CHECKLIST WS6 + PROPOSAL §6.10.1).** Two clauses struck
as done; four corrected against measurement rather than left as aspiration:

- *"(+ hooks projection)"* is **retracted as a miscount.** The 350-line figure
  §2 pairs with trace capture's 1.2k is
  `observability/hooks/projection.rs` — installed-extension `[[hooks]]`
  manifest discovery and admission. It shares a directory with trace capture
  and nothing else, and neither destination the clause names can receive it.
- *OpenAI-compat + NEAR-login route mounts* — **half already discharged, half
  blocked by one of this programme's own gates.** NEAR-login serve already
  lives in `ironclaw_operator`; composition keeps a 20-line accessor over
  runtime-private state. The OpenAI-compat half cannot move as written:
  `ironclaw_reborn_openai_compat`'s `BoundaryRule` forbids `ironclaw_threads`,
  `ironclaw_turns` and `ironclaw_event_streams`, and ~1,240 of
  `openai_compat_serve.rs`'s 1,471 LOC are adapters naming exactly those. They
  implement the owner crate's own ports, so composition holding them is the
  target state; the row is re-scoped to the ~230-LOC residue that is genuinely
  movable, itemized by symbol.
- *project filesystem reader → `identity::projects`* — **blocked, and one step
  earlier than the row assumes.** §6.4.11 says "the product port stays in
  `product_contracts`"; `ProjectService` is in fact declared in
  `ironclaw_product/src/reborn_services/projects.rs`. §6.4.11 also says
  "identity's pinned allowlist is unchanged"; `reborn_identity_allowed` is an
  armed allowlist of `{reborn_identity, host_api, filesystem}`, which cannot
  admit an adapter implementing a product-tier port. Two prerequisites, both
  decisions. `project_create_capability.rs` has a third of its own
  (`ironclaw_loop_host` is `loops`).
- *Google OAuth secret store + NEAR-AI MCP* — one is **double-counted**, one
  has **no destination**. §6.10.3's own 2026-08-04 amendment already rules the
  Google half moves with the CLI shed "or not at all"; keeping this clause
  open books the same slice twice. The NEAR-AI MCP module is first-boot
  provisioning and the v3 manifest has no bootstrap/auto-activate recipe to
  receive it (`rg 'auto_activate|auto_install|bootstrap'
  crates/ironclaw_extension_contracts/src` is empty), and
  `[admin_configuration]` governs a live installation, not provisioning that
  runs before one exists. Building that recipe is a feature; the clause owes a
  mechanism decision first.

**`local_runtime` (#7098) — authoritative recount; neither document was
right.** 326 occurrences across 50 files workspace-wide (194 in composition
alone), 24 distinct identifier spellings. §6.10.1's "six public API symbols"
is correct and #7152's "none public" is wrong — plus a seventh neither
counted, `ironclaw_reborn_config::RebornProfile::local_runtime_storage_subdir`,
public on the zero-workspace-dep boot-contract crate. #7152's
"`RebornLocalRuntimeIdentity` is `pub(crate)`" is correct and §6.10.1's "the
public type" is wrong. Not executed: #7153 records Slice B as the sanctioned
exit, and #7152 renames the composition crate wholesale, so a 326-site sweep
would collide on nearly every file. Recorded for #7152's refresh.

**traces `ScopedFilesystem` — blocker discharged, count corrected.** #7152
deferred it behind #7124's `contribution.rs` split; that split has landed.
Re-counted on the split tree: **39** production `fs` call sites across 5
files, not #7153's "11 + ~7". The `device_key.rs` caveat is promoted from
footnote to deciding question — its 8 sites carry 0700-permission logic and
`ScopedFilesystem` has no permission vocabulary, so this is two decisions, not
one conversion. Not attempted here; it is a persistence-plane behaviour change
that does not belong in an eviction PR.

Verification:
  bash scripts/ci/check-composition-budget.sh -> OK (42938 / ceiling 42938 + 150)
  cargo test -p ironclaw_architecture --test reborn_restructure_baselines -> 1 passed

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

* docs(webui): commit the handlers.rs module-charter map and enforce it (WS6, §6.9.4)

`src/webui_v2/handlers.rs` is **4,593 lines** — the largest file in the crate.
WS6's module-charters row names a "webui `handlers.rs` charter map (§6.9.4)",
but **§6.9.4 contains no such clause**: the definition had to be reconstructed
from §6.9.1 ("module-charter map ... the audited **>=11** sub-owners") and
§6.4.15 ("module-charter work, **not a split**"). §6.9.4 now carries the clause
so the next reader does not reconstruct it a third time.

`crates/ironclaw_webui/CLAUDE.md` gains a **19**-sub-owner map covering every
top-level item: `session`, `threads`, `admin-users`, `workspace-fs`,
`projects`, `attachments`, `streaming`, `runs`, `commands`, `automations`,
`traces`, `outbound`, `skills`, `extensions`, `admin-config`, `dispatch`,
`operator`, `llm-admin`, `run-artifact`.

## The waiver stays, and a test now says so

`the_large_file_waiver_survives_the_charter_map` fails if
`// arch-exempt: large_file` is deleted **or** if it stops naming plan #5985 —
the plan number is the only thing that makes the waiver revocable, and
`scripts/pre-commit-safety.sh` requires it. This is the opposite disposition to
§6.4.14's `contribution.rs` waiver, which was deleted with the traces split;
the difference is that this plan has not landed. Without the test, the natural
next move for someone reading a charter map is to delete the waiver as
"handled", silently dropping the file out of ARCH-SPRAWL tracking.

## Owners are conceptual, not positional — forced by "not a split"

The obvious mechanism for a single file is banner-delimited regions, one per
owner. It is unbuildable here without moving code: `threads` holds **two**
regions (`create_thread`/`delete_thread` at `:265-303` and
`send_message`/`get_timeline` at `:591-654`), split by the admin-users block.
Making them contiguous is exactly the movement §6.4.15 forbids for this row.
The gate is therefore **item**-granular — every top-level `fn`/`struct`/`enum`/
`const`/`type` in `handlers.rs` and its `handlers/` submodules maps to exactly
one owner, positions irrelevant. Recorded because the next reader will reach
for banners first.

## Three placement calls (delegated authority)

- **The `*_activity_id` family splits three ways.**
  `product_capability_activity_id` and `product_surface_activity_id` are
  `dispatch` (the generic derivation every owner reaches);
  `extension_lifecycle_`/`llm_provider_upsert_`/`outbound_preferences_`/
  `admin_configuration_activity_id` go to the concern whose request fields each
  one reads.
- **`capability_failure_http_class` is `outbound`, not `dispatch`**, despite
  the generic name: it is the classification the outbound-preferences routes
  introduced, and every caller is in that owner. The promotion trigger is
  stated in advance — a second concern calling it moves it to `dispatch` —
  rather than argued later.
- **`get_attachment` is `attachments`, not `workspace-fs`.** Both serve bytes,
  but attachment identity is a thread-scoped ref rather than a mount path, and
  the path-scoping rules in `workspace-fs` do not apply to it. Keeping them
  apart is what stops a future path-scoping fix from being *assumed* to cover
  attachment downloads.

## Verification (map, not move)

| Check | Result |
|---|---|
| `git diff --stat <base> -- crates/ironclaw_webui/src` | **empty** — zero source lines changed, so the item roster is identical by construction |
| Charter coverage | **219 of 219** top-level items in `handlers.rs`, plus 5 in `handlers/run_artifact.rs`; 0 uncharted, 0 phantom, 0 double-claimed |
| Sub-owner count | **19** vs §6.9.1's floor of 11 (pinned by a test) |
| Unfiltered `cargo test -p ironclaw_webui --all-features` | 469 passed / 0 failed (**+4**, exactly the new gate) |
| `cargo clippy -p ironclaw_webui --all-features --all-targets -- -D warnings` | clean |
| `cargo fmt --check` | clean |

**Sabotage-proved in five directions**, each restored green: drop an item from
the map -> "1 handler item(s) have no sub-owner"; add a phantom item -> "no
longer exists"; claim an item twice -> "claimed by more than one"; delete the
`large_file` waiver -> the waiver test; add a brand-new uncharted handler to
the file -> "no sub-owner" (the real-world case). The gate self-guards against
going vacuous three ways: zero parsed rows, implausibly few walked items, and
zero submodule items collected (which would leave the `run-artifact` row
unchecked).

## What this does not do

It is not plan #5985 and does not shrink the file by a line. What it buys is
that #5985 inherits a decided seam list — each of the 19 rows is one candidate
module — instead of re-litigating the boundaries when the split is attempted.

This closes the WS6 module-charters row: all four clauses are now done.

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

* refactor(layers): re-layer extension_support -> runtimes; the exception register reaches empty (1 -> 0)

WS3 closeout. LAYER_MATRIX_EXCEPTIONS is now the empty list and
WS0_LAYER_MATRIX_EXCEPTION_BASELINE is 0 - PROPOSAL SS11.2.2's end state and
CHECKLIST WS12's gate condition, reached from 20 at WS0. It did not close the
way the row said it would, and the measurement that found that is the substance
of the change.

The last entry was host_runtime -> extension_support, removes_in "WS3
(first-party activation wiring)". Two measurements:

1. The row's blocker was false. It says the edge "clears only when the last
   executor family lands". grep -rl over host_runtime/src returns three files
   and only two are edges - first_party_tools/mod.rs:29 (extension_support::
   coding) and first_party_tools/skill_management.rs:18 (::skills); latency.rs
   is a doc comment. Both belong to families whose executors have ALREADY moved.
   The five families still awaiting a move keep their executors in host_runtime
   and hold no edge at all.

2. Under WS3's own executor/adapter seam the edge is structural. The seam
   (recorded 2026-08-03 in this row, PROPOSAL SS8.2 and families/extensions.md)
   leaves each tool's FirstPartyCapabilityHandler, CapabilityManifest and
   registry wiring host-side, because extension_support's BoundaryRule forbids
   naming ironclaw_host_runtime. That makes the kernel a DESIGNED consumer. A
   design cannot both route the kernel into a crate and declare that crate two
   rungs above the kernel.

Shedding the two adapters upward anyway was priced, not assumed: ~8 kernel
private->pub widenings (mod post_edit_check is private in lib.rs:57 and neither
run_post_edit_check nor PostEditCheckSeenLines is re-exported;
first_party_capability_manifest, resource_profile,
first_party_origin_gate_matrix are module-private; bounded_input_size,
bounded_output_bytes, FIRST_PARTY_MAX_OUTPUT_BYTES are pub(super)) plus - unlike
the gsuite/web_access registrars this pattern comes from - these are BUILTIN
capabilities, so relocating their registration changes which hosts have
read_file/write_file/list_dir/glob/grep/apply_patch, reached by 145 references
across 31 files including three in the root integration harness. Semantic
change, not a move; the same refutation SS6.5.9's binder half already carries.

What landed instead: ironclaw_extension_support layer loops -> runtimes, one
manifest line. runtimes is the LEAST demotion that legalizes a kernel consumer
and is the layer this crate's SS8.2 row already describes in posture (mediated
services by injection, kernel X, invoked only via capability dispatch - the
lanes/ cell verbatim). Checked both directions through cargo metadata:

- all 7 normal deps fit the narrower row (auth, extractors, filesystem,
  observability, safety, skills = substrates; host_api = contracts), as do all
  three domains the charter reserves (memory, traces, triggers = substrates);
- all 5 consumers are kernel or above (host_runtime kernel; extension_host,
  extension_manager products; reborn_composition, ironclaw app);
- ZERO same-layer edges created - no runtimes crate is a dep or a consumer.
  substrates, the demotion its two family siblings took, would instead have
  hidden six of the crate's seven deps from the matrix.

The widening is pinned by a DowngradePin (#7149) freezing the five consumers.
The gate demanded it by name before it was written, rejected
"ironclaw_reborn_cli" as not a package name, and - sabotage-tested - names
ironclaw_host_runtime as unreviewed reach when that row is deleted.

One gate had to change shape: at baseline 0, len() <= BASELINE is usize <= 0, a
tautology to -D warnings (clippy::absurd_extreme_comparisons) on the one gate
whose job is to be loud. Rewritten as saturating_sub(baseline) == 0 - identical
ceiling semantics for every baseline, no allow on a guard. Sabotage-tested: a
fake entry appended to the empty list fails it with the right message.

Also: WS3 catalog-defaults, network test_rewrite and verify rows re-verified on
this tree (bollard/rcgen through cargo metadata over every dependency kind of
every package, as that row demands, not a literal path); WS4 re-layer rows
re-verified; WS12's empty-register row ticked with what it does not claim spelled
out; PLAN's "never 0" Wave 3 exit prediction falsified and corrected in place.
The first_party_tools row stays [~] at five of six families - it no longer buys
an exception deletion, and now says so.

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>

* test(arch): re-key the struct-debt inventory entry that followed trace capture out of composition

`reborn_production_struct_test_support_and_dead_code_members_do_not_grow` went
red on this branch with:

    test-support method in crates/ironclaw_runner/src/trace_capture.rs: 1

That is not new debt. It is `TraceCaptureTurnEventSink`'s `#[cfg(test)]
with_history_source` seam, already inventoried at
`ironclaw_reborn_composition/src/observability/trace_capture.rs`, arriving at
its new home — the gate is path-keyed, so a file move reads as a deletion plus
a birth. Exactly the WS10 path-keyed-gate hazard, and the gate did its job by
refusing to stay green through a move.

The entry is re-keyed in place: same struct, same member, `count: 1`
unchanged. Both totals the ratchet bounds are therefore untouched
(`FROZEN_PATH_COUNTS.len()` and the summed members), so neither
`WS0_PRODUCTION_STRUCT_DEBT_PATH_BASELINE` (79) nor
`WS0_PRODUCTION_STRUCT_DEBT_MEMBER_BASELINE` (276) moves — which is the point:
a move must not be able to launder debt in either direction, and the ratchet's
lower-bound assertion would have caught the "delete the row" shortcut.

Verification:
  cargo test -p ironclaw_architecture --test reborn_struct_test_support_ratchet
    -> 2 passed, 0 failed
  cargo test -p ironclaw_architecture --no-fail-fast -> 37 suites ok, 0 failed

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>

* refactor(reborn): slim the composition re-export wall, type ExtensionId, collapse ref-store wrappers

Five WS6/WS5/WS1-residue CHECKLIST rows. Three clauses landed as code, two
were refuted or measured-and-scoped-out with the evidence recorded on the row.

**`RebornRuntime` slimmed (WS6).** The re-export wall goes 52 -> 36 statements,
179 -> 109 exported names, snapshot 132 -> 82 lines, with zero statements left
that no external consumer imports (there were 13). 17 whole statements were
pure pass-throughs of *other* crates' types whose consumers already depend on
the owner; four consumers were repointed to the owner rather than the survivor
kept. The census was confirmed by the compiler: `pub use` deletion emits no
`unused` warning, but removing the dead entries turned on 15 `unreachable_pub`
warnings — every one a `pub` item whose only path out had been a re-export
nobody imported. Those are now `pub(crate)`, and
`memory_provider_factory::create_provider` went `#[cfg(test)]` after `cargo
check` proved production reaches mem0 through `resolve_memory_provider`.

A second gate, `composition_public_pub_use_entries_name_their_consumer`,
requires every entry to carry `// consumer: <who> · pinned by: <test>`. The
snapshot gate pins *what* is exported; this pins *why*. It caught three real
cases on its first run and is sabotage-tested.

The row's other two clauses are already-done (39 `_for_test` fns in
`runtime.rs`, all gated — #7107's "22 of 39" undercounted by missing the
`test-support`-only spelling) and refuted (`product_live_adapters` is live
cross-crate test-support API; the refutation now lives at the declaration site,
not only in a planning doc).

**Typed `ChannelExtensionBinding.extension_id` (WS6).** Seam plus the first
downstream hop — `GenericExtensionHostParams.channel_adapters` and
`register_extras` — which is the smallest cut that does not immediately untype
the field. `extension_contracts::channel_adapter`'s wire types stay `&str`; that
is a contracts change touching every `ChannelAdapter`, and the boundary is
marked. The row's env-reads clause is measured (14 reads, 12 movable) and not
started.

**Ref-store collapse (WS5).** Both LibSql/Postgres wrapper pairs were
byte-identical modulo the concrete filesystem type; openai-compat's had zero
construction sites repo-wide, product's zero production ones. 229 lines
deleted. The product half needed three delegating root constructors first,
because the generic type exposed only the scoped family.

**`webui` boundary rule re-derived (WS5).** Zero removals, nine additions, all
no-op ratchets. `ironclaw_wasm_limiter` is the one no other gate covered.

**`skills` v1 doc (WS6).** Two of its three claims were false and one
misstated the security model: `SkillTrust` gates *content exposure*, not tool
access. Corrected in `lib.rs`, `types.rs` and `AGENTS.md`.

Also fixes two pre-existing clippy `-D warnings` breaks on the base branch
(`empty_line_after_doc_comments`; an orphaned dead test helper).

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

* WS2.5: gate + CHECKLIST reconciliation, and two pre-existing clippy reds

- `reborn_extension_host_port_inversion.rs`: `channel_host.rs`'s ledger reason
  loses its stale `ProductActorUserResolver` half (that port is inverted now).
- `reborn_extension_specificity.rs`: the moved `ChannelConnectionService` doc
  carried a `slack` example into `ironclaw_auth`. Reworded generically rather
  than carved, which also made the product entry stale — deleted, allowlist
  baseline 123 -> 122. The gate reported both directions; neither was allowlisted.
- Two clippy reds that pre-exist on this base and bite a `-D warnings` bar: an
  empty line splitting a doc-comment run in the specificity gate, and a
  never-used negative-control fixture in `ironclaw_extension_support`. The
  fixture is `#[allow(dead_code)]`-ed rather than deleted, with the reason.
- CHECKLIST WS2 re-layer row, blockers half: dated and measured annotation of
  what fell, why the "narrow the vocabulary out" framing was only half right,
  and that §12.11 D-A's factory port is unstarted.

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

* refactor(ws6): evict fire-time trigger-access policy from composition to ironclaw_triggers

WS6's composition-evictions row, "Still owed" clause 1, trigger-fire half
("approval/authorization/trigger-fire policy -> ... `runtime_policy`+`triggers`").
Zero new dependency edges, in either direction.

The split follows what each piece actually is, not the file it happened to sit in:

MOVED to `ironclaw_triggers::fire_access` — decisions about a persisted
trigger's own stored scope, with no backend behind them:
- the check contract (`TriggerFireAccessCheck`/`Decision`/`Error`/`Checker`),
  previously declared in `composition/src/runtime_input.rs`;
- `StaticOwnerTriggerFireChecker` — a pure comparison, including the
  load-bearing `tenant_id` bound (the due-trigger repository is global, so
  owner+scope alone could authorize a foreign tenant's trigger);
- `CompositeTriggerFireChecker` — the OR-combinator, including its
  "unavailable beats denied" rule so a transient identity-store fault stays
  retryable rather than becoming a hard denial.

STAYED in `ironclaw_reborn_composition`, each for a stated reason:
- `TriggerFireAccessPolicy`/`TriggerFireAccessGrant` — the deployment grant the
  `serve`/`run` edge resolves. §6.10.1's Keeps list names "deployment
  config-as-data" as composition's charter, and this is exactly that;
  `build_reborn_runtime` still turns a policy into a checker.
- `IdentityMembershipTriggerFireChecker` — a lookup against a backend
  composition selects (`RebornUserDirectory`). An adapter over a chosen backend
  is assembly, and moving it would have bought `ironclaw_triggers` a dependency
  on the identity crate to hold one `get_user` call.

To keep the two halves from drifting, the deny reason and the exact-scope rule
are exported once (`trigger_fire_access_denied`, `trigger_fire_scope_matches`)
and called by the composition-side checker rather than restated there — a second
copy of the deny string is how two checkers diverge.

Un-masking evidence (full unfiltered suites):
- triggers 169 -> 175 tests, composition 924 -> 918. The 6 that left composition
  are the 6 that arrived in triggers, leaf names identical, zero unclassified,
  no surviving assertion edited.
- composition lib 531 -> 525 passing (-6), 0 failed across all 35 binaries
  (916 passing); triggers 175 passing, 0 failed.
- Full `ironclaw_architecture` package green: 37 binaries, 259 passing, 0 failed.
- Root integration suite green except `backend_parity_replies_to_greeting::case_3`,
  which fails at harness construction with "StorageMode::Postgres requires a
  reachable Docker daemon" — no Docker on this host. Environmental, unmodified,
  unrelated to this diff.

A note for whoever reads the CI history of this branch: an earlier local run of
these same suites produced up to 15 spurious `runtime::tests::*` failures on a
tree that had already been verified green. The cause was the host filling its
root filesystem to 117Mi free; those tests exercise on-disk filesystem backends
and fail or time out when writes cannot land. Recorded rather than quietly
re-run, because "it passed the second time" is how a real failure gets buried.

Composition production LOC 42,943 -> 42,688.

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

* docs(ws6): re-ratchet composition mass and record the eviction + sever measurements

Closes out the two WS6 policy evictions in this branch and writes down what
measuring them refuted, so the next agent measures from here rather than
re-deriving.

**Re-ratchet (the wave-close rule, executed not deferred).** Composition
production LOC 45,127 -> 42,688 (-2,439). `[gate].loc_ceiling`, `loc_observed`,
and `COMPOSITION_ABSOLUTE_SRC_LOC` all lowered to the measured count in the same
PR that removed the lines, so the improvement is banked as a floor. Read with
`bash scripts/ci/check-composition-budget.sh --print` on the post-eviction tree,
not derived by subtracting the diff. The absolute gate now prints no NUDGE.
Siblings are shrinking composition too: a numeric conflict here is expected and
trivial — take the lower number, then re-measure on the merged tree rather than
trusting either side.

Two NUDGEs remain and are deliberately NOT actioned, because neither slack is
this PR's to bank: `ceiling_bp` (17.80pp) is the share metric whose poisoned
denominator this same file documents, and whose ~17.4pp of slack predates this
branch and is tracked by CHECKLIST WS0; `arc_dyn_ceiling` (277) sat 263 below
its ceiling at the base commit, so lowering it to 845 would lock in mostly other
people's headroom and collide with every sibling shrinking composition.

**CHECKLIST WS6 / PROPOSAL §6.10.1** — the approval/authorization/trigger-fire
clause is struck with what actually landed, including the two same-layer kernel
edges it cost and why `runtime_policy` needed nothing (`builtin_capability_policy`
is pinned at composition's crate root by an architecture test).

**Trusted-submit: STOPPED and recorded, not attempted.** The row names
`triggers`/`conversations`; measured on this tree, both are refused by a stated
rule. `ironclaw_conversations`' own crate doc says "It is not the transcript …
Keep it that way", and the materializer's job is writing the transcript;
`ironclaw_triggers` would need four or five new same-layer edges to host an
adapter that is composition-shaped. The clause needs a destination decision, not
an eviction. All five trusted-submit security gates verified green and unmodified.

**`product -> loop_host`: the recorded count is wrong and hides a seam.** WS1's
row says "three production import sites". It is five production files across
three seams, and the third — an input-queue enqueue seam in `reborn_services.rs`,
`steering.rs`, and `inbound_turn.rs`, five symbols all from
`loop_host/src/input_queue.rs` — appears nowhere in these documents. It is a port
inversion, not a move: `RebornServices` holds an `Arc<dyn HostInputEnqueuePort>`.
Also refuted: §6.4.11's destination for the project-create capability is
unreachable as written — `ironclaw_projects` is `substrates` and
`ironclaw_loop_host` is `loops`, so that edge is upward and matrix-illegal. The
reachable owner is `ironclaw_first_party_extension_ports`, which already holds
the sibling `skill_activation_capability.rs`, and only after `ProjectService`
leaves `ironclaw_product`. The manifest dep therefore cannot be dropped by any
wave that moves only the two recorded sites, and this one did not try.

Full `ironclaw_architecture` package green (37 binaries, 259 passing, 0 failed);
`cargo check --workspace --all-features --all-targets` clean.

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

* fix(batch2): drop the stale product-rooted auth-prompt imports the residue fold superseded

The #7186 fold's shared-context imports of blocked_auth_flow_canceller
and product_auth_challenge_provider still pointed at ironclaw_product's
root; #7189 moved both to ironclaw_auth::product_prompt and the merge
already carried its corrected re-export lines above them. The two
stale duplicates broke composition on both clippy lanes — caught by
the pre-push bar, exactly as intended.

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

* fix(batch2): reachability-correct auth-prompt re-export; charter rows for the moved auth files

The residue fold's auth-prompt re-export was pub for both names while
only product_auth_challenge_provider has an external path (the slimmed
wall); split to pub(crate)/pub matching the two consumers — the exact
shape #7186's original lines had, now pointed at ironclaw_auth. The
auth module-charter gate (from #7179) correctly demanded sub-owner
rows for #7189's two new files; both are product-auth surface.

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

* docs(arch): re-measure the product→loop_host sever on the batch union — 6 files/4 seams, carried over as a design slice

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:30:17 +00:00
Josh Ford
6605c80cc1 fix(ci): unbreak per-package clippy on bin-only crates; classify .gitignore (#7167)
* fix(ci): unbreak per-package clippy on bin-only crates; classify `.gitignore`

Two latent CI bugs, one per lane, that #6965 is the first PR to trip.

`Check production-target lints` runs `cargo clippy -p <changed package>
--lib --bins`. `--lib` is a hard error on a bin-only package, so the first
PR whose only changed package is `ironclaw` (crates/ironclaw_reborn_cli)
dies on `no library targets found in package `ironclaw`` — exit 101, before
a single lint runs. `--bins` alone is the quieter half of the same bug: on a
lib-only package cargo warns `target filter `bins` specified, but no targets
matched; this is a no-op` and the lane reports green having linted nothing.
Cargo's default target set is already lib + bins, with tests, examples, and
benches excluded, so the production-target lane needs no filter at all.

`reborn_pr_test_plan.py` had no rule for root `.gitignore`, so its
fail-closed arm raised `unclassified pull-request path: .gitignore` and
failed the whole `Tests (Reborn)` roll-up on any PR that adds an ignore
rule. It joins the decided-paths set rather than the repo-root prose set,
because something does read it: Code Style filters on it for `has_code` and
runs `Reject tracked files that match .gitignore`. That is the shape already
recorded there for `scripts/no_panics_reborn_baseline.txt` — owned by a
static check, read by no Reborn lane — and it leaves the decision in the
plan's `reasons`.

`validate_production_lint_targets` keeps the lane filter-free. It rejects
every explicit target selector, not just the two that caused the outage:
`--bin` pins a multi-bin package to one target, and `--test`/`--example`/
`--bench` and their plurals pull in what the lane exists to exclude. Flags
are matched on word boundaries so `--bins` is not also reported as `--bin`,
and `clippy_matrix` is checked too, since `${{ matrix.flags }}` expands into
the same command.

The check reads the step body rather than locating the command and parsing
its arguments: a matcher is a thing to fool, and scanning has no match
position to displace and no command formatting to get wrong. The trade —
a command deliberately written to look inert would pass — is recorded
beside the constant, along with the zero-target-package gap that is
unreachable today.

Verified red before the fix, green after: reverting the workflow to
`--lib --bins` fails the contract; removing the `.gitignore` classification
reproduces `ValueError: unclassified pull-request path: .gitignore`;
unhooking the validator from `validate_workflow_texts` fails the top-level
test. Suites: 30 workflow contracts, 44 planner, 6 shards.

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

* fix(ci): reject edits that run the production lint and discard its verdict

From review: the contract checked which flags the lint passes but not whether
anyone reads its exit status. `|| true`, `|| :`, `set +e`, and a step-level
`continue-on-error: true` all leave the lane running clippy and ignoring the
result — the silent-green failure the whole contract exists to prevent.

These are worth catching where a disguised command is not. Each is a
plausible edit made on purpose and for a stated reason — unblock the queue,
quiet a flaky lane — rather than an attempt to fool a validator, and the
check is a substring scan over the same step body, so it adds no matcher to
bypass. The `echo cargo clippy` case raised alongside it stays out of scope
for the reason recorded beside the constant: it requires deliberate disguise
in a file that only changes through reviewed PRs, and chasing it is what
grew the previous parser through three bypasses.

Verified: removing the check fails all four sabotage cases.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:06:38 +00:00
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
Benjamin Kurrek
1ce5250a5a refactor(ws6): consolidate the six Wave 4 PRs into one (#7124, #7117, #7106, #7099, #7101, #7128) (#7139)
* refactor(loop-host): move system-prompt content out of the composition root (WS6)

CHECKLIST WS6 "Composition behavior evictions" — the `system-prompt content
→ owning prompt asset` clause. PROPOSAL §6.10.1 lists it among the items still
resident in `ironclaw_reborn_composition`; `families/app.md` already says
"prompt content of any kind" never belongs to the app family.

The four assets move from `ironclaw_reborn_composition/assets/prompts/` to
`ironclaw_loop_host/prompts/`, beside the five prompt assets that crate already
ships and beside `identity_context.rs`, whose `HostIdentityContextSource` is
what puts them in front of a model. `system_prompt_assets.rs` exports them as
`pub const`; composition consumes the consts instead of `include_str!`.

Resolved owner is the **loop** half of "loop/product owner": the port is
loop_host's, and loop_host already owns `prompts/`.

What deliberately did *not* travel: the seeding/validation of the on-disk,
user-editable `SYSTEM.md`. That is boot-time `std::fs` work on a real host
path and `ironclaw_loop_host` has zero `std::fs` uses — moving it would put
host-path I/O into a loops crate. Composition keeps assembly + seeding.

The runtime storage path `system/prompts/default-system.md` is unchanged; it
is where existing installs' user-edited file lives, so renaming it would be a
behavior change, not a move.

Enforcement (new, in the same diff):
`reborn_composition_boundaries.rs::composition_root_embeds_no_prompt_content`
fails on either half of the debt — a re-added `include_str!("….md")` in
composition source, or a re-added shipped `.md` asset under the crate that is
not crate guidance. Sabotage-checked both halves independently. It is keyed on
markdown, not on `include_str!`, so `builtin_capability_policy.toml`
(config-as-data, composition's charter) is untouched.

Un-masking:
- `ironclaw_loop_host` 803 → 806 tests; the diff of the unfiltered `--list`
  rosters is exactly the three new `system_prompt_assets::tests::*`.
- `ironclaw_reborn_composition` 928 → 928; roster diff is empty.
- No existing test edited.

Docs corrections, each quoting the text it replaces:
- CHECKLIST WS6 + PROPOSAL §6.10.1: the `local_dev` misnomer's "one residue:
  the local variable at `runtime.rs:3016`" is wrong twice. The variable is at
  `runtime.rs:3095`, and `local_runtime` appears 191 times in composition's
  `src` — including six public API symbols, the public type
  `RebornLocalRuntimeIdentity`, and an assembly struct field.
  `reborn_standalone_typename_ratchet` stayed green because it governs *type*
  names only. Tracked as #7098 as a pure-rename PR, not folded in here.
- PROPOSAL §2: `root/default_system_prompt.rs` is re-described as assembly +
  seeding now that its content assets are gone.
- `families/loop.md` + loop_host `AGENTS.md`/`CLAUDE.md` record the new owner
  and the enforcing test.

Refs #7098

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

* review(ws6): fail-close the markdown ownership gate; fix two stale doc measurements

Addresses both CodeRabbit threads on #7099. Both were right; verified before
fixing, and each fix is sabotage-checked.

**1. The markdown ownership gate had three false-negative paths.**
- `include_str!` / `include_bytes!` were matched per *line*, so a `rustfmt`-wrapped
  invocation — `include_str!(\n    "…/some-prompt.md"\n)`, which is what the
  formatter produces for a long path — evaded the scan entirely. Replaced with
  `markdown_include_sites()`, which scans complete invocations across line
  breaks, plus four unit tests including the multiline regression case. Verified
  by planting a multiline `include_str!("../../AGENTS.md")` in composition
  source: the gate now fails and names the flattened site.
- `markdown_assets()` skipped unreadable directories and entries with
  `let Ok(..) else { continue }`, so "the walk could not see it" and "there is
  nothing there" looked identical to an ownership gate. It now panics on a
  failed `read_dir`, entry, or `file_type`.
- Extensions were compared case-sensitively; `.MD` slipped past. Now
  `eq_ignore_ascii_case`, on both the extension and the guidance-file exemption.

Also added a scanned-file floor (>= 50 sources) so a broken walk fails instead
of reporting clean — the same "measured scan" idiom
`reborn_registration_pipeline_boundary.rs` uses.

**2. PROPOSAL §2.4 still carried the pre-correction `local_runtime` measurement.**
Line 81 said `runtime.rs:3016` and "the local *variable* name survived" while
§6.10.1 (line 670) already carried the correction — a document contradicting
itself. §2.4 now cites `runtime.rs:3095`, states the 191-occurrence scope, and
points at §6.10.1 and #7098. The one surviving `:3016` in the file is inside the
verbatim quote of the text being replaced, which is deliberate.

**Also in this commit — two WS6 rows re-measured, because they would otherwise
have been redone.** `RebornRuntime` slimming, at `origin/main` @ `0f897e9366`:
- "~40 `_for_test` accessors behind `test-support`" is **already done**:
  `runtime.rs` has 38 and zero are ungated; crate-wide 149, and all 13 without
  their own attribute sit in a module gated at its declaration site
  (`lib.rs:64-65`, `factory.rs:1388-1389`). No `_for_test` function compiles
  into a production build.
- "delete the dead `product_live_adapters` export block" is **refuted**: it is
  live cross-crate test-support API. `ironclaw_product` declares
  `ironclaw_reborn_composition = { …, features = ["test-support"] }` as a
  dev-dependency and its `tests/support/planned_agent_loop.rs` imports seven of
  the eight names; composition has a suite dedicated to them. Deleting it would
  strand a sibling crate's test support.
Only the third clause (re-export wall vs. snapshot) is still live.

`crates/AGENTS.md`'s `ironclaw_loop_host` row now names the prompt assets and
says the seeding stays in the composition root.

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

* fix(ci): stop the Reborn test planner failing closed on the crate-family map

`crates/AGENTS.md`, `crates/Architecture.md` and `crates/README.md` sit directly
under `crates/` and belong to no package directory. The planner skips markdown
only at the repository root (`path.endswith(".md") and "/" not in path`), and
`IGNORED_PREFIXES` does not include `crates/`, so all three fell through to the
fail-closed package-resolution arm:

    Reborn PR test planner failed: unmapped crate path: crates/AGENTS.md

That failed `Detect Reborn test scope`, which failed the `Tests (Reborn)`
roll-up — on **any** PR that edited them. Hit while updating `crates/AGENTS.md`
in this branch; filed as #7100 with the blast radius.

It blocks the exact maintenance the house rule asks for: `crates/AGENTS.md` is
the crate-level map WS11 requires updating when crate ownership changes, and
`crates/Architecture.md` is already recorded in PROPOSAL §2 as carrying a stale
`build_reborn_services` reference that WS11 has to fix.

Fix: classify markdown *directly* under `crates/` as crate-family guidance with
no test surface, ahead of the package-resolution arm. Deliberately narrow:
- markdown *inside* a package directory is untouched and stays package-owned
  (`test_nested_crate_markdown_remains_package_owned` still passes);
- anything non-markdown directly under `crates/` still falls through to the
  explicit-decision arm, which is the point of that arm.

Two regression tests beside the existing nested-markdown one: all three
family-map files plan to `mode=none` with no changed packages, and
`crates/unexpected.txt` still raises `unmapped crate path`. Sabotage-checked by
breaking the new arm's path-depth test — 3 errors, restored to green.

Verified end to end: the planner run over this branch's own 14-file diff now
succeeds and selects `ironclaw_architecture`, `ironclaw_loop_host`,
`ironclaw_reborn_composition`. 44/44 planner tests pass.

Fixes #7100

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

* revert(ci): back out the planner fix — #7084 already carries it, better

I hit `Reborn PR test planner failed: unmapped crate path: crates/AGENTS.md`
after adding one line to the crate-family map, diagnosed it as an unhandled
fail-closed arm, filed #7100 and fixed it. Then I checked whether other open PRs
touch those files — #7084 and #7065 do — and expected them to be red for the
same reason. **They are green**, which refuted the "any PR that edits them
fails" framing and sent me to look at why.

#7065 branched before the planner existed (#6952). **#7084 already modifies
`scripts/ci/reborn_pr_test_plan.py` and already fixes this**, in the same
function and the same arm I was editing:

    if package is None:
        # Markdown that belongs to no crate is prose, in the same class
        # as `docs/` and `.claude/` … Depth-independent by construction,
        # so it keeps holding for `crates/AGENTS.md` and for a future
        # `crates/<family>/AGENTS.md` after the WS7 family move.
        if path.endswith(".md"):
            continue

with a regression test (`test_markdown_owned_by_no_crate_is_prose`) covering
`crates/AGENTS.md`. Their rule is **strictly better than mine**: mine keyed on
`path.count("/") == 1`, which would silently stop covering the file the moment
WS7 moves crates under family directories. Theirs is depth-independent.

So this reverts my planner change and its two tests, and drops the
`crates/AGENTS.md` edit that provoked it — #7084 is on the do-not-disturb list
and this would have collided with it line-for-line.

The guidance follow-up is recorded on the CHECKLIST WS6 row with the exact text
owed and the condition (#7084 landing) that unblocks it. #7100 is updated to
say it is already fixed rather than left implying open work.

Everything else on this branch is unchanged: the system-prompt asset eviction,
the markdown ownership gate, and the doc corrections all stand.

Refs #7100, #7084

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

* review(ws6): statement-bounded include scan; fail-close the Rust-source walk

Second CodeRabbit round on #7099. Both findings verified against the code before
fixing; both were right.

**1. `markdown_include_sites` missed a nested argument macro.** Confirmed:

    include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/prompt.md"))

The first-`)` scan stopped at `(concat!(env!("CARGO_MANIFEST_DIR")` — before the
path — and reported clean.

Rather than teach the scan balanced-delimiter parsing (which then also owes
string-literal, raw-string and comment handling — each an independent silent
leak), the span is now bounded by the **statement**: from the macro-name
occurrence to the next `;`. Whatever the nesting, spacing or line breaks, the
path literal is inside that span. It also requires the name to be a whole
identifier followed by optional whitespace and `!`, so `my_include_str!` and a
plain `include_str_path` variable are not findings.

It over-reports rather than under-reports — a comment mentioning `.md` inside an
include statement is flagged — and says so. A false positive is a loud failure a
human clears in one line; a false negative is prompt content silently back in
the composition root.

Seven scanner unit tests now: single-line, multiline, nested argument macro,
whitespace before `!`, a comment inside the argument, uppercase `.MD`,
non-markdown (`builtin_capability_policy.toml`, which must stay clean), and
similar identifiers. Sabotage-checked against the real crate with the exact
nested form above: the gate fails and prints the flattened site.

**2. The file-count floor did not close the `rust_sources` hole.** Right — it
only catches an empty-ish walk; an unreadable directory *after* 50 files still
passed silently. `rust_sources` now panics on a failed `read_dir` and a failed
entry, matching what it already did for unreadable file contents — this is
consistency inside that function, not a new policy, and it hardens the three
other tests in the file that share it.

The floor is kept and re-justified for the case that stays silent even so: a
walk that reads a perfectly good directory which is no longer the crate. After
the WS7 family move relocates `crates/…` under family directories, a stale path
can resolve to something small and readable rather than erroring.

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

* fix(arch): restore four tests my previous commit silently deleted

`fe641b7709` rewrote `reborn_composition_boundaries.rs` by replacing a *span*
between two doc-comment anchors. The two anchors were at opposite ends of the
file — `markdown_include_sites` near the top, `markdown_assets` near the bottom
— so the replacement swallowed everything between them:

  - `composition_public_pub_use_surface_matches_snapshot`
  - `extension_host_cluster_stays_internal`
  - `reborn_binary_main_is_thin_bootstrap`
  - `composition_crate_installs_installed_tier_only_through_registrar`
  - helpers `composition_src_path`, `extract_pub_use_surface`, `has_module_decl`,
    `is_test_module_file`, `strip_test_module`

It compiled and the file's own suite went green, because each deleted test left
with the helpers only it used — which is exactly why "the suite passed" is not
evidence. It was caught by diffing the function roster against `origin/main`
rather than by a test, and by the commit's own −301/+114 line count.

This restores the file from `origin/main` and re-applies the change with
targeted edits instead of a span replacement. The roster is now **purely
additive** against `origin/main` — 9 functions added, **0 removed**, verified
with `comm -23`:

  - `composition_root_embeds_no_prompt_content` (the gate)
  - `markdown_include_sites`, `markdown_assets` (helpers)
  - 8 scanner unit tests

7 tests on `origin/main` -> 16 here. Both halves of the gate re-sabotage-checked
after the restore: a nested `include_str!(concat!(env!(…), "…default_system.md"))`
fails it, and a shipped `assets/prompts/s.MD` fails it.

Also fixes what `Fast deterministic checks` caught on `fe641b7709`: clippy's
`items after a test module` (the scan's test module now sits at the end of the
file, after every helper) and two `doc list item without indentation` warnings
(the doc comment is prose, not a list). `cargo clippy -p ironclaw_architecture
--benches --tests --examples --all-features` is clean.

The substance of `fe641b7709` is unchanged and still stands: statement-bounded
include scanning, and `rust_sources` failing closed on unreadable directories
and entries.

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

* review(arch): skip Rust trivia when bounding the include statement

Third CodeRabbit round on #7099. Both findings verified, both real, both fixed.

**1. `.find(';')` could end the span before the path.** A semicolon inside a
comment above the argument (`// see the note; below`) or inside the path literal
itself (`"../a;b/prompt.md"`) terminated the scan early — and an ownership gate
that ends early goes quiet, which is the failure mode this gate exists to
prevent.

`statement_end_after` now finds the first `;` that actually terminates a
statement, skipping line comments, nestable block comments, normal strings with
escapes, raw strings with any number of hashes, and char literals (while not
mistaking a lifetime for one). It only has to locate a delimiter, not parse the
expression, which keeps it ~50 lines.

Three new tests, and the third is the one that keeps the fix honest: the span
must still *stop*, or a markdown path in the **next** statement would make every
non-markdown include a false positive. Sabotage-checked against the real crate
with a semicolon-in-comment form — the gate fails.

**2. `path.is_dir()` swallowed metadata errors in `rust_sources`.** Right:
`Path::is_dir()` returns `false` on an error, so an unreadable directory left
the walk silently. It now asks `entry.file_type()` and panics, matching
`markdown_assets`.

**Not done, with a reason rather than silently:** the suggested regression test
for "an unreadable directory beneath an otherwise readable workspace". The only
portable way to create one is `chmod 000`, which does not make a directory
unreadable for `root` — and the CI containers run as root, so the test would
pass locally and be vacuous in CI. A test that cannot fail where it matters is
worse than none. The invariant is instead carried by construction: every read in
both walks is `unwrap_or_else(panic!)`, with no `let Ok(..) else` and no
`is_dir()` left in either.

`reborn_composition_boundaries.rs` is 7 tests on `origin/main` -> 19 here, and
the function roster is still purely additive (`comm -23` empty). Full
`ironclaw_architecture` suite green; clippy `--all-features` clean.

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

* review(arch): reject symlinks in both composition ownership walks

Fourth CodeRabbit round on #7099, and it is right. `DirEntry::file_type()`
reports the **link's** type without following it, so a symlink pointing at a
source directory is neither `is_dir()` nor an `.rs` file: both walks stepped
over the entire subtree and the gate reported clean on source it never opened.
Same "uninspected reads as absent" failure the fail-closed reads added in the
previous round exist to prevent — one level further out.

`reject_symlink` now panics for either walk, naming the path and the two ways
forward. Rejecting is chosen over following deliberately: following needs
canonical-root containment plus cycle detection to be safe, and neither scanned
crate has ever contained a symlink (`find crates/ironclaw_reborn_composition/src
-type l` is empty). The panic is where that decision gets made on purpose rather
than silently.

Regression test `a_symlinked_subtree_fails_the_walk_instead_of_being_skipped`
builds a tempdir with a real source directory plus a symlink to it and asserts
**both** `rust_sources` and `markdown_assets` panic. `#[cfg(unix)]`, since the
workspace has a Windows lane and `std::os::unix::fs::symlink` is not portable.

Sabotage-checked: commenting out both `reject_symlink` call sites turns the test
red ("a symlinked subtree must fail the walk, not be skipped"); restoring them
returns 20/20.

`reborn_composition_boundaries.rs`: 7 tests on `origin/main` -> 20 here, roster
still purely additive (`comm -23` empty). Full `ironclaw_architecture` suite
green; clippy `--all-features` clean.

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

* refactor(event-store): stop leaking the Postgres driver in the public API (WS6)

CHECKLIST WS6 / PROPOSAL §6.3.2: "stop leaking `deadpool_postgres::Pool` in the
public API (wrap)". `ironclaw_reborn_event_store`'s public API now names
`deadpool_postgres` zero times; the driver survives only inside its private
`postgres_backed` module, which is where the TLS policy and pool construction
§6.3.2 assigns this crate actually live.

"Wrap" turned out to be three things, not one.

**1. Half the leak was dead code, so it is deleted rather than wrapped.**
`open_postgres_pool` and `open_postgres_pool_with_max_size` had exactly one
caller each — composition's `open_reborn_postgres_pool` and
`open_reborn_postgres_pool_with_max_size` — and those two had **zero** callers
anywhere in `crates/`, `tests/`, `tools/` or `scripts/`. A four-function
pass-through chain across two crates whose only remaining effect was to publish
a third-party type in two public APIs.

**2. The survivors take a carrier.** `open_postgres_pool_with_tls_options`
returns `ironclaw_filesystem::PostgresConnectionPool` and
`RebornEventStoreConfig::PostgresPool` holds one.

The newtype lives in `ironclaw_filesystem`, not in event_store, for two reasons:
it is the only crate `event_store`, `auth` and `composition` can all name
without a new dependency edge, and that crate *is* the Postgres substrate, so
the driver is chartered there (§11.2.6) rather than leaked. It is a carrier, not
an abstraction — `driver()` / `into_driver()` exist for code that runs SQL — and
it deliberately has no `Deref` (an implicit unwrap re-admits the driver into a
signature unnoticed) and a hand-written `Debug` that renders nothing. The
driver's own `Debug` prints its `tokio_postgres::Config`, which redacts the
password (`tokio-postgres-0.7.16/src/config.rs:766-776`) but still prints
`user`, `dbname`, `host`, `hostaddr`, `port` and `ssl_mode` — deployment
topology that a derived `Debug` on any holder would inherit.

**3. Stated residue: composition still names the driver, by charter.** §11.2.6
makes it "the one app-layer crate permitted a database driver", and it needs the
raw pool for `PostgresRootFilesystem::new` and
`CredentialRefreshLeaderLock::for_postgres`. It unwraps the carrier at exactly
one site (`factory.rs`, `open_postgres_pool_from_source`). Pushing the carrier
further down means changing `PostgresRootFilesystem::new`, which has **13 call
sites across 5 crates plus `tests/integration/support/builder.rs`** — a separate
test-wide slice, not this row. Recorded in both docs rather than left implied.

**Enforcement (new file, lands with the change):**
`crates/ironclaw_architecture/tests/reborn_persistence_driver_boundary.rs`
- a shrink-only ratchet on which crates may hold a *normal* `deadpool-postgres`
  dependency (8 today, read from `cargo metadata`, not by eye), and
- a scan proving event_store names the driver only below its private
  `postgres_backed` module — including that the module stays private, since a
  `pub mod` would silently defeat the scan.
Both halves sabotage-checked: a planted
`pub fn sabotage(p: deadpool_postgres::Pool)` fails the second and names the
line; a planted `deadpool-postgres` dep on `ironclaw_projects` fails the first
and names the crate.

**Un-masking** (unfiltered `--list`, name-by-name, against `origin/main` in a
clean baseline worktree):
- `ironclaw_reborn_event_store` 71 → 71, roster identical
- `ironclaw_reborn_composition` 928 → 928, roster identical
- `ironclaw_filesystem` 296 → 296, roster identical
- `ironclaw_architecture` 206 → 208, exactly the two new gate tests
Deleting the four dead functions surfaced nothing, which is the evidence they
were dead. No existing test edited.

Guidance travels: `ironclaw_filesystem/CLAUDE.md` documents the carrier and its
two deliberate omissions; `ironclaw_reborn_event_store/AGENTS.md` records that
the driver cone is owned but not exported, and names the gate.

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

* review(arch): reject a symlink handed in as the walk root too

Fifth CodeRabbit round on #7099, and right again — the previous fix closed the
hole one level too late. `reject_symlink` only sees entries `read_dir` yields,
but both walks push their **root** onto the stack before that ever runs, so a
symlinked root was followed to its target silently. The regression test I added
covered symlinked children only.

`reject_symlink_root` now validates the root with `symlink_metadata` (which does
not follow) before either walk starts, reusing the same rejection so the message
and the policy stay in one place.

The regression test is extended rather than duplicated: it now also symlinks a
root and asserts **both** `rust_sources` and `markdown_assets` panic on it.
Sabotage-checked — removing the two `reject_symlink_root` calls turns it red
("a symlinked walk root must fail rust_sources, not be followed").

Roster still purely additive against `origin/main` (`comm -23` empty); 20 tests
in this file; full `ironclaw_architecture` suite green; clippy `--all-features`
clean.

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

* review(arch): widen the driver-boundary scan past its two blind spots

Three CodeRabbit threads on #7101, all naming the same real defect from
different angles, and all correct: `take(module_start)` stopped the scan at the
`mod postgres_backed` **header**, so the gate was strictly weaker than the three
places documenting it claimed.

Two blind spots, both now sabotage-fixtures rather than prose:
- anything **after** the module body in `lib.rs` — a `pub fn` there naming
  `deadpool_postgres::Pool` kept the gate green;
- **every sibling file** in the crate (`coalescing_sink.rs`, `durable_log.rs`),
  which the scan never opened at all.

The scan now reads every `.rs` file under `crates/ironclaw_reborn_event_store/
src/` minus the brace-matched **body** of the private module. The brace match is
trivia-aware (line comments, nestable block comments, strings, raw strings, char
literals) so a `}` inside a literal cannot end the body early and silently drag
the rest of the file into the exempt range — the same failure class one level
down. It panics on an unterminated body rather than exempting to end-of-file,
and asserts it saw at least two source files.

Four unit tests on the brace matcher: a mention inside the body is exempt, a
mention after the body is not, a brace in a literal does not end the body, and a
file without the module has no exempt range.

Sabotage-checked against the real crate for both former blind spots:
- `pub fn sabotage_after_body(p: deadpool_postgres::Pool)` appended to `lib.rs`
  -> fails, naming `lib.rs:2215`
- the same appended to `coalescing_sink.rs`
  -> fails, naming `coalescing_sink.rs:321`

Also corrected the prose the reviewer flagged as over-claiming, in both places:
`ironclaw_reborn_event_store/AGENTS.md` and the CHECKLIST WS6 row now say
"module **body**" and state that the scan covers every file in the crate, with
the earlier revision's blind spots recorded rather than quietly fixed.

Clippy `--all-features` clean (the scan's test module moved to the end of the
file for `items after a test module`); full `ironclaw_architecture` suite green.

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

* refactor(extractors,observability): typed extraction failures and a one-dependency latency crate (WS6)

CHECKLIST WS6 row "extractors: typed error across the boundary + delete
caller-less `extract_text` (§6.4.10); observability: `json_value_bytes`
eviction (§6.2.5)". Measurements from #7102.

## extractors (§6.4.10)

Failures now cross the boundary as `ExtractionError`, not `String`, at both
public sites (`DocumentExtraction::Failed` and
`extract_document_text_by_filename`). Two variants: `UnsupportedType { mime }`
(nothing was attempted) and `NotExtractable { detail }` (an extractor ran and
could not produce text). `Display` renders the classification and nothing
else; `Debug` carries the payload.

That is not a shape change. The invariant — "carries the error reason for
logging only; callers render a model-safe marker, never this string" — lived
as a doc comment on one of the two boundary sites, and the *other* one leaked:
`ironclaw_extension_support`'s `read_file` interpolated the raw extractor
diagnostic into a model-facing safe summary (`coding/file.rs:325-329`) while
carefully redacting the path one argument earlier. With `Display` content-free
that call site is safe unchanged. Its regression test sits at the call site,
not on `Display`, because the wrapper composing the summary is what leaked.

`extract_text` and `TRUNCATION_MARKER` were both `pub` with zero external
callers; both are private now. The row only named the first. The second
mattered more: `ironclaw_agent_loop` and `ironclaw_mcp` each declare their own
`TRUNCATION_MARKER` with a different value, so it must be resolved by crate,
not by name. The census is exact — no crate writes `use ironclaw_extractors::…`,
so a full-path grep is complete. The private ZIP-safety enum was renamed
`ExtractionError` -> `ZipEntryError` to free the natural name.

## observability (§6.2.5) — delegated ruling, PROPOSAL §12.12 D-K

`json_value_bytes` and its `JsonByteCounter` are localized into the two
consumers; `serde_json` leaves the manifest with them, so the crate now holds
exactly one dependency, `tracing`.

The row's stated reason ("gravity-well hygiene") was wrong; the ruling
survives on a measured one. Of five call sites in extension_support, three
feed `ResourceUsage::set_output_bytes` — resource accounting, not a trace
field — so "it is a latency helper, in charter" is false. And sharing bought
no invariant: `output_bytes` is already computed three different ways in
production (this counter, `output.stdout.len()` in `ironclaw_scripts`,
`Value::to_string().len()` in `ironclaw_loop_host`), because each producer
measures what it produced. `ironclaw_common` was rejected (the crate the
restructure is actively narrowing) and `ironclaw_host_api` was rejected
explicitly rather than by omission (behavior in the contracts leaf is the
specific criticism already on record against it). Cost, stated: ~18 lines and
2 unit tests duplicated across two crates.

## Guidance and docs

New `AGENTS.md` for both crates (both rows asked for one). PROPOSAL §6.4.10
and §6.2.5 amended with dated notes quoting what they replace; §12.12 opened
as the Wave 4 delegated-decision log, continuing §12.11's lettering and
marking discipline. `families/domains.md` and `families/substrates.md`
updated, including a sharpened "never contains" test for observability and a
corrected security role for extractors (its failure type is a redaction
boundary; "none" was wrong).

## Tests

Unfiltered per-crate `--list`, before -> after: extractors 26 -> 28,
observability 2 -> 2, attachments 39 -> 39, host_runtime 1247 -> 1249,
extension_support 152 -> 156, architecture 206 -> 206. Nothing deleted;
nothing edited for content. Observability's two tests moved with the function
and are now duplicated in both consumers (2 -> 4 workspace-wide); its two
replacements pin what actually remains in the crate. Both new guards were
sabotage-verified: break the invariant, confirm red with the right message,
restore, confirm green.

Coverage floors untouched and deliberately so: the source crate
(`ironclaw_observability`) has no floor entry, and the destination
`ironclaw_host_runtime` gains covered lines rather than losing them.

Found and filed rather than patched: #7103 (the coding tool computes its JSON
byte count before checking whether latency tracing is on) and #7104 ("no text
found" classifies as `Failed` rather than `Empty`, so the model is told the
wrong thing about a valid but text-free document).

* fix(extractors): ASCII-only extension normalization + narrow the Debug-payload guidance

Review triage for #7106.

**CodeRabbit thread 2 — accepted.** `.claude/rules/types.md:170` and
`review-discipline.md:45` require case-insensitive external values to be
normalized with `to_ascii_lowercase()`, not Unicode case folding. Both
extension registries in this crate used `to_lowercase()`; the sibling
registry in `ironclaw_extension_support::coding::file`
(`should_extract_document_before_text`) already got it right, so this is the
outlier. Note it is a latent-hazard fix, not a live bug: the eight keys
(pdf/docx/pptx/xlsx/doc/ppt/xls/rtf) contain none of the letters a Unicode
fold can produce from a foreign codepoint, so I could not construct an input
where the two differ today. It removes the hazard for the next key added.
Test pins both halves: ASCII case-insensitivity still works, and a non-ASCII
extension is not folded into an ASCII key.

**CodeRabbit thread 1 — guidance tightened, code change refuted.** The
reviewer is right that this crate's doc told callers to `tracing::debug!(?error,
…)` without naming a ceiling, while `ironclaw_host_runtime/AGENTS.md:28`
forbids unredacted user content in that crate's logs. Both docs now say the
payload belongs in an operator log and nowhere else, and record what it
actually carries. The proposed code change is refused with measurement in
the PR thread: it would log strictly less than `main` does today.

* fix(extractors): the Unicode extension fold was a live bug, not a latent one

Correcting my own claim in 0e7d14e and in the #7106 review reply. I wrote
that `to_lowercase()` vs `to_ascii_lowercase()` was observationally
equivalent here and that I "could not construct an input where the two
differ". That was measured against only ONE of the two extension registries.

`try_extract_by_extension`'s key set is much larger than
`extract_document_text_by_filename`'s eight, and it contains `markdown`:

    "MAR\u{212A}DOWN".to_lowercase() == "markdown"     // U+212A KELVIN SIGN -> k
    "MAR\u{212A}DOWN".to_ascii_lowercase() == "MAR\u{212A}DOWN"

So on `main`, a file named `notes.MAR<U+212A>DOWN` carrying an unrecognized
MIME type took the filename fallback in `extract_text`, was UTF-8-decoded,
and reached the model as markdown instead of being rejected as an unsupported
type. `bash` and `zsh` are in the same key set for the same reason.

Caught by CodeRabbit on #7106, which constructed the input I said did not
exist. Recorded here rather than quietly repaired: the earlier reply's
measurement was wrong and the switch at :707 is a behaviour fix.

Regression test extends `extension_matching_is_ascii_case_insensitive_and_
nothing_more` with the `markdown` fold in both registries plus the public
`extract_document` path that actually reaches the fallback. Sabotage-verified:
reverting :707 to `to_lowercase()` turns it red on the named assertion.

* fix(arch): make the driver-boundary visibility check reachable and the scan multi-line safe

Review found this gate weaker than its docs for the third time. Both findings
were real; both are fixed at the seam and pinned in both directions.

1. The `pub mod` assertion could never fire. The header was matched with
   `starts_with("mod postgres_backed {")`, so a line beginning `pub ` was not
   the matched header and the `!starts_with("pub ")` assertion below it was
   dead. A visible module was simply not found: the exempt range came back
   empty and the failure blamed whichever driver mention was reported first
   rather than the visibility change that broke containment. The header now
   keys on the `mod postgres_backed {` token and asserts on the captured
   visibility prefix, so `pub` and `pub(crate)` both fail by name.

2. String state did not survive a newline, and that was fail-open. Block
   comments were carried across lines; regular and raw strings were not, so the
   continuation lines of a multi-line literal were scanned as code. A `}` there
   truncated the body, and a `{` there stretched it past the module's real end
   and swallowed every driver mention after it. With an unbalanced `{` in a
   multi-line literal and a `deadpool_postgres::Pool` in a public signature
   after the body, the old scan reported ok; the new one fails on lib.rs:2217.
   The raw-string terminator is now searched over bytes, so a multi-byte
   character in a literal cannot leave the index off a char boundary and panic.

Regression tests (all failed before the fix, except the last which had no
fixture at all): multi-line literal boundary in both directions plus raw
strings, `pub mod` and `pub(crate) mod` rejection, the widened header match not
mistaking a comment or string for the declaration, and the unterminated-body
panic that AGENTS.md and CHECKLIST.md both present as part of the guarantee.

Both fixes sabotage-checked against the real event_store source, not only
fixtures. The weakness is recorded in the CHECKLIST row and AGENTS.md rather
than quietly repaired.

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

* refactor(config): retire the vendor config sections behind a generic window (WS6)

`[slack]` and `[telegram]` were the last per-vendor sections in
`ironclaw_reborn_config`. Nothing reads them: the enablement gate they fed
was deleted with the unified extension runtime (#6116), so `config set
slack.enabled true` printed "saved" for a value with no runtime consumer.

Replaces the typed vendor schema with a generic retired-section table:

- delete `SlackSection`, `SlackChannelRouteSection`, `TelegramSection`,
  their three builders, and `update_slack_enabled`
- `RebornConfigFile` no longer names a vendor; retired sections are split
  off the raw document before the typed parse, so the schema stays
  `deny_unknown_fields`
- `reject_legacy_slack_config` becomes `reject_retired_config_sections`,
  data-driven by the same table (PROPOSAL §12.2's "relocated shape")
- `config set slack.enabled` now answers with migration guidance instead
  of writing a value nothing reads

Compatibility window preserved and widened: an existing `config.toml`
still parses, a retired *setup* key still fails the boot closed with the
same message, an inert section still boots — and now says so instead of
being silently ignored. Inline-secret rejection over retired sections
goes from nine hardcoded keys to every string at any depth.

Parse diagnostics: files with no retired section keep the line/column
span on unknown-field errors (the split re-parses the original text);
only files already carrying a retired section see the degraded form.
Measured, and pinned by a test.

Sabotage-testing the new guards found one of them inert: the scalar
re-insert test only covered `slack = 1` alone, which takes the fast path
and would catch it either way. Widened to `slack = 1` beside a genuine
retired section, which is the case that actually bypasses
`deny_unknown_fields` without the re-insert. The reachability-vs-fidelity
limit of the table-driven key test is recorded in its doc rather than
papered over.

Extension-specificity allowlist 127 -> 125 (baseline lowered to match):
the two surviving vendor tokens are the TOML table names, quarantined in
`retired_sections.rs`.

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

* docs: correct the Slack/Telegram enablement gate that no longer exists

The retired `[slack]`/`[telegram]` sections had a documentation half. Five
operator-facing docs still taught a gate deleted by #6116 (2026-07-21):
`setup-slack-for-reborn-binary.md` called it the binary's "one gate" and
described `IRONCLAW_REBORN_SLACK_ENABLED=false` as a "deployment kill
switch" (it is not — Slack stays mounted), and its troubleshooting step
could never fix anything. README instructed a `config set slack.enabled`
command that now fails.

Replaces the gate story with the real one everywhere: the ingress route is
compiled in and mounted unconditionally, answers 503 until the extension's
signing secret is registered, and 401 on signature mismatch — Slack and
Telegram go live by installing the extension and finishing setup at
/extensions. Adds a migration note where an operator with an existing file
would look.

Also removes `IRONCLAW_REBORN_SLACK_PERSONAL_OAUTH_REDIRECT_URI` from
`docs/channels/slack.mdx`: zero readers in `crates/`. The CLI already had a
regression test asserting that variable must never be advertised in
remediation text, so its retirement was known — only the docs kept saying it.

Records amendments in the target-architecture docs (CHECKLIST WS6 rows,
PROPOSAL §6.10.3 with the placement decision and rejected alternatives,
§12.2's compat constraint) and corrects a phantom test citation in the
extension-runtime checklist.

Filed rather than patched: #7115 (docker entrypoint gates its migration on
the dead env var, so following the docs skipped it) and #7116 (live-QA
runner gates Slack cases on a value it writes itself).

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

* ci(planner): classify `.env.example` so a comment fix is not a full-matrix failure

The Reborn PR test planner is fail-closed on unknown paths, and had no rule
for `.env.example`. Repo-root `*.md` was classified; its non-`.md` sibling
was not, so this PR's env-var comment correction aborted the planner with
`unclassified pull-request path: .env.example` and failed the whole
`Tests (Reborn)` roll-up on a change with no build surface.

Nothing reads the file — no crate, test, or workflow; only doc comments name
it by name. Classified rather than exempted, following the `.claude/`
precedent added 2026-08-03, whose comment states the rule this follows:
classify the path, do not loosen the arm that catches genuinely unknown ones.

Regression test asserts all three halves: the path is accepted, it selects no
Rust lane (so a future "classification" that turns a comment fix into a full
matrix also fails), a real change riding along still selects its lane, and an
unknown root file (`.env.local`) still raises. Verified by sabotage — removing
the classification turns the new test red.

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

* fix(composition): gate three test-support-only imports so dependency builds lint clean

`origin/main` already fails `Code Style` clippy for the package set
`{ironclaw, ironclaw_reborn_config}` — verified on a clean detached
checkout of `dfdd02b9fb`, exit 101, three unused imports in
`composition/src/runtime.rs`. This PR is simply the first to produce that
set, so it inherited the failure.

Mechanism: the PR clippy lane derives `-p` from the diff and adds
`--all-features`, which applies to *selected* packages only. All three
imports are named solely by `#[cfg(any(test, feature = "test-support"))]`
accessors, so when composition is a mere dependency its `test-support` is
off, `--lib --bins` also drops `#[cfg(test)]`, and the imports go unused.
With composition in the selected set, `--all-features` turns the gate on
and the same command passes.

Gating the imports to match their users is the minimal correct fix —
they are used, so deleting them would be wrong and `#[allow]` would hide
the real property. Verified both directions: the PR-lane invocation and
`-p ironclaw_reborn_composition --all-targets --all-features` are now
both exit 0.

The class of bug — a lint gate whose verdict depends on which packages a
PR happened to touch — is #7119; this commit only unblocks. Touching an
otherwise-occupied crate deliberately kept to three `#[cfg]` attributes
and a comment.

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

* docs: review fixes — google CLI path, Slack setup location, retired-key wording

Three CodeRabbit findings, each verified before acting:

- `capabilities/configuration.mdx`: `config set google.*` is still a
  supported path (README and `using/cli.mdx` both document it), so
  "configure it from the web interface rather than by hand" was wrong.
  Names both paths now.
- `reborn/setup-slack-for-reborn-binary.md`: the 503 troubleshooting step
  pointed at `/extensions` generically and then called the same thing
  "Admin Configuration" — a third name for a place `docs/channels/slack.mdx`
  documents precisely (Extensions -> Channels tab -> Configure on the Slack
  card), including a warning that Extensions opens on the Registry tab,
  which is not it. Aligned to that wording, since it is the more specific
  of the two and matches the UI.
- `using/cli.mdx`: "everything else is edited in config.toml directly" no
  longer holds for retired keys.

The fourth finding is refuted in the thread: it asked for a
"retired setup keys fail at serve" caveat on the `[telegram]` note, but
`RETIRED_SECTIONS` gives telegram `rejected_keys: &[]` — it never had a
setup field, so no `[telegram]` section can fail a boot. Adding the caveat
would document behaviour that does not exist.

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

* docs(slack): tie "Admin Configuration" to the Slack card once, in the guide

The setup guide names the operator-facing concept ("Admin Configuration for
Slack", 7 references) while docs/channels/slack.mdx names the UI path
(Extensions -> Channels tab -> Configure on the Slack card). They are the
same dialog, but nothing said so, and my earlier fix only rewrote the
troubleshooting paragraph — leaving one place described two ways.

Defines the equivalence once, next to the first use, and points the 503/401
steps back at it instead of restating the UI path a second time. Rewriting
all seven references would churn a guide this PR is otherwise only
correcting for the retired enablement gate.

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

* refactor(traces): split contribution.rs into chartered modules

`crates/ironclaw_reborn_traces/src/contribution.rs` was 17,470 lines — the
largest single file in the tree — and carried an `// arch-exempt: large_file`
waiver from a 2026 mechanical rename (plan #6168). WS6's domain-internal
cleanup row and PROPOSAL §6.4.14 both call for splitting it into chartered
modules.

It becomes a directory module of 13 production submodules plus a mirrored test
tree, each named for one owner in the pipeline (capture → redact → classify →
score → queue → submit). `src/contribution/mod.rs` carries the charter table
that says which module a new item belongs to, plus the two rules that keep it
honest: redaction is split by key (pattern vs tool-name), and `queue` owns
state / `remote` owns the wire / `submission` is the only caller of both.

The waiver is deleted rather than carried forward, and no new one is added:
every file is under the 1,500-line ARCH-SPRAWL threshold (largest is 1,290).

No public API change and no consumer edits. The submodules are private and
`mod.rs` glob-re-exports them, so `contribution::X` remains the single public
path for all four consumer crates. Items that newly cross a module line were
widened to `pub(crate)`, never to `pub`.

Verification:
- Item roster diffed against origin/main: 501 top-level items before, 501
  after, zero missing and zero extra.
- Unfiltered `--list` before and after: 216 lib tests, leaf names identical.
  All 216 + 2 integration tests pass.
- `cargo clippy --benches --tests --examples --all-features` clean on
  ironclaw_reborn_traces and ironclaw_architecture.

The four `PATH_TERM_COLLISIONS` carve-outs that pinned the old file path are
repointed and, in the process, narrowed: the vendor-name safety denylist now
resolves to `tool_payloads.rs` (the rule tables) and `classification.rs`
(external-write detection, `slack` only) instead of one 17k-line whole-file
carve-out, so the specificity gate now polices the rest of the module. Those
entries are staleness-checked, so the old path would have failed loudly.

Adds the crate's first guidance file, recording the glob-re-export invariant
and the three known gaps on §6.4.14's row that this PR does not close
(ScopedFilesystem adoption, the two re-export modules, the crate rename).

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

* docs(reborn): record the traces contribution.rs split and correct two stale clauses

Amends CHECKLIST WS6's domain-internal-cleanups row and PROPOSAL §6.4.14
(plus the anti-pattern inventory and the crate-disposition table) with what
landed, quoting the text each amendment replaces.

Two corrections the work surfaced, recorded rather than silently fixed:

- §6.4.14's "17,467-line contribution.rs" measured 17,470 on main; the file
  drifted after the entry was written.
- The CHECKLIST's shorthand "`ScopedFilesystem` + re-export modules dropped"
  is worded backwards for the first clause. `ScopedFilesystem` is
  `ironclaw_filesystem`'s type, is used by ~170 files across the workspace,
  and is absent from `ironclaw_reborn_traces` entirely — there is nothing to
  drop. §6.4.14's actual instruction is adoption ("take a `ScopedFilesystem`
  instead of raw `dirs`/env access"), which is a persistence-plane change
  across ~91 raw fs call sites, not a deletion. Left as-is with the reason
  stated, so the next reader measures rather than inherits.

Also records why the two remaining traces clauses did not land in this wave:
dropping the `recording`/`paths` re-export shims needs edits in
`ironclaw_reborn_cli`, and `recording` additionally needs a decision because
the CLI has no `ironclaw_llm` dependency to fall back on.

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

* fix(traces): serialize test process-env mutation behind lock_env()

The split re-surfaced five unguarded `std::env::set_var`/`remove_var` call
sites that CI's `check-hermetic-env.sh` had been grandfathering: they are
byte-identical pre-existing lines (contribution.rs:10501/10513/10515/15648/
15661 on origin/main), and the gate only skipped them because it is
delta-scoped and the file had not been re-added since it was written.

This is a real gap, not a false positive, so it is fixed rather than
annotated. `EnvVarRestore` restored the previous value on drop but took no
lock, so two tests mutating the environment on different threads still raced —
undefined behavior on Rust 1.82+ regardless of whether they name the same
variable. `workload_token_env_mode_reads_env_unchanged` used a uniquely named
variable, which avoids logical interference but not the setenv/getenv data
race.

Both now acquire `ironclaw_common::env_helpers::lock_env()`, the sanctioned
helper the gate's message names. `EnvVarRestore` holds the guard as a field
declared last, so it is released only after `Drop::drop` has restored the
value — the restore is inside the critical section, not after it.

The real process environment is kept (not `env_helpers::set_runtime_env`'s
overlay) because the sidecar isolation test needs a value a child process
would inherit, to prove `CommandPrivacyFilterAdapter` clears it.

One `#[allow(clippy::await_holding_lock)]` on the async test, matching the
precedent in `ironclaw_operator/src/llm_admin/llm_config_service.rs`: holding
the lock across the await is the intent, and `#[tokio::test]` drives the
future on a current-thread runtime so the guard never crosses threads.

Verified: `check-hermetic-env.sh` exits 0, clippy clean, 216 + 2 tests pass.

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

* fix(traces): apply CodeRabbit review — carried waiver, inert test, charter drift

Six findings verified against the code; four were defects this PR introduced or
carried, and each is fixed.

1. **A second file-size waiver was carried forward after all.** `queue.rs` still
   held the in-body "File-size justification … already-oversized module …
   decomposition tracked in issue #4088" block, which contradicts a PR whose
   whole point is performing that decomposition. Deleted; the coupling
   rationale it was wrapped around (why credential resolution lives beside the
   policy/scope-dir helpers) is kept, since that still explains the layout.

2. **`invite_code_gated_by_auth_mode` was inert.** It re-implemented the
   `match policy.auth_mode` expression from
   `build_trace_upload_claim_issuer_request` and asserted against its own copy,
   so deleting the `DeviceKey => None` arm in production left it green. It now
   calls the production builder and asserts on the *serialized* request, so a
   field rename cannot hide a leak either. Sabotage-proved: removing that arm
   now fails with the leaked invite code visible in the body.

3. **The charter claimed "each stage owns one file"**, which `remote`'s four
   files contradict. Reworded to module-level ownership, naming `remote` as a
   directory module and why. `CLAUDE.md`'s test-layout paragraph gets the same
   correction plus the explicit `remote` → four-test-module mapping.

4. **Five policy-serde tests sat in `claims.rs`.** They verify
   `StandingTraceContributionPolicy`, whose owner is `policy.rs`, and the PR's
   own rule is that a test lives with its production owner. Moved to a new
   `tests/policy.rs`; leaf names unchanged.

5. **Three orphan section headers** left behind by the split, describing tests
   that now live in other modules (`credentials.rs`, `profile.rs`, `value.rs`).
   Deleted.

The remaining two findings are real but pre-existing and need behavior changes,
so they are filed as #7127 rather than fixed here: the case-sensitive remote
`status` comparison that skips the local revocation record, and
`fetch_account_traces` taking two adjacent `&str` where its sibling takes
`&TenantId, &UserId` (its fix needs an edit in `ironclaw_product`). The issue
also carries the `trace_scope_has_pending_queue` doc/code mismatch, which needs
an intent decision rather than a guess.

Re-verified: 501/501 production items, 216 tests with identical leaf names,
clippy clean, hermetic-env clean, every file under 1,500 lines.

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

* test(traces): use the RAII env guard and cover the bearer at the caller

Second CodeRabbit pass, both findings on the test this PR had already touched.

1. **RAII guard instead of manual cleanup.** `workload_token_env_mode_reads_env_unchanged`
   set the variable, awaited, asserted, then removed it — so any panic before
   the last line leaked the variable into every later test. It now uses
   `EnvVarRestore::set`, whose `Drop` restores during unwinding while holding
   the same process-env lock. That also deletes both `unsafe` blocks and the
   `#[allow(clippy::await_holding_lock)]`: the guard lives in a struct field,
   which the lint does not flag, so the suppression is no longer needed.

2. **The bearer token had no caller-tier coverage.** Five tests assert what
   `issuer_request_bearer` returns; none asserted the token reaches the wire.
   The direct issuer path attaches it conditionally
   (`if let Some(bearer) = issuer_bearer { request.bearer_auth(bearer) }`), so
   a helper regressing to `None` would send an unauthenticated request with
   every existing test green — the repo's "test through the caller" rule names
   exactly this shape.

   Adds `workload_token_reaches_the_issuer_request_as_a_bearer_header`: a mock
   issuer captures the `Authorization` header while
   `fetch_trace_upload_claim_from_issuer` drives the real path. Sabotage-proved
   — dropping the `bearer_auth` attach fails it with
   `left: None, right: Some("Bearer wire-bearer-xyz")`; restored, green.

Test accounting: 216 → 217. All 216 original leaf names still present (diffed
against the `origin/main` baseline); the one addition is the new caller-tier
test. Clippy clean, hermetic-env clean.

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

* docs(llm): add the enforced sub-owner map (WS6 module charters)

PROPOSAL §6.4.13 asks `ironclaw_llm` for "internal module charters for its
five sub-owners". This adds the map to `crates/ironclaw_llm/CLAUDE.md` and,
because a charter nobody checks rots within a release, a test that pins it.

**Five sub-owners were not enough, measured.** `providers` / `auth-sessions` /
`registry` / `decorators` / `recording` own 28 of 48 files (79.6% of lines),
leaving 20 unowned — including `lib.rs`, `provider.rs`, `error.rs` and
`config.rs`. Five more are named, each with a stated reason rather than a
residual bucket: `core-contract` (the trait, vocabulary, error taxonomy and
config are *upstream* of every implementor, so charging them to `providers`
would make providers own decorators' and recording's own dependencies),
`normalization` (cross-provider wire hygiene, as opposed to the single-provider
shims that stay beside their provider), `model-catalog` (facts about *models*,
a different noun from registry's catalog of *providers*), `transcription`
(`TranscriptionProvider` is a different trait; nothing there implements
`LlmProvider`), and `test-support` (a published feature with its own
compatibility obligation).

**`tests/module_charter.rs` enforces it.** Every `src/**/*.rs` must appear in
exactly one row, every path in a row must exist, and no file may be claimed
twice. Sabotage-proved in all three directions — dropping `retry.rs` from the
table, adding a phantom path, and double-claiming `registry.rs` each fail with
the right message; restored green. The test also guards itself: it fails if the
table parses to zero rows or if the source walk finds implausibly few files, so
a table-shape change cannot silently turn it into a no-op.

**§6.4.13's "Deletes: reasoning.rs (4.5k lines, zero external references)" is
refuted.** The file is 1,299 lines after #6964 removed its dead half, and the
survivor is live: `lib.rs:88-91` re-exports three helpers with five production
call sites in `crates/ironclaw_loop_host/src/model_gateway.rs`. It is charted
under `normalization`. `AGENTS.md` carried the same staleness ("legacy
reasoning engine") and is corrected; it also now points at the map as
authoritative so its informal buckets cannot quietly become a second source of
truth.

Four placement calls are recorded rather than left implicit: `token_refreshing.rs`
is auth-sessions not decorators (CLAUDE.md and AGENTS.md disagreed);
`runtime.rs` and `smart_routing.rs` force the decorator definition to widen
from "reliability wrapper" to "wraps `dyn LlmProvider` and is not credential
work"; `url_check.rs` is core-contract; and `gemini_oauth.rs` is genuinely two
owners in one file, charged to the larger half with the split recorded as owed.

CHECKLIST and PROPOSAL §6.4.13 carry dated amendments quoting the text they
replace, including why the row's `providers.json` clause is blocked (its
load-bearing include site is in `ironclaw_reborn_cli`, which is occupied, and
it needs a new mechanism rather than a new path).

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

* docs(traces): correct the claims/policy test-module docs after the move

The script that moved the five policy-serde tests copied `claims.rs`'s
preamble verbatim, so `policy.rs` ended up with two module docs — its own and
a carried-over line describing claims. And `claims.rs`'s own doc still opened
with "Standing-policy serde", which stopped being true the moment those tests
left.

`policy.rs` keeps only its own doc; `claims.rs` now describes what it actually
covers (upload-claim cache keys, issuer error labels, the bearer the issuer
request carries, device-key auth modes) and points at `policy.rs` for the
policy serde contract.

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

* fix(arch): lower the specificity ALLOWLIST baseline 125 -> 124 after the re-baseline

#7117 measured `ALLOWLIST` 127 -> 125 against `origin/main` @ `1e2a294083`.
#7094 then deleted one entry on `main` (127 -> 126), so this branch's two net
removals now land on 124, not 125. The ratchet is `<=`, so it stayed green at
125 while carrying a unit of untracked slack — exactly what the constant's own
doc forbids: "Lower it in the same PR that deletes entries so the new floor is
locked in."

Read off the ratchet's own failure message with the baseline temporarily set to
`0` ("ALLOWLIST grew to 124 entries"), never counted by eye — a plain paren
count over the literal answers 142, because the entries' comments contain
parentheses too.

Sabotage-verified in both directions: baseline 123 goes red naming 124, and 124
is green 7/7. The file's function roster is unchanged.

* docs(checklist): map the WS6 "Domain-internal cleanups" row clause by clause

The row bundles eight clauses and the Wave 4 part-1 consolidation closes one of
them (the `traces` `contribution.rs` split). It stays open, correctly — but a
reader of the row could not tell which of the remaining seven had been measured
and which had not, and the `llm` `providers.json` measurement lived on the
"Module charters" row two rows down because that is where the agent who made it
was working.

Adds item 7: a clause-by-clause status map — one done, three measured with the
blocker named (including a pointer to where `providers.json` was measured), four
untouched. No box is ticked; the row's real condition is unmet and stays unmet.

Also fixes a stray space-semicolon left in the "Composition behavior evictions"
row where the system-prompt clause was struck through.

* review(ws6): fix seven findings on code this consolidation introduced

CodeRabbit's pass over the consolidation raised 40 threads. 29 are on
production code #7124 only *moved* and are filed as #7144. These seven are on
code this program wrote, and all seven were correct.

**A gate that was not scanning what its doc claimed.** The driver-boundary walk
used a flat `read_dir` while its doc said it scans "**every** `.rs` file in the
crate". `crates/ironclaw_reborn_event_store/src` is flat today, so nothing
escaped — but `src/postgres/pool.rs` is exactly where a driver mention would go,
and a skipped file is indistinguishable from a clean one. Now recursive and
symlink-rejecting, matching the shape `reborn_composition_boundaries.rs` already
uses in this same PR. Sabotage-proved against the real crate: a nested
`postgres/pool.rs` naming `deadpool_postgres::Pool` now fails the gate naming
`pool.rs:1`, and passed silently before. This is the third revision of this gate
found weaker than its own docs; the doc now says why.

**A charter gate that a table reformat would have broken.** `module_charter.rs`
matched the separator row with `cells[0].starts_with("---")`, so an aligned
separator (`|:---|:---|`) parsed as a *data* row: `:---` became an assigned path,
`saw_row` went true so the shape guard stayed quiet, and the stale assertion
reported `:---` instead of a diagnosis. Sabotage-proved both ways — with the fix
reverted and the table rewritten in aligned form the test goes red on `:---`;
with the fix it passes.

Also:
- `CONTRACT.MD` added to the composition guidance allowlist. The repo already
  ships it as crate-local guidance (`ironclaw_reborn_identity`, `ironclaw_trust`)
  and CLAUDE.md's module-spec table names it, so a composition `CONTRACT.md`
  would have been reported as prompt content and sent the author to the wrong fix.
- `markdown_assets` gains its first real test: the case-insensitive `.md` match
  and the caller's guidance filter were both unpinned, and both drift quiet.
- Two fixtures for comment-braced module bodies (line comment, nested block
  comment) — the scan handled them, nothing pinned it.
- The symlink rationale doc block moved onto `reject_symlink`, which it describes;
  it was stacked above `reject_symlink_root` with no item between, so both
  attached to the wrong function and `reject_symlink` was undocumented.
- The retired-section deprecation warn gains `target = "ironclaw::reborn::cli::serve"`,
  like every other warn on that path. Announcing an inert section is pointless if
  an operator filtering the documented startup target cannot see it.
- `ironclaw_reborn_traces/CLAUDE.md` claimed a one-to-one test mapping that
  `tests/credentials.rs` breaks (it spans `queue.rs` and `remote/claim.rs`). The
  exception is now stated rather than left to be inferred.

Rosters in both architecture test files are purely additive; no test removed.

* docs: correct the extension-specificity allowlist numbers after the re-baseline

Caught in review of #7139. Both ledgers still recorded #7117's measurement,
`Extension-specificity allowlist **127 → 125**`, taken against `origin/main` @
`1e2a294083`. #7094 then deleted an entry on `main` (127 → 126), so the same two
net removals land on **124**, which is what the shipped baseline says.

This is the cross-slice-number failure mode the consolidation exists to catch,
one layer down: the code was corrected in 811bfedeff and the prose was not.
Both amendments quote the text they replace and record the method — read off the
ratchet's own failure message with the baseline temporarily set to 0, never
counted by eye.

No checkbox state changed.

* review(ws6): three more review findings, one of which broke my own fix

**My `target =` fix did not work, and CodeRabbit was right to call it.**
`tracing::warn!(target = "…")` records a *field* named `target`; it does not set
the event's metadata target, which stays the module path. So the retired-section
notice — given a target in #7117 precisely so operators would see an inert
`[slack]`/`[telegram]` section announced — was still invisible to a subscriber
filtering `ironclaw::reborn::cli::serve`.

Measured with a capturing subscriber rather than argued:

    EQUALS-SYNTAX target = "target_probe"                    <- module path
    COLON-SYNTAX  target = "ironclaw::reborn::cli::serve"    <- correct

Now `target:`, and pinned by `retired_section_notice_is_emitted_on_the_serve_target`,
which asserts the emitted **metadata** target through the real
`reject_retired_config_sections` call. Sabotage-proved: the `=` form makes it red
with `observed targets: ["ironclaw::commands::serve"]`.

This is repo-wide — **121 sites** use the `=` form against an `ironclaw::…`
target, including the three sibling warns on this same serve path (`:318`,
`:387`, `:454`). Filed as #7146 rather than fixed here; a consolidation should
not carry a 121-site mechanical change.

**The markdown gate's test was testing a copy of itself.** My new test carried
its own duplicate of the guidance allowlist, so the production filter could drop
`CONTRACT.MD` and the test would still pass — the "test through the caller" rule.
Extracted `is_crate_guidance` / `shipped_non_guidance_markdown`; the gate and the
test now share one path. Sabotage-proved by dropping `CONTRACT.MD` from the
shared helper: red with `left: ["CONTRACT.md", "seed.MD"]`.

**The separator fix had no committed regression test.** It was sabotage-proved by
hand, which does not survive the session. `parse_sub_owner_table` is split out
from the file read so a fixture can supply separator shapes the checked-in
`CLAUDE.md` does not use, and `an_aligned_separator_row_is_not_parsed_as_data`
covers unaligned, left-aligned and centred. Red when the fix is reverted.

Rosters purely additive in all three files; no test removed.

* chore(ci): exempt the traces-split re-attributed lines that failed the merge queue

The merge-queue run (Tests (Reborn) 30917327135) failed the changed-line
coverage gate at 80.67% vs the 90% floor. 1,284 of the 1,311 uncovered
changed lines are the #7124 contribution.rs split re-attributed as new
code — the same queue run PASSED ironclaw_reborn_traces' per-crate
covered-line floor, which is direct proof the split lost no coverage
(the #6963 gate-vs-restructure collision class, same as the WS1.1
precedent entry). Exact-line exemptions per the manifest's policy; the
27 genuinely-new uncovered lines in other slices are deliberately NOT
exempted (post-exemption aggregate ≈99.5%). Also merges main @
d06f80413d (clean). 113/113 changed-coverage self-tests green.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:52:44 +00:00
jinxin
ae1dc1178a fix(projects): enable lifecycle deletion with E2E coverage (#7058)
* fix(projects): allow scoped project record deletion

* test(e2e): cover project lifecycle and membership

* test: strengthen project lifecycle review coverage

* fix(ci): route Reborn E2E paths to dedicated workflow

---------

Co-authored-by: aiworkbot <220660587+aiworkbot@users.noreply.github.com>
2026-08-04 16:09:43 +00:00
jinxin
e24717d612 test(e2e): cover first-run LLM onboarding (#7057)
* test(e2e): cover first-run LLM onboarding

* test(e2e): strengthen onboarding assertions

* fix(ci): classify e2e paths in PR planner

---------

Co-authored-by: aiworkbot <220660587+aiworkbot@users.noreply.github.com>
2026-08-04 15:31:57 +00:00
firat.sertgoz
283e1f6b7c fix(release): require explicit approved release cuts (#7122)
* fix(release): require explicit approved release cuts

* fix(release): harden manual release cut

* fix(release): honor annotated tag depth limit
2026-08-04 11:31:21 +00:00
Benjamin Kurrek
f946a93fae Close Wave 2: extension registry re-layer, include_str! kills, nested-tree coverage (WS2 + #7083) (#7094)
* refactor(extensions): re-layer the extension registry to substrates (WS2)

`ironclaw_extensions` moves `loops` -> `substrates`, which is PROPOSAL
§6.8.1's assignment for the crate. Four `LAYER_MATRIX_EXCEPTIONS` fall out
with it and the WS0 ratchet baseline drops 10 -> 6.

The four were one edge under four names: `host_runtime`, `capabilities`,
`mcp` and `scripts` — kernel/runtimes-tier crates — reaching the registry for
its manifest DTOs. None is waived and none of those edges is deleted; the
registry moved down to a layer every tier above may legally reach. §6.8.1
predicted two of them ("Layer substrates legalizes `capabilities ->
extensions` and `host_runtime -> extensions`"); it undercounted, and the
amendment records that.

The one edge that blocked the move is gone rather than waived. At
`substrates` a crate may name only `contracts`/`substrates`, and
`ironclaw_extensions` named `ironclaw_trust` (kernel) for exactly one type,
`TrustPolicyInput`, used by one method. Every field of that type is already
`host_api` vocabulary — `PackageIdentity`, `RequestedTrustClass`,
`BTreeSet<CapabilityId>` — and the type names no decision, ceiling, or
provenance, so it is requested-trust vocabulary like everything else in
`ironclaw_host_api::trust`. It moves there, which is §6.8.1's own
prescription ("`trust`-vocabulary via `host_api`"), and every consumer's
import is repointed rather than shimmed behind a re-export
(.claude/rules/type-placement.md).

Behavior-free: a type relocation, a layer declaration, and the exception
entries the layer change makes unreachable.

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

* refactor(extensions): source package manifests from the inventory, not include_str! (WS2)

The WS2 `include_str!` row's named targets — gmail, github, nearai-mcp — plus
the two cross-crate manifest reach-ins #7018 added.

**nearai-mcp gets its inventory module.** It was the one asset directory of
twelve with no module in `ironclaw_extension_support::packages`, so
`available_extensions.rs` embedded its manifest and three assets directly.
Those embeds move to `packages/nearai.rs` beside every other package's, and
`available_extensions.rs` consumes `nearai_bundle()`.

It is deliberately **not** a `PACKAGES` entry, and the module says why at
length: every other entry is a config-free `fn() -> PackageBundle`, but NEAR
AI's shipped `[mcp].server` is a placeholder the host rewrites from the
operator's LLM-admin bootstrap config. A config-free builder cannot produce
that value, so the *embeds* live with the inventory and the *patch* stays with
the endpoint authority. `PackageBundle::manifest_toml` is already a `Cow`
precisely so the patched manifest is representable.

This makes `ironclaw_extension_support` a normal dependency of
`ironclaw_extension_host` instead of a `test-support`-only one. No new
dependency cone: the binary already links it, and `extension_host` already
built against it under that feature.

**github and gmail fixtures are inlined.** Both were test-only reach-ins into
a shipped product manifest. Each test needs one property — a v3 manifest
asserting first-party trust; a no-channel manifest with an
`[admin_configuration]` group — now spelled out in the test that needs it
instead of borrowed from 200 lines it does not own.

**The two cross-crate sites route through `bundled_packages()`.** slack and
telegram carry adapter crates, so `extension_manager`'s manifest reach-ins
were classified cross-crate. The tests' stated intent — project the *shipped*
field set, not a drifting fixture — is unchanged; only the path changed, from
a relative file path to the inventory that owns the bytes.

Measured with the §11.2.7 scan: escaping sites 133 -> 128, cross-crate
19 -> 17. `REPORT_ONLY` stays `true` — the 17 survivors belong to three other
owners (the support crate's own slack/telegram package crates, host_runtime's
seven memory-provider embeds, and four test-only doc reach-ins in
`operator`/`product`), none of which this row owns. The doc amendment names
each.

The `("…/available_extensions.rs", "nearai-mcp")` specificity carve-out is
deleted with the embed it covered; the allowlist is shrink-only and
staleness-checked, so leaving it would fail.

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

* ci: make Reborn coverage aggregation nested-tree-safe (#7083)

Coverage was structurally dark for every crate under `crates/extensions/`.
`reborn_coverage_lcov.py` keyed on `crates/(ironclaw_[A-Za-z0-9_]+)/` — a
literal path shape requiring `ironclaw_*` *directly* under `crates/` — and the
`if match:` it gated guards the global aggregate as well as the per-crate
table, so an unmatched record left both numerator and denominator. Five crate
directories (~33.7k instrumented lines) contributed nothing to a gate reading
`enforce = true`, and nothing said so: there is no `else`, no counter, no
warning.

This is a regression, not a never-worked condition. All five were flat
`crates/ironclaw_*` until #7037 colocated packages three days ago; the module
has one commit in its history and predates the move. The `[global]` floor was
captured 2026-07-30, before that, so the denominator has silently shrunk under
the gate that enforces it.

A better regex cannot fix it. Four of the five directory basenames contain no
`ironclaw_` at all (`packages/slack`, `telegram`, `mem0`, `memory-native` —
PROPOSAL §5.1 names package directories by extension identity), and a greedy
nested pattern mis-attributes an in-crate `src/ironclaw_*/` module directory
to a crate that does not exist. So the fix is the one the *merge* script one
step upstream already applies: anchor on the discovered crate inventory
(`crate_tree.py`). The data was always in the merged lcov; only the aggregator
was blind, and the two disagreeing is what made the hole silent.

Three consequences worth stating:

- **The accounting key is the crate directory basename** — what
  `crate_tree.crate_directory()` resolves by and what `classify-test-scope.sh`
  keys on. Every existing floor and exemption key is already a basename, so
  none churns; basenames also survive the family moves still ahead
  (`crates/ironclaw_llm` -> `crates/substrates/ironclaw_llm`).
- **Separate workspace roots are excluded explicitly**, checked *before* the
  crate pattern. "Outermost wins" would otherwise attribute
  `packages/slack/wasm-src/` to `packages/slack/`, putting never-compiled
  guest code in a denominator. Same precedence `reborn_changed_coverage.py`
  applies.
- **It fails closed.** No discoverable crate tree is now a refusal, not a
  percentage computed over an empty inventory — the WS10 rule this whole class
  of bug violates.

Regression proof: six new cases (A6b/A6c/A6d/A6e, R19/R19b) covering a nested
crate in the table *and* the aggregate, a non-`ironclaw` basename, a
separate-workspace guest, a vendored third-party `crates/` subtree, the
fail-closed refusal, and a floored nested crate passing and failing its
covered-lines floor. The suite gains a shared fixture crate tree, because the
aggregator now needs one — the old shape needed no tree at all, which is
exactly why every case stayed green while 11 crates went dark. Local:
166/178, with the same 12 pre-existing macOS bash-3.2 `mapfile` failures in
the C section that the tree has today (148/160 before this change).

Floors for the newly-visible crates are captured separately, from a real
coverage run — floors invented without a measurement would bake the hole in.

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

* test(extensions): restore behavioural coverage for the retired slack_user migration

`remove_retired_internal_installation` has had **no behavioural coverage since
#6616**, which deleted
`restore_removes_retired_slack_user_installation_without_catalog_entry` and
replaced it with `assert_eq!(RETIRED_SLACK_USER_EXTENSION_ID, "slack_user")` —
a constant compared to its own literal.

That gap matters more than most: the branch runs on **every boot** and
**destructively** deletes persisted installation rows, and its disposition is
an open owner decision (PROPOSAL §12.11 D-I, escalated 2026-08-02, deliberately
not ruled by the delegated-authority pass). D-I's recommended sequencing lists
restoring this coverage as step (i), "required either way" — so it lands now,
whichever way the owner rules, and the behavior is untouched.

Extends the existing crate-integration suite rather than adding a file: it
already drives `restore_extension_lifecycle_state` over a real
`ExtensionInstallationStore` on a real `RootFilesystem`, which is the whole
seam this branch lives on.

Pins the ACTUAL behavior, including the parts that read as surprising:

- Both port reads return `None` — `delete_installation` alone deliberately
  leaves the manifest projection authoritative, so the branch's second store
  call is load-bearing and the test says so.
- **"Deleted" means tombstoned, not erased.** The v2 record survives with
  `removed_at` stamped, `removal_cleanup_pending` converged, and the embedded
  manifest retained; only the two legacy projections are hard-deleted. A test
  asserting erasure would pin a contract this code does not implement and would
  hide that the migration is recoverable evidence rather than data loss.
- **The control**: a second, equally uncatalogued installation must survive.
  Deletion keys on the extension id, never on "the catalog could not resolve
  it".

Both halves red-checked against the live tree: disabling the branch fails the
removal assertions (and only this test); widening it to delete every
catalog-miss row fails the control. Neither the branch nor any other
production file is touched.

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

* docs(target-architecture): record the Wave 2 closeout and its five corrections

Amendments for the WS2 work in this PR, each quoting the text it replaces.

**PLAN Wave 2 ✎ note** — its open-item list was stale within a day: strays
landed as #7040, and package colocation, the telegram merge and the
memory-provider move all landed as #7037. Four carry-forwards: the re-layer is
two independent halves and only one was reachable; a *downward* re-layer is
costed from the crate's own manifest, mirroring Wave 3's finding that an
*upward* one is costed from its consumer set; a stale wave list costs a slot
its first hour, so re-measure the list itself; and branch names collide across
parallel worktrees — verify `git ls-remote` matches your tip before trusting a
run attached to it.

**PROPOSAL §6.8.1** — "two more W7 exceptions gone" is wrong by two. Four fall:
`mcp` and `scripts` reach the registry for the same manifest DTOs `capabilities`
and `host_runtime` do. Baseline 10 -> 6. The entry's own `Deps` line turned out
to be the executable instruction for the one blocking edge.

**PROPOSAL §12.11 D-A** — the ruling stands; its sizing does not. "The seam is
narrow, which is why this is cheap" is true of `channel_host.rs` and false of
the crate: twelve production files name `ironclaw_product`, and
`ironclaw_host_ingress` is a second blocking edge D-A never named. Recorded as
an amendment rather than a silent re-scope so the next slot costs it from
evidence. Filed as #7092.

**CHECKLIST WS2** — the `include_str!` row ticks with the per-owner breakdown
of the 17 surviving cross-crate sites and why `REPORT_ONLY` cannot flip on
them (#7093); the re-layer row records the half that landed and the half that
did not, with the twelve-file measurement; the escalated §12.11 D-I row records
that its own step (i) is done and the escalation is unaffected.

**CHECKLIST WS10** — the path-keyed-gates row missed a sixth gate, one step
down the same pipeline it audited (#7083). Two corrections to how that row
framed the risk: fix a path-keyed gate along its whole pipeline, not at the
file the audit opened; and the dark-verdict failure is not only about
`git mv` — package colocation broke this one first, because a gate keyed on a
name shape fails for any tree change, not just the scheduled one.

Crate guides travelling with the change: `ironclaw_trust`'s AGENTS/CLAUDE/
CONTRACT stop claiming `TrustPolicyInput`; `ironclaw_extensions`'s AGENTS
records its `substrates` layer and that it must not regain `ironclaw_trust`;
`ironclaw_extension_support`'s AGENTS records why `nearai` is a package module
that is deliberately not a `PACKAGES` entry.

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

* ci: floor the crates the coverage fix made visible (#7083)

Captured from this PR's own dispatch run 30865483401 at `4c841a4321`, **after**
the aggregator fix. Capturing beforehand would have recorded zeros and pinned
the hole shut, which is the one outcome #7083 exists to prevent.

Four new `[[crate]]` entries — `ironclaw_extension_support` (82.64%, 6826 /
8260), `slack` (93.95%, 3697 / 3935), `telegram` (90.31%, 1435 / 1589),
`memory-native` (82.85%, 2850 / 3440). None of the four lacked a floor because
anyone judged it unworthy of one; they were invisible to the gate. All four
were compiled, instrumented, and present in the merged tracefile the whole
time.

Keys are crate **directory** basenames, which is what the aggregator keys on
and what every pre-existing entry already is — they coincide with package names
only for flat `crates/ironclaw_*` crates. `mem0` is deliberately absent and the
file says why: it compiles only behind the `memory-mem0` feature, which no
coverage lane enables, so it contributes no instrumented lines and a floor
would enforce nothing.

`[global]` recaptured 85.11% / 375097 → **86.96% / 386885**. The old
denominator never described the tree it was enforcing: #7037 landed on
2026-08-03 and four crate directories left both numerator and denominator
silently, under `enforce = true`. Both numbers are read off the same
`RATCHET PASS: global` line of the same `reborn-coverage-ratchet.sh` invocation
that enforces this file, so the mapping is the enforcing mapping by
construction — the existing comment's caution is about comparing *across*
toolchains, which this does not do.

`ironclaw_extension_host` recaptured 84.83% / 19907 / 23467 → **87.99% / 21605
/ 24554**. It is the SOURCE side of a move (the NEAR AI embeds left for the
package inventory), and a source floor is the one that silently stops
describing its crate when code leaves it. Recorded honestly as a ratchet
tightening rather than a repair: the denominator moved only −1.46%… +4.63%,
below this file's own 5% materiality threshold, and both fields rose — partly
because this PR also adds the retired-`slack_user` test the crate had been
missing since #6616.

Verified locally against the run's own `reborn-integration-merged.lcov`:
`reborn-coverage-ratchet.sh` exits 0 with 22 `RATCHET PASS` and zero `FAIL`,
and every observed figure matches the CI job line-for-line.

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

* fix(ci): refuse colliding crate basenames; tighten the tombstone assertion

Review triage on #7094. Two of three findings accepted; the third is declined
with reasons, recorded in the PR thread rather than silently skipped.

**Accepted — colliding crate basenames must be a refusal (Major).**
`crate_key()` reduces a discovered directory to its basename, so two crate
directories sharing one would silently fold into a single coverage bucket and a
single ratchet floor. That is a *quieter* version of the bug this PR fixes: the
merged number looks entirely plausible and nothing reports the merge.
`crate_tree.crate_directory()` already refuses an ambiguous basename rather than
picking one; this applies the same rule to the aggregation key, raising
`CrateTreeError` so it lands on the existing fail-closed path.

Unreachable on today's tree — all 65 basenames are distinct — and reachable the
moment crates move under family directories, which is the next wave. Pinned by
a new self-test case (A6f) with `crates/{domains,substrates}/ironclaw_threads`,
asserting the refusal, that both colliding directories are named, and that the
message says why a merged number is not offered.

**Accepted — `removed_at` presence is not enough.** `Value::get` returns
`Some(Value::Null)` for an explicit JSON null, so the tombstone claim would go
vacuous if the serializer ever emitted one. Now rejects null explicitly, and
the message says why presence alone was insufficient.

**Declined — requiring absolute `SF:` paths to be contained under the resolved
repo root.** Real in principle; wrong to apply here. `reborn-coverage-merge-lcov.sh`
— which produces the tracefile this module reads, and which already filtered
every record in it — anchors on the discovered inventory with the identical
`(?:^|/)` form and no root containment. Adding containment to the consumer and
not the producer re-creates exactly the producer/consumer divergence that made
#7083 silent. The documented real-world case (`.../wasmtime-46.0.1/crates/wasmtime/`)
is already excluded by inventory anchoring in both, and the scenario the finding
describes needs a vendored tree that reproduces a full IronClaw crate directory
path *inside an already-filtered lcov*. It would also require rewriting every
fixture path in the suite, since they use synthetic absolute prefixes.

Self-test 178 -> 181 cases; same 12 pre-existing macOS bash-3.2 C-section
failures. Ratchet re-verified against the run's own artifact: exit 0, 22 PASS,
0 FAIL — the captured floors are unaffected (a test-file assertion and a
script-level guard change no instrumented line).

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

* docs(ci): correct the coverage-lib header's entry-point signature

`aggregate()` gained a `repo_root` parameter with the #7083 fix and the module
header still advertised the three-argument form. Names the default resolution
order and points at `crate_pattern()` for why the accounting scope is
inventory-derived rather than a path shape. Comment only.

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

* test(ci): pin producer/consumer agreement on a vendored crate-path collision

Review catch (#7094): the vendored fixtures guarding the #7083 coverage fix
(M5, A6d) pass because their `wasmtime` path matches no inventory entry — so
they prove the inventory filter *runs*, not that it is contained. The
adversarial input — a path outside the repository that repeats a discovered
crate directory verbatim — was never in the suite. A fixture that passes
because its input never reaches the code under test is exactly the failure
mode this file exists to kill.

A6g supplies that input
(`.../foreign-1.0.0/crates/extensions/packages/slack/src/lib.rs`) and pins the
property that actually protects the accounting: the producer
(`reborn-coverage-merge-lcov.sh`, which filters every record the aggregator
ever sees) and the consumer (`lib/reborn_coverage_lcov.py`) make the SAME call
on it. Both are asked about the same fixture in parallel — chaining the
consumer onto the merge's output would only ever compare it against a record
the producer had already dropped, so a producer-only change would stay
invisible. That mistake was made and caught here before the case landed.

Restricting the consumer alone to paths contained under the resolved repo root
was reviewed and not taken: the producer applies the identical `(?:^|/)`
inventory anchor with no containment, and producer/consumer disagreement is
what made #7083 silent instead of loud. Whichever way the rule goes, it goes
in both halves at once. This case is what turns a one-sided change red.

Sabotage-probed in both directions rather than assumed green:

    consumer-only "repo-owned" filter -> FAIL A6g: producer and consumer
                                          make the same call ...
    producer-only "repo-owned" filter -> FAIL A6g: producer and consumer
                                          make the same call ...

and unsabotaged: 174/186, the same 12 pre-existing macOS bash-3.2 C-section
failures as before the change (181 -> 186 cases).

Not reachable from the real pipeline as it stands, measured rather than
asserted: `cargo llvm-cov` emits only workspace-member sources, so every `SF:`
record in a lane tracefile already lives under the checkout root — 1067 of
1067 per lane and 1139 of 1139 merged, read off run 30865483401's own
artifacts, with zero records dropped by the inventory filter and zero carrying
a `/crates/` segment anywhere but the repo-root position. The guard is for the
day that stops being true.

Test-only. No production behavior changes and no instrumented line moves, so
the coverage floors captured for this PR are untouched.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:25:44 +00:00
ironloopai[bot]
d289dd8985 fix(live-qa): drop obsolete Slack config gate (#7123)
* fix(live-qa): drop obsolete Slack config gate

* fix(live-qa): scope Slack preflight to Slack cases

---------

Co-authored-by: aiworkbot <220660587+aiworkbot@users.noreply.github.com>
Co-authored-by: serrrfirat <f@nuff.tech>
2026-08-04 09:56:37 +00:00
Illia Polosukhin
79435f4b91 docs(tests): add repo-wide scenario test coverage map (#7112)
* docs(tests): add repo-wide scenario test coverage map

Adds `tests/CLAUDE.md`: an inventory of every scenario test in the repo,
written in plain English ("The user can…") and mapped back to the exact
file and test name that proves it, so coverage and gaps are visible in
one place instead of spread across four tiers.

Covers all four tiers:
- 50 Rust group scenarios (tests/integration/group_*/scenario_*.rs)
- 54 flat integration bins (tests/integration/)
- 39 top-level Rust bins (QA phrases, binary-level, parity)
- 102 Python E2E scenario files / 867 tests (tests/e2e/scenarios/)

Includes a §7 gap list grounded in the inventory (no Reborn coverage of
proactive/background execution per #6369; one group scenario for skills;
no group-tier Telegram lifecycle; memory deletion/retention, cross-actor
trigger isolation, attachments and sub-agents have no group scenario),
and points at the existing fail-loud coverage gates (provider capability
inventory, journey coverage, state-machine coverage) as the stronger
place to record provider/journey coverage.

The document carries a binding maintenance rule: adding, renaming or
deleting a scenario test updates the map in the same commit. Pointers to
that rule added to tests/integration/CLAUDE.md, tests/e2e/CLAUDE.md and
the root CLAUDE.md module-spec table.

Docs-only; no production or test code changes.

Every cited path and file::test citation was mechanically verified to
resolve against the tree; counts are measured, not estimated.

Also flags that the "Test Scenarios" table in tests/e2e/CLAUDE.md is
stale (it still lists the eight legacy browser scenarios deleted under
Tier B); left in place rather than rewritten in a mapping-only change.

* fix(ci): classify scenario coverage map as guidance

* docs(tests): address scenario coverage review

---------

Co-authored-by: serrrfirat <f@nuff.tech>
2026-08-04 08:30:27 +00:00
Benjamin Kurrek
0f897e9366 refactor(loop): shed the model gateway and tool disclosure into loop_host (WS3/WS4) (#7064)
* refactor(loop): shed the model gateway and tool disclosure into loop_host (WS3/WS4)

Moves two clusters out of `ironclaw_runner` into `ironclaw_loop_host` and
re-layers the two loop-tier crates, clearing three `LAYER_MATRIX_EXCEPTIONS`.

Model gateway + port adapters -> loop_host (PROPOSAL §6.7.2 "gains: runner's
model-gateway adapter"): `model_gateway.rs` (+ `prompt_cache_activity`),
`model_gateway_error_mapping.rs`, `model_routes.rs`, the driver-host model
gateway and port adapters, and their two integration targets. `model_routes`
had to travel (the gateway names eight of its types, so leaving it behind
would make `loop_host -> runner` a cycle); `model_failure_mapping.rs` had to
stay (its only callers are the two drivers that stay).

Tool disclosure -> loop_host with zero new dependencies: it is a
`LoopCapabilityPort` decorator, which `families/loop.md` already assigns to
loop_host. The row's `/product` alternative is refuted, not skipped —
`loops -> products` is an illegal upward edge, so its ~160 lines of prompt
content cannot relocate there, and need not: `crates/ironclaw_loop_host/prompts/`
already holds five prompt assets.

Net: `ironclaw_runner` sheds `ironclaw_llm`, `ironclaw_common`, `base64` and
`jsonschema` outright — the provider cone is out of the turn runner — and drops
33.2k -> 22.0k source lines. `LAYER_MATRIX_EXCEPTIONS` 13 -> 10 (`runner ->
agent_loop`, `runner -> loop_host`, `hooks -> wasm_limiter`), with the baseline
lowered in the same change.

Enforcement: `reborn_runner_sheds.rs` pins the moved items at their new home
and absent from the old, proves the manifest edges through `cargo metadata`,
holds a reasoned shrink-only residue list, and pins the two `loops` layer
declarations so a revert cannot silently need the deleted exceptions back.

Un-masking: runner 458 -> 259, loop_host 572 -> 771 — 199 moved by identical
name, 3 changed module path only, 0 lost, 0 edited for content.

Deferred with measurements (see CHECKLIST WS4): `runtime.rs` `build_*` ->
composition costs seven `pub(crate)` -> `pub` widenings in the crate the row
narrows and moves decorator-chain ownership into the app layer; the
`production_readiness` deletion is callerless as claimed but cascades into
five `driver_registry.rs` types.

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

* docs(loop): repoint guidance that named the shed clusters at their old home

Fixes the live references the WS3 move invalidated: the trace command's
model-call row, the engine-v2 parity map's test paths, the integration
test's path + visibility note, and one scenario doc comment. Also corrects
`model_gateway.rs`'s module doc, which claimed the adapter lives "in the
standalone Reborn composition crate" — never true of any tree.

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

* ci: classify .claude/ in the Reborn PR test planner

`scripts/ci/reborn_pr_test_plan.py` had no rule for `.claude/`, so its
fail-closed arm raised `unclassified pull-request path` on any PR that edited
a skill, a command, or a rule — failing the `Detect Reborn test scope` job and
skipping every downstream Reborn lane, on a documentation-only change. This PR
hit it by repointing `.claude/commands/trace.md`'s model-call row at the moved
gateway.

Agent guidance is prose with no Rust or E2E surface any Reborn lane can
exercise — the same class as `docs/`, which is already ignored. Classifying it
is the fix; loosening the fail-closed arm is not, and the arm is untouched.

Two regression tests, both red without the classification (verified by
reverting it): guidance paths are accepted and select no lane, and a guidance
edit riding along with a crate change still selects that crate's lane, so the
ignore stays per-path rather than per-PR.

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

* fix(runner): keep the carved thread-scope tests inline

`check_no_panics.py`'s `has_cfg_test_module_declaration` only recognises a
FLAT `#[path = "x.rs"]`; a module declared in a non-`mod.rs` file must spell
the directory (`#[path = "loop_driver_host/x.rs"]`), which the regex misses.
The carved file therefore read as production to the delta scan, and its six
fixture `.unwrap()`s failed `Fast deterministic checks`.

Inlining the module is both the fix and the local convention —
`loop_driver_host.rs` already carries four inline `#[cfg(test)]` modules — and
it keeps the three test paths identical (`loop_driver_host::thread_scope_tests::*`).

The scanner gap is pre-existing and latent for the two sibling files declared
the same way (`tests.rs`, `compaction_tests.rs`); neither has ever tripped it
because their panics sit under item-level `#[cfg(test)]` attributes the scanner
does track. Reported rather than fixed here: widening that regex changes a
security-adjacent gate's classification and has baseline implications.

Verified: `--base origin/main --head HEAD`, `--reborn-baseline`, and
`--self-test` all clean; runner test roster unchanged at 259.

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

* docs(target-architecture): record the two CI gate defects on WS10's loud inventory

Both pre-existing on main, both found by this PR: the PR test planner's
missing `.claude/` classification (fixed here) and `check_no_panics.py`'s
flat-only `#[path]` recognition (reported, sidestepped by inlining).

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

* ci: decide the two repo-root script paths the Reborn planner refused

Round 2 surfaced the next `unmapped test or CI path`:
`scripts/no_panics_reborn_baseline.txt` and `scripts/reborn-e2e-rust.sh`.
That arm is deliberate — repo-root `scripts/` is not prefix-classified, so
each file gets a decision rather than a blanket ignore — and both decisions
are recorded beside the constant: the panic baseline is owned end-to-end by
Code Style's `check_no_panics.py --reborn-baseline`, and the E2E selector
script is driven by the `Reborn E2E` workflow, which has its own scope
detector and which this planner does not schedule.

The self-test asserts both halves: the two decided paths are accepted and
select no lane, AND an undecided sibling still refuses — so the fix cannot
drift into the blanket prefix the arm exists to prevent.

Process note: discovering these one CI round at a time is avoidable. Running
the planner locally over the PR's own changed-path set finds every unclassified
path in one pass; the whole set now plans as `selected` over 3 buckets, root
partition 0, and integration lanes 0 and 1.

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

* refactor(loop-host): address review — host-owned gateway factory, non-vacuous scan self-test

Five findings from the CodeRabbit pass, all accepted:

- **`ThreadResolvingLoopModelGateway` fields go back to private.** The shed had
  turned eleven `pub(super)` fields into `pub` so the runner's struct literal
  kept compiling — a real widening, and against root `CLAUDE.md`'s
  "module-specific initialization must live in the owning crate as a public
  factory". Replaced by `ThreadResolvingLoopModelGatewayParts` + a `new` that
  destructures it: the caller still gets a compile error when a field is added
  (the property the `pub`-fields shape had) without any downstream crate being
  able to assemble a gateway outside the host's construction path.
- **The definition scanner's impl-header self-test was vacuous.** It asserted
  on a name absent from the fixture, so a regression that accepted `impl X for
  Y` headers as definitions would have stayed green. The fixture now carries a
  name that appears ONLY as an impl target.
- **The `cargo metadata` rename comment described behaviour the code does not
  have.** It claimed the helper resolves through `rename`; it reads `name`,
  which under `--no-deps` is the package identity — which is exactly why a
  renamed edge cannot hide. Comment corrected to the real mechanism.
- **`MOVED_ITEMS`' doc contradicted its own contents** on the five `pub(crate)`
  tool-disclosure types. They are pinned deliberately: visibility is not the
  criterion, membership in the moved unit's contract is, and a half-move that
  left one behind would compile.
- **`turn_error_to_host_error` gained the two uncovered arms**, `Unauthorized`
  and the two request-shaped variants. The function moved crates and became
  `pub` in this PR, so the security-relevant arm was newly reachable and
  untested. Red-then-green verified by reclassifying `Unauthorized` to
  `InvalidInvocation`: that test alone fails, and only it.

Also hoisted a loop-invariant `crate_directory` walk out of the residue scan's
per-file loop.

Rosters: runner 259 (unchanged), loop_host 771 -> 773 (the two new tests).

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

* fix(coverage): recapture the runner floor the WS3 shed invalidated

The merge queue rejected this PR with `RATCHET FAIL: ironclaw_runner`
(82.53%, 9470/11474, against an effective floor of 85.05%) and took
#7040 down with it. The PR page showed 30/30 green because
`reborn_pr_test_plan.py`'s FULL_EVENTS omits `pull_request` (#7036),
so the ratchet runs for the first time in the queue.

This entry held `floor_percent = 85.55` across the shed on the reasoning
that "the moved half is adapter code with roughly the crate's own
coverage profile, so the ratio is the invariant that survives a split".
Measured, that is false: the moved files score 6108/6577 = 92.87% at
their new home, 10.33pp above the 82.53% of what stayed, so the shed
removed the crate's better-covered half and un-masked a weaker remainder.

Move, not regression -- proven, not asserted:
  * counterfactual: adding the moved files back gives 15578/18051 =
    86.30%, which CLEARS the old 85.55% floor by 0.75pp, so the
    composition shift alone explains the entire drop;
  * the #[test]/#[tokio::test] roster across the two crates goes
    1070 -> 1072 (+2 added, zero names lost by set-diff of test-fn
    names at origin/main vs this branch).

Both entries recaptured from this PR's own merged artifact
(merge_group run 30855460733) through the gate's own aggregate():
  * ironclaw_runner    9470/11474  = 82.53%
  * ironclaw_loop_host 24598/27063 = 90.89%

The destination is RAISED from the inherited 85.55 rather than left with
5.34pp of arrival slack (~1.4k covered lines it could have lost
silently), per the WS2.4 extension_host/extension_manager pattern.

Verified by replaying scripts/ci/reborn-coverage-ratchet.sh over the
exact failing artifact: reproduces the CI verdict byte-for-byte before
the change, and exits 0 with zero RATCHET FAIL after.

CHECKLIST WS10 records the rule this cost us: a shed/move re-captures the
SOURCE crate's floor, not just the destination's.

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

* docs(checklist): retract the FULL_PR_PATHS claim — the mechanism does not exist

WS10's coverage-policy row asserted "FULL_PR_PATHS lists
tests/integration/coverage-floor.toml but not
changed-coverage-exemptions.toml". `rg -n FULL_PR_PATHS` over the repo
returns nothing; reborn_pr_test_plan.py sets coverage_mode in exactly
two places (:301 "full" in _full_plan(), :508 "none" in the selected
plan) with no path-keyed escalation.

The effect is the opposite of what the sentence implied, and it is the
trap this PR fell into: editing a coverage floor does NOT buy a PR its
ratchet verdict. Measured, --event pull_request over this PR's own two
changed paths returns coverage_mode: none.

The retraction quotes the text it replaces, per the docs-truth rule.

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

* docs(checklist): name the real planner constant in the FULL_PR_PATHS retraction

The retraction's conclusion was right but its reasoning was sloppy: it
claimed "no path-keyed escalation of any kind", when a path-keyed
mechanism does exist -- it just de-escalates.

Re-verified from scratch:
  * FULL_PR_PATHS: 0 occurrences on this branch AND on live main via the
    GitHub contents API, counted in Python rather than grep.
  * The real set is PR_STATIC_CONTROL_PATHS (:36). It contains BOTH
    coverage-floor.toml (:43) and coverage-exemptions.toml (:42), and its
    branch at :353 appends "static CI or workspace-policy checks own" and
    continues -- de-escalation, not escalation.
  * Three probes, each a single real path in a real file on disk (no
    process substitution, no docs/ path mixed in since docs/ is in
    IGNORED_PREFIXES and can mask a probe): coverage-floor.toml,
    changed-coverage-exemptions.toml and coverage-exemptions.toml all
    return coverage_mode: none.

The old sentence was wrong twice -- misnamed mechanism, and a backwards
contrast, since neither file escalates.

Decisive and independent of any code reading: on #7064 itself, which
edits coverage-floor.toml, the "Reborn integration-tier coverage report"
check is `skipping` (run 30857632335, job 91836054560). The ratchet
verdict came only from workflow_dispatch.

Also corrects the command form this amendment previously documented: the
probe was run against a real file, not the <(printf ...) substitution the
earlier text showed.

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

* docs(checklist): state the general rule — no PR lane yields a coverage verdict

Generalizes the WS3 amendment from "the floor file does not escalate" to
the structural fact the remaining Wave 3 lanes depend on: NO
pull_request-triggered lane produces a coverage verdict for ANY changed
path.

Proof is structural, not a sample. _full_plan() -- the only producer of
coverage_mode "full" -- has exactly two call sites, both in the first
four lines of build_plan(): :315 `if event in FULL_EVENTS` and :317
`if event != "pull_request"`. On event == "pull_request" both are false
by construction, so _full_plan() is unreachable and coverage_mode can
only be "none".

This also corrects the bullet's own opening claim that a PR "escalates to
full only on an empty diff, an unmapped path, or a package outside the
canonical set". Measured, those arms do not escalate -- they raise and
fail the planner (exit 1: "unclassified pull-request path: ..." and
"empty pull-request diff cannot be classified; refusing to launch an
unbounded PR matrix").

Probes on --event pull_request, single real paths: ordinary crate source,
shared integration support, workspace Cargo.toml and WebUI frontend all
return coverage_mode: none.

Records the two verification routes that do work (dispatch judged by
per-job tally, or a local replay of the merged artifact through
reborn-coverage-ratchet.sh) and that a green PR page carries no coverage
information at all.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: firat.sertgoz <firat.sertgoz@near.ai>
2026-08-03 23:18:46 +00:00
Henry Park
4195f2c574 fix(extensions): resolve custom MCP auth during registration (#7024)
* fix(extensions): handle metadata-less MCP auth challenges

* fix(tests): follow extension contract split

* test(extensions): cover concurrent MCP refresh

* fix(extensions): resolve hosted MCP auth setup

* fix(extensions): keep rejected auth choice recoverable

* fix(extensions): preserve finalized no-auth state

* fix(extensions): persist unresolved auth recovery

* fix(extensions): release failed preparation checkpoints

* fix(extensions): fence preparation lease cleanup

* docs(extensions): clarify fenced lease cleanup

* fix(mcp): resolve auth during registration

* fix(extensions): address MCP lifecycle review findings

* refactor(mcp): share hosted client setup

* fix(mcp): require explicit auth when OAuth setup is unusable

* ci: map hosted MCP support to integration lane

* fix(mcp): address registration review findings

* test(mcp): cover auth recovery paths

* test(mcp): satisfy changed coverage gate
2026-08-03 22:03:39 +00:00
firat.sertgoz
53b5780d88 fix(webui): unblock main E2E coverage — SSE keep_alive cursor, admin retry, stale selectors (#7070)
* fix(webui): unblock main E2E coverage — SSE keep_alive cursor, admin pagination retry, stale selectors

Main Code Coverage has been red since #6876 (9792a9fc7e, Jul 30) due to
five Reborn WebUI v2 E2E tests. This fixes all five in one change; only
the SSE keep_alive cursor fix and the admin load-more retry are prod
behavior changes (both correctness fixes for bugs shipping to users).
The rest are test-only.

1. SSE keep_alive frames must not carry a Last-Event-ID cursor
   (test_reborn_v2_sse_reconnect_resumes_without_gap_or_duplicate_served)

   `webchat_sse_event_from_envelope` stamped the envelope's projection
   cursor into the SSE `id:` field on every frame, including keep-alives.
   The product seam advances the cursor on `KeepAlive` payloads
   (projection.rs push_turn with KeepAlive), so when a keep-alive was the
   last frame before a disconnect, the browser's `EventSource` echoed its
   inflated cursor back as `Last-Event-ID` on reconnect and the resumed
   stream skipped real events that preceded it. The reconnect test saw
   `(12, 1, 0) == (None, 0, 6)` — a keep-alive cursor vs the real terminal
   cursor.

   Fix: omit `event.id(...)` when the frame is `KeepAlive`. Keep-alives are
   liveness pings, not resume positions; the browser keeps the last real
   event's id as the resume point. Added
   `stream_events_keep_alive_frame_carries_no_sse_id` pinning the wire
   contract, and confirmed the preceding real event still carries its id.

2. Tool-gate run artifact 404 after gate resolution
   (test_reborn_v2_approval_gate_decline_has_no_successful_tool_result,
    test_reborn_v2_manual_token_auth_gate_resolves_and_resumes)

   `_wait_for_run_artifact_status` called `_fetch_run_artifact`, which
   asserted `status_code == 200` on the first call. After a gate resolve
   resumes a run, the terminal turn record and its projection can lag the
   artifact read by a short window, so the first 404 was fatal for the
   whole 60s polling loop. This is a read-after-write projection lag, not
   a permanent authorization/routing failure.

   Fix: split `_try_fetch_run_artifact` that raises `_ArtifactNotReady` on
   404, and have `_wait_for_run_artifact_status` tolerate transient 404s
   inside the polling loop (retry until deadline, surface the last 404 in
   the failure message if it never lands). Non-404 non-200 responses still
   assert immediately. No prod change.

3. Admin users load-more must retry transient failures
   (test_admin_users_ui_paginates_retries_and_deduplicates)

   Since #6908 (fe8f5c245), `loadMore` in `useAdminUsers` uses a manual
   `fetchAdminUsers(...).catch(...)` with no retry, but the E2E test pins
   the user-facing "retries safely" contract (expects 2 attempts on a 503
   before surfacing the structured load-more error). The stale React
   Query retry assumption (inherited from the pre-#6908 infinite-query
   path) dropped that behavior.

   Fix: restore a single retry on the cursor page load, narrowed to
   transient errors (429, 5xx, or `payload.retryable === true`); 4xx
   authorization/validation failures fail fast. Added a VM test pinning
   two cursor fetches after a transient rejection and one fetch for a
   non-retryable 403.

4. Extension card state label is "finish setup", not "setup needed"
   (test_reborn_v2_current_extension_setup_and_delivery_matrix)

   The i18n key `extensions.state.setup_needed` renders as "finish setup"
   in en.ts; the E2E still asserted the retired literal "setup needed".
   Updated two assertions in the legacy extensions matrix to the current
   label. No prod change.

Test Strategy
- Unit (Rust): `cargo test -p ironclaw_webui --test webui_v2_handlers_contract`
  — 125 passed, incl. new `stream_events_keep_alive_frame_carries_no_sse_id`.
- Unit (TS): `pnpm vitest run src/pages/admin` — 49 passed, incl. new
  retry/fail-fast tests.
- Clippy: `cargo clippy -p ironclaw_webui --tests -- -D warnings` — clean.
- Typecheck: `pnpm typecheck` — clean.
- Architecture: no dependency ownership change (test-only + handler-local).
- E2E: pending CI on the PR (Playwright scenarios not run locally).

Compatibility / rollback
- SSE keep_alive: no wire breaking change; clients that ignored keep_alive
  ids are unaffected. Clients that resumes from keep_alive ids was already
  broken. Rollback = revert the handler hunk.
- Admin retry: one extra fetch on transient cursor failures only; 4xx
  unchanged. Rollback = revert useAdminUsers.ts hunk.
- E2E-only changes: revert freely.

Refs: #6876 (trigger), runs 30550182236 → 30816140609 (chronic red).

* ci(reborn-pr-plan): skip tests/e2e/ paths as E2E-workflow-owned

The Reborn PR test planner raised `unmapped test or CI path` for any
`tests/e2e/...` path (including `tests/e2e/scenarios/`), failing the
`Detect Reborn test scope` job on PRs that touch E2E scenarios. E2E
scenarios are owned by the dedicated `reborn-e2e.yml` workflow, which
runs its own changed-path filter and provider/shard matrix; they are not
part of the crate-bucket / root-partition / integration-lane plan emitted
by `reborn_pr_test_plan.py`.

Skip `tests/e2e/` paths with a reason instead of failing closed, mirroring
how the planner already defers frontend, stress-tool, and fixture paths
to their owning workflows. Added a planner test pinning that a
scenario-only change schedules no crates/lanes and records the E2E
ownership reason.

Test Strategy
- Unit: `python3.11 scripts/ci/test_reborn_pr_test_plan.py` — 33 passed.
- Reproduced the CI failure locally with the PR's changed files, then
  confirmed the skip resolves it.

* test(review): retain transient 404 detail in artifact polling; cover mixed E2E+crate plan

Address CodeRabbit review on #7070:

1. `_wait_for_run_artifact_status` cleared `last_not_ready` on the first
   non-terminal 200, so a `404 -> non-terminal 200 -> timeout` sequence
   reported `transient_404=None` and hid the earlier miss. Stop clearing
   it; the transient 404 detail now survives through the timeout message.
   Added a regression test
   `test_wait_for_run_artifact_status_preserves_transient_404_through_timeout`
   that scripts the 404 -> non-terminal 200 -> timeout sequence with a
   mock client and asserts the AssertionError includes both the 404
   detail and the non-terminal last artifact.

2. The planner test only covered an E2E-scenario-only change. Added
   `test_reborn_e2e_and_crate_changes_keep_both_owners` proving a mixed
   PR (E2E scenario + changed crate) skips the E2E path (owned by
   reborn-e2e.yml) while still scheduling the crate in the affected
   crate buckets.

Test Strategy
- Unit (py): `python3.11 scripts/ci/test_reborn_pr_test_plan.py` — 34 passed.
- Unit (py logic): verified the artifact-polling regression test's
  assertion logic with an inline replica of the helper + mock client
  (3 scripted calls, timeout, message retains `transient_404=status 404`
  and the non-terminal `Running` artifact).

* test(review): drop redundant @pytest.mark.asyncio marker (asyncio_mode=auto)

* fix(ci): preserve Reborn E2E harness fail-closed mapping
2026-08-03 21:16:05 +00:00
firat.sertgoz
fbf6a2a665 Instrument progressive-disclosure canary metrics (#6968)
* Instrument canary model and tool usage

* ci: unblock the queue — histogram diff gate fix, wasmtime RUSTSEC bump, stale events floor recapture (#6966)

* fix(ci): use histogram diff in the changed-coverage gate

`reborn_changed_coverage.py` built its denominator from `git diff
--unified=0` with no `--diff-algorithm`, so it inherited git's default
(myers). Myers anchors greedily: on a deletion-shaped diff it shreds one
large removal into interleaved -/+ hunks and re-emits surviving, unchanged
text as added lines. The gate then demands 100% coverage for code the PR
never touched.

Discovered on #6964 (deleting the verified-dead half of `llm::reasoning`),
where the gate saw 478 changed lines / 208 branch arms and failed at 83.89%
/ 65.87%. Measured on that same range with the gate's own parser:

  myers (old):      917 changed production lines
  histogram (new):   14 changed production lines

All 14 are doc comments and imports — zero executable — so the true
changed-testable denominator was zero and the 478 was entirely artifact.

The regression test asserts the invocation rather than re-staging a myers
pathology: the pathology depends on git's internal heuristics, so a fixture
built around one can quietly stop reproducing on a future git and leave a
vacuous green test. Verified red-then-green — removing the flag fails the
new case.

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

* chore(deps): bump wasmtime 47.0.2 -> 47.0.3 (RUSTSEC-2026-0222, RUSTSEC-2026-0223)

Two Wasmtime advisories published today fail `cargo deny check advisories`
on every branch, which is the "Fast deterministic checks" red cascading
into the required Code Style aggregate:

  RUSTSEC-2026-0222 — stores can mix up type indices between engines
  RUSTSEC-2026-0223 — preemption/traps during bulk operations can break
                      internal VM state

Both name `>=47.0.3` as the fix for the 47.x line.

`cargo update -p wasmtime`. Cargo.lock-only. 28 packages move, every one of
them on Wasmtime's own lockstep release train — wasmtime* 47.0.2 -> 47.0.3,
cranelift* 0.134.2 -> 0.134.3 (its codegen backend), pulley* 47.0.2 ->
47.0.3 (its interpreter). Nothing outside that family changed; no package
added or removed.

Verified locally with cargo-deny 0.19.9: both advisories reproduce on the
old lock and `advisories ok` after.

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

* ci(coverage): recapture the stale ironclaw_events floor (inherited from #6943)

The ironclaw_events floor was captured before #6943 deleted
`events::{parse_jsonl, replay_jsonl}`, so main has been sitting under its
own covered-lines floor ever since: observed 1197 covered / 1486 total
against a floor of 1252 covered (effective 1232). Every branch that reaches
the coverage job fails on it — PR #6958, which touches no events file,
fails with the byte-identical block.

Recaptured to the observed numbers per coverage-floor.toml's own
same-PR recapture workflow for legitimate deletion-driven shrinkage.

The entry is copied byte-for-byte from #6964's commit 7ca468d62, which
carries the same fix. Identical text on both branches means git merges them
cleanly in either order. #6964's ironclaw_llm entry is deliberately NOT
brought along — that shrinkage is caused by that PR's own deletion and
belongs to it.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* Render aggregate metrics in canary PR reports

* test(reborn): allow instrumented runtime paths to settle

* Map live QA harness in Reborn test planner

* Preserve selected Reborn coverage mode

* Ignore workspace MSRV in selected Reborn lanes

---------

Co-authored-by: Benjamin Kurrek <57506486+BenKurrek@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 20:44:15 +00:00
Illia Polosukhin
80a433a38d Reborn queued-message steering (ported to current main, turn-boundary races fixed) (#5981)
* feat(steering): queue busy-thread messages as mid-run steering input (forward-port of #5981)

A user message sent to a busy thread is accepted and queued as steering
input for the active run instead of rejected, shown as queued in the
WebUI, drained mid-run by the loop, and delivered to the model.

Forward-port of the #5279/#5981 slice onto current main (crate renames
product_workflow→product, webui_v2→webui, loop_support→loop_host; the
WS1.1/WS1.2 loop-contract moves), plus fixes for the turn-boundary
races found in review:

- executor: ack drained inputs eagerly, after a durable cursor
  checkpoint but BEFORE the iteration's prompt build — the ack is what
  flips the queued transcript row model-visible, so the old deferred
  ack fed the model a prompt without the steering message (or dropped
  it entirely when the drain landed on the run's final iteration)
- executor: the reply-only exit's follow-up drain now consumes
  Steering inputs, so a message arriving during the run's final model
  call forces one more iteration instead of stranding unconsumed
- threads: append_finalized_assistant_message appends a DIFFERENT
  finalized reply in the same run as a sibling row (a steered run
  replies more than once); same-content retries stay idempotent
- composition: the durable FilesystemHostInputQueue is wired
  unconditionally over the composed scoped filesystem — the ported
  cfg(feature=libsql/postgres) selection was dead code (those features
  no longer exist) that silently degraded to the in-memory queue
- integration: tests/integration/steering.rs drives the whole journey
  through the real stack — park the run's final model call, submit a
  second message, assert DeferredBusy + Queued row bound to the run,
  release, then assert the steering text reaches a model request, the
  final reply is the steered script entry, and the row flips
  Queued→Submitted
- known follow-up: a run cancelled/failed before draining leaves its
  queued message Queued (pinned by the composition test); terminal
  reconciliation of stranded queued inputs is tracked separately

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

* feat(steering): terminal reconciliation of stranded queued inputs + allow_steering enforcement

Implements the two follow-ups called out on the queued-message steering
port:

- cancel-time reconciliation: HostInputQueue::reject_unconsumed claims a
  run's undrained queue entries (marking them acked so a racing drain
  cannot deliver them) and flips their transcript rows Queued →
  RejectedBusy — resend affordance, never an auto-resubmission. The
  composed TurnCoordinator is decorated with
  CancelReconcilingTurnCoordinator (ironclaw_runner) so every cancel
  caller — WebUI cancel, gate deny, auth cancel, automation — inherits
  it; the integration harness mirrors the same wiring. Runs that reach
  Failed without a cancel remain the documented residual gap.

- SteeringPolicy.allow_steering enforcement: the resolved profile's flag
  is persisted into the run's process metadata at submit (legacy rows
  default to allowed), surfaced on TurnRunState, and consulted by the
  busy-submit enqueue gateway before queueing; false falls back to the
  RejectedBusy outcome exactly like the no-queue mode. The interactive
  default profile keeps steering on;
  RunProfileDefinition::with_steering_policy makes a no-steering profile
  definable.

Tests: queue-level reconcile coverage on both backends (claim-then-flip,
consumed rows untouched, idempotent), coordinator-decorator unit tests,
the composition cancel journey updated from stays-Queued to
flips-RejectedBusy, and a full-chain product-tier enforcement test
(no-steering profile resolved → persisted → busy submit rejected, queue
empty; red-checked against the gateway check).

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

* fix(steering): address review findings — replay re-enqueue, queue compaction, strict cursors, scope split

Rebases onto current main (WS1.4/WS1.6/WS1.7) and addresses the open
review findings on the queued-message steering slice:

- crash-orphan replay (High x2): a Queued replay now idempotently
  re-enqueues through the shared gateway before replaying DeferredBusy
  (inbound + WebUI); a replay whose bound run is terminal or gone
  settles the row as RejectedBusy via SteeringEnqueueError::ActiveRunGone
- durable restart guarantee (High): new integration scenario parks a run
  on a durable approval gate, queues a message, restarts the planned
  runtime over the same LibSQL store, and asserts the resumed run drains
  the persisted input (Queued→Submitted, steering text in the
  post-restart model request); the harness now wires the durable
  FilesystemHostInputQueue in production shape
- queue hygiene: compact watermark/sparse ack state on both backends
  (no unbounded tombstones), terminal reconciliation deletes the run's
  queue document/entry, sequences start at 1 so the origin cursor is
  unique, next_after is strictly-after and rejects unissued future
  cursors, and the in-memory ack records under the lock before the async
  transcript flip (no redelivery window)
- error taxonomy: HostInputQueueError::Disabled distinguishes the
  RejectingInputEnqueue null port from genuine durable I/O failures,
  which now surface as retryable errors instead of RejectedBusy
- fail-loud: mark_message_queued_or_consumed / _or_replay propagate real
  failures; leniency is limited to confirmed races, with the WebUI
  variant short-circuiting an already-RejectedBusy row via a typed
  QueuedMarkOutcome; SteeringEnqueueError uses thiserror; lock-poison
  and queue diagnostics log with ids at debug level (REPL rule)
- new tests: corrupt-document preservation, CAS contention exact-once,
  origin-cursor uniqueness, both crash-orphan replays, terminal-run
  replay settlement, and the ack-before-prompt ordering pin on the
  late-steering executor test
- scope split: approval-card/approval-risk/gate-arguments/gate-kinds,
  the gates.ts details merge, tool.error* i18n keys, and the unrelated
  WebUI shutdown-drain change are reverted to main (split 2 material)

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

* refactor(steering): gateway owns the admission disposition policy

steering.rs is now the single owner of busy-steering admission:
admit_busy_steering (fresh) / readmit_queued_steering (replay) classify
the outcome (Deferred{run} | Rejected) with every settle already applied,
so the four call sites collapse to three-arm response mapping. Deletes
both per-surface reconcile helpers (replaced by one point-read reconcile
over the new SessionThreadService::read_thread_message), both
'kept for exhaustiveness' unreachable arms, both callers'
HostInputQueueError imports, the duplicate run-id parse, and the second
get_run_state on the replay path (the run snapshot rides in the
admission). The request struct retires the too_many_args arch-exempt and
the re-derived thread_id argument.

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

* refactor(queues): one RunQueueModel behind both backends

The cursor/ack semantics now live once, in a serde-able RunQueueModel
(sequences from 1, strictly-after scan, watermark+BTreeSet ack state,
validate-and-ack, unacked claim). The in-memory backend is a per-run map
of models under a mutex; the durable backend persists the model verbatim
as its CAS-guarded JSON document (wire-identical field layout). Deletes
the duplicated is_acked/record_ack pair (which had already drifted:
BTreeSet vs sorted-Vec), the duplicated scan/ack/claim loops with their
verbatim invariant comments, the mirror status-update struct, and the
copy-pasted phase-2 flip loops — the shared flip helpers also fix the
leftover warn!-level log and the inconsistent component name the review
flagged. reject_unconsumed moves to a dedicated HostInputQueueReconcile
trait (its only caller is the runner's cancel reconciler), deleting the
dead stub arms from three drain-side test doubles. The durable file's
tests move to durable_input_queue/tests.rs, and the shared behaviors are
now a parameterized conformance suite run against both backends per the
dual-backend parity rule.

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

* refactor(executor): commit to eager drain-time acks, delete the dead deferred-ack threading

The drain-time ack made every downstream PendingInputAck site provably
empty: replace() had one fill site and the drain stage now acks in-stage
on every path. Delete the always-empty threading — the pending_input_ack
fields on PromptInput/PromptStep/StopInput/StopStep/BudgetInput and the
resume/skip-model variants, the ack_pending_input_before_model /
_before_exit / _before_resume_capability / _skip_model latency stages,
the compaction helpers' ack params, and the
cancel_if_requested_after_pending_input_ack checkpoint helper (callers
use the plain cancel check). The ack lifecycle is now local to
InputStage: collected by the drain, delivered after the durable cursor
checkpoint, never handed to a later stage. Net −372 lines through the
executor spine; two tests whose scenarios are now structurally
impossible (acks carried through stop/skip-model) are deleted rather
than weakened.

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

* refactor(steering): require the enqueue port at construction, hoist the reuse predicate

Remaining findings from the branch quality review:

- `DefaultInboundTurnService::new` now takes the steering enqueue port as a
  required constructor argument; the `with_input_enqueue` builder is gone.
  Disabled steering is a chosen mode (`Arc::new(RejectingInputEnqueue)`) at
  every construction site, never a forgotten default. `RebornServices`
  deliberately keeps its builder: that type's idiom is null-object defaults
  wired by composition builders across a dozen capabilities, and the
  production shape is pinned by the steering integration test.
- `should_reuse_assistant_run_message` hoisted to `ironclaw_threads::contract`
  so the filesystem and in-memory backends share one predicate instead of two
  copies that could drift.
- `InMemorySessionThreadService::read_thread_message` reuses the canonical
  `get_message_mut` lookup instead of an ad-hoc scan.
- Dead code swept: `PendingInputAck::is_empty` (orphaned by the eager-ack
  commit) and stale test imports in the runner reconcile decorator.

CI follow-ups folded in:

- The durable-queue test module is declared as a plain `#[cfg(test)] mod
  tests;` (the `#[path]` spelling defeated check_no_panics.py's test-module
  exemption).
- `test_reborn_v2_failed_cancel_keeps_active_run_visible` now expects the
  composer send affordance to stay ENABLED after a failed cancel — the run is
  still active and a busy run no longer gates the composer (the sibling
  composer-while-running test was already updated; this one was missed).
- Changed-coverage exact-line exemptions for the fault-injection / legacy-
  default / default-impl / alternate-backend lines the hermetic integration
  harness cannot execute; class-by-class breakdown and resolution paths in
  issue #7006. All exempted lines are crate-tier covered.

No behavior change; suites green across the touched crates.

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

* ci(coverage): widen the steering exemptions to full regions

The first exemption pass listed the exact lines one CI run reported, and the
next run reported neighbors: llvm's line attribution for match arms and fn
signatures wobbles between runs, and exempting every measured line of a file
flips it into the gate's "contributed no instrumented lines" arm once the
remaining candidates carry no DA records. Both failure modes are fixed the
same way — list the full contiguous region for the error arms/mapping fns,
and for files whose only changed lines are exempt classes (default-impl
stubs, serde defaults, compat mapping arm) exempt every candidate line so the
file skips cleanly. Verified against the gate's own loader and candidate
computation. Classes and tracking: issue #7006.

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

* fix(steering): address second-round review — queue lifecycle, bounds, races, error fidelity

Review round on #5981 (serrrfirat + CodeRabbit + Copilot), all current
findings addressed; every behavioral fix carries its regression test in this
same commit.

Queue lifecycle (loop_host):
- RunQueueModel gains a closed-run tombstone and pending-flip retry state:
  terminal reconciliation now closes-and-claims under CAS and RETAINS the
  durable document until every claimed row's transcript flip lands (no more
  claim-then-flip data-loss window); fully settled queues are reclaimed.
- A consumed input whose Submitted flip failed is repaired — not
  redelivered — by an idempotent replay: enqueue dedups against the pending
  flip and returns the original sequence instead of minting a new one.
- Rows settled elsewhere are confirmed off the pending state via a point
  read so they cannot pin the queue record forever.
- Per-run capacity ceiling (MAX_QUEUED_INPUTS_PER_RUN = 32) with a typed
  CapacityExhausted error; RunClosed for post-terminal enqueues. Admission
  settles both as RejectedBusy.
- Token-constructor and lock-poison failures log their cause before
  collapsing to the sanitized Internal variant.

Terminal reclamation (runner + composition):
- New SteeringReconcilingProcessTransitions decorates the ONE composed
  ProcessTransitionPort, so Completed/Failed/Cancelled runs (not only
  coordinator cancels) close, settle, and reclaim their steering queue.
  Wired via DefaultPlannedRuntimeParts.input_queue_reconcile.

Admission (product):
- CancelRequested runs are classified unserviceable (settled RejectedBusy,
  never enqueued).

Threads:
- Assistant-message reuse identity includes attachments; a same-text /
  different-attachments finalized replay fails loud (InvalidMessageTransition)
  instead of silently dropping the new attachment set or duplicating the
  visible reply. read_thread_message maps only genuine lookup misses to None.

OpenAI compat: DeferredBusy acks map to the retryable busy shape (429), not
internal 500. WebUI queued-replay re-enqueue was already wired; pinned by the
existing contract test.

Tests: dedup/forged-ack conformance parameterized over both backends;
ack-vs-reject exactly-once settle (both orders, both backends); capacity,
closed-queue, replay-repair, retained/reclaimed-document coverage; decorator
terminal/failure-isolation tests; legacy allow_steering serde default;
attachment-identity regression through both thread services; frontend
busy-path test now genuinely exercises an active-run second send.

Changed-coverage exemptions updated from the gate's hole list (fault-only
arms covered at crate tier; line shifts from the queue rework).

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

* fix(steering): third-round review — bound pending flips, observer-backed scheduler reconcile

- Capacity ceiling now counts live entries PLUS pending Submitted flips, so
  a persistently failing thread store cannot grow the queue record one
  failed flip at a time; capacity conformance test drives the pending
  accumulation through ghost-thread flip failures.
- New SteeringReconcileCommitObserver subscribes to the durable process
  journal (cursor-tracked, retried, replayed across restarts) and reconciles
  on every terminal agent-turn commit — covering the scheduler/supervisor
  crash-reclaim and panic terminalizations that write through the raw
  ProcessRuntimePort handle the transition-port decorator never sees.
  Regression test drives fail_process on the raw journal store and asserts
  the observer reconciles; reconcile failures return Ok so one unreconcilable
  run can never wedge the observer cursor.
- Renamed the settled-queue conformance helper to match what it pins (the
  tombstone late-enqueue refusal is pinned by the retained-document test).
- Changed-coverage exemption lines remapped for the shifts.

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

* ci(coverage): exempt the observer-wiring fault arms in runner runtime

The gate's hole list on 9062824dc: the SteeringReconcileObserver build-error
Display arm and subscription error arm (subscribe fails only on a duplicate
observer id), plus the two input_queue_reconcile None branches — production
and the integration harness always wire the handle. Crate-tier tests cover
the wiring shape.

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

* ci(coverage): clarify the input_queue_reconcile exemption rationale

The None arms ARE reachable — in crate-tier compositions (inbound_turn_contract
et al.) that pass input_queue_reconcile: None. The waiver exists because this
gate measures integration-tier lcov only, and the integration harness always
wires Some; the crate-tier coverage of the None arms is invisible to it.

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

* test(steering): update the generated double-submit invariant to the steering contract

The lane-discovery locale fix merged from main re-enabled this bin in CI,
surfacing that it still pinned the pre-steering busy contract (second submit
to a busy thread => RejectedBusy). With queued-message steering wired — the
production default on this branch — a busy submit is accepted AND queued for
the active run: an ordered second submit must be DeferredBusy naming the
winner's run; only a true simultaneous race may also settle RejectedBusy.
The load-bearing invariant is unchanged and still enforced: exactly one
submission mints a run, exactly one gated effect performs.

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

* fix(webui): keep cancel reachable on a cooled-down composer; pin optimistic identity

Two suppressed Copilot findings on the steering composer, both real.

The queued-steering composer swaps the cancel button for send as soon as the
user types, so a follow-up can be queued behind the running turn. But it
swapped on `hasPayload` alone: with a draft AND a disabled send the user saw a
dead send button and no cancel, and had to erase their draft to reach cancel.
Cooldown is the reachable case — a gate or onboarding step clears `canCancel`,
so `sendDisabled` co-occurring with `canCancel` means `cooldownSeconds > 0`.
Swap only when send is actually usable.

`buildOptimisticMessage` spread `extra` last, so a caller's side metadata could
silently reshape `id`/`role`/`content`/`isOptimistic` — undermining the single
-source-of-truth guarantee the helper exists to provide. Spread `extra` first
so the canonical fields always win.

Regression tests (both verified red first):
- `ChatInput keeps cancel reachable when a draft exists but send is disabled`
- `buildOptimisticMessage identity fields win over colliding `extra` keys`

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

* ci(coverage): remap the steering exemptions across the main merge

The changed-coverage gate keys exemptions to exact line numbers. Merging main
moved two of the regions this PR exempts, so the recorded numbers pointed at
unrelated source and the gate would have re-flagged the real holes:

- reborn_services.rs: steering_admission_error mapping + queued-replay
  run-id parse arms shifted up 94/94 lines.
- product_wire.rs: the DeferredBusy variant fields moved 308-314 -> 392-398
  under the WS5 product_wire inversion.

Remapped by locating the same source lines in the post-merge file; verified
all 450 PR-owned exempted lines still point at byte-identical source text.
main's own exemption blocks are untouched.

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

* ci(coverage): remap the steering exemptions across the latest main merge

main's #7050 process-journal work inserted ~93 lines above the steering
additions in ironclaw_threads/src/filesystem_service.rs, so the exact-line
changed-coverage exemptions for read_thread_message / mark_message_queued
no longer named the lines they were written for. Remap them onto the merged
tree (verified line-by-line against the post-merge file).

The ironclaw_product/src/reborn_services.rs steering exemptions are
unaffected — main's edits to that file landed below them.

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

* ci: map tests/e2e scenarios to the dedicated Reborn E2E workflow

The affected-area PR test planner (#7019) classifies every changed path and
raises `unmapped test or CI path` on anything under `tests/` it does not
recognize. It has no rule for `tests/e2e/` at all, so any PR touching a
browser/E2E scenario fails "Detect Reborn test scope" before a plan is
produced — and takes "Tests (Reborn)" down with it.

Those files already have an owner: `reborn-e2e.yml` triggers on
`tests/e2e/**` and runs its own scope detection, so the Rust planner must
not also schedule lanes for them. Map the prefix to that workflow.

The `tests/e2e/reborn_*` harnesses keep their existing hard error — they are
shared fixtures rather than one scenario, so a change there still demands an
explicit mapping decision. The new pair of planner tests pins both halves;
the first fails with the exact CI error without this fix.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: firat.sertgoz <firat.sertgoz@near.ai>
2026-08-03 17:46:57 +00:00
firat.sertgoz
b89fcd3575 feat(reborn-ironhub): deep-link register/install gateway + private manifest source (#6780)
* feat(reborn): port IronHub install flow

* fix(reborn-ironhub): preserve skill install source and scope on rollback

- Preserve URL-sourced skill provenance during forced-replacement rollback.

- Restore exact extension installation ownership during compensation.

- Reject persisted HostBundled provenance before manifest parsing.

- Bound IronHub coordination maps and evict idle keyed locks.

- Retain serde error causes in debug logs without changing public error kinds.

- Add execute-seam coverage for replacement rollback, integrity checks, and replay rejection.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* fix(reborn-ironhub): skip host-bundled stamps per entry instead of aborting the catalog

Fixes an availability regression introduced by e97c12457: persisted HostBundled stamps remain rejected, but now skip only the affected extension so valid catalog entries still load.

Also removes the IronHub test fixture lint exemption and isolates lock-eviction assertions with fixture-unique identities.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* feat(reborn-ironhub): deep-link register/install gateway + private manifest source

Re-port of #5409 onto the current extension-host layout. The original branch was
stacked on #4479 and predates the extension-host extraction (#6116, #6616), the
removal of ironclaw_product_workflow (#6583), and the webui_v2 crate rename, so
the integration is reimplemented against today's APIs rather than rebased.

- Public POST /api/ironhub/register handshake (HMAC-SHA256, constant-time via
  verify_slice) mounted outside bearer auth, plus the bearer-authed
  ironhub_deliver_install route on the v2 webui surface.
- The gateway is disabled by default: it mounts only when IRONHUB_AGENT_SHARED_KEY
  is set to a non-empty value of at least 16 bytes, and the install-delivery route
  stays fail-closed as unavailable unless that service is attached.
- Install delivery is scoped to the authenticated caller's UserId, so egress runs
  under the caller rather than a runtime owner.
- Install nonces are single-use and consumed durably (keyed by SHA-256 digest,
  bounded length, control characters rejected); timestamps are drift-checked
  within a 300-second window.
- Private manifest source: Ed25519-signed org-scoped manifests install from the
  configured catalog host behind a Private provenance tier that is rejected
  unless the install genuinely came from a private manifest.
- Manifest replay/downgrade protection keys on (host, signed repo) so it is
  independent of the rotating per-install access token in the URL.

Link logic lives in ironclaw_extension_host::ironhub (agent_link, link_service);
the product-layer service moved to ironclaw_product::reborn_services::ironhub_link
now that product_workflow is gone; serve wiring is composition-owned.

Supersedes #5409.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* fix(turns): trust verified catalog descriptions instead of denying the whole prompt

Installing Attio from the signed IronHub catalog exposed an official description containing API key and Bearer authentication vocabulary. Prompt validation treated that trusted text as an unsafe summary and denied every subsequent turn.

Carry verified catalog provenance into capability descriptors and route it through a trusted prompt-text surface, following the certified-skill fix from #5169/#5258. Structural checks still apply, while invalid untrusted descriptors are omitted individually with host diagnostics naming the capability and matched pattern.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* test(golden): re-bless capability surface hashes after the description-trust field

Adding CapabilityDescriptionTrust to CapabilityDescriptorView changes the capability
surface fingerprint, so the golden payload snapshots carry a new surface sha256.

Verified the change is hash-only: all 7 changed content lines are byte-identical once
the surface hash is normalized, with zero other content deltas. The trust field does not
appear in model-visible prompt content — the capability list and every description are
unchanged. Only insta's stale assertion_line metadata was additionally dropped.

* fix(ironhub): return complete, self-describing search results instead of a silent truncation (#6808)

IronHub search returned a result-reference prefix, leading the agent to report attio missing even though it was present in the signed catalog. Return compact catalog projections with explicit completeness metadata and a bounded, unmistakably incomplete fallback.

Closes #6788

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* test(integration): cover the install-then-turn prompt-denial incident at the turn seam

Drive registry-verified and local extension descriptions through the real product workflow, scheduler, agent loop, and model request boundary. This closes the Attio incident gap by proving verified Bearer-header wording survives intact while one unsafe local prompt entry degrades without collapsing the remaining capability surface.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* fix(ironhub): measure catalog_total inside the truncation budget

Follow-up to b218c051a. The bounded-fallback path sized `Self::incomplete(...)`
against MAX_SEARCH_RESPONSE_BYTES while that shape still had `catalog_total: None`
— omitted from JSON by skip_serializing_if — and then assigned Some(..) to the
value actually returned. The emitted payload was therefore ~20 bytes larger than
the budget that admitted it, so a truncated result could exceed the cap it exists
to enforce.

`incomplete` now takes `catalog_total`, so the shape measured is exactly the shape
emitted. Extended the existing oversized-catalog test rather than adding a fourth
search test (it already owns the truncated path): it now pins catalog_total on
both the struct and the serialized payload, alongside its existing size bound.

* fix(host_api): redact credentials in model previews instead of dropping them

A deployed agent could not return the IronHub catalog. The payload was fine —
13,755 bytes, complete, well under every size bound. It never reached the model.

`result_preview_parts` built the preview, then discarded it because
`ModelResultPreview::new` refuses any content containing a credential marker
("access token", "api key", "bearer ", "password", "secret", ...). One catalog
entry's summary says "no API key" — describing the ABSENCE of one — and that
phrase refused the entire catalog. The caller's `else` arm drops the preview AND
the continuation metadata that travels with it, so the model received a bare
result reference with no preview, no total_bytes and no next_offset: unreadable
and unpageable. The logs show it then trying result_read (which returned a
reference to a reference), curl, wget, python3, an HTTP fetch that 404'd, and
five more identical searches.

Masking, not refusal, for model-visible CONTENT:

- `credential_redaction::redact_credential_text` masks credential markers (at a
  word boundary, so "Secretary" survives) and credential-shaped tokens (sk-,
  ghp_, AKIA...) with [redacted], preserving everything else.
- `ModelResultPreview::redacted` falls back to the masked text when the strict
  contract refuses; `ModelResultPreview::new` is unchanged for callers that can
  legitimately reject an operation.
- The preview path uses it, so content and its continuation metadata survive.

The security property is unchanged: credential material still never reaches the
model. Only the disposal changed — mask the span rather than discard the payload.
The existing resolution test now asserts exactly that: the secret is absent from
the preview AND the surrounding content survives.

REVISIT: the marker list is credential *vocabulary*, so prose like "no API key
required" is masked despite containing no credential.
`contains_unredacted_credential_value` already models the sharper "label followed
by a value" rule and its own doc notes that vocabulary alone is valid diagnostic
context. Narrowing this is a separate decision about a shared credential boundary
and is deliberately not made here — masking is strictly better than today's
wholesale refusal.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* fix(host_api): share the marker boundary rule so redaction masks every marker

4b85acce6 added credential masking but reimplemented the marker boundary check
instead of reusing the detector's, and got it wrong: it required an alphanumeric
boundary on BOTH sides for every marker. Markers that already carry a delimiter
("bearer ", "authorization:") therefore never matched — "presented as a Bearer
header" left `bearer ` untouched, `contains_credential_marker` still returned
true, validation still failed, and the preview was still dropped.

Net effect: the fix did not fix the production case. An `extension_search`
result for attio (1088 bytes) still reached the model as a bare reference with
no preview, and a `result_read` on it returned another reference whose own read
failed with "result reference is unavailable in this thread".

`marker_match_at` is now extracted from `contains_marker_at_word_boundary` and
used by BOTH the detector and the redactor, so the two cannot drift again: a
marker carrying its own delimiter skips the boundary check on that side.

The seam regression now uses the real production string ("Authenticated with a
workspace API key presented as a Bearer header") rather than a single-marker
stand-in. That string is what the earlier test missed: it contained only "api
key", which masked correctly, so the bug hid behind a passing test.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* fix(ironhub): carry published credential recipes into the generated manifest

An IronHub tool that needs a credential installed "successfully" and could never
authenticate. Attio is the reported case: it was installed, activated, and
callable as attio.invoke, but nothing in the extension model knew an API key was
required, so no auth challenge was raised and the in-chat credential card never
rendered. The agent filled the gap by inventing a CLI command.

Cause: `generic_tool_manifest` synthesised the v3 manifest from the catalog
entry's name/version/description alone and hardcoded `effects = ["network"]`.
The tool's own credential recipe travels in the capabilities artifact — already
downloaded, digest-verified, and written into the package as
`legacy/capabilities.json` — and was never read. Attio publishes:

  "http": { "credentials": { "attio_api_key": {
      "location": { "type": "bearer" }, "host_patterns": ["api.attio.com"] } } }

`mapped_credentials` now reads that and emits a `[[tools.credentials]]` block
plus the `use_secret` effect, matching how bundled first-party extensions
(github, slack) declare credentials. Location mapping, from a survey of all nine
credentialed catalog tools:

- bearer (7 tools) -> header "authorization" with prefix "Bearer "
- header (monday)  -> that header name, NO prefix; monday.com sends the raw token
                      as the Authorization value, so an invented prefix would
                      break every request
- basic  (wazuh)   -> UNSUPPORTED: v3 injection models header/query/path/pointer,
                      not HTTP Basic. Fails the install with a message naming the
                      tool and the location type, rather than repeating the
                      silent-success failure this change fixes.

Policy fields stay host-authored. `trust`, `origin_gate_matrix`,
`default_permission` and `visibility` remain hardcoded: a third-party package
declares which credential it needs, never what it is allowed to do. That is why
generation is kept and the tool's own manifest.toml is still not trusted.

Tests cover each location shape, the credential-free path (unchanged output),
and that the generated manifest parses as real v3 with the credential block and
use_secret effect present — not just that the string contains them.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* fix(ironhub): emit the [auth.<vendor>] recipe credentials require

e53ecc061 propagated credential recipes into the generated manifest but omitted
the vendor auth recipe, so every credentialed IronHub install failed:

  credential vendor `attio` has no [auth.attio] recipe;
  v3 manifests must declare one for every referenced vendor

That surfaced to the user as `ironhub_install` -> operation_failed with no
diagnostic detail, which is worse than the bug it replaced: before, install
"succeeded" and could not authenticate; after, install failed opaquely.

The generated manifest now carries `[auth.<vendor>]` with `method = "api_key"` —
the variant that maps to RuntimeCredentialAccountSetup::ManualToken, which is
the flow that renders the masked in-chat credential card. display_name and the
per-field labels come from the tool's own published `auth` and
`setup.required_secrets` blocks, so the user sees the vendor's own wording.
`validation` is omitted deliberately: it is optional, and a probe the host
invents could fail against a service it has never contacted.

Why this shipped: the previous test asserted the manifest was valid TOML and
contained the right keys. It was, and it did — but `registry_extension_package`
runs the production v3 parser and package validation, which the string test
never reached. The new test drives `ironhub_tool_package` (the real caller seam)
with a real WASI component fixture, so manifest validation is actually
exercised. Per .claude/rules/testing.md: test through the caller, not the helper.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* fix(ironhub): derive the auth method from the tool, not a hardcoded api_key

dfff89568 emitted `method = "api_key"` for every credentialed tool. Four catalog
tools (gitlab, clickup, microsoft-365, xero) are genuinely OAuth2 and publish an
`auth.oauth` block; forcing api_key on them means the user pastes an access
token by hand that then expires with no refresh — gitlab's own description
promises "host-managed token refresh".

The method now follows what the tool published:

- `auth.oauth` present -> `method = "oauth2_code"`, carrying
  authorization/token endpoints, the scope ceiling, PKCE (S256 default, explicit
  "none" only on opt-out), and client_id_env/client_secret_env as deployment
  secret HANDLES so no secret material enters the manifest.
- absent -> `method = "api_key"`, which maps to
  RuntimeCredentialAccountSetup::ManualToken and renders the masked in-chat card.

`token_response` is required by the recipe but absent from the capabilities
artifact, so it is synthesised as /access_token, /refresh_token, /expires_in.
Unlike the `validation` probe (a URL only the vendor can know, still omitted),
this shape is defined by RFC 6749 section 5.1 and implemented by every OAuth2
vendor in the catalog; the pointers declare where to look, not that the fields
must be present. `identity`, `refresh` and `revoke` stay absent — all optional.

Both arms are now proven through `ironhub_tool_package`, the production package
validator, with a real WASI component fixture. The api_key arm alone would have
kept the four OAuth tools broken in a way string assertions could not catch:
the oauth2_code recipe has stricter requirements than api_key, and it was
exactly `missing field token_response` that the caller-seam test surfaced.

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>

* docs(ironhub): name the supported credential locations in the failure

The fail-closed arm now lists what the host can inject ('bearer', 'header') and
records why the rest are absent: QueryParam/PathPlaceholder/BodyJsonPointer are
modelled by RuntimeCredentialTarget but published by no catalog tool, so their
tool-side spelling is unverified and mapping them now would be speculative;
'basic' is genuinely inexpressible in v3 injection, which has no HTTP Basic
variant.

* refactor(extension-host): key persisted manifest sources by ExtensionId

manifest_sources was BTreeMap<String, ManifestSource>, introduced by aabbc70fc.
The construction site in factory.rs already held a validated ExtensionId and
threw the type away (`record.manifest().id.as_str().to_string()`), so an
unnormalized key would silently miss every lookup rather than fail — the exact
class .claude/rules/types.md exists to prevent.

Keyed by ExtensionId end to end: the boundary keeps the typed identity, the
catalog lookup drops `.as_str()`, and the two test fixtures construct real ids.
Contained to 2 files; no behavior change, and the per-entry host-bundled
provenance regression still passes.

* feat(ironhub): install from the published manifest and carry its setup steps

An IronHub tool did not ship the manifest IronClaw installs from. IronClaw built
one at install time by string-concatenating TOML from `capabilities.json`, a
schema this repository does not own. That reconstruction lost fields silently
three times — the credential blocks, then the `[auth.<vendor>]` recipe those
credentials referenced, then the OAuth versus API-key distinction — and each
loss reached a user as an install that could not authenticate, because the only
machine the translation ran on was theirs.

nearai/ironhub#254 publishes the manifest as a signed catalog artifact. Install
from it:

- `IronHubToolEntry` gains an optional `manifest` artifact, downloaded and
  digest-verified like the wasm and capabilities. Optional so a catalog
  predating published manifests still lists every tool; installing one of those
  is what fails, naming the cause.
- `ironhub_tool_package` places the wasm and the two host-owned generic schemas
  at the paths the manifest declares, so publisher and host never have to agree
  a filename convention across two repositories. `crate_name` existed only to
  build those paths and is gone.
- The 349-line translator is deleted, along with the catalog field it needed.
- A manifest whose id contradicts the catalog entry is refused rather than
  resolved in the manifest's favour: installing `github` when the user asked for
  `attio` would shadow an unrelated extension.

The second half is what a user actually feels. `capabilities.json` already
recorded how to obtain each credential — Attio's says to open Workspace
Settings > Developers and create an access token, with the URL beside it — but
the auth recipes had nowhere to put it, so an installed tool could say it needed
a secret and nothing about where the secret comes from. Users had no way to
activate what they had just installed, and models asked for help invented the
steps.

`ApiKeyRecipe` and `OAuth2CodeRecipe` gain optional `instructions` and
`setup_url`, and the shared import seam turns them into the onboarding copy the
extensions UI already knows how to render. Deriving it at that seam means
uploaded packages get it too, not just registry ones. `setup_url` is an
`HttpsEndpoint` because it becomes a link the user is invited to follow; the
text is rendered through JSX interpolation and never reaches model-visible
prompt text.

Verified against the live IronHub catalog: all 17 publishable tools install
through the production package validator, 8 of them now carrying their vendor's
real setup steps (the other 9 declare no `auth.instructions` upstream). Attio
surfaces "open Workspace Settings > Developers ..." and its settings URL.

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

* fix(ironhub): stop naming a concrete extension in generic package code

The extension-specificity gate flags a first-party extension name appearing in
generic code. The identity-pin comment used two real extensions to illustrate
the shadowing it prevents, and the regression test built its contradicting
manifest under a real extension id. Neither needed to be a real name: the rule
is about an id the user did not ask for, whichever id that is.

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

* refactor(architecture): extract the IronHub client into ironclaw_ironhub (#6870)

crates/AGENTS.md gives ironclaw_extension_host an explicit non-goal: "Host
authority (signing secrets, bot tokens, network egress)". The ironhub module
inside it did network egress (catalog and artifact downloads), held the pinned
Ed25519 catalog trust anchor, and owned the HMAC deep-link shared key — plus
skill installs, which are not extension lifecycle at all. It landed there
because the seam it drives (registry_extension_package) lives there, not
because the crate owns the concern; the boundary tests never saw it because no
dependency edge changed.

Move the module wholesale to a new crate directly above extension_host. The
placement rule it restores: generic registry seams (registry_extension_package,
parse_imported_manifest, ManifestSource::RegistryInstalled) stay in
extension_host, and the one concrete catalog client is vendor-scoped by
charter, the same way each concrete extension crate is scoped to its product.
The module already touched its host crate through exactly four public symbols,
so extension_host's API is unchanged apart from deleting `pub mod ironhub` and
dropping ed25519-dalek, which nothing else in the crate used.

Wiring changes, all shape-preserving:

- The binary's exact dependency allowlist stays closed: composition re-exports
  the command vocabulary as `ironclaw_reborn_composition::ironhub` with the
  house consumer-and-test doc comment, and the CLI imports the facade instead
  of reaching extension_host.
- ironclaw_ironhub gets its dependency BoundaryRule in the same PR (a new
  crate is unruled by default): no execution runtimes, no secret storage, no
  serve/assembly layers, no concrete extension crates.
- extension_host's `test-support` feature now forwards the
  filesystem/host_api/processes test-support seams and exposes
  lifecycle_test_support behind it, so the moved integration-style tests
  drive the same real lifecycle services from outside the crate.
- Fixture include paths shorten by one directory level; the moved files are
  otherwise verbatim (git tracks them as renames).

Verified: full architecture suite (18/18 result sets), ironclaw_ironhub 34/34,
workspace clippy clean with -D warnings. Composition lib tests: 638 passed,
with two known parallel-execution flakes that pass serially and one
pre-existing trace-capture failure unrelated to this move (fails identically
with the module in either location).

Co-authored-by: serrrfirat <firatsertgoz@alumni.sabanciuniv.edu>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(ironhub): accept hub-prefixed artifact digests

* fix(ironhub): address review feedback

* fix(ironhub): propagate manifest URL to CLI commands

* fix(ironhub): close rereview findings

* fix(composition): preserve configured IronHub catalog

* fix(ironhub): restore failed skill replacements safely

* fix(ironhub): restore complete skill bundles

* fix(skills): preserve restore failure context

* test(ironhub): cover mediated service entrypoints

* fix(ironhub): install verified tool schemas

* test(coverage): recapture extension host after IronHub extraction

* test(coverage): track IronHub changed-code gaps

* test(ironhub): execute reviewed coverage paths

* test(ironhub): close changed coverage gap

* test(composition): pin IronHub register default-off

* Fix changed coverage exemption lines

* fix(ironhub): address replay persistence review

* test(ironhub): cover install error paths

* ci: rebase changed coverage exemptions

* fix(ironhub): share durable link state across surfaces

* ci(coverage): rebase IronHub runtime exemptions

* fix: refresh capabilities after IronHub install

* test: remove stale extension host lifecycle fixture

* fix: use canonical extension schemas in the loop

* fix(ironhub): address coderabbit review — harden shared key validation (#6780)

* fix(ci): update IronHub fixture paths after package move

* fix(ci): preserve selected integration coverage mode

* fix(ci): keep MSRV override for selected integration lanes

* fix(extensions): address think-in-universe review — provider schemas and setup copy (#6780)

---------

Co-authored-by: neo-sky <brandon.m.henderson93@gmail.com>
Co-authored-by: serrrfirat <firatsertgoz@alumni.sabanciuniv.edu>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:25:29 +00:00
Benjamin Kurrek
c410ebbd36 refactor(extensions): colocate packages under crates/extensions/ (WS2) (#7037)
* refactor(extensions): colocate packages under crates/extensions/ (WS2)

Physical colocation only — no behaviour change. `crates/extensions/` now
holds `ironclaw_extension_support/` (renamed from
`ironclaw_first_party_extensions`) beside `packages/`, which carries 14
self-contained package directories: the twelve extension packages plus the
two `[memory]` providers, each with its manifest, prompts, schemas,
committed `wasm/` and the `wasm-src/` guest that produced it.

`ironclaw_telegram_v2_adapter` is merged into `ironclaw_telegram_extension`,
giving Telegram the one-crate-per-package shape Slack already had and the
same four-crate contract-tier dependency set.

512 renames, 489 of them byte-identical; every content change in a moved
test file is a path or crate-name repoint.

Two silent registration traps this move created, both reproduced live
before being fixed:

* `classify-test-scope.sh` keys its arms on the crate directory basename,
  and package directories are named by extension identity, so all four
  moved package crates classified `has_reborn_tests=false` — the entire
  Reborn suite would have stopped running on a change to any of them, on a
  green PR.
* A data-only package has no `Cargo.toml`, so five `wasm-src/` guests
  became the outermost manifest on their path and were promoted to
  first-class crates, putting ~12k lines of workspace-excluded,
  never-compiled guest code into the changed-coverage and composition-budget
  denominators. Both inventories now agree that a manifest declaring its own
  `[workspace]` table roots a different workspace and is not a crate of this
  one; `nested_workspace_root()` keeps those paths attributable so the
  fail-closed unattributable-path guard still means what it says.

Adds the committed-`.wasm` freshness gate the tree never had: the rebuild
job overwrites artifacts in the working tree before testing them, so a
stale artifact shipped silently. It records a digest of each guest's
sources rather than of the artifact, because the guest builds are not
reproducible.

The memory-provider enforcement is re-pointed to PROPOSAL §8.2's amended
rule over both providers, with the three surviving dependents held as named
shrink-only residue. The layer flip and binary-only linking do not land
here: `host_runtime` constructs `NativeMemoryService` itself, so flipping
first would create a kernel→products edge. WS3 owns the unblock.

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

* fix(ci): make the classifier and the panic delta scan survive a move

Three CI-surfaced defects, all the same shape — a gate that assumed the
pre-move tree.

`classify-test-scope.sh` REFUSED on `packages/github/manifest.toml`: a
data-only package carries no `Cargo.toml` by design, so every file in it is
attributable to no crate, and the fail-closed arm fired. The refusal was
correct behaviour against an incomplete rule. A `package_asset_dir`
predicate — anchored on the support crate so `packages/` is found as its
sibling rather than by literal path — routes package data to the shared arm,
which is exactly where it landed before the move as
`first_party_extensions/assets/**`. The self-test had probed a package crate
and a `wasm-src` guest but not a data-only asset; all three are probed now.

`check_no_panics.py`'s delta scan had no rename detection, so a relocated
file had no pre-image on its destination path and every one of its lines
read as added: a pure `git mv` of `memory-native/src/repo/filesystem.rs`
contributed 1,754 spurious added lines and failed the scan on six
`unreachable!()` calls that are in the reviewed baseline. Both git
invocations now pass `-M`, the added-line map is computed once for the whole
range (pairing a rename needs both sides in one invocation), and the parser
is split out and pinned by three tests: pure rename contributes nothing,
rename-with-edits reports only the edited lines, creation still reports
every line.

`test-reborn-coverage.sh`'s M4 case had its fixture paths rewritten with the
move while its assertions still named the old crate directories.

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

* docs(checklist): record the rename-aware panic-scan fix

* fix(ci): let the changed-coverage gate pair a crate-directory rename

`diff_pathspecs()` built one `<crate>/src/**` pair per crate in the CURRENT
inventory, which cannot name the SOURCE of a rename whose crate directory
moved: `crates/ironclaw_memory_native/` is not in the inventory once the
crate lives at `crates/extensions/packages/memory-native/`. `-M` therefore
had nothing to pair against and every surviving line of a moved file landed
in the denominator — measured on this PR, **33,026 added production lines**
across 95 files, none of them an actual edit, against a 90% changed-line
floor.

Same defect `-M` was added for in #7005, one level up: there a file moved
within a crate, here the crate itself moves. It fails in the expensive
direction, because the floor then demands coverage for a pure `git mv`.

The pathspec is now the crates root. Precision is unchanged — `parse_diff`
already classifies each destination path with `is_production()` and drops
everything else — so a non-move diff measures exactly what it measured
before. On this PR the denominator falls to 2,357, of which 1,187 are
test-path files the gate already excludes.

Pinned by two tests: the pathspec contract, and an end-to-end regression
that `git mv`s a whole crate into `extensions/packages/` and asserts zero
added production lines.

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

* docs(checklist): generalize the move-breaks-gates finding

* fix(ci): teach the coverage gate that crate-root scaffolding is uninstrumentable

Two classes of line that LLVM can never emit a coverage region for were not
recognized, and each is enough on its own to fail a pure relocation, because
one unclassified line defeats the `candidate_lines <= uninstrumentable_lines`
escape:

* **Inner attributes.** The classifier matched outer `#[...]` but not `#![...]`.
  Every crate root carries `#![forbid(unsafe_code)]`, so a moved `lib.rs` of
  pure `mod`/`pub use` declarations landed in "absent from coverage or contain
  no DA records" on the strength of that single line. Fixing it closes
  `packages/telegram/src/lib.rs` completely.
* **`const`/`static` items.** Their initializers are compile-time and carry no
  region — `const MANIFEST: &str = include_str!(…)` least of all. Repointing an
  asset path is the entire Rust content of a package move, so four inventory
  modules had nothing but const declarations changed and read as "contributed
  no instrumented lines". Spans to the terminating `;` like `use`, so
  multi-line initializers are covered too.

Both pinned by tests, including a negative assertion that a real function body
stays measurable.

What is left is exempted with per-site evidence rather than classified: nine
`include_bytes!(concat!(…))` lines sitting inside `macro_rules!` BODIES across
the same four modules. A macro body is template text — LLVM attributes the
expansion to the expansion site, so those line numbers can never carry a DA
record however many tests run. The expansions themselves are covered by
`ironclaw_extension_support`'s 152 tests, and the changed content is a string
literal inside `concat!`: a wrong path fails the build, which is stronger than
a coverage hit, and `check-include-str-paths.sh` independently asserts all 119
include targets exist. Classifying macro template text correctly needs a real
parser, not a lexer.

Whole manifest re-validated through the gate's own loader: 83 exemptions, no
stale path, no expired review date.

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

* test(telegram): cover the payload parser's fail-closed error paths

The package move made `payload.rs` unpairable for `git diff -M` — the
2,166-line original split into four files under the 999-line budget and no
destination clears the 50% rename threshold — so it reads as a new file with
zero pre-existing exclusions, and 88 uncovered instrumented lines surfaced.
They are almost all `PayloadParseError` construction sites and defensive
branches on an untrusted-input boundary, which is worth testing rather than
waiving.

18 tests in a new `src/tests/payload_errors.rs`, each driving a realistic
malformed or hostile webhook body through BOTH `parse_telegram_update` and
`normalize_telegram_update` so the two entry points cannot drift: missing
update_id, the full chat-kind classification table, out-of-range UTF-16
mention windows, anonymous-admin replies, negative media-group message ids,
attacker-authored media_group_id and display names, oversize and
control-char message bodies, unsliceable bot_command entities, the
leading-command rule, and every attachment slot.

Changed-line coverage of `payload.rs`: 83.64% -> 95.91% (516/538).
Production change is the four-line `#[path]` module declaration; no existing
test was weakened or removed (89 -> 107 lib tests).

Records one live defect rather than fixing it, since this is a move-only PR:
Telegram voice notes and stickers hard-fail the ENTIRE update today.
`collect_attachments` pairs voice with `audio/ogg` + `Voice` and sticker with
`image/webp` + `Sticker`, but `validate_attachment_kind`
(ironclaw_extension_contracts/src/external.rs:370) requires the kind to match
the MIME base type unless it is `Other` — so both are rejected and the `?`
aborts the parse. A well-formed voice message returns
`InvalidExternalRef { kind: "attachment_descriptor" }` instead of being
delivered. That is why those blocks had zero coverage. Pinned with an
explicit "contract pin, not an endorsement" comment.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:38:46 +00:00
firat.sertgoz
d03f815254 ci: bound Reborn PR validation latency (#7019)
* ci: share coverage compilation within Reborn buckets

* test: reuse prepared bundled WASM contracts

* ci: bound pull request validation latency

* ci: assign WebUI checks to Code Style

* ci: pair Reborn provider shards

* ci: isolate commented review concurrency

* ci: prove lockfile scope from structured diff

* ci: parallelize paired provider shards

* docs: describe concurrent provider pairs

* ci: share browser worker with fast contracts

* ci: bound pull request validation to ten minutes

* ci: scope PR lint and crate test targets

* ci: fold PR mutation selection into planning

* ci: preserve exact-target build settings

* test: bind exact-target workflow assertion

* ci: lint workspace dependency changes on PRs
2026-08-03 12:33:29 +00:00
Benjamin Kurrek
3be5f056ef refactor(contracts): consolidate the Wave 2 port-inversion stack (WS2.2, WS2.4, WS5) (#7018)
* refactor(contracts): invert extension_host's product-facing ports onto product_contracts (WS2.1)

`ironclaw_extension_host` sits below product in the target tree, so a
product-side port it satisfies must be declared at the product boundary and
implemented downward — never declared inside `ironclaw_product` and reached
upward. This moves every such port that `ironclaw_product_contracts` may
legally name, and dissolves the product re-export facade for the extension
host.

Nine port families move (definitions only; every implementation stays with its
owner, PROPOSAL §6.1.4): delivery resolution + reply context, account-connection
status + setup descriptors, channel config, the view-provider conduit, command
context + actor-role admission, gate-prompt enrichment, the lifecycle product
service, the admin-user directory, and the operator tool catalog. Product keeps
`DeliveryCoordinator`, `NoReplyContext`, `ExtensionAccountSetupRegistry`,
`UnsupportedLifecycleProductService`, `RejectingAdminUserService`,
`UnavailableRebornViewProvider`, `DirectConversationCommandAdmission`, the
frozen `Reborn*` wire DTOs, and the inbound-action ledger.

extension_host's product symbol usage drops 146 -> 62 across 46 -> 35
production files. The edge itself does not die here and could not: the
survivors are `channel_host.rs`'s construction of product's concrete assembly,
the `extension_manager` split inventory, `product::adapter_registry`, and the
named strays — each owned by a later WS2 row. Six ports also could not move,
all for one mechanical reason: `product_contracts` may depend only on
`host_api` + `extension_contracts`, so a signature naming `ironclaw_auth`,
`ironclaw_threads`, `ironclaw_turns`, or `ironclaw_conversations` cannot be
declared there. `ProductSurfaceFailure` is the linchpin — extension_host uses
product's *internal* workflow error as its own lifecycle error vocabulary in 19
files, and it carries `ironclaw_turns::TurnError`.

Regression cover: `reborn_extension_host_port_inversion.rs` pins the nine moved
ports where they landed and holds the six-entry residue shrink-only, with the
per-entry reason each could not move; a new product-declared port implemented
by extension_host fails the build. The moved typed-token tests travel with
their code and `ActionFingerprintKey` gains the coverage it lacked.

Enumerating gates, all update-never-relax: the composition pub-use snapshot
gains one line (two names re-sourced from `product_contracts`, so one `pub use`
splits into three); the extension-specificity allowlist, the struct/test-support
ratchet, the §11.2.7 include inventory, the `ProductSurface` method freeze, and
`LAYER_MATRIX_EXCEPTIONS` (13) are all untouched — extension_host carries no
layer-matrix exception and never did, since both crates are `products`-layer.

`secrecy` joins `product_contracts` with a manifest comment: `AdminUserService`
takes secret material and `AdminCreatedUser` carries a one-time token, both
`SecretString`. It is a value wrapper, not a framework/driver/runtime client.

CHECKLIST WS2 row 1 ticked with the four dispositions the lead sheet did not
predict.

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

* test(contracts): cover the moved port surfaces and close the impl-scanner bracket hole

Two follow-ups on the WS2.1 port inversion, both found by measuring rather
than assuming.

**Coverage of the surfaces this PR created.** `cargo llvm-cov` over
`ironclaw_product_contracts` showed the relocated bodies had no crate-tier
coverage of their own: `ProductCommandContext::from_envelope`,
`AdminUserRole::is_admin`, `AccountConnectionStatusError::new`,
`ChannelConnectionNoticePolicy::generic`, the bounded-token
`TryFrom`/`AsRef`/`Display` arms, and — the one that matters most — the two
`LifecycleProductService` **default** method bodies, which every production
implementor overrides, so nothing exercised the fail-closed defaults. Each is
now tested at its contract meaning, not for the line count: bundle import
defaults to `InvalidRequest` rather than silently succeeding; activation errors
default to none so the wire field stays absent; a non-command envelope is
rejected as an invalid request rather than an internal error; a token that
deserializes runs the same validation as its constructor; the generic notice
policy names the channel in all five notices and does not collapse them into
one string. Every added production line in the new modules is now covered.

**The scanner had a hole the review caught, and it was real.**
`implemented_trait_names` closed the impl's generic-parameter list at the first
`>`. For `impl<T: Iterator<Item = X>> Port for Host<T>` that `>` closes
`Iterator`, leaving `> Port` — not an identifier, so the impl was dropped and a
new product-defined port could have entered `extension_host` without tripping
the shrink-only gate. Now closed by balancing, with `->` inside a bound
(`impl<F: Fn(&str) -> bool>`) excluded from the count, and both shapes added to
the scanner self-test — which fails without the fix. Re-verified after the fix:
the residue is still exactly the six frozen entries, so the wider scan found no
previously hidden implementation.

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

* fix(arch): make the port-inversion scanner fail loud, and reconcile the doc counts

Review triage on #6998. Four findings taken, four rejected with evidence in the
thread; the taken ones are all about the gate telling the truth.

**The scanner could pass on an incomplete scan.** `rust_files` returned early on
a `read_dir` error and dropped per-entry errors through `.flatten()`, and
`traits_implemented_by` skipped any file it could not read. A permission or
transient I/O error in CI would have thinned the input and turned the ratchet
green while enforcing nothing — the exact failure class this file exists to
catch. Every I/O error is now fatal.

**`#[cfg(test)]` blocks were located by raw brace bytes.** A `{` inside a
comment or string literal in a gated block desynchronizes the depth count and
either leaks a test-only `impl` into the production set or swallows the
production code that follows it. Comments and strings are now stripped first;
`cfg_test_stripping_survives_braces_in_comments_and_strings` is the pin, and it
fails with the old composition (verified by reverting the order and watching it
go red). The doc comment now also states why `#[cfg(feature = "test-support")]`
is deliberately *not* stripped: that feature compiles into a real build, so an
`impl` behind it is a genuine normal-dependency edge, unlike `#[cfg(test)]`.

**The prose counts had drifted.** Eleven port declarations moved, not nine —
nine that `extension_host` implements (the pinned `INVERTED_PORTS`) plus
`AdminUserService` and `RebornOperatorToolCatalog`, which it only consumes and
composition implements. CHECKLIST, both CLAUDE files, and the module-count line
now agree and all defer to the architecture test as the enforced inventory.
`families/contracts.md` also still listed `ironclaw_common` in the family-level
dependency bullet; that is the second of the two places, now corrected too.

**One mismatch recorded rather than fixed.** `LifecycleProductService::
import_extension_bundle`'s default said "unavailable" while returning
`InvalidRequest`/400. The move carried both verbatim; changing the code changes
an HTTP status on a live route, which does not belong in a move-shaped PR. The
doc now describes what the code does, names the discrepancy, and points at the
test that pins today's behavior so a silent flip is impossible.

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

* docs(contracts): state the module count as shipped-modules-plus-dev-seam

The count line said 'seventeen modules' while `src/lib.rs` carries eighteen
`pub mod` declarations — the difference is `test_support`, which is gated
behind `#[cfg(any(test, feature = "test-support"))]` and is deliberately
absent from the table above it. Saying 'seventeen shipped modules plus the
dev-only test_support' makes the table and the manifest agree on inspection
instead of looking like drift.

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

* refactor(contracts): resolve the ProductSurfaceFailure linchpin (WS2.2)

`ironclaw_extension_host` used `ironclaw_product`'s internal workflow error
as its own lifecycle error vocabulary across 19 production files — WS2.1's
recorded linchpin, blocking half the port-inversion residue and the layer
flip. Measured with `#[cfg(test)]` stripped, it constructs exactly six
variants (150 sites), all plain-`String` or unit, and none of the two
kernel-typed ones that kept the enum out of contracts.

The boundary half is now
`ironclaw_product_contracts::error::ProductOperationFailure`;
`ironclaw_product` keeps `ProductSurfaceFailure` unchanged in shape and
absorbs it with a total, payload-preserving `From`. The projection to
`ProductSurfaceError` is defined once, in contracts, and product's
`lifecycle_product_surface_error` delegates its six shared arms to it so the
two paths cannot drift. Only the logging stayed with each caller — contracts
may not log.

Narrowing the enum instead was rejected on evidence: `auth_continuation.rs`
matches all eight `TurnErrorCategory` values structurally and distinguishes
two the sanitized projection collapses, and constructs by matching
`TurnError` variants the projection cannot express — so narrowing is lossy
in a live auth path.

Unlocks `ProductConversationSubjectRouteResolver` (trait residue 6 -> 5, with
its route key and request type) and takes extension_host's files naming the
workflow error 19 -> 2. Corrects the two surviving residue reasons, which
named the error rather than the real blocker.

Regression coverage: nine crate-tier tests including the projection-agreement
pin and the `From` totality pin, plus two new architecture gates (frozen
residue files; the contract error names no kernel type), each verified by
negative probe. Extension-specificity allowlist shrinks 130 -> 129.

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

* fix(arch): apply the parent's scanner hardening to the WS2.2 half

The merge brought in WS2.1's review fixes (I/O errors fatal, comments and
strings stripped *before* `#[cfg(test)]` brace matching). Both apply verbatim
to `production_files_naming`, which this branch added after that review:

- An unreadable file was silently skipped, which is exactly how the frozen
  residue-file scan would go quietly vacuous. Now fatal, matching the three
  other readers in the file.
- The strip order was backwards. A `{` inside a comment or string literal can
  desynchronise the `#[cfg(test)]` brace matcher, so comments and strings go
  first. Re-probed both directions afterwards: a code reference still trips
  the gate, a comment mentioning the type (now with an unbalanced brace) still
  does not.

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

* test(contracts): close the changed-coverage holes the port move opened

CI's changed-coverage gate failed on the WS2.1 move, exactly where a
move-shaped diff is expected to: relocated bodies read as added production
lines. Every hole is now closed with a test. One line is exempted, with its
callers named.

**Five relocated port modules had no LCOV record at all.** `delivery`,
`channel_config`, `operator_tools`, `prompt_source`, and `views` are pure
declarations, so rustc emitted no source record and the gate reported them
absent. Each now carries a contract test rather than a waiver, and the
properties they pin are the ones these ports actually owe:

- **object safety** for all seven traits — every consumer holds them as
  `Arc<dyn _>`, so a signature change that breaks dyn-safety now fails at the
  contract instead of at the far-away wiring site;
- **argument pass-through and ordering** for the delivery ports — `reply_context`
  takes extension id, installation id, and conversation fingerprint as three
  bare strings, so nothing but a test stops a transposition turning into a
  silent mis-delivery (this is the identity-mixup risk review raised; the types
  stay verbatim, the ordering is now pinned);
- **absence without error** — an unresolved channel, an empty channel-config
  field set, an empty operator tool catalog, and a missing approval-prompt
  context are all normal outcomes that must not be expressible only as failures;
- **caller scoping** on the operator catalog, whose `caller` parameter is the
  #5459 disclosure control;
- **`next_cursor` omission** on an unpaginated view page — serializing `null`
  would make every unpaginated view look paginated to the browser.

**Two genuinely untested error paths in `extension_host`, both fail-closed
seams the move touched.** `AccountConnectionStatusSource::connected` now has
coverage proving it fails *closed* on a pairing-backend outage (activation must
not proceed on an unknown connection state) and *sanitized* (the test asserts
the driver, host, and port do not appear in the product-facing error). The
lifecycle output-serialization mapping moved out of an inline closure into a
named `lifecycle_output_decode_error` so the mapping is reachable from a test:
the failure is defensive, but *what it maps to* is a live contract — the model
gets `OutputDecode` and never the serde error, which can quote projection
contents.

**A dead branch arm.** `validate_typed_token` guards `c == '\0' || c.is_control()`
and only the second arm was exercised. NUL has its own arm because a token with
an embedded NUL truncates at a C boundary rather than merely looking odd.

**Diff shape.** The remaining reports were an artifact of relocating types
inline: a fully-qualified `ironclaw_product_contracts::<mod>::<Item>` in a
signature turns an untouched line into a changed one. Those 17 files now import
the symbol like every other, which shrinks the diff, restores the crate's
prevailing style, and drops the lines out of the gate's denominator because a
`use` line is uninstrumentable by construction.

**One exemption, with evidence.** `factory/test_support.rs`'s
`channel_config_service` accessor: the repoint collapsed its signature onto one
line, and the merged lcov does not attribute its two integration callers back
to the composition bucket build. Both callers are named in the manifest, the
service and the port contract are covered by tests added here, and it is filed
under the same #6963 lane-attribution lane as the WS1 entries above it.

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

* refactor(contracts): invert ironclaw_operator's product-facing ports onto product_contracts

Five operator ports and their wire vocabulary move from `ironclaw_product` to
`ironclaw_product_contracts` (PROPOSAL §6.1.3, §6.9.2): `LlmConfigService` +
`ActiveModelReader` (new `llm_config` module) and `OperatorStatusService` +
`OperatorLogsService` + `OperatorServiceLifecycleService` (new
`operator_service` module). Every implementation stays with its owner.

`ironclaw_operator`'s `ironclaw_product` dependency is dropped, not waived —
the ownership inversion §6.9.2 describes is now a Cargo fact.

Also: operator's duplicate route-mount carriers are deleted in favour of
`ironclaw_host_ingress::PublicRouteMount`, which dissolves the composition-side
repackaging shim that existed only to convert between them.

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

* test(contracts): make the catalog and view doubles discriminate on their arguments

Review caught two tests of mine that asserted the double's behavior rather
than the contract, and it was right about both.

`EmptyCatalog` ignored `caller` and always returned an empty vector, so
`the_catalog_is_caller_scoped...` would have passed against a production
catalog that disclosed every user's private installs — the exact leak the
`caller` parameter exists to close (#5459 P1). It is now backed by an
ownership-filtering double, two callers, one tenant-shared tool and one private
tool each, asserting both directions of isolation and that the answer *can*
differ by caller. `OneRowView::query` ignored `_caller` and `_params` and the
test only checked the cursor; the provider now echoes all three conduit
arguments and the test asserts all three.

Both were verified red-then-green rather than assumed: dropping the caller
filter fails the catalog tests, and dropping params from the echo fails the
view test. (My first attempt at the view mutation substituted the expected
literals and passed — a reminder that a mutation which doesn't fail proves
nothing about the mutation, only about the mutant.)

The over-claim went into the PR body too, and is corrected there: a contracts
crate can pin that the port *hands the implementation the caller* and that its
shape admits a per-caller answer. It cannot pin that production filters
correctly — that is composition's implementation and composition's test. The
doc comments now say so instead of implying the stronger claim.

Also lands the CHECKLIST note this PR earned for the rest of Wave 2/3: a
move-shaped PR fails the changed-coverage gate on its first CI run, in three
distinct shapes needing three different answers, with the two mechanical habits
that shrink all three.

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

* test(arch): gate the operator port inversion, and shed skill_learning's product edge

New `reborn_operator_port_inversion.rs`. The layer matrix cannot see this edge —
`ironclaw_operator` and `ironclaw_product` are both `products`, so
`products -> products` is legal and invisible — which is why the row needs a
purpose-built gate. Four halves: the product-declared-trait residue is frozen
exact-match at zero and shrink-only; the manifest edge is proved gone through
`cargo metadata` (not a literal path, so a WS10 directory move fails loudly);
each inverted port is pinned declared-in-contracts / not-re-declared-in-product
/ implemented-by-its-owner; and the scanner is self-tested, fatal on every I/O
error, and asserts non-vacuity on every walk it performs.

Verified by negative probe rather than asserted — re-adding the manifest
dependency, a stale residue row, re-declaring a moved port in product, a
compat-alias DTO in product, and a renamed crate path each fail for their own
reason, the last with "cannot read ..." rather than a silent pass.

`ironclaw_operator` also gains AGENTS.md, CLAUDE.md, and a `BoundaryRule` — it
had none of the three, which is how its product dependency survived every
earlier sweep.

Separately, the `skill_learning.rs` stray: its entire `ironclaw_product`
dependency was one import behind a four-line adapter. `LiveSkillLearnedNotifier`
moves to composition, whose ownership the port's own doc already asserted, and
the file's product references go to zero.

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

* docs(target-architecture): record the operator inversion and the strays re-verification

CHECKLIST WS5's operator row is checked with five dispositions the lead sheet
did not predict, and WS2's strays row is annotated item by item: one executed,
three corrected with the evidence that blocks them, one reassigned, one out of
scope. Two new `[decision]` rows — the contracts-family vendor-rule hole the
LLM-config port opened, and whether any live store still carries a `slack_user`
installation row.

PROPOSAL §6.1.3 and §6.9.2 carry dated amendments, including two corrections to
§6.9.2's own wording: the route clause was satisfied by deleting a duplicated
carrier rather than moving a route, and the missing guidance/boundary rule was
causal rather than cosmetic.

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

* refactor(extensions): split ironclaw_extension_manager out of extension_host (WS2.4)

The extension host held two jobs: lifecycle authority (the only writer of
installation state, ingress verification, activation transactions) and the
extension-management product face that arrived with #6616/#6669. PROPOSAL
§6.8.3 splits the second into its own products-layer crate so the first can
move below product in WS2's layer flip.

Six of the nine inventory items moved; three are structurally blocked and
each is recorded with its measurement. extension_host production files
naming ironclaw_product: 20 -> 13. Port-inversion residue 5 -> 4.

Behavior-free: modules move, imports repoint, one 100-line product
projection is extracted from channel_config.rs.

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

* test(contracts): close the coverage-gate shapes on the WS2.2 slice

Applies the cross-slot lessons from WS2.1/WS2.3's coverage rounds to this
row's own new code, before the gate has to ask.

Pure-declaration modules gained real contract tests rather than waivers:

- `subject_route`: the port is held as `Arc<dyn _>` in five places, so object
  safety is a contract; a resolver is handed every field unswapped
  (`adapter_id`/`installation_id` are both string newtypes, so a swap would
  otherwise be silent); and an unconfigured route is absence, not failure.
  The double is **route-keyed, not fixed-answer** — two configured routes
  resolve to *different* subjects and a third resolves to `None`, so a
  resolver that ignored its argument could not pass. A fixed-answer double
  would have made all three assertions vacuous.
- `error`: `Display` is exercised for every variant, asserting each one keeps
  the text the LLM tool path forwards — `ProviderInstanceNotConfigured`
  carries the operator's exact `config set` remediation.
- `lifecycle_surface_error`: pinned against the contract's own projection
  (drift guard) *and* against absolute statuses (so both drifting together
  still fails).

`channel_config_unavailable` is extracted from a `map_err` closure because it
sat on the one path unreachable in test without fault-injecting the concrete
config service. Naming it makes the classification directly testable, and the
classification matters: a store failure is transient (retryable 503), never a
rejection (permanent 4xx) that would leave a correctly-configured channel
looking broken. The other 44 closures in this crate are pre-existing bodies
where only the type name changed (45 on the parent), so they are left alone
rather than churned on speculation.

Each new test was verified red-then-green by **mutating production code**, and
every mutation compiles cleanly so the red is an assertion failure rather than
the compiler catching the mutant:

- route key stops discriminating by conversation -> two routes collapse to one
  subject (`left: eng-subject, right: support-subject`)
- `Display` drops `{reason}` -> "rendered as ..., dropping ..."
- `lifecycle_surface_error` stops delegating -> "projection drifted for ..."
- store failure reclassified permanent -> "must be transient, got ..."

Scope is calibrated in the doc comments: the contracts-crate test pins the
port's shape and that it admits a per-route answer; it does not claim the
production resolver filters correctly — `channel_subject_routes`' own tests
(`foreign_adapter_or_installation_resolves_nothing`,
`malformed_config_json_fails_closed`) already own that.

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

* docs(ws2.4): date the two row corrections and quote the text they replace

The CHECKLIST disposition named the contradiction without quoting the
inventory line it corrects or carrying a date; PROPOSAL §6.8.3 pointed at
it without the verbatim text. Both now quote both sides.

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

* test(extension_host): cover the log-sanitization guard; exempt the type-position residue

CI's second changed-coverage run came back at 99.32% line / 100% branch, with
one uncovered line and six files reporting "contributed no instrumented lines".
Two different problems, two different answers.

**The uncovered line was coverable, so it is covered.**
`lifecycle_output_decode_error`'s `tracing::debug!` body never ran under test:
with no subscriber installed `tracing` short-circuits on the null dispatcher,
so the message literal is a region that cannot be reached. The fix is not a
waiver — it is the subscriber. The test now installs a DEBUG-level
`tracing_subscriber::fmt` over a shared writer (the pattern
`ironclaw_turns/tests/agent_loop_host_contract.rs` already uses) and asserts
*both* halves of the guard's contract: the model gets `OutputDecode` and never
the serde error, **and** the serde detail is not simply dropped — it reaches
the debug log, which is where an operator diagnoses it from. Without the
subscriber a test cannot tell "logged the detail" from "discarded it", which is
the whole point. `tracing-subscriber` joins this crate's dev-dependencies for
that, with a manifest comment saying why.

**The six files are the type-position residue, and it is precedented.**
Deleting `ironclaw_product`'s re-exports forced every signature naming a moved
symbol to be rewritten; where the name sits in a *type* position — a struct
field, a function parameter, a struct-literal field's enum path — the line
changes but LLVM emits no coverage region, so it can never be covered. Nine
exact lines across six files, each entry naming the construct, filed under the
same #6963 lane the four WS1 entries use. Every line was re-read against the
source before the entry was written; none is a guess.

The balance for the PR as a whole: ten exemption lines, all type positions or
one lane-attribution accessor, against ~30 tests written for surfaces that
genuinely lacked them.

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

* fix(coverage): exempt the tracing message literal, with the evidence that it is an artifact

Last line on the changed-coverage gate, and the obvious reading of it is wrong.

`extension_lifecycle_capabilities.rs:217` is the message string inside a
`tracing::debug!`. It reads as uncovered — but the event body demonstrably
executes: the DEBUG-subscriber test added in the previous commit asserts the
rendered log contains that exact message, and it passes, including in the
`extension-operator` bucket, which is green.

The proof it is an attribution artifact rather than a dead path comes from that
bucket's own tracefile (run 30689416105, `bucket-extension-operator.lcov`):

  line 213 (fn signature)       hits 1
  line 214 (macro invocation)   hits 1
  line 217 (message literal)    hits 0
  line 219 (error construction) hits 1
  line 220 (closing brace)      hits 1

The function ran, the macro ran, the error was built. What LLVM does not count
is the literal: `tracing` bakes the message into the callsite's `static`
`Metadata`, so the region on that line belongs to a static initializer and is
never attributed to an executed path. Nothing short of changing the log target
moves that counter, and changing a log target is a behavior change this
move-shaped PR will not make. Every `tracing::debug!` in the workspace has the
same shape; they only escape this gate because their lines are not in a diff.

Verified by replaying the gate locally against CI's own merged lcov with this
entry in place: changed line coverage 100.00% (147/147), changed branch
coverage 100.00% (10/10).

The test stays. It is what proves the 0 is an artifact, and it still pins the
guard's real contract: the model gets `OutputDecode` and never the serde error,
and the detail reaches the debug log rather than being dropped.

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

* test(extension-host): prove the transient cause survives the sanitized 503

The lifecycle warning is the entire reason this crate kept a local projection
wrapper rather than calling the contract's `From` directly — and that claim
was asserted in a doc comment and nowhere else.

`tracing` short-circuits on the null dispatcher, so under a plain unit test the
macro body never runs and a test cannot distinguish "logged the cause" from
"dropped it" — which is exactly the distinction that matters when the 503 body
is sanitized. Installing a scoped subscriber (`with_default`, so parallel tests
are unaffected) over a shared writer, following the pattern
`ironclaw_turns/tests/agent_loop_host_contract.rs` established, makes both
halves of the guard's contract assertable, and both are asserted:

- the caller's 503 is sanitized — the cause appears nowhere in the serialized
  `ProductSurfaceError`; and
- the cause is not discarded — it reaches the warning, with its stable message.

A second test pins the other direction: a rejection carries no operational
cause and must not spend a warning, so "log everything" cannot satisfy the
first test.

Both verified red-then-green by mutating production code, compiling cleanly so
the red is an assertion:
- drop the warning -> "the transient cause must survive in the log, got \"\""
- warn on every variant -> "a rejection must not emit the transient warning,
  got ... invalid binding request: bad package ref"

`tracing-subscriber` joins `[dev-dependencies]` and the `Cargo.lock` delta is
**zero** — it was already resolved for the workspace.

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

* test(coverage): recapture the extension_host floor and ratchet the manager (WS2.4)

Both numbers come from this PR's own merged coverage artifact
(reborn-integration-coverage-merged, run 30689658637), read through the
same aggregation that enforces the file. extension_host regains its
covered-line floor at 19907/23467 = 84.83% (the ratio ROSE across the
split); the manager is ratcheted from birth at 4602/5440 = 84.60%.

Verified by running the enforcing ratchet against the artifact: both
entries PASS, 17 crates pass, exit 0.

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

* test(contracts): close the changed-coverage holes in the two new operator modules

Measured with the same `cargo llvm-cov --skip-functions -p … --all-targets`
shape the crate-bucket lane uses, rather than waiting for CI to report it.
Seven uncovered lines; each closed with a test, none with an exemption.

Two were real, and one of them is the kind a test can hide rather than find:

- `truncate_utf8_with_suffix`'s character-boundary back-up loop had **no**
  executing test. The multi-byte case looked covered, but the cut offset is
  256 - 16 = 240 and 2, 3, and 4 all divide 240 — so every homogeneous
  `glyph.repeat(n)` input lands exactly on a boundary and the loop body never
  runs. Driving it needs a shifted input (one ASCII byte then 3-byte
  characters), which is now the case, with an assertion that the kept prefix is
  strictly shorter than the naive offset so the loop having run is what is
  proven.
- The degenerate bound (a limit shorter than the truncation marker) is
  unreachable through the public entry point, whose bound is a constant, so it
  is exercised directly through the private helper. It is a fail-safe against
  the subtraction below it underflowing if that constant is ever lowered, and
  an untested fail-safe is how an arithmetic panic reaches a log-query path.

The other four were unexercised methods on the `LlmConfigService` double —
`delete_provider` and `complete_nearai_wallet_login`. A double method no test
calls is a contract the suite silently stopped covering, so both are now
driven, the first asserting its argument reaches the error it produces and the
second asserting both directions of its outcome.

Both modules are now at zero uncovered added production lines: `llm_config`
288/288 DA, `operator_service` 243/243.

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

* test(composition): cover LiveSkillLearnedNotifier through the real publisher

Review triage. The strays row introduced a six-argument forwarding
adapter with no test of its own, which is the shape that fails silently:
swapping `skill_name`/`feedback` compiles (both `&str`), and dropping the
`Some(owner)` wrapper compiles (the publisher takes `Option<&UserId>`)
while re-keying every learned-skill bubble onto the runtime operator's
stream instead of the user's. `skill_learning.rs`'s `StubNotifier` tests
stop at the port and cannot see either.

The new test drives the production trait object over a real
`LiveProjectionPublisher` — no double anywhere — with the runtime actor
deliberately different from the run owner, and reads the result back off
the product event stream the WebUI drains.

Red-then-green proved by mutating the adapter, not the test, and both
mutations compile:
- swap `skill_name`/`feedback`  -> left: [(["picked this up summing a
  report column"], ["csv-column-sum"])]
- `Some(owner)` -> `None`       -> owner drain empty; with the first
  assertion neutralised, the negative assertion fires on its own with
  the bubble found on the runtime actor's stream.

Also from the same review:
- `llm_config.rs`'s comment claimed `assert_not_impl_any!` "would be the
  direct form" two lines above two live `assert_not_impl_any!` calls. It
  now says what the assertions enforce and why: both request types carry
  `api_key: Option<SecretString>`, so a `Serialize` impl is what would
  let the key ride back out.
- CHECKLIST's `reborn_extension_specificity.rs` pointer named `:1177-1180`,
  which in this PR's own tree is the `capability_surface.rs` pair; the
  `lifecycle_restore.rs`/`slack` entry sits at `:1202`. Replaced the line
  range with the allowlist entry itself, which cannot drift.

Verification: fmt clean; clippy -D warnings clean on
ironclaw_reborn_composition + ironclaw_product_contracts; 66 test
binaries, 1181 passed, 0 failed across composition, product_contracts,
and the full architecture suite.

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

* fix(contracts,extension-host): preserve the acquire cause and pin every HostApiError projection

Review triage for #7000.

- `import_bundle`'s decode-limiter `map_err(|_| ...)` discarded the
  `AcquireError`. The mapping is now a named `map_import_decode_acquire_error`
  that logs the bound source before mapping. Named rather than inlined so it is
  reachable from a test: nothing in the workspace calls `Semaphore::close`, so
  an inline closure would be a permanently uncovered branch that the
  changed-line coverage gate could only accept as a standing exemption. New
  regression test builds a genuine `AcquireError` from a closed semaphore and
  asserts the failure is `Transient` (retryable), not a client mistake.

- `From<HostApiError> for ProductOperationFailure` was pinned by one variant.
  It now enumerates all ten, asserts each carries its own rendering (so the
  cause cannot be flattened at the boundary) and projects to a 400, and adds an
  exhaustive `host_api_error_tag` match so a new `HostApiError` variant stops
  compiling the test instead of inheriting the blanket mapping silently.
  `InvariantViolation` is pinned as-is, not reclassified: the mapping mirrors
  product's pre-existing `From<HostApiError> for ProductSurfaceFailure` and
  changing it is a behavior change this slice does not own.

Red-then-green proved by mutating the code under test: InvariantViolation ->
Transient, flattening the reason text, and Transient -> InvalidBindingRequest
each fail the corresponding assertion.

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

* test(arch): pin the rustfmt-wrapped impl header the operator scanner reads

Review argued `split_once(" for ")` misses a wrapped `impl` header and
that the frozen-empty residue half would therefore fail open. Measured:
it does not. rustfmt indents the continuation line, and that indent is
what keeps `" for "` intact as a substring — real rustfmt output for a
long header is `impl<'a> Trait<Arg>` / newline / `    for Type<'a>`, and
the scanner reads `Trait` from it.

Pinned rather than argued: `impl_scanner_reads_the_trait_out_of_real_impl_shapes`
now carries a wrapped-header case. Proved non-vacuous by mutating the
scanner to truncate each segment at its first newline, which compiles and
fails the test:

    WrappedHeaderPort was not read: {"ActiveModelReader", "LlmConfigService",
    "Local", "OperatorLogsService", "OperatorStatusService",
    "ReturnArrowInBound"}

Also dropped the `:933` line pointer from `ironclaw_operator/AGENTS.md`:
the `include_str!` is at `:934`, so it was already stale, and nothing
verifies it. Path plus `reborn_cross_crate_include_scan.rs` locate the
debt.

Verification: fmt clean; `cargo test -p ironclaw_architecture --test
reborn_operator_port_inversion` 7 passed / 0 failed.

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

* fix(architecture,ci): close the review gaps on the extension_manager split

Review triage for #7003. All four are artifacts this PR introduced, not moved code.

- The new `ironclaw_extension_manager` boundary rule forbade
  `"ironclaw_reborn_cli"`, which is the crate DIRECTORY. `forbidden` entries are
  compared against `cargo metadata` package names and the CLI's package is
  `ironclaw`, so the entry could never fire — the edge it named was unguarded.
  Fixed, and pinned: `boundary_rule_names_are_package_names_not_crate_directories`
  flags any forbidden entry that is not a package but IS a directory under
  `crates/`. That discrimination matters — ~60 entries legitimately name retired
  v1 crates (`ironclaw_legacy`, `ironclaw_engine`, `ironclaw_gateway`,
  `ironclaw_tui`, `ironclaw_storage`) as reintroduction pins, and those have no
  directory. `ironclaw_reborn_cli` was the only entry in all 693 that had one.

- `production_files_naming` took a flat `files.len() >= 10` to accommodate the
  manager, which silently dropped the host's vacuous-scan guard from >20 to 10.
  The same diff had already parameterized `traits_implemented_by` for exactly
  this reason. Parameterized to match: host 21, manager 10.

- `classify-test-scope.sh` gained a `crates/ironclaw_extension_manager/*` arm
  with no self-test case, so a manager-only diff classifying
  `has_reborn_tests=false` would have gone unnoticed — the failure #6947 records
  for the stale `crates/ironclaw_product_*/*` arm. Case added.

- `coverage-floor.toml`'s "9.7k lines moved" explained an instrumented-line
  delta of 3,102 with a source-line figure. Both units are now stated with their
  measurements (source: 57,464 -> 47,794 in the host, 9,979 in the manager;
  instrumented: 26,569 -> 23,467 against 5,440) and why they do not reconcile.

Red-then-green proved by mutating the code under test: reverting the forbidden
entry to the directory spelling fails the new meta-test with the fix-it message;
removing the manager glob from the classifier fails the new self-test case
(has_reborn_tests=false); raising the manager's file floor to 40 fails only the
manager call site, proving the floor is per-call-site and consumed.

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

* refactor(contracts): resolve the ProductSurfaceFailure linchpin (WS2.2)

`ironclaw_extension_host` used `ironclaw_product`'s internal workflow error
as its own lifecycle error vocabulary across 19 production files — WS2.1's
recorded linchpin, blocking half the port-inversion residue and the layer
flip. Measured with `#[cfg(test)]` stripped, it constructs exactly six
variants (150 sites), all plain-`String` or unit, and none of the two
kernel-typed ones that kept the enum out of contracts.

The boundary half is now
`ironclaw_product_contracts::error::ProductOperationFailure`;
`ironclaw_product` keeps `ProductSurfaceFailure` unchanged in shape and
absorbs it with a total, payload-preserving `From`. The projection to
`ProductSurfaceError` is defined once, in contracts, and product's
`lifecycle_product_surface_error` delegates its six shared arms to it so the
two paths cannot drift. Only the logging stayed with each caller — contracts
may not log.

Narrowing the enum instead was rejected on evidence: `auth_continuation.rs`
matches all eight `TurnErrorCategory` values structurally and distinguishes
two the sanitized projection collapses, and constructs by matching
`TurnError` variants the projection cannot express — so narrowing is lossy
in a live auth path.

Unlocks `ProductConversationSubjectRouteResolver` (trait residue 6 -> 5, with
its route key and request type) and takes extension_host's files naming the
workflow error 19 -> 2. Corrects the two surviving residue reasons, which
named the error rather than the real blocker.

Regression coverage: nine crate-tier tests including the projection-agreement
pin and the `From` totality pin, plus two new architecture gates (frozen
residue files; the contract error names no kernel type), each verified by
negative probe. Extension-specificity allowlist shrinks 130 -> 129.

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

* fix(arch): apply the parent's scanner hardening to the WS2.2 half

The merge brought in WS2.1's review fixes (I/O errors fatal, comments and
strings stripped *before* `#[cfg(test)]` brace matching). Both apply verbatim
to `production_files_naming`, which this branch added after that review:

- An unreadable file was silently skipped, which is exactly how the frozen
  residue-file scan would go quietly vacuous. Now fatal, matching the three
  other readers in the file.
- The strip order was backwards. A `{` inside a comment or string literal can
  desynchronise the `#[cfg(test)]` brace matcher, so comments and strings go
  first. Re-probed both directions afterwards: a code reference still trips
  the gate, a comment mentioning the type (now with an unbalanced brace) still
  does not.

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

* test(contracts): close the coverage-gate shapes on the WS2.2 slice

Applies the cross-slot lessons from WS2.1/WS2.3's coverage rounds to this
row's own new code, before the gate has to ask.

Pure-declaration modules gained real contract tests rather than waivers:

- `subject_route`: the port is held as `Arc<dyn _>` in five places, so object
  safety is a contract; a resolver is handed every field unswapped
  (`adapter_id`/`installation_id` are both string newtypes, so a swap would
  otherwise be silent); and an unconfigured route is absence, not failure.
  The double is **route-keyed, not fixed-answer** — two configured routes
  resolve to *different* subjects and a third resolves to `None`, so a
  resolver that ignored its argument could not pass. A fixed-answer double
  would have made all three assertions vacuous.
- `error`: `Display` is exercised for every variant, asserting each one keeps
  the text the LLM tool path forwards — `ProviderInstanceNotConfigured`
  carries the operator's exact `config set` remediation.
- `lifecycle_surface_error`: pinned against the contract's own projection
  (drift guard) *and* against absolute statuses (so both drifting together
  still fails).

`channel_config_unavailable` is extracted from a `map_err` closure because it
sat on the one path unreachable in test without fault-injecting the concrete
config service. Naming it makes the classification directly testable, and the
classification matters: a store failure is transient (retryable 503), never a
rejection (permanent 4xx) that would leave a correctly-configured channel
looking broken. The other 44 closures in this crate are pre-existing bodies
where only the type name changed (45 on the parent), so they are left alone
rather than churned on speculation.

Each new test was verified red-then-green by **mutating production code**, and
every mutation compiles cleanly so the red is an assertion failure rather than
the compiler catching the mutant:

- route key stops discriminating by conversation -> two routes collapse to one
  subject (`left: eng-subject, right: support-subject`)
- `Display` drops `{reason}` -> "rendered as ..., dropping ..."
- `lifecycle_surface_error` stops delegating -> "projection drifted for ..."
- store failure reclassified permanent -> "must be transient, got ..."

Scope is calibrated in the doc comments: the contracts-crate test pins the
port's shape and that it admits a per-route answer; it does not claim the
production resolver filters correctly — `channel_subject_routes`' own tests
(`foreign_adapter_or_installation_resolves_nothing`,
`malformed_config_json_fails_closed`) already own that.

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

* test(extension-host): prove the transient cause survives the sanitized 503

The lifecycle warning is the entire reason this crate kept a local projection
wrapper rather than calling the contract's `From` directly — and that claim
was asserted in a doc comment and nowhere else.

`tracing` short-circuits on the null dispatcher, so under a plain unit test the
macro body never runs and a test cannot distinguish "logged the cause" from
"dropped it" — which is exactly the distinction that matters when the 503 body
is sanitized. Installing a scoped subscriber (`with_default`, so parallel tests
are unaffected) over a shared writer, following the pattern
`ironclaw_turns/tests/agent_loop_host_contract.rs` established, makes both
halves of the guard's contract assertable, and both are asserted:

- the caller's 503 is sanitized — the cause appears nowhere in the serialized
  `ProductSurfaceError`; and
- the cause is not discarded — it reaches the warning, with its stable message.

A second test pins the other direction: a rejection carries no operational
cause and must not spend a warning, so "log everything" cannot satisfy the
first test.

Both verified red-then-green by mutating production code, compiling cleanly so
the red is an assertion:
- drop the warning -> "the transient cause must survive in the log, got \"\""
- warn on every variant -> "a rejection must not emit the transient warning,
  got ... invalid binding request: bad package ref"

`tracing-subscriber` joins `[dev-dependencies]` and the `Cargo.lock` delta is
**zero** — it was already resolved for the workspace.

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

* fix(contracts,extension-host): preserve the acquire cause and pin every HostApiError projection

Review triage for #7000.

- `import_bundle`'s decode-limiter `map_err(|_| ...)` discarded the
  `AcquireError`. The mapping is now a named `map_import_decode_acquire_error`
  that logs the bound source before mapping. Named rather than inlined so it is
  reachable from a test: nothing in the workspace calls `Semaphore::close`, so
  an inline closure would be a permanently uncovered branch that the
  changed-line coverage gate could only accept as a standing exemption. New
  regression test builds a genuine `AcquireError` from a closed semaphore and
  asserts the failure is `Transient` (retryable), not a client mistake.

- `From<HostApiError> for ProductOperationFailure` was pinned by one variant.
  It now enumerates all ten, asserts each carries its own rendering (so the
  cause cannot be flattened at the boundary) and projects to a 400, and adds an
  exhaustive `host_api_error_tag` match so a new `HostApiError` variant stops
  compiling the test instead of inheriting the blanket mapping silently.
  `InvariantViolation` is pinned as-is, not reclassified: the mapping mirrors
  product's pre-existing `From<HostApiError> for ProductSurfaceFailure` and
  changing it is a behavior change this slice does not own.

Red-then-green proved by mutating the code under test: InvariantViolation ->
Transient, flattening the reason text, and Transient -> InvalidBindingRequest
each fail the corresponding assertion.

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

* refactor(conversations): fix the conversations/threads naming trap (WS5)

Rename the five names `ironclaw_conversations` shared with
`ironclaw_threads` and unify the external actor/conversation pair onto its
one home in `ironclaw_extension_contracts`.

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

* refactor(attachments): widen ironclaw_attachments to own its ports and ceilings (WS5)

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

* docs(target-arch): record the WS5 naming-trap and attachments-widening outcomes

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

* docs(conversations): state the threads boundary in the crate doc

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

* fix(attachments,conversations,product): close the review gaps on the WS5 naming-trap slice

Review triage for #7005.

- `project_scoped.rs`: delete a stranded `///` block that described
  `ProjectScopedAttachmentReader`, ended mid-clause, and rustdoc was attaching
  to the `InboundAttachmentLander` impl. The module doc already records that the
  reader stays in `ironclaw_product`.

- `cleanup_stale`'s third pre-scan exit — an in-root reference whose relative
  depth is not `<date>/<message>/<file>` — had zero coverage anywhere in the
  tree. Extended the existing empty/unowned fail-closed test rather than adding
  a redundant one, asserting the `Internal` code and that the seeded batch
  survives the aborted pass.

- `stored_refs` / `ids`: state the rollback boundary. Compatibility is
  upgrade-only by decision — this build reads `thread_id`/`message_id` and
  writes only `topic_id`/`reply_target_message_id`, so a record written here and
  read by a pre-rename binary silently collapses every threaded route to its
  conversation root. Dual-writing is refused on the row's own type-placement
  rule, and is self-defeating besides: verified that a reader with
  `#[serde(alias)]` rejects a record carrying both spellings
  (`duplicate field \`topic_id\``).

- `gate_routes`: pass `None` for the source branch's reply target. Provably
  behavior-identical (`conversation_fingerprint` hashes space + conversation +
  topic and excludes the reply-target hint), but the previous spelling could
  only be read as correct together with the fingerprint body, and it reads as a
  per-message id baked into a stable route key.

- `run_delivery_contract`: the gate-route test could not see any of that. Its
  prompting event is now a threaded reply carrying both a topic and a reply
  target, which makes the source branch's key distinguishable from the
  delivered-message loop's, and it pins the fingerprint's reply-target
  independence directly.

- `inbound.rs`: rename the private `session_thread_service` field/param to
  `conversation_service`. `SessionThreadService` is the `ironclaw_threads` type
  this PR exists to stop colliding with.

- CHECKLIST WS5 sub-item 3: dated amendment quoting the sentence it annotates;
  the re-word/re-home obligation is now tracked in #7010.

No test was added and none removed — two were extended. Red-then-green proved
by mutating the code under test, not the tests: the malformed-reference branch
downgraded to `continue`; the fingerprint widened to include the reply target;
and three separate breaks of the source branch (topic keyed off the reply
target, topic dropped, branch records nothing). An earlier version of the
gate-route assertion passed under all three and was reworked until it failed.

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

* test(extension-host): close the WS2.2 changed-coverage gate with tests, not waivers

The `ProductSurfaceFailure` -> `ProductOperationFailure` repoint put 137
already-uncovered error-path lines into the changed-line denominator: the new
name is two characters longer, so every construction site's first line changed
and rustfmt re-wrapped the arms that crossed 100 columns. The gate ran on this
PR for the first time (stacked PRs never triggered it) and reported 67.38%
line / 83.33% branch.

Measured, not assumed. Replaying CI's own merged lcov (run 30706965794) against
the base lcov from `main` @ 569d8e4895 (run 30705915898) shows 122 of the 137
lines are 1:1 rename-only replacements that each scored `DA:<line>,0` at their
pre-image, and the other 15 are rustfmt re-wraps of those same lines. Zero are
"no LLVM region" type positions -- all 137 carry a DA record, because the gate
intersects the changed set with DA records, so region-less lines never enter
the denominator at all.

60 of those lines get real tests here rather than a waiver
(ironclaw_extension_host 419 -> 438 tests), covering every pure boundary mapper
the repoint touched:

* the retryable-vs-caller-error split in `product_lifecycle`,
  `lifecycle_restore`, `active_publication`, `lifecycle_product_service`,
  `extension_activation_credentials` and `hosted_mcp_manifest`;
* `map_skill_error`'s `FilesystemDenied -> BindingAccessDenied` projection,
  which is an authorization outcome and must not read as retryable;
* both post-install activation fail-open classifiers (service tier and
  capability tier), which decide which activation failures are swallowed
  behind a successful install -- they must agree, and now both are pinned;
* `ensure_caller_may_mutate_tenant_installation`, the tenant-admin guard on
  shared installations, pinned on the denial and on both ways through;
* `UnavailableExtensionActivationCredentialGate`, pinned fail-closed;
* `pending_manifest`'s hosted-MCP name and client-profile input guards, which
  are what keep caller text out of interpolated manifest TOML;
* `prepare_install`'s refusal of a retained definition that disagrees with the
  catalog.

Every one was verified by mutating the code under test and confirming the
assertion went red -- not the compiler. 12/12 mutants killed.

The remaining 77 lines and 1 branch are exempted with per-site evidence in four
classes: map_err arms on argument-free infrastructure constructors that cannot
fail from any input; defensive arms dominated by the guard immediately above
them; paths gated behind `VerifiedAuthClaim`, which has no constructor outside
`ironclaw_host_api`; and pre-existing fault-injection paths inside async `&self`
service methods, each still scoring 0 hits at its pre-image in the base lcov.

Also corrects a stale entry inherited from WS2.1: the `tracing` message-literal
exemption named line 217 (`?error,`) instead of 218 (the literal), and its
evidence block was off by one throughout. Inert today because neither line is in
this PR's changed set, but it would have silently failed to apply the moment a
PR touched the real line -- the stranded-exemption failure mode the manifest is
supposed to prevent.

Local gate: 100.00% line (343/343), 100.00% branch (2/2), exit 0.

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

* docs(coverage): tighten the WS2.2 exemption evidence to what the lcovs actually show

Three reasons overstated their evidence. Corrected against the base lcov:

* `channel_subject_routes.rs` 231-233 have no 1:1 pre-image because the hunk
  is 1->3 (`@@ -217 +231,3 @@`); base line 217 held the whole closure and
  scored `DA:217,0`, so it is one uncovered closure re-wrapped, which the
  reason now says instead of claiming a per-line pre-image.
* `product_lifecycle.rs` 783-786 map to base 785-787, where the `.map_err(`
  call scored 136 hits and only the closure body scored 0. The reason now
  names both numbers rather than implying the whole span was cold.
* `test_support.rs` 633-634 are the only genuinely NEW lines in this PR -- a
  `.map_err(ProductSurfaceFailure::from)` conversion, not a rename. Calling
  them "rename-only" was wrong. The honest evidence is that every line of the
  enclosing `#[cfg(feature = "test-support")]` helper scored 0 hits at base
  (624-635), so the conversion was added to an already-dead seam.

The header block's "the remaining 15 are rustfmt re-wraps" is corrected to
13 re-wraps plus those 2 new lines. No line numbers changed; gate still
100.00% line (343/343), 100.00% branch (2/2), exit 0.

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

* test(ws5): close the changed-coverage gate on the naming-trap slice

The gate attaches for the first time now that #7005 targets main. It
reported 21 uncovered changed lines, 8 uncovered changed branch arms, and
one file contributing no instrumented lines at all.

Every genuinely reachable hole is closed with a real test, driven through
the caller that owns the side effect rather than the helper:

- `ProjectScopedAttachmentLander::rollback` refuses malformed batch
  references. Rollback deletes a whole batch directory, so each guard in
  `attachment_batch_parent` is a delete-target check; the test lands a
  real batch first and asserts a refused rollback never removes it.
- `map_external_ref_error`'s non-`InvalidIdentifier` fallback keeps this
  crate's error vocabulary and carries the source message verbatim.
- The durable `RebornFilesystemConversationServices` forwards the
  inbound-message half of its contract (accept + replay), which only
  `InMemoryConversationServices` had ever exercised.
- `external_ref` maps `ProductAdapterError` to `InvalidMaterialization`
  without leaking a `RedactedString` detail, both directly and through
  `trigger_conversation_fields`.
- The two standalone attachment test-support accessors land bytes and
  read them back through both returned read views. They had no callers
  anywhere in the repo; `#[allow(dead_code)]` on the impl block hid it.
- `delivered_conversation_fingerprints` drops a vendor message ref that
  cannot key a route, covering the two reachable `Err` arms.

Two exemptions, both with per-site evidence, neither a shortcut:

- `types.rs`: seven `pub struct` / field declarations from the DTO
  rename. A serde round-trip contract test for all five types was
  written first to test the obvious hypothesis that the derives would
  instrument them; re-measuring showed the file still reports the
  identical 42 DA records over the identical 17..312 span, because
  derive-generated code is `#[automatically_derived]` and emits no
  region at the declaration site. The round-trip test is kept: it pins
  the persisted encoding across the rename, which is the risk the WS5
  CHECKLIST row actually cares about.
- `gate_routes.rs` branch arms 45/58/74: every argument is an accessor
  read off an already-validated `ExternalConversationRef`, whose fields
  are private and whose only value-producing paths all run
  `validate_external_id`. Re-validating a value that already passed a
  pure predicate cannot fail. The two sibling sites that also take the
  unvalidated vendor ref are tested, not exempted.

Each new test was mutation-verified red-then-green.

* fix(architecture,extensions): close the paranoid-architect review findings on the WS2.4 split

Review pass over #7003 (four parallel deep reviews; no Critical/High — the
move itself verified behavior-free). Everything found, fixed here:

Gate hardening (crates/ironclaw_architecture/tests):
- ratchet_support gains cfg_test_only_files: files reachable only through
  #[cfg(test)] mod chains (incl. #[path] overrides) are classified test code.
  channel_host/e2e_auth_challenge.rs — a fake AuthChallengeProvider impl
  wearing a production filename — no longer counts toward any residue row,
  implementor pin, or error-vocabulary floor. Pinned by a real-tree test that
  was red before the #[path] resolution landed.
- Trait matching is qualified by a whole-token crate reference (names_crate),
  so a name-colliding local trait can no longer satisfy an implementor pin,
  and a manifest rename of ironclaw_product can no longer blind the manager
  residue scan (metadata tie: dep exists iff the residue list is non-empty,
  never renamed).
- The manager gets its own product-defined-trait residue freeze (twin of the
  host's, frozen at ExtensionCredentialSetupService).
- each_half_of_the_split_kept_its_own_job: authority checks are symmetric
  across file/directory spellings and back every module with a content
  witness, so an empty stub cannot satisfy retention.
- untrusted_ingress_paths scan roots fail loudly on a missing root instead of
  silently dropping a tree from the guard.
- Fork-check message names its two-crate scope.
All new checks probed red-for-the-right-reason and reverted (hollow witness,
product alias, stale scan root, authority-as-directory, unguarded secret).

Manifest hygiene:
- extension_host drops the ed25519-dalek dep orphaned when ironhub moved.
- Ten manager deps used only by tests/the test_support fixture leave the
  production graph: fixture deps become test-support-gated optionals, pure
  test deps move to [dev-dependencies]. All three build shapes verified.

Manager/host code:
- channel_config: the pub resolved_manifest widening is narrowed to a
  declares_admin_configuration() boolean — the manifest read stays internal.
- admin_configuration view: secret field values are redacted in render_group
  (same defense-in-depth as render_state), with a sentinel regression test;
  the service-error table test now pins code/kind beside status/retryable.

Docs (single-source-of-truth):
- families/extensions.md confesses the direct auth/host_runtime deps and the
  transitional dep tail the four-crate target does not name.
- The residue characterization says what the list actually holds: DTOs,
  capability-id constants, and two port-inversion residues.
- 20 -> 13 becomes 20 -> 12 (the 13th was the cfg(test)-only fixture);
  coverage-floor/CHECKLIST stale "recapture owed" drafts corrected to the
  shipped recapture; line counts de-precisioned; stale exemption comment
  repointed to the manager.

Verification: architecture 143/0; manager 64/0 (--all-features);
extension_host 388/0 (--all-features); cargo check --workspace --all-targets
--all-features 0 errors / 0 warnings; clippy -D warnings clean on all three
touched crates; both CI script self-tests pass; cargo metadata --locked clean.

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

* fix(conversations): keep the durable grammar so the rename survives a rollback

Human review on #7005 (serrrfirat, `stored_refs.rs:53`) and CodeRabbit
(`stored_refs.rs:38`) both found the same real defect, and the module's own
refutation was aimed at a different proposal than the one that fixes it.

`stored_refs` refuted DUAL-WRITING (emitting both spellings), correctly: a
reader with `#[serde(alias)]` rejects a record carrying both as a duplicate
field. But the ask was WRITE-LEGACY / READ-EITHER, which that objection does
not touch. Measured against `origin/main`, the released readers are
`RawExternalConversationIdentity` and the `ExternalConversationRef` wire struct
in `ironclaw_conversations/src/ids.rs`; both name `thread_id`/`message_id` and
carry no aliases. So a record this build wrote read back as `None` on a
rollback, with no error.

Worse than the reported "remaps to the conversation root": the identity keys
`BindingKey`, and `StoredConversationState::into_state` rebuilds the map with
`Vec<(K, V)>::into_iter().collect()`, so two threaded bindings in one
conversation collapse onto one key and the earlier one is dropped.

- `conversation_ref::serialize` now writes `{space_id, conversation_id,
  thread_id, message_id}` through a borrowed representation; both spellings
  still read, and a record carrying both still fails closed.
- `ExternalConversationIdentity` gets a matching hand-written `Serialize`.
- `stored_refs::actor_ref` deleted: the actor change was additive, so the
  canonical impls already do everything it did. `actor_serde_needs_no_adapter`
  pins that equivalence instead of asserting it.

Tested through the durable store, not a surrogate
(`filesystem_conversation_services_persist_external_refs_in_the_durable_grammar`
walks every key of the real persisted document). Both fixes verified
red-then-green by mutating the writers: reverting the ref writer fails the unit
AND store tests; making the identity emit `topic_id` fails only the store test,
naming all six sites — which is exactly the gap the review reported.

Also from the same review round:

- Rename `product/src/scoped_fs/attachment_landing.rs` -> `attachment_reader.rs`
  now that the lander moved out (serrrfirat, `attachment_landing.rs:1`).
- Reuse `ratchet_support::strip_comments_and_strings` instead of a third local
  copy; the extended self-test fixture proves the deleted line-based copy leaked
  block comments (serrrfirat, `reborn_conversations_threads_attachments.rs:135`).
- Amend `docs/reborn/contracts/conversation-binding.md` and the conversations
  CLAUDE.md for the renamed service, the moved ref pair, and `topic_id`
  (serrrfirat, `conversations/src/lib.rs:38`).
- Widen the `crates/AGENTS.md` attachments row and record the justified WebUI
  edge in `ironclaw_webui/AGENTS.md` (serrrfirat, `attachments/src/lib.rs:23`).
- Assert the topic participates in the fingerprint, so the route-membership
  check cannot pass vacuously (CodeRabbit, `run_delivery_contract.rs:1347`).

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

* refactor(operator,contracts): close the WS5 operator review findings

Human review on #7004 (serrrfirat). Five findings taken, one deferred with a
named home, one answered in place.

- Composition calls operator's route mount through the crate-root facade
  (`ironclaw_operator::nearai_login_callback_mount`) instead of naming the
  three-segment module path. The deep path was pre-existing — it lived in the
  composition shim this PR deleted — but the shim was what encapsulated it, so
  the facade re-export is this PR's to add. `llm_admin/mod.rs` already
  re-exports free functions (`apply_stored_api_key`, `resolve_reborn_runtime_llm`),
  so this follows the existing convention rather than inventing one.

- `map_llm_config_error` deleted; its 10 call sites across three files now use
  `.map_err(ProductSurfaceError::from)`. The helper forwarded its sole argument
  unchanged and its own doc comment admitted it survived only to preserve a
  spelling. Guidance in product/CLAUDE.md, PROPOSAL, and CHECKLIST repointed.

- The operator-service DTO family gains item-level docs. Two semantics were
  genuinely non-obvious and are now stated: `RebornOperatorStatusState::Unsupported`
  means "no probe exists yet" and is excluded from the `overall` fold, and
  `RebornServiceLifecycleState::Unknown` maps to *available* alongside
  Installed/Running/Stopped, while only Unsupported/Failed mark the surface
  unavailable. Docs only — the serde attribute inventory is byte-identical.

- operator/AGENTS.md: the `nearai_mcp` debt now points at the cross-crate
  `include_str!` row that actually owns it (the strays row measured the claim
  and handed it over) and names the package-inventory-from-the-binary
  replacement. The source map's six `llm_admin/` paths are corrected —
  five were written as if they sat at `src/` root — `mod.rs` is added, and the
  re-derivation command is recursive, since `ls src/` lists four entries and
  none of the ten files the map documents.

- CHECKLIST gains a WS10 row owning the architecture-test scanner consolidation
  (raised on both #7003 and #7004), with the measurement: four helpers across
  five files, the two port-inversion copies a 172-line near-identical block, and
  `ratchet_support` exporting none of them today.

- changed-coverage exemption for composition/runtime.rs corrected 3703 -> 3701:
  the facade repoint removed two net lines above it, and the entry must keep
  naming `failure_explanation_scope.clone()` rather than silently re-point at
  the `TurnRunId::new()` line that drifted into its place.

Verification: fmt clean; clippy -D warnings --all-targets --all-features on
operator/product_contracts/product/composition all zero; tests operator 154/0,
product_contracts 119/0, product 1032/0, composition 915/0, architecture 145/0;
workspace cargo check --all-targets --all-features clean; exemption manifest
loads through the gate's own loader (61 entries, 282 coordinates).

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

* fix(extension-host,coverage): close the human review on WS2.2

Review findings from @serrrfirat and CodeRabbit on #7000. Three of them were
right about things this branch had reasoned wrongly, so the reasoning is
corrected rather than the symptom patched.

**The WS2.2 changed-coverage tranche is deleted, not re-dated.** The
`ProductSurfaceFailure` -> `ProductOperationFailure` repoint reached `main`
ahead of this branch (via #7002), so the ~250 lines of waivers were derived
from a comparison that no longer exists. Verified by replaying
`scripts/ci/reborn_changed_coverage.py` against CI's own merged lcov (run
30715247952) both with and without the tranche: 100.00% (11/11), 0 uncovered
either way, byte-identical to CI's published `reborn-changed-coverage.json`.
Not one entry was load-bearing. They are deleted because an exemption is
scoped to a `(path, line)` pair and not to the PR that added it -- an inert
entry today silently waives whichever line lands on that number tomorrow.
The pre-existing 217 -> 218 correction is kept: that entry was waiving the
`?error,` field rather than the message literal.

**The Class C "VerifiedAuthClaim cannot be constructed" premise was false.**
`VerifiedAuthClaim`'s constructors are `pub(crate)`, but
`ProtocolAuthEvidence::test_verified` is `pub` under `test-support`, and this
crate's `[dev-dependencies]` already enable it -- `channel_command_roles.rs`
in this same crate has been building command contexts through that seam all
along. `lifecycle_caller`'s and `lifecycle_resource_scope`'s Command arms are
now tested instead of waived, including the invalid-subject rejection.

Also from review:

* The import-limiter mapper's debug event is asserted (message + rendered
  cause), so deleting it or dropping `%error` fails a test. Its doc comment no
  longer claims nothing calls `Semaphore::close` -- no *production* path
  closes the limiter; a test closes a standalone semaphore to mint a real
  `AcquireError`.
* `UnavailableExtensionActivationCredentialGate`'s fail-closed half is
  exercised through the trait methods with a credential-requiring fixture. It
  was asserted in prose and never run.
* Comments naming a nonexistent `install_response` helper now name
  `installed_response()`.
* `map_extension_error` had three byte-identical copies; the two private ones
  in `active_publication.rs` and `lifecycle_restore.rs` now call the
  `pub(crate)` helper `hosted_mcp_manifest.rs` already used.
* The post-install "hosted MCP discovery still left it installed" decision was
  two inline string comparisons string-coupled to a producer in a third
  module. Extracted to `hosted_mcp_discovery_left_the_install_usable` beside
  that producer, with a test feeding the producer's actual output into the
  predicate. The two classifiers themselves are deliberately NOT merged --
  different return types, and their `Err` arms encode different policies.

Every new assertion was verified red-then-green by mutating production code,
never the expected literal: 8/8 mutants killed, each an assertion failure
rather than a compile error.

Verification: `cargo fmt --check` clean; `cargo clippy -p ironclaw_extension_host
-p ironclaw_product_contracts -p ironclaw_product -p ironclaw_architecture
--all-targets --all-features -- -D warnings` clean; `cargo test -p
ironclaw_extension_host` 441/0; `cargo test -p ironclaw_architecture` 28
binaries green; `cargo check --workspace --all-targets --all-features` clean;
manifest re-validated -- all 71 remaining exemptions pass, no stale paths, no
lines past EOF.

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

* fix(extension-host): keep the malformed OAuth-metadata cause diagnosable

`map_err(|_| ...)` on `serde_json::from_slice` discarded the parse error. The
`reason` that crosses the boundary is deliberately fixed text — the document
came from a third-party server and must not be echoed — so the dropped error
was the only thing that said *why* the document was malformed. Now logged
before mapping, matching the sibling fetch arm four lines above and this
crate's standing rule that a `map_err` discarding its cause must log the bound
source first.

Raised by CodeRabbit on #7000. Safe for the changed-coverage gate: the closure
already scores hits in CI's merged lcov (run 30715247952, lines 554-557 each
at 1), so the added event sits on an executed path rather than creating a hole.

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

* docs(conversations): spell the binding-identity guardrail topic_id

CodeRabbit on CLAUDE.md:5. Line 8 still defined the external route identity
as (space_id, conversation_id, thread_id). In this crate thread_id now means
the canonical ThreadId, so the guardrail restated the exact naming trap WS5
removed -- in the one file an agent reads before touching the crate.

Now topic_id, with the durable-record exception named explicitly so it does
not read as a contradiction of stored_refs.

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

* docs(target-arch): correct the WS5 durable-grammar row after review

The CHECKLIST and PROPOSAL both recorded the original one-way shape
('writes the canonical spelling and reads either'). That decision was
reversed on review; docs/reborn/target-architecture is the single source
of truth for these findings, so the correction lands here and not only in
the PR body.

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

* fix(coverage,architecture): close the human review on the WS2.4 split

Review findings from @serrrfirat on #7003. The first one was blocking CI
outright.

**The coverage exemption did not move with its file (HIGH).**
`extension_lifecycle_capabilities.rs` left `ironclaw_extension_host` for
`ironclaw_extension_manager` in this PR; its changed-coverage exemption kept
naming the old path. That is not cosmetic staleness — the manifest validator
is fail-closed on it, so the whole changed-coverage gate aborts with **no
verdict at all** rather than reporting a number. Reproduced on this branch
before the fix:

    GATE ERROR: exemption #71 names stale path:
      crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs

exactly the entry index the reviewer named. Path repointed to the manager and
the line corrected 217 -> 218 (217 was the `?error,` field, not the message
literal the reason describes; the off-by-one was fixed on the parent). Whole
manifest re-validated: **71 entries, no stale paths, no lines past EOF.**

**Direct `#[cfg(test)]` module seeding was untested.** Confirmed empirically
rather than by reading: deleting the seeding loop from `cfg_test_only_files`
left the only in-tree pin green (9 passed), because its chain starts at
`e2e_tests.rs` — already seeded by the `*_tests.rs` name rule — and reaches
its child through an explicit `#[path]`. So neither the `cfg(test)` gate nor
default `<dir>/<name>.rs` resolution was exercised, and a production-named
file declared `#[cfg(test)] mod fixture;` could have become countable
silently. Added `direct_cfg_test_module_and_default_child_are_test_only` on a
synthetic tree covering both shapes plus the negative case; it goes red under
that same deletion.

**Crate contracts contradicted the move.** The CLI's exhaustive
`[dependencies]` inventory omitted `ironclaw_extension_manager` (and, found
while checking, `ironclaw_product_contracts` and `ironclaw_extension_contracts`
— all three added by this layer). The product-contract docs still said
`LifecycleProductService`, `ChannelConfigProductService` and
`RebornViewProvider` are implemented by `ironclaw_extension_host`, while this
branch's own `INVERTED_PORT_IMPLEMENTORS` says `ironclaw_extension_manager`.
Reconciled toward the enforced pin in `reborn_cli/AGENTS.md`,
`product_contracts/CLAUDE.md` (now a per-port implementor table, and citing
the constant by its real name), `lifecycle_service.rs`, `views.rs`,
`channel_config.rs`, and `crates/AGENTS.md` — the last of which the review did
not flag but was stale the same way.

**The production-source walker is centralized — for the two ratchets named.**
`ratchet_support::production_rust_files` now owns the fatal walk, the
name/directory exclusions and the `cfg_test_only_files` subtraction, and both
`reborn_extension_host_port_inversion.rs` and `reborn_extension_manager_split.rs`
delegate to it. The reviewer's concern was already realized rather than
hypothetical: the two walkers **had** drifted — one skipped `node_modules` and
the other did not. ~19 other ratchets still carry their own walk; migrating
them belongs in a dedicated change against `ratchet_support`, not in a crate
split, and that is recorded at the new helper and at the call site.

Verification: `cargo fmt --check` clean; `cargo clippy -p ironclaw_architecture
-p ironclaw_product_contracts -p ironclaw_extension_manager -p
ironclaw_extension_host --all-targets --all-features -- -D warnings` clean;
`cargo test -p ironclaw_architecture` 28 binaries green, 0 failed;
`cargo check --workspace --all-targets --all-features` clean.

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

* test(product): cover the attachment reader's product-surface error taxonomy

The changed-coverage gate failed on 4cd166f727 at 90.87% (209/230), naming 21
lines in scoped_fs/attachment_reader.rs. Cause: renaming the module (review
finding, @serrrfirat) makes git pair the deleted attachment_landing.rs with
ironclaw_attachments/src/project_scoped.rs -- the lander's real destination --
so the surviving reader reads as a new file and its pre-existing uncovered
lines become changed-code candidates.

They were genuinely untested, not an attribution artifact: every existing test
drives read_attachment_bytes (the LoopAttachmentReadPort half), so the whole
InboundAttachmentReader::read map_err taxonomy below it was dead to the suite.
That taxonomy IS the bytes endpoint's contract -- a caller separates "gone"
from "not yours" from "broken" only by the status this closure picks.

Four tests added to the existing inline module (extended, not a new file):
success through the thread scope, 404 NotFound, 403 Forbidden (a denied mount
must not answer 404 -- that would tell a caller the attachment does not
exist), and a malformed storage key that must fail closed at ScopedPath::new
and become a 500 that does not echo the rejected value back.

No exemption claimed: an exemption is for lines that cannot execute, and these
can. Verified with the same instrumentation CI uses -- cargo llvm-cov over
this test binary reports non-zero hits on all 21 lines CI listed (63, 101-117,
120, 121, 123), zero still uncovered.

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

* test(product): replace a vacuous non-leak assertion with the sanitized shape

Self-review of 5b85634272. The malformed-storage-key test asserted
!format!("{error:?}").contains("evil.example.com"). That can never fail:
ProductSurfaceError has no reason-bearing field (code, kind, status_code,
retryable, field, validation_code) and internal_from logs its source through
tracing then returns Self::internal(), discarding it. The non-leak is
structural, not behavioral, so the assertion was decoration.

Replaced with assert_eq!(error, ProductSurfaceError::internal()), which pins
the whole sanitized shape -- and would fail if a future variant added a
detail-bearing field. The comment now says which property is structural and
why no assertion is made for it.

Mutation-proved rather than asserted: making the ScopedPath failure classify
as NotFound instead of Backend fails exactly this test and no other. The
sibling 403 test was proved the same way (collapsing Forbidden into the
NotFound shape fails only it).

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

* refactor(extensions): split ironclaw_extension_manager out of extension_host (WS2.4) (#7003)

* refactor(contracts): invert extension_host's product-facing ports onto product_contracts (WS2.1)

`ironclaw_extension_host` sits below product in the target tree, so a
product-side port it satisfies must be declared at the product boundary and
implemented downward — never declared inside `ironclaw_product` and reached
upward. This moves every such port that `ironclaw_product_contracts` may
legally name, and dissolves the product re-export facade for the extension
host.

Nine port families move (definitions only; every implementation stays with its
owner, PROPOSAL §6.1.4): delivery resolution + reply context, account-connection
status + setup descriptors, channel config, the view-provider conduit, command
context + actor-role admission, gate-prompt enrichment, the lifecycle product
service, the admin-user directory, and the operator tool catalog. Product keeps
`DeliveryCoordinator`, `NoReplyContext`, `ExtensionAccountSetupRegistry`,
`UnsupportedLifecycleProductService`, `RejectingAdminUserService`,
`UnavailableRebornViewProvider`, `DirectConversationCommandAdmission`, the
frozen `Reborn*` wire DTOs, and the inbound-action ledger.

extension_host's product symbol usage drops 146 -> 62 across 46 -> 35
production files. The edge itself does not die here and could not: the
survivors are `channel_host.rs`'s construction of product's concrete assembly,
the `extension_manager` split inventory, `product::adapter_registry`, and the
named strays — each owned by a later WS2 row. Six ports also could not move,
all for one mechanical reason: `product_contracts` may depend only on
`host_api` + `extension_contracts`, so a signature naming `ironclaw_auth`,
`ironclaw_threads`, `ironclaw_turns`, or `ironclaw_conversations` cannot be
declared there. `ProductSurfaceFailure` is the linchpin — extension_host uses
product's *internal* workflow error as its own lifecycle error vocabulary in 19
files, and it carries `ironclaw_turns::TurnError`.

Regression cover: `reborn_extension_host_port_inversion.rs` pins the nine moved
ports where they landed and holds the six-entry residue shrink-only, with the
per-entry reason each could not move; a new product-declared port implemented
by extension_host fails the build. The moved typed-token tests travel with
their code and `ActionFingerprintKey` gains the coverage it lacked.

Enumerating gates, all update-never-relax: the composition pub-use snapshot
gains one line (two names re-sourced from `product_contracts`, so one `pub use`
splits into three); the extension-specificity allowlist, the struct/test-support
ratchet, the §11.2.7 include inventory, the `ProductSurface` method freeze, and
`LAYER_MATRIX_EXCEPTIONS` (13) are all untouched — extension_host carries no
layer-matrix exception and never did, since both crates are `products`-layer.

`secrecy` joins `product_contracts` with a manifest comment: `AdminUserService`
takes secret material and `AdminCreatedUser` carries a one-time token, both
`SecretString`. It is a value wrapper, not a framework/driver/runtime client.

CHECKLIST WS2 row 1 ticked with the four dispositions the lead sheet did not
predict.

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

* test(contracts): cover the moved port surfaces and close the impl-scanner bracket hole

Two follow-ups on the WS2.1 port inversion, both found by measuring rather
than assuming.

**Coverage of the surfaces this PR created.** `cargo llvm-cov` over
`ironclaw_product_contracts` showed the relocated bodies had no crate-tier
coverage of their own: `ProductCommandContext::from_envelope`,
`AdminUserRole::is_admin`, `AccountConnectionStatusError::new`,
`ChannelConnectionNoticePolicy::generic`, the bounded-token
`TryFrom`/`AsRef`/`Display` arms, and — the one that matters most — the two
`LifecycleProductService` **default** method bodies, which every production
implementor overrides, so nothing exercised the fail-closed defaults. Each is
now tested at its contract meaning, not for the line count: bundle import
defaults to `InvalidRequest` rather than silently succeeding; activation errors
default to none so the wire field stays absent; a non-command envelope is
rejected as an invalid request rather than an internal error; a token that
deserializes runs the same validation as its constructor; the generic notice
policy names the channel in all five notices and does not collapse them into
one string. Every added production line in the new modules is now covered.

**The scanner had a hole the review caught, and it was real.**
`implemented_trait_names` closed the impl's generic-parameter list at the first
`>`. For `impl<T: Iterator<Item = X>> Port for Host<T>` that `>` closes
`Iterator`, leaving `> Port` — not an identifier, so the impl was dropped and a
new product-defined port could have entered `extension_host` without tripping
the shrink-only gate. Now closed by balancing, with `->` inside a bound
(`impl<F: Fn(&str) -> bool>`) excluded from the count, and both shapes added to
the scanner self-test — which fails without the fix. Re-verified after the fix:
the residue is still exactly the six frozen entries, so the wider scan found no
previously hidden implementation.

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

* fix(arch): make the port-inversion scanner fail loud, and reconcile the doc counts

Review triage on #6998. Four findings taken, four rejected with evidence in the
thread; the taken ones are all about the gate telling the truth.

**The scanner could pass on an incomplete scan.** `rust_files` returned early on
a `read_dir` error and dropped per-entry errors through `.flatten()`, and
`traits_implemented_by` skipped any file it could not read. A permission or
transient I/O error in CI would have thinned the input and turned the ratchet
green while enforcing nothing — the exact failure class this file exists to
catch. Every I/O error is now fatal.

**`#[cfg(test)]` blocks were located by raw brace bytes.** A `{` inside a
comment or string literal in a gated block desynchronizes the depth count and
either leaks a test-only `impl` into the production set or swallows the
production code that follows it. Comments and strings are now stripped first;
`cfg_test_stripping_survives_braces_in_comments_and_strings` is the pin, and it
fails with the old composition (verified by reverting the order and watching it
go red). The doc comment now also states why `#[cfg(feature = "test-support")]`
is deliberately *not* stripped: that feature compiles into a real build, so an
`impl` behind it is a genuine normal-dependency edge, unlike `#[cfg(test)]`.

**The prose counts had drifted.** Eleven port declarations moved, not nine —
nine that `extension_host` implements (the pinned `INVERTED_PORTS`) plus
`AdminUserService` and `RebornOperatorToolCatalog`, which it only consumes and
composition implements. CHECKLIST, both CLAUDE files, and the module-count line
now agree and all defer to the architecture test as the enforced inventory.
`families/contracts.md` also still listed `ironclaw_common` in the family-level
dependency bullet; that is the second of the two places, now corrected too.

**One mismatch recorded rather than fixed.** `LifecycleProductService::
import_extension_bundle`'s default said "unavailable" while returning
`InvalidRequest`/400. The move carried both verbatim; changing the code changes
an HTTP status on a live route, which does not belong in a move-shaped PR. The
doc now describes what the code does, names the discrepancy, and points at the
test that pins today's behavior so a silent flip is impossible.

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

* docs(contracts): state the module count as shipped-modules-plus-dev-seam

The count line said 'seventeen modules' while `src/lib.rs` carries eighteen
`pub mod` declarations — the difference is `test_support`, which is gated
behind `#[cfg(any(test, feature = "test-support"))]` and is deliberately
absent from the table above it. Saying 'seventeen shipped modules plus the
dev-only test_support' makes the table and the manifest agree on inspection
instead of looking like drift.

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

* refactor(contracts): resolve the ProductSurfaceFailure linchpin (WS2.2)

`ironclaw_extension_host` used `ironclaw_product`'s internal workflow error
as its own lifecycle error vocabulary across 19 production files — WS2.1's
recorded linchpin, blocking half the port-inversion residue and the layer
flip. Measured with `#[cfg(test)]` stripped, it constructs exactly six
variants (150 sites), all plain-`String` or unit, and none of the two
kernel-typed ones that kept the enum out of contracts.

The boundary half is now
`ironclaw_product_contracts::error::ProductOperationFailure`;
`ironclaw_product` keeps `ProductSurfaceFailure` unchanged in shape and
absorbs it with a total, payload-preserving `From`. The projection to
`ProductSurfaceError` is defined once, in contracts, and product's
`lifecycle_product_surface_error` delegates its six shared arms to it so the
two paths cannot drift. Only the logging stayed with each caller — contracts
may not log.

Narrowing the enum instead was rejected on evidence: `auth_continuation.rs`
matches all eight `TurnErrorCategory` values structurally and distinguishes
two the sanitized projection collapses, and constructs by matching
`TurnError` variants the projection cannot express — so narrowing is lossy
in a live auth path.

Unlocks `ProductConversationSubjectRouteResolver` (trait residue 6 -> 5, with
its route key and request type) and takes extension_host's files naming the
workflow error 19 -> 2. Corrects the two surviving residue reasons, which
named the error rather than the real blocker.

Regression coverage: nine crate-tier tests including the projection-agreement
pin and the `From` totality pin, plus two new architecture gates (frozen
residue files; the contract error names no kernel type), each verified by
negative probe. Extension-specificity allowlist shrinks 130 -> 129.

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

* fix(arch): apply the parent's scanner hardening to the WS2.2 half

The merge brought in WS2.1's review fixes (I/O errors fatal, comments and
strings stripped *before* `#[cfg(test)]` brace matching). Both apply verbatim
to `production_files_naming`, which this branch added after that review:

- An unreadable file was silently skipped, which is exactly how the frozen
  residue-file scan would go quietly vacuous. Now fatal, matching the three
  other readers in the file.
- The strip order was backwards. A `{` inside a comment or string literal can
  desynchronise the `#[cfg(test)]` brace matcher, so comments and strings go
  first. Re-probed both directions afterwards: a code reference still trips
  the gate, a comment mentioning the type (now with an unbalanced brace) still
  does not.

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

* test(contracts): close the changed-coverage holes the port move opened

CI's changed-coverage gate failed on the WS2.1 move, exactly where a
move-shaped diff is expected to: relocated bodies read as added production
lines. Every hole is now closed with a test. One line is exempted, with its
callers named.

**Five relocated port modules had no LCOV record at all.** `delivery`,
`channel_config`, `operator_tools`, `prompt_source`, and `views` are pure
declarations, so rustc emitted no source record and the gate reported them
absent. Each now carries a contract test rather than a waiver, and the
properties they pin are the ones these ports actually owe:

- **object safety** for all seven traits — every consumer holds them as
  `Arc<dyn _>`, so a signature change that breaks dyn-safety now fails at the
  contract instead of at the far-away wiring site;
- **argument pass-through and ordering** for the delivery ports — `reply_context`
  takes extension id, installation id, and conversation fingerprint as three
  bare strings, so nothing but a test stops a transposition turning into a
  silent mis-delivery (this is the identity-mixup risk review raised; the types
  stay verbatim, the ordering is now pinned);
- **absence without error** — an unresolved channel, an empty channel-config
  field set, an empty operator tool catalog, and a missing approval-prompt
  context are all normal outcomes that must not be expressible only as failures;
- **caller scoping** on the operator catalog, whose `caller` parameter is the
  #5459 disclosure control;
- **`next_cursor` omission** on an unpaginated view page — serializing `null`
  would make every unpaginated view look paginated to the browser.

**Two genuinely untested error paths in `extension_host`, both fail-closed
seams the move touched.** `AccountConnectionStatusSource::connected` now has
coverage proving it fails *closed* on a pairing-backend outage (activation must
not proceed on an unknown connection state) and *sanitized* (the test asserts
the driver, host, and port do not appear in the product-facing error). The
lifecycle output-serialization mapping moved out of an inline closure into a
named `lifecycle_output_decode_error` so the mapping is reachable from a test:
the failure is defensive, but *what it maps to* is a live contract — the model
gets `OutputDecode` and never the serde error, which can quote projection
contents.

**A dead branch arm.** `validate_typed_token` guards `c == '\0' || c.is_control()`
and only the second arm was exercised. NUL has its own arm because a token with
an embedded NUL truncates at a C boundary rather than merely looking odd.

**Diff shape.** The remaining reports were an artifact of relocating types
inline: a fully-qualified `ironclaw_product_contracts::<mod>::<Item>` in a
signature turns an untouched line into a changed one. Those 17 files now import
the symbol like every other, which shrinks the diff, restores the crate's
prevailing style, and drops the lines out of the gate's denominator because a
`use` line is uninstrumentable by construction.

**One exemption, with evidence.** `factory/test_support.rs`'s
`channel_config_service` accessor: the repoint collapsed its signature onto one
line, and the merged lcov does not attribute its two integration callers back
to the composition bucket build. Both callers are named in the manifest, the
service and the port contract are covered by tests added here, and it is filed
under the same #6963 lane-attribution lane as the WS1 entries above it.

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

* test(contracts): make the catalog and view doubles discriminate on their arguments

Review caught two tests of mine that asserted the double's behavior rather
than the contract, and it was right about both.

`EmptyCatalog` ignored `caller` and always returned an empty vector, so
`the_catalog_is_caller_scoped...` would have passed against a production
catalog that disclosed every user's private installs — the exact leak the
`caller` parameter exists to close (#5459 P1). It is now backed by an
ownership-filtering double, two callers, one tenant-shared tool and one private
tool each, asserting both directions of isolation and that the answer *can*
differ by caller. `OneRowView::query` ignored `_caller` and `_params` and the
test only checked the cursor; the provider now echoes all three conduit
arguments and the test asserts all three.

Both were verified red-then-green rather than assumed: dropping the caller
filter fails the catalog tests, and dropping params from the echo fails the
view test. (My first attempt at the view mutation substituted the expected
literals and passed — a reminder that a mutation which doesn't fail proves
nothing about the mutation, only about the mutant.)

The over-claim went into the PR body too, and is corrected there: a contracts
crate can pin that the port *hands the implementation the caller* and that its
shape admits a per-caller answer. It cannot pin that production filters
correctly — that is composition's implementation and composition's test. The
doc comments now say so instead of implying the stronger claim.

Also lands the CHECKLIST note this PR earned for the rest of Wave 2/3: a
move-shaped PR fails the changed-coverage gate on its first CI run, in three
distinct shapes needing three different answers, with the two mechanical habits
that shrink all three.

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

* refactor(extensions): split ironclaw_extension_manager out of extension_host (WS2.4)

The extension host held two jobs: lifecycle authority (the only writer of
installation state, ingress verification, activation transactions) and the
extension-management product face that arrived with #6616/#6669. PROPOSAL
§6.8.3 splits the second into its own products-layer crate so the first can
move below product in WS2's layer flip.

Six of the nine inventory items moved; three are structurally blocked and
each is recorded with its measurement. extension_host production files
naming ironclaw_product: 20 -> 13. Port-inversion residue 5 -> 4.

Behavior-free: modules move, imports repoint, one 100-line product
projection is extracted from channel_config.rs.

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

* test(contracts): close the coverage-gate shapes on the WS2.2 slice

Applies the cross-slot lessons from WS2.1/WS2.3's coverage rounds to this
row's own new code, before the gate has to ask.

Pure-declaration modules gained real contract tests rather than waivers:

- `subject_route`: the port is held as `Arc<dyn _>` in five places, so object
  safety is a contract; a resolver is handed every field unswapped
  (`adapter_id`/`installation_id` are both string newtypes, so a swap would
  otherwise be silent); and an unconfigured route is absence, not failure.
  The double is **route-keyed, not fixed-answer** — two configured routes
  resolve to *different* subjects and a third resolves to `None`, so a
  resolver that ignored its argument could not pass. A fixed-answer double
  would have made all three assertions vacuous.
- `error`: `Display` is exercised for every variant, asserting each one keeps
  the text the LLM tool path forwards — `ProviderInstanceNotConfigured`
  carries the operator's exact `config set` remediation.
- `lifecycle_surface_error`: pinned against the contract's own projection
  (drift guard) *and* against absolute statuses (so both drifting together
  still fails).

`channel_config_unavailable` is extracted from a `map_err` closure because it
sat on the one path unreachable in test without fault-injecting the concrete
config service. Naming it makes the classification directly testable, and the
classification matters: a store failure is transient (retryable 503), never a
rejection (permanent 4xx) that would leave a correctly-configured channel
looking broken. The other 44 closures in this crate are pre-existing bodies
where only the type name changed (45 on the parent), so they are left alone
rather than churned on speculation.

Each new test was verified red-then-green by **mutating production code**, and
every mutation compiles cleanly so the red is an assertion failure rather than
the compiler catching the mutant:

- route key stops discriminating by conversation -> two routes collapse to one
  subject (`left: eng-subject, right: support-subject`)
- `Display` drops `{reason}` -> "rendered as ..., dropping ..."
- `lifecycle_surface_error` stops delegating -> "projection drifted for ..."
- store failure reclassified permanent -> "must be transient, got ..."

Scope is calibrated in the doc comments: the contracts-crate test pins the
port's shape and that it admits a per-route answer; it does not claim the
production resolver filters correctly — `channel_subject_routes`' own tests
(`foreign_adapter_or_installation_resolves_nothing`,
`malformed_config_json_fails_closed`) already own that.

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

* docs(ws2.4): date the two row corrections and quote the text they replace

The CHECKLIST disposition named the contradiction without quoting the
inventory line it corrects or carrying a date; PROPOSAL §6.8.3 pointed at
it without the verbatim text. Both now quote both sides.

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

* test(extension_host): cover the log-sanitization guard; exempt the type-position residue

CI's second changed-coverage run came back at 99.32% line / 100% branch, with
one uncovered line and six files reporting "contributed no instrumented lines".
Two different problems, two different answers.

**The uncovered line was coverable, so it is covered.**
`lifecycle_output_decode_error`'s `tracing::debug!` body never ran under test:
with no subscriber installed `tracing` short-circuits on the null dispatcher,
so the message literal is a region that cannot be reached. The fix is not a
waiver — it is the subscriber. The test now installs a DEBUG-level
`tracing_subscriber::fmt` over a shared writer (the pattern
`ironclaw_turns/tests/agent_loop_host_contract.rs` already uses) and asserts
*both* halves of the guard's contract: the model gets `OutputDecode` and never
the serde error, **and** the serde detail is not simply dropped — it reaches
the debug log, which is where an operator diagnoses it from. Without the
subscriber a test cannot tell "logged the detail" from "discarded it", which is
the whole point. `tracing-subscriber` joins this crate's dev-dependencies for
that, with a manifest comment saying why.

**The six files are the type-position residue, and it is precedented.**
Deleting `ironclaw_product`'s re-exports forced every signature naming a moved
symbol to be rewritten; where the name sits in a *type* position — a struct
field, a function parameter, a struct-literal field's enum path — the line
changes but LLVM emits no coverage region, so it can never be covered. Nine
exact lines across six files, each entry naming the construct, filed under the
same #6963 lane the four WS1 entries use. Every line was re-read against the
source before the entry was written; none is a guess.

The balance for the PR as a whole: ten exemption lines, all type positions or
one lane-attribution accessor, against ~30 tests written for surfaces that
genuinely lacked them.

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

* fix(coverage): exempt the tracing message literal, with the evidence that it is an artifact

Last line on the changed-coverage gate, and the obvious reading of it is wrong.

`extension_lifecycle_capabilities.rs:217` is the message string inside a
`tracing::debug!`. It reads as uncovered — but the event body demonstrably
executes: the DEBUG-subscriber test added in the previous commit asserts the
rendered log contains that exact message, and it passes, including in the
`extension-operator` bucket, which is green.

The proof it is an attribution artifact rather than a dead path comes from that
bucket's own tracefile (run 30689416105, `bucket-extension-operator.lcov`):

  line 213 (fn signature)       hits 1
  line 214 (macro invocation)   hits 1
  line 217 (message literal)    hits 0
  line 219 (error construction) hits 1
  line 220 (closing brace)      hits 1

The function ran, the macro ran, the error was built. What LLVM does not count
is the literal: `tracing` bakes the message into the callsite's `static`
`Metadata`, so the region on that line belongs to a static initializer and is
never attributed to an executed path. Nothing short of changing the log target
moves that counter, and changing a log target is a behavior change this
move-shaped PR will not make. Every `tracing::debug!` in the workspace has the
same shape; they only escape this gate because their lines are not in a diff.

Verified by replaying the gate locally against CI's own merged lcov with this
entry in place: changed line coverage 100.00% (147/147), changed branch
coverage 100.00% (10/10).

The test stays. It is what proves the 0 is an artifact, and it still pins the
guard's real contract: the model gets `OutputDecode` and never the serde error,
and the detail reaches the debug log rather than being dropped.

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

* test(extension-host): prove the transient cause survives the sanitized 503

The lifecycle warning is the entire reason this crate kept a local projection
wrapper rather than calling the contract's `From` directly — and that claim
was asserted in a doc comment and nowhere else.

`tracing` short-circuits on the null dispatcher, so under a plain unit test the
macro body never runs and a test cannot distinguish "logged the cause" from
"dropped it" — which is exactly the distinction that matters when the 503 body
is sanitized. Installing a scoped subscriber (`with_default`, so parallel tests
are unaffected) over a shared writer, following the pattern
`ironclaw_turns/tests/agent_loop_host_contract.rs` established, makes both
halves of the guard's contract assertable, and both are asserted:

- the caller's 503 is sanitized — the cause appears nowhere in the serialized
  `ProductSurfaceError`; and
- the cause is not discarded — it reaches the warning, with its stable message.

A second test pins the other direction: a rejection carries no operational
cause and must not spend a warning, so "log everything" cannot satisfy the
first test.

Both verified red-then-green by mutating production code, compiling cleanly so
the red is an assertion:
- drop the warning -> "the transient cause must survive in the log, got \"\""
- warn on every variant -> "a rejection must not emit the transient warning,
  got ... invalid binding request: bad package ref"

`tracing-subscriber` joins `[dev-dependencies]` and the `Cargo.lock` delta is
**zero** — it was already resolved for the workspace.

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

* test(coverage): recapture the extension_host floor and ratchet the manager (WS2.4)

Both numbers come from this PR's own merged coverage artifact
(reborn-integration-coverage-merged, run 30689658637), read through the
same aggregation that enforces the file. extension_host regains its
covered-line floor at 19907/23467 = 84.83% (the ratio ROSE across the
split); the manager is ratcheted from birth at 4602/5440 = 84.60%.

Verified by running the enforcing ratchet against the artifact: both
entries PASS, 17 crates pass, exit 0.

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

* fix(contracts,extension-host): preserve the acquire cause and pin every HostApiError projection

Review triage for #7000.

- `import_bundle`'s decode-limiter `map_err(|_| ...)` discarded the
  `AcquireError`. The mapping is now a named `map_import_decode_acquire_error`
  that logs the bound source before mapping. Named rather than inlined so it is
  reachable from a test: nothing in the workspace calls `Semaphore::close`, so
  an inline closure would be a permanently uncovered branch that the
  changed-line coverage gate could only accept as a standing exemption. New
  regression test builds a genuine `AcquireError` from a closed semaphore and
  asserts the failure is `Transient` (retryable), not a client mistake.

- `From<HostApiError> for ProductOperationFailure` was pinned by one variant.
  It now enumerates all ten, asserts each carries its own rendering (so the
  cause cannot be flattened at the boundary) and projects to a 400, and adds an
  exhaustive `host_api_error_tag` match so a new `HostApiError` variant stops
  compiling the test instead of inheriting the blanket mapping silently.
  `InvariantViolation` is pinned as-is, not reclassified: the mapping mirrors
  product's pre-existing `From<HostApiError> for ProductSurfaceFailure` and
  changing it is a behavior change this slice does not own.

Red-then-green proved by mutating the code under test: InvariantViolation ->
Transient, flattening the reason text, and Transient -> InvalidBindingRequest
each fail the corresponding assertion.

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

* fix(architecture,ci): close the review gaps on the extension_manager split

Review triage for #7003. All four are artifacts this PR introduced, not moved code.

- The new `ironclaw_extension_manager` boundary rule forbade
  `"ironclaw_reborn_cli"`, which is the crate DIRECTORY. `forbidden` entries are
  compared against `cargo metadata` package names and the CLI's package is
  `ironclaw`, so the entry could never fire — the edge it named was unguarded.
  Fixed, and pinned: `boundary_rule_names_are_package_names_not_crate_directories`
  flags any forbidden entry that is not a package but IS a directory under
  `crates/`. That discrimination matters — ~60 entries legitimately name retired
  v1 crates (`ironclaw_legacy`, `ironclaw_engine`, `ironclaw_gateway`,
  `ironclaw_tui`, `ironclaw_storage`) as reintroduction pins, and those have no
  directory. `ironclaw_reborn_cli` was the only entry in all 693 that had one.

- `production_files_naming` took a flat `files.len() >= 10` to accommodate the
  manager, which silently dropped the host's vacuous-scan guard from >20 to 10.
  The same diff had already parameterized `traits_implemented_by` for exactly
  this reason. Parameterized to match: host 21, manager 10.

- `classify-test-scope.sh` gained a `crates/ironclaw_extension_manager/*` arm
  with no self-test case, so a manager-only diff classifying
  `has_reborn_tests=false` would have gone unnoticed — the failure #6947 records
  for the stale `crates/ironclaw_product_*/*` arm. Case added.

- `coverage-floor.toml`'s "9.7k lines moved" explained an instrumented-line
  delta of 3,102 with a source-line figure. Both units are now stated with their
  measurements (source: 57,464 -> 47,794 in the host, 9,979 in the manager;
  instrumented: 26,569 -> 23,467 against 5,440) and why they do not reconcile.

Red-then-green proved by mutating the code under test: reverting the forbidden
entry to the directory spelling fails the new meta-test with the fix-it message;
removing the manager glob from the classifier fails the new self-test case
(has_reborn_tests=false); raising the manager's file floor to 40 fails only the
manager call site, proving the floor is per-call-site and consumed.

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

* fix(architecture,extensions): close the paranoid-architect review findings on the WS2.4 split

Review pass over #7003 (four parallel deep reviews; no Critical/High — the
move itself verified behavior-free). Everything found, fixed here:

Gate hardening (crates/ironclaw_architecture/tests):
- ratchet_support gains cfg_test_only_files: files reachable only through
  #[cfg(test)] mod chains (incl. #[path] overrides) are classified test code.
  channel_host/e2e_auth_challenge.rs — a fake AuthChallengeProvider impl
  wearing a production filename — no longer counts toward any residue row,
  implementor pin, or error-vocabulary floor. Pinned by a real-tree test that
  was red before the #[path] resolution landed.
- Trait matching is qualified by a whole-token crate reference (names_crate),
  so a name-colliding local trait can no longer satisfy an implementor pin,
  and a manifest rename of ironclaw_product can no longer blind the manager
  residue scan (metadata tie: dep exists iff the residue list is non-empty,
  never renamed).
- The manager gets its own product-defined-trait residue freeze (twin of the
  host's, frozen at ExtensionCredentialSetupService).
- each_half_of_the_split_kept_its_own_job: authority checks are symmetric
  across file/directory spellings and back every module with a content
  witness, so an empty stub cannot satisfy retention.
- untrusted_ingress_paths scan roots fail loudly on a missing root instead of
  silently dropping a tree from the guard.
- Fork-check message names its two-crate scope.
All new checks probed red-for-the-right-reason and reverted (hollow witness,
product alias, stale scan root, authority-as-directory, unguarded secret).

Manifest hygiene:
- extension_host drops the ed25519-dalek dep orphaned when ironhub moved.
- Ten manager deps used only by tests/the test_support fixture leave the
  production graph: fixture deps become test-support-gated optionals, pure
  test deps move to [dev-dependencies]. All three build shapes verified.

Manager/host code:
- channel_config: the pub resolved_manifest widening is narrowed to a
  declares_admin_configuration() boolean — the manifest read stays internal.
- admin_configuration view: secret field values are redacted in render_group
  (same defense-in-depth as render_state), with a sentinel regression test;
  the service-error table test now pins code/kind beside status/retryable.

Docs (single-source-of-truth):
- families/extensions.md confesses the direct auth/host_runtime deps and the
  transitional dep tail the four-crate target does not name.
- The residue characterization says what the list actually holds: DTOs,
  capability-id constants, and two port-inversion residues.
- 20 -> 13 becomes 20 -> 12 (the 13th was the cfg(test)-only fixture);
  coverage-floor/CHECKLIST stale "recapture owed" drafts corrected to the
  shipped recapture; line counts de-precisioned; stale exemption comment
  repointed to the manager.

Verification: architecture 143/0; manager 64/0 (--all-features);
extension_host 388/0 (--all-features); cargo check --workspace --all-targets
--all-features 0 errors / 0 warnings; clippy -D warnings clean on all three
touched crates; both CI script self-tests pass; cargo metadata --locked clean.

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

* fix(coverage,architecture): close the human review on the WS2.4 split

Review findings from @serrrfirat on #7003. The first one was blocking CI
outright.

**The coverage exemption did not move with its file (HIGH).**
`extension_lifecycle_capabilities.rs` left `ironclaw_extension_host` for
`ironclaw_extension_manager` in this PR; its changed-coverage exemption kept
naming the old path. That is not cosmetic staleness — the manifest validator
is fail-closed on it, so the whole changed-coverage gate aborts with **no
verdict at all** rather than reporting a number. Reproduced on this branch
before the fix:

    GATE ERROR: exemption #71 names stale path:
      crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs

exactly the entry index the reviewer named. Path repointed to the manager and
the line corrected 217 -> 218 (217 was the `?error,` field, not the message
literal the reason describes; the off-by-one was fixed on the parent). Whole
manifest re-validated: **71 entries, no stale paths, no lines past EOF.**

**Direct `#[cfg(test)]` module seeding was untested.** Confirmed empirically
rather than by reading: deleting the seeding loop from `cfg_test_only_files`
left the only in-tree pin green (9 passed), because its chain starts at
`e2e_tests.rs` — already seeded by the `*_tests.rs` name rule — and reaches
its child through an explicit `#[path]`. So neither the `cfg(test)` gate nor
default `<dir>/<name>.rs` resolution was exercised, and a production-named
file declared `#[cfg(test)] mod fixture;` could have become countable
silently. Added `direct_cfg_test_module_and_default_child_are_test_only` on a
synthetic tree covering both shapes plus the negative case; it goes red under
that same deletion.

**Crate contracts contradicted the move.** The CLI's exhaustive
`[dependencies]` inventory omitted `ironclaw_extension_manager` (and, found
while checking, `ironclaw_product_contracts` and `ironclaw_extension_contracts`
— all three added by this layer). The product-contract docs still said
`LifecycleProductService`, `ChannelConfigProductService` and
`RebornViewProvider` are implemented by `ironclaw_extension_host`, while this
branch's own `INVERTED_PORT_IMPLEMENTORS` says `ironclaw_extension_manager`.
Reconciled toward the enforced pin in `reborn_cli/AGENTS.md`,
`product_contracts/CLAUDE.md` (now a per-port implementor table, and citing
the constant by its real name), `lifecycle_service.rs`, `views.rs`,
`channel_config.rs`, and `crates/AGENTS.md` — the last of which the review did
not flag but was stale the same way.

**The production-source walker is centralized — for the two ratchets named.**
`ratchet_support::production_rust_files` now owns the fatal walk, the
name/directory exclusions and the `cfg_test_only_files` subtraction, and both
`reborn_extension_host_port_inversion.rs` and `reborn_extension_manager_split.rs`
delegate to it. The reviewer's concern was already realized rather than
hypothetical: the two walkers **had** drifted — one skipped `node_modules` and
the other did not. ~19 other ratchets still carry their own walk; migrating
them belongs in a dedicated change against `ratchet_support`, not in a crate
split, and that is recorded at the new helper and at the call site.

Verification: `cargo fmt --check` clean; `cargo clippy -p ironclaw_architecture
-p ironclaw_product_contracts -p ironclaw_extension_manager -p
ironclaw_extension_host --all-targets --all-features -- -D warnings` clean;
`cargo test -p ironclaw_architecture` 28 binaries green, 0 failed;
`cargo check --workspace --all-targets --all-features` clean.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: BenKurrek <benjaminkurrek@gmail.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>

* fix(extension-manager): close the operator-config review findings from #7000

Three findings from the CodeRabbit review on #7000 that had never been triaged:
12 of its 19 actionable comments failed to post as inline threads (GitHub
returned "Inline review comments failed to post") and existed only inside the
review body.

1. The `always_allow` arm wrote its two stores in the dangerous order. It minted
   the persistent `Dispatch` grant first and cleared the contradicting
   `ToolPermissionOverride::Disabled` second. The pair is not atomic, so a
   partial failure persisted live auto-approval authority underneath a stale
   disable: the gate honours the explicit override, so the tool reads as
   disabled while carrying a grant that takes effect the moment anything else
   clears the override. Reversed, so a partial failure can only ever leave
   *less* authority than the operator asked for.

2. Nothing drove a locked tool through `handler.dispatch`. `hard_floor_tool`
   and `tool_permission_locked` gate a persistent authority write with a
   wrapper and a catalog lookup between them and that write, so a wrong
   `matches!` arm or an inverted `==` in the caller would have shipped green
   (`.claude/rules/testing.md`, "Test through the caller"). Added a
   caller-level test over all four locked shapes -- the three hard-floor
   effects and a `PermissionMode::Deny` default -- asserting each is refused as
   `PolicyDenied` *and* that neither store was written.

3. Six `warn!` sites in a REPL-reachable dispatch handler moved to `debug!`,
   per CLAUDE.md's REPL/TUI logging rule (`info!`/`warn!` corrupt the
   interactive display; internal diagnostics use `debug!`).

Regression test for (1) injects a failing override `clear` and asserts no
persistent grant survives; confirmed red against the pre-fix order with exactly
that assertion, green after. The store fixture is extracted so the new tests
extend the existing suite rather than duplicating its wiring.

ironclaw_extension_manager: 75 passed, 0 failed; clippy -D warnings clean.

* docs(target-architecture): correct the WS2.4 arrival count and the host's ownership row

Three doc-accuracy findings from the #7000 review, all verifiable against the
lists they sit beside.

The WS2.4 row's headline read "Six of the nine inventory items moved; three
could not" in CHECKLIST and "Six of the nine ... three could not" in PROPOSAL
§6.8.3. Both are wrong in the same way: the two lists directly below enumerate
FIVE arrived (`extension_lifecycle_capabilities` + `extension_lifecycle_command`,
the lifecycle product service, the `channel_config` product service,
`webui_extension_credentials`, the admin/operator/skill-auto-activate capability
handlers) and FOUR that did not (`product_lifecycle`, the available-extension
catalog + import, pairing workflow orchestration, `SharedCommandSurface`). Five
plus four is the nine the sentence claims, so the lists were right and the count
was not. PROPOSAL's version even contradicts itself in the same sentence --
"four of its entries are entangled with the half that stays".

`crates/AGENTS.md`'s `ironclaw_extension_host` row still listed "extension
lifecycle command execution" among what the host owns, which WS2.4 moved: the
`ironclaw extension` command and the lifecycle capabilities are the manager's
now, as the very next row says. Replaced with what actually stayed -- lifecycle
*authority*, `ExtensionLifecycleManager` and the operation lock/activation
transactions/`lifecycle_restore` it drives -- so the two rows no longer claim
the same thing.

Each correction is dated inline and quotes the text it replaces, per the
docs-amendment convention.

* fix(extension-manager,architecture): close the remaining #7000 review findings

The rest of the CodeRabbit review on #7000 that had never been triaged --
12 of its 19 actionable comments failed to post as inline threads and existed
only inside the review body -- plus the unanswered inline threads.

**A guardrail that failed open (`ratchet_support`).** `out_of_line_mod_decls`
read the `cfg(test)` gate by walking backwards from `mod` over the attribute
run, but the slice it walked ends at the visibility qualifier, so
`trailing_attribute_run_contains` saw `pub` and returned false. Every visibility
form was affected. A `#[cfg(test)]`-gated module therefore read as *production*,
which is the fail-open direction: `cfg_test_only_files` leaves a test-only file
classified as production and a test double in a production-named file can
satisfy a residue row or an implementor pin. Fixed with a
`strip_trailing_visibility` that steps over a balanced `(...)` group only when
it is immediately preceded by the whole token `pub`. There is a real instance in
tree -- `ironclaw_reborn_cli/src/runtime/mod.rs` ships
`#[cfg(test)] pub(crate) mod test_env;` and was being counted as production --
but no current ratchet walks that crate, so no pin flipped; the guardrail simply
now fails closed for the ratchets its module doc plans to migrate. Fixtures
cover all five gated visibility forms, a negative (`#[allow(dead_code)] pub mod`
must stay ungated so the fix cannot be faked by treating any attribute as
gating), and the caller-level `production_rust_files` classifier.

**A silent failure (`channel_config_product_service`).** `if let Ok(true)`
swallowed the manifest-read error, so a storage fault returned an empty field
list and the WebUI rendered "nothing to configure" for an extension that has
fields. Now an exhaustive match: admin-configured returns empty, `NotInstalled`
falls through, and any other error propagates. Covered with a fault-injecting
filesystem.

**Cross-user state and error hygiene.** The skill-auto-activate handler
validated a per-user scope and then wrote process-wide state; the administrator
-configuration paths returned the store's own error text as the payload (which
is filesystem-backed and can carry a mount path) while logging nothing, and four
`map_err(|_| …)` closures discarded their cause. Each now preserves the cause at
`debug!` -- not `warn!`, per CLAUDE.md's REPL/TUI rule -- and returns a
sanitized message.

**One duplicated security helper.** `terminal_safe` escapes untrusted
extension-supplied text before it reaches a terminal, and it was duplicated
verbatim in `extension_lifecycle_command` and `ironhub::render`, so a future
hardening fix applied to one copy would silently leave the other unescaped. Both
now consume a single crate-private `terminal_render`, whose test pins the
dangerous shapes literally (ESC, CR, LF, backspace) rather than asserting the
absence of an escape.

**One finding was investigated and deliberately not "fixed".**
`webui_extension_credentials` maps `CrossScopeDenied` to `Ok(None)` on the
*status* path, which the reviewer read as an auth denial failing open. It is
not: the selection scope is built from the authenticated caller and
`CredentialAccountOwnerScope::matches` compares tenant and user for equality, so
a foreign owner's account is filtered out before the requester gate runs. What
survives to raise the variant is "the caller owns an account for this provider,
but it is not granted to this extension" -- a missing connection. Reporting it
as 403 would strand the user without the connect affordance, fail the whole
extensions listing (product collects readiness with `try_collect`), and act as
an existence oracle. Enforcement lives on the runtime path, which maps the same
variant to `CredentialStageError::AuthRequired`. The collapse is now logged so
it is observable rather than silent, and the reasoning is recorded at the site.

ironclaw_extension_manager: 83 passed, 0 failed.
ironclaw_architecture: 187 passed, 0 failed.
clippy -D warnings --all-targets --all-features clean on both.

* fix(extension-manager): finish the sanitized-error work the review asked for

The previous commit captured this file mid-edit and shipped the broken half:
`admin_configuration.rs` still mapped the installation-store failure with
`ProductSurfaceError::internal_from(error.to_string())`, which is exactly the
leak the finding was about -- `internal_from` logs whatever it is given at
`error!`, so the store's own text (filesystem-backed, and therefore capable of
carrying a mount path) landed in an always-on line in every deployment's log.
It now routes through `installed_extension_listing_error`, which records the
real cause at `debug!` and hands `internal_from` a fixed, user-safe string.

Verified through the caller: the view's captured log is now exactly

    DEBUG administrator-configuration view could not list installed extensions
          error=extension installation store unavailable: /var/lib/.../extensions.db is unreadable
    ERROR internal product surface error error=installed-extension listing is unavailable

so the cause stays diagnosable and the path never reaches an always-on line.
The test asserts that every line carrying the sentinel starts with `DEBUG`, and
it fails against the previous commit for precisely that reason.

Also here, from the same review: the two remaining `map_err(|_| …)` closures in
`admin_configuration_capability.rs` now carry their cause through
`rejected_input`, which `.claude/rules/error-handling.md` requires (a comment
cannot make a dropped cause reappear), and both tracing-capture fixtures set
`.with_ansi(false)` -- they parse the level prefix, and colour escapes would
wrap it, which is what made the assertion read as flaky rather than false.

ironclaw_extension_manager: 83 passed, 0 failed, in isolation and in the full
suite; clippy -D warnings clean.

* test(product,contracts): cover the changed lines the coverage gate named

The changed-coverage gate fired on the consolidation at 96.70% line / 95.00%
branch. Closing the sites this stack actually introduced, with tests rather
than waivers.

**The three vendor-login error paths** (`reborn_services.rs`
`start_nearai_login` / `start_codex_login` / `complete_nearai_wallet_login`).
WS5 replaced product's own `map_llm_config_error` with the `From` projection
declared beside the port, so these three `.map_err(ProductSurfaceError::from)`
call sites are exactly what proves product still answers with the sanitized
taxonomy -- and a wrapper plus an `Option` unwrap sit between the port and the
answer, so testing the `From` impl directly would not prove it. The recording
double's three login methods used to `panic!("not used by operator setup
tests")`, which made the failure half unreachable; they now answer with an
armed error. The test asserts `Unavailable` is the one retryable arm (503), the
other three arms keep their statuses, and no backend string crosses the
membrane -- `InvalidRequest`'s reason is deliberately dropped by the projection.

**The `end > 0` arm of the log-context back-up loop**
(`operator_service.rs:58`). Every existing case is ASCII, so
`is_char_boundary` is true on the first look and the loop never runs; the guard
is only reached when the walk backs all the way to zero, which needs a
multi-byte character straddling the cut. Extended the existing fail-safe test
with a `€`-repeat at `marker + 1`, where the cut lands inside the leading
3-byte character and `end` steps 1 -> 0. That is the arm that stops the
`max_bytes - SUFFIX.len()` subtraction from underflowing, and an untested
fail-safe is how an arithmetic panic reaches a log-query path.

Both extend suites that already own the seam rather than adding new files.

ironclaw_product: 383 + 272 passed, 0 failed.
ironclaw_product_contracts: 137 passed, 0 failed.
clippy -D warnings clean on both.

* fix(arch): close the re-export guard's braced-form hole

Raised by CodeRabbit on #7018 against #7005's §11.2.4 trap guard.

The check compared two hand-written path spellings --
`pub use ids::{name}` and
`pub use ironclaw_extension_contracts::external::{name}` -- so it caught only
the single-item form. The idiomatic braced group
(`pub use …::external::{ExternalActorRef, ExternalConversationRef};`) matched
neither, and neither did `crate::ids::X`, `self::ids::X`, or a re-export
through any other intermediate module. The gate passed while the second import
path existed, which is the fail-open direction for a guard whose whole promise
is "consumers must import it from its owner, not through this crate".

It now matches the type name as a whole word inside any `pub use` statement.
Two traps found while building it, both kept as comments because they are the
kind of thing that gets re-introduced:

  * a statement runs from its own `pub use` to the next `;`. Splitting the
    concatenated crate source on `;` and keeping chunks that *contain*
    "pub use" is not equivalent -- a chunk is bounded by the *previous*
    statement's semicolon, so it carries unrelated code and any mention of the
    type in that code reads as a re-export.
  * `use` needs a trailing word boundary: the field declaration
    `pub user_id: UserId,` contains the substring "pub use", and without the
    check it opened a bogus statement that swallowed the rest of the struct.

Both mistakes were caught by running the guard against the real tree, where
they produced a false positive naming `pub user_id: UserId,` as the offending
re-export; the failure message now prints the offending statement, which is how
they were diagnosed.

Negative-probed on all three previously-missed spellings (braced,
`crate::ids::` single-item, `self::ids::` braced) -- each fails the guard -- and
positive-probed on the unmodified tree, which passes.

ironclaw_architecture: 31 suites, all ok; clippy -D warnings clean.

* ci(coverage): exclude pre-existing-uncovered lines from the changed-line denominator

The changed-line gate's denominator is a textual diff, so a line whose only
change is a type rename or a rustfmt re-wrap reads as new and must be covered.
Measured: PR #7000 was flagged for 137 uncovered lines of which 127 were
already uncovered at its base commit; PR #7005 saw a rename re-pair the diff so
a surviving file read as brand new. The gate was taxing refactors instead of
measuring whether the change added untested behaviour.

A changed line whose pre-image was already uncovered at the base commit now
leaves the denominator. A genuinely new line still counts, and a line that was
*covered* at base and is uncovered now still gates — that is the regression the
gate exists for, and it is pinned by its own test.

Base coverage comes from the merged-lcov artifact CI already publishes for
every run (`reborn-integration-coverage-merged`), resolved for the base SHA
through the workflow-scoped runs endpoint so merge-queue runs are found when
the `push` run was cancelled by the next merge. The whole mechanism fails
closed: no run, expired artifact, auth/network failure, corrupt zip, or an lcov
with no DA records all fall back to today's behaviour — every changed line
counts — and say so in the output and in `base_coverage_status`. Nothing is
ever subtracted from an inference; only from coverage the gate positively read.

Pre-images come from a second `git diff -M -C` pass. Copy detection is kept out
of the denominator diff on purpose: `-C` turns copied lines into context, and a
line that never enters the denominator can never be reported, which is the
silent subtraction this change is meant to make impossible. Every exclusion is
printed with the base path and line it inherited from, and carried in full in
`reborn-changed-coverage.json`.

Verification: self-tests 58 -> 103 and 6 -> 21, covering excluded /
genuinely-new / covered-at-base / renamed / fetched-artifact / every
unavailable path; twelve mutations of the implementation and the workflow each
turn the intended test red. Replayed against archived CI data: #7000 420 -> 293
denominator and 137 -> 10 uncovered, every already-passing PR in a nine-PR
sweep keeps a byte-identical denominator with zero exclusions, and the
hosted-MCP feature PR #6930 sheds 27 of 441 with an unchanged verdict.

* ci(coverage): record the base-lcov completeness guarantee and pin the flag conflict

Addendum to the pre-existing-uncovered subtraction, kept separate so the policy
change stays reviewable on its own.

Three things a reviewer will look for and could not previously find in the
script:

* Why a partial base lcov cannot over-forgive. The publishing job
  (`coverage-report`) carries no `if: always()`, so GitHub skips it unless
  every coverage lane it `needs` succeeded — a degraded merged lcov is never
  published. Confirmed over 14 consecutive main runs: the artifact is present
  exactly when that job succeeded, regardless of the run's overall conclusion.

* How often base coverage actually resolves. Measured with the shipped lookup
  over 30 consecutive main commits: 17. Every miss is a commit whose push run
  was cancelled by the next merge landing, so the strict fallback is the
  ordinary path about two times in five rather than a rare edge. Raising that
  is a concurrency-key change in the workflow, not a change in this gate.

* `--base-lcov` together with `--fetch-base-coverage` is refused, not silently
  resolved by precedence; it now has a self-test, and removing the guard turns
  that test red.

The self-test section also writes its own `[policy]` rather than inheriting the
previous section's, because every assertion in it is an exact denominator that
a stray exemption would move without failing anything.

Self-tests 103 -> 105; the twelve implementation and workflow mutations still
each turn their intended test red.

* test(ws2): close the changed-coverage gate's 37 lines and 2 branch arms

The gate named 37 uncovered changed lines and 2 uncovered branch arms on
this stack (94.31% line / 92.86% branch). 25 of them were real holes and
are now tested; 14 are structurally uncoverable or pre-existing and are
exempted with per-site evidence.

Regression tests added:

* `skill_auto_activate_capability`: the rejection taxonomy of a
  well-authenticated caller -- an undeclared capability id, a non-object
  payload, a missing `enabled`, and a closed schema carrying an unknown
  sibling key -- each with its own `RuntimeDispatchErrorKind` and none
  reaching the store; plus the rootless-package guard on
  `extend_builtin_first_party_package`.
* `operator_config_capability` (new `tests/` contract): all five store
  writes failing -- the auto-approve toggle, the override clear behind
  `default`, the persistent grant behind `always_allow`, the override
  write behind `disabled`, and the policy revoke behind `ask_each_time`
  -- each surfacing as `Backend`, plus the counterpart that a revoke
  answering `UnknownPolicy` stays a success. Placed in `tests/` because
  appending the fixture stack to the source file pushed it below git's
  rename-similarity threshold against its pre-move home, which made the
  gate treat all 500 of its unchanged lines as newly added.
* `admin_configuration`: `used_by[].installed` is set from the
  installation store, asserted to differ between an installed consumer
  and an absent sibling in the same group.
* `channel_config_product_service`: the host->wire field projection
  (`handle` -> `name`, label/secret/provided), reached through the
  `NotInstalled` fall-through that exists for exactly this case.
* composition `test_support`: `RebornRuntimeStores::channel_config_service`
  hands out a live product port.

All 13 mutants of the code under test are killed by these tests.

Exemptions (13 lines, 79 entries total): four `tracing` message literals
whose macro-invocation lines score hits in the same tracefile; three
error arms no input can reach (`?` on a compile-time schema literal, an
infallible `serde_json::to_value`, and an idempotency key built from a
`Uuid` `Display`); and five lines whose only change is a crate/type path
or a rustfmt re-wrap from the WS2 port inversion, each with its
zero-scoring pre-image in the base commit's tracefile named.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: BenKurrek <benjaminkurrek@gmail.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
2026-08-02 23:38:56 +00:00
firat.sertgoz
0399cef53d ci: restore the original 90% changed-line coverage floor (#7013)
* ci: relax changed coverage floors to 95/85

* ci: restore original 90% changed-line floor
2026-08-02 20:13:07 +00:00
firat.sertgoz
7d7c117b7f ci: scope Reborn PR tests by affected area (#6952)
* ci: scope Reborn PR tests by affected area

* ci: defer exhaustive Reborn validation to merge queue

* ci: keep Reborn package discovery explicit

* ci: fail closed for Reborn PR test planning

* ci: shorten affected Reborn validation

* ci: split Reborn E2E critical path

* ci: reconcile affected test workflow with WS11

* ci: preserve nightly compatibility in selected buckets

* ci: parallelize Reborn E2E critical path

* test: follow combined Reborn E2E step

* ci: fan out Reborn E2E after binary build

* ci: reduce Reborn E2E artifact transfer

* ci: preserve prebuilt E2E binary freshness

* ci: reuse WebUI binary for black-box smoke

* ci: cancel superseded main coverage runs

* ci: remove final Reborn E2E lane wait

* ci: share Cargo target across WebUI variants

* ci: balance provider E2E lanes

* ci: keep recorded QA replay on every PR

* ci: trigger validation after main merge

* ci: recalibrate events coverage after dead-code removal
2026-08-02 19:47:29 +00:00
firat.sertgoz
cca2fc45ba Alert Slack on merge queue failures (#7007) 2026-08-02 19:41:27 +00:00
Illia Polosukhin
5a1d812852 fix(ci): pin comm to LC_ALL=C in reborn crate discovery (#6992)
* fix(ci): pin comm to LC_ALL=C in reborn crate discovery

discover-reborn-package-crates.sh sorts both comm inputs with LC_ALL=C
but ran comm itself in the ambient locale. Under a UTF-8 collation
(which orders ironclaw_events before ironclaw_event_streams, unlike C)
comm rejected the C-sorted input with "comm: input is not in sorted
order", killing the pre-push coverage ratchet for any contributor with
a UTF-8 locale.

Regression test scripts/ci/test-ci-comm-locale-pin.sh asserts every
comm invocation in scripts/ci and .githooks is LC_ALL=C-pinned and
exercises the failing fixture pair under a UTF-8 locale; wired into the
code_style static-check self-tests.

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

* test(ci): harden comm locale-pin regression test per review

Address CodeRabbit review on #6992:
- Join backslash-newline continuations before inspection so multiline
  comm invocations are caught and the valid "LC_ALL=C \" + "comm"
  continuation form is not false-flagged; match comm with or without
  options; prune __pycache__ and follow .githooks symlinks.
- Select the case-2 locale by proving the mismatch (unpinned comm must
  reject the C-sorted fixture under it) instead of silently falling
  back to a C-compatible collation like C.UTF-8; skip explicitly when
  no installed locale disagrees. A "zzz" sentinel second file forces
  comm's order check, which never fires on two identical files.

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

* test(ci): use assert_success/assert_failure helpers in comm locale-pin test

The regression-test enforcement gate recognizes shell regression tests by
their assertion idiom (assert_success/assert_failure among them); the custom
report-only form was invisible to it. Restructure the fixture case into
explicit assert_failure (unpinned comm rejects the C-sorted fixture) and
assert_success (pinned comm accepts it), which also states the red side of
the regression explicitly.

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

* test(ci): per-invocation comm scan and sentinel-order guard per review

Two review findings on the locale-pin regression test:

- The scan excluded a whole joined logical line once it contained one
  pinned comm, so 'LC_ALL=C comm a b; comm c d' passed with the second
  invocation unpinned. Compound commands are now split at ;, &, and |
  before the pin check, and scanner self-checks pin the compound,
  multiline-continuation, and pinned-clean cases.

- The mismatch-locale selection now verifies the zzz sentinel still
  sorts after both fixture entries under the candidate locale; without
  that, a locale ordering the sentinel early would never exercise the
  fixture-order path comm is being tested on.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 05:14:22 +00:00
Benjamin Kurrek
fa3c95d9c0 ci(gates): close #6963 — inventory-driven discovery + fail-closed across the remaining path-keyed gates (#6996)
* ci(gates): add owning-crate path attribution to crate_tree

Shared discovery helper for the #6963 gates: resolve which crate directory
owns a repo-relative path, outermost-wins, from where Cargo.toml files
actually are. Callers that classify production sources need this to stop
keying on the flat crates/ironclaw_* shape.

Refs #6963

* ci(gates): inventory-driven discovery + fail-closed for the script and workflow gates

Closes the script/workflow half of #6963. Every gate below resolved its
scope from the literal flat `crates/ironclaw_*` tree shape and stops
matching at the first family `git mv`; six of them then reported success
having scanned nothing. Each now discovers through the crate inventory
(scripts/ci/lib/crate_tree.py), asserts it measured something, and carries
positive + negative fixtures.

Workflow scope filters (code_style has_reborn_cli, platform-and-compat
has_direct_wasm_abi_risk, ironclaw-stress paths) are matched by crate NAME
at any depth and pinned in scripts/ci/ws12_workflow_contracts.py against
the real inventory, so a renamed, moved or deleted crate fails loudly in
Code Style instead of quietly unhooking a lane.

Two stale terms removed, both matching nothing today:
ironclaw_wasm_product_adapters (crate deleted) from the WASM ABI filter and
ironclaw_run_state (deleted with #6696) from HIGH_RISK_PATTERNS.

Regression tests: test_ws12_workflow_contracts.py (+11 sabotage cases),
test-regression-test-check.sh, test-check-composition-budget.sh (51),
test-build-wasm-extensions.sh (new, 14), test-reborn-changed-coverage.sh
(56), test-critical-mutation-gate.sh (60).

Refs #6963

* fix(ci): repoint the CLI smoke pin on the dist-build scope regex to the crate name

The Reborn CLI smoke contract greps code_style.yml for a `grep -Eq` line
containing the flat literal `crates/ironclaw_reborn_cli/`. Making that scope
regex depth-agnostic broke the needle, and the test failed loudly — which is
the point: it is a fourth pin on the same regex and the only reason a
one-sided edit could not land silently.

Repointed to the crate name (`ironclaw_reborn_cli/`), which survives the
family move for the same reason the regex now does.

Regression coverage: the existing
release_ci_publishes_reborn_without_enabling_legacy_or_docker_paths is the
regression test — it went red on the one-sided edit and green on the repoint,
verified locally.

Refs #6963

* test(architecture): inventory-driven roots + fail-closed censuses in the gate crate

Three slices of the #6963 class inside crates/ironclaw_architecture.

1. reborn_registration_pipeline_boundary (#6963 comment, arrived with #6930).
   workspace_root() walked up a fixed two levels, so under a family move the
   "root" resolved to crates/, the scan targeted crates/crates, and the gate
   passed having visited ZERO files. Its two hardcoded hosted_mcp_ prefixes
   also stopped matching, which would have false-positived against the
   registration pipeline's own files with baseline 0 blocking the fix. Now
   inventory-driven, with measured_scan() asserting inventory size, scanned
   file count, and that every owned scope resolves to at least one real file.
   Its self-test now exercises is_owned(), flat and nested.

2. reborn_sealed_evidence_mint_ratchet. HostProtocolAuthenticator and
   ChannelIngressVerifier are unsealed traits whose mint methods are provided,
   so a bare "impl Trait for X {}" anywhere confers the power to mint
   ProtocolAuthEvidence::Verified. The source census IS the enforcement, and it
   evaded on a multiline impl header, on "use ... as" aliases (plain, braced,
   and raw-identifier), and across a re-export split over two files. Headers
   are now extracted and whitespace-collapsed, in-file aliases resolved,
   matching is identifier-bounded, a re-export guard removes the cross-file
   shape, and a headers-parsed floor keeps the new normalizer from degrading
   silently. Closes the #6995 fail-open; seam origin PR #6981.

3. The shared root idiom. 23 of 24 gate files resolved the workspace root by
   walking up a fixed number of levels. ratchet_support::workspace_root() now
   searches for the nearest ancestor holding both crates/ and Cargo.toml, and
   11 private copies were deleted in its favour. Two gates that went silently
   green under nesting (reborn_authorized_seal_ratchet - worst under a PARTIAL
   move, 1309 -> 45 files scanned with no error; reborn_retired_taxonomy -
   1492 -> 0) gained measurement assertions. Two vacuous
   assert!(!path.exists()) absence checks in telegram_extension_gates now
   require their containing directory to exist.

Five stale entries removed, each matching zero files today and therefore
behavior-free: crates/ironclaw_gateway/ and extension_host/
extension_installation_store.rs from two SANCTIONED_PATHS allowlists (both now
carry stale-entry detection), crates/ironclaw_reborn_api/src and two duplicate
crates/ironclaw_product/src entries from the dependency-boundary roots, and the
deleted repo-root src/ monolith from the manifest reparse scan.

Regression tests: +8 in the family sweep, +7 in the registration boundary, +4
in the sealed-evidence census; every added assertion sabotage-tested red then
green. 26 binaries / 146 passed / 0 failed; clippy -D warnings clean.

Refs #6963, #6995

* docs(checklist): tick the WS0 path-keyed-gate prerequisite and reconcile its neighbours

Row 17 is the WS7-blocking prerequisite; it stayed open until #6963 closed.
#6946 landed the five gates the WS10 row names and #6996 closed the rest, so
the box is ticked citing both PRs and the issue.

The row's prose is reconciled rather than merely ticked: the two staleness
notes it carried (ironclaw_wasm_product_adapters, ironclaw_run_state) are now
historical, and the two places #6963's inventory was wrong are recorded -
build-wasm-extensions.sh already had its empty-set guard, and
check-composition-budget.sh is silently green under a PARTIAL move, which is
the batch shape WS7 will actually use.

Two neighbouring rows described the registration-boundary gate as a silent
trap and are updated to match what it now is: row 124 (the WS6 rename) still
owes it a repoint, but a missed rename now fails loudly; the WS10
loud-inventory row's amendment records what #6996 fixed there and narrows
what remains to the named-path keying the row was always about, across the
20 gates that fail loudly at the git mv.

Residue recorded, not hidden: #6947 stays open, and #6999 was filed for the
server-lifecycle rule's WebChat v2 gap this sweep uncovered. Neither blocks
WS7.

Regression test: docs-only reconciliation of prose whose subject is the gates
landed in the preceding commits of this PR; those gates carry the tests
(scripts/ci/test_ws12_workflow_contracts.py and eight sibling suites, plus 19
new architecture-crate tests).

Refs #6963, #6996, #6999

* fix(ci): make the unattributable-path refusal reachable in the mode CI runs

Review catch on #6996, verified before fixing and worth stating plainly: the
fail-closed check this PR added to the changed-coverage gate could not fire in
production.

git_diff() narrows the diff to per-crate src/ pathspecs, so a Rust file under
crates/ that belongs to no discovered crate was filtered out of the diff text
before parse_diff ever saw it. Only --diff-file, which is handed an un-narrowed
diff, reached reject_unattributable - and that is the mode the self-test used.
The workflow runs --base/--head. Measured on a real git fixture carrying an
orphaned crates/not_a_crate/src/lib.rs: --diff-file refused it; --base/--head
printed "no Reborn production lines added" and exited 0.

screen_unattributable() now walks the unfiltered changed-file list under
crates/ before the narrowing pathspecs are applied, so both modes refuse. A
fail-closed check that cannot fail in the mode that matters is exactly the
defect class this PR exists to close, so it is fixed rather than documented.

Three smaller review items in the same pass:
- both bash callers of crate_tree.py captured stdout with stderr merged in, so
  a Python warning would have been folded into the inventory itself and read as
  a crate directory. Captured separately now.
- crate_tree's memoized inventory sorted longest-first while
  owning_crate_directory documents outermost-wins. Order is provably irrelevant
  today (no entry is a prefix of another), but the code now reads the way the
  rule is written.
- regression-test-check probed for the workspace manifest twice; resolve_prefixes
  owns that decision and main reads its result.

Declined, with reason: high-risk matching keeps `prefix in path` rather than
`startswith`. Switching would narrow the match set, and equivalence with the
pre-existing behavior is this PR's whole contract; the substring form can only
over-match, which makes the gate stricter, never fail-open.

Regression test: "an unattributable path is refused through --base/--head too"
plus its message assertion in test-reborn-changed-coverage.sh, driven through a
real git fixture. Verified red against this PR's own pre-fix gate (rc=0, path
absent from output) and green after. Suite is now 58 cases.

Refs #6963

* fix(architecture): close the gate crate's own fail-open reads and censuses

Review triage on #6996. The headline finding is the embarrassing one: several
of the gates this PR hardens were themselves reading fail-open, which is
precisely the defect class the PR exists to eliminate. Fixed first, and proven.

Fail-open I/O, now fatal (10 sites across 5 gates):

- reborn_sealed_evidence_mint_ratchet.rs: seven `read_to_string(..).
  unwrap_or_default()` plus three swallowed `read_dir`/entry errors. An
  unreadable file contributed no impl headers and no offenders, so it scanned
  exactly like a clean one. This census IS the enforcement for two unsealed
  traits whose mint methods are provided, so a `impl Trait for X {}` it cannot
  see is forged `ProtocolAuthEvidence::Verified`.
- reborn_authorized_seal_ratchet.rs: the same shape on the gate that polices
  the sole minter of `AuthorizationGrant`.
- reborn_registration_pipeline_boundary.rs: two dropped `read_dir` errors and
  one dropped source read, threaded into the `Result<ScanOutcome, String>`
  `measured_scan` already returned.
- reborn_retired_taxonomy.rs: `scan_dir` now propagates, matching its twin
  `reborn_memory_retired_vocabulary.rs`, which already did.
- reborn_manifest_reparse_gate.rs: same.

The floors could not cover any of this: one unreadable crate `src/` tree leaves
every count comfortably above its floor while the gate reports "no violations"
for a subtree it never read. Absent-vs-unreadable is kept distinct — a missing
scan root still fails, and the retired-taxonomy floor test now pins the
*partial* tree (the staged-family-move shape), which is the only thing a floor
can still catch that an I/O error cannot.

Two matcher fail-opens in the evidence census, both verified realizable before
fixing:

- `header_implements` did not skip whitespace before a trait's generic
  arguments. `impl ChannelIngressVerifier <> for Rogue {}` compiles (checked
  against rustc: empty angle brackets after a space are accepted on a
  non-generic trait) and the header collapse *creates* that space whenever a
  line break falls there. Undetected, and it mints.
- `reexports_a_grant_trait` was line-based, so rustfmt's own output for a long
  braced import — `pub(crate) use ..::auth::{\n    ChannelIngressVerifier as
  V,\n};` — evaded it: line 1 has no trait name, line 3 does not start with
  `pub`. That guard is what removes the census's two-file alias blind spot.
  Replaced with a brace-balanced item scan that reports the item's own line.

Both proven red-then-green: sabotage the fix, exactly the self-test that pins
it goes red, the whole-workspace censuses stay green (so both are behavior-free
on today's tree).

Also closed, same class:

- The evidence census walked `crates/` only. `tools/ironclaw_stress` is a
  workspace member that depends on `ironclaw_host_api`, so it can implement a
  witness trait and mint — invisibly, with the `> 500` file floor comfortably
  cleared. Scan roots now come from the root manifest's `members` list
  (1309 -> 1332 files); a new member root joins automatically.
- `node_modules` excluded from both registration-boundary walks.
- The twelfth private `workspace_root()` copy, in the registration-boundary
  gate, deleted in favour of `ratchet_support` — it had survived behind a
  comment claiming it needed one, which was never true. The crate now has
  exactly one definition of the rule, and the CHECKLIST row that claimed
  "11 private copies ... across the whole crate" is corrected to 12 and is now
  true.
- `SANCTIONED_PATHS` fragments in the memory vocabulary gate must resolve to
  exactly ONE scanned file; ambiguity is a refusal, not a silent widening.
  Kept as fragments rather than workspace-relative literals on purpose: a
  literal would re-key the list to the flat `crates/<name>/` depth this PR
  exists to remove.
- `extract_paths_globs` refuses two `paths:` blocks instead of pinning the
  first unconditionally, matching `extract_scope_regex`. Without it a workflow
  that grew a second filter validated GREEN against the wrong block (measured:
  zero errors).
- Both fixture suites derive the crate-discovery floor from `crate_tree.py`'s
  own `MIN_CRATE_DIRECTORIES` instead of copying `24`. Measured: raise the
  floor to 40 and the literal form breaks 37 of 51 cases with an error pointing
  at the fixture; the derived form passes 51/51.

Regression tests: +7, each a negative probe that fails for a deterministic,
platform-independent reason (`read_dir` on a regular file, `read_to_string` on
a directory, a dangling symlink) rather than a chmod that root ignores inside a
container. `cargo test -p ironclaw_architecture` 26 binaries / 146 -> 153
passed / 0 failed — exactly +7, so no pre-existing test changed its verdict.

Declined, with evidence, in the review replies: anchoring high-risk matching
(substring can only over-trigger, which is the fail-closed direction for a
trigger; measured zero delta over all tracked paths, and anchoring would break
this PR's equivalence contract), splitting `validate_crate_scope_filters` for a
Ruff branch-count gate this repo does not have, and making the CLI smoke pin
multiline-safe (it fails loudly, which is the documented intent).

Verification: cargo fmt --all --check clean; cargo clippy -p
ironclaw_architecture --tests --all-features -D warnings clean; composition
budget byte-identical at 6.42% (642 bp) - 43251 / 673642 LOC, 836 Arc<dyn>;
ws12 contracts 25 cases; composition-budget 51; build-wasm 14;
changed-coverage 58; critical-mutation 60; regression-test-check all pass.

Refs #6963

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

* docs(architecture): repoint the sanctioned-paths doc at its renamed test

The memory vocabulary gate's SANCTIONED_PATHS doc still named
sanctioned_paths_all_match_real_files after that test became
sanctioned_paths_each_resolve_to_exactly_one_file, and it described only the
stale half of a check that now also refuses ambiguity. Documentation promising
a guarantee has to match the test that enforces it.

Refs #6963

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:26:00 -04:00
Benjamin Kurrek
6daf0b7c60 refactor(contracts): consolidate sealed evidence minting behind witness grants (WS1.5) (#6981)
* refactor(contracts): complete the turn vocabulary in host_api and retire the turns shims (WS1.1)

`ironclaw_host_api::turn` becomes the complete canonical turn vocabulary:
it absorbs `TurnStatus` (with the inseparable `GateKind`/`BlockedReason`
gate correspondence), `EventCursor`, and `RunOriginAdapter`. The three
`ironclaw_turns` re-export shims named by CHECKLIST WS1.1 are deleted —
`src/ids.rs`, `src/scope.rs`, and the whole `src/product_adapter/`
module, whose `fakes.rs` moves beside the traits it implements in
`host_api::product_adapter::test_support`.

`ids.rs` carried `pub type GateRef = TurnGateRef`: a second name for a
host_api type that collided with the unrelated
`ironclaw_host_api::ids::GateRef` (an opaque uuid GateRecord key, versus
turns' bounded `gate:`-prefixed routing string). The alias is retired
rather than relocated, so the workspace now has exactly one `GateRef`.

The six vocabulary-only consumers — auth, event_streams, outbound,
telegram_extension, triggers, event_projections — import from
`ironclaw_host_api::turn` and drop their `ironclaw_turns` dependency
entirely. Five `*→turns` LAYER_MATRIX_EXCEPTIONS are therefore not
waived but obsolete: the edges no longer exist. The §11.2.2 ratchet
baseline moves 20 → 15.

No behavior change. `RunOriginAdapter`'s validation error becomes
`Result<_, String>` (matching every other bounded ref in
`host_api::turn`) with a byte-identical message pinned by a test, so
both production `e.to_string()` call sites are unchanged.

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

* docs(target-architecture): tick WS1.1 and close PLAN decision round #1

WS1.1's box is ticked with what the change actually landed, including the
three lead-sheet corrections it turned up: the row named `TurnStatus` but
not `EventCursor`/`RunOriginAdapter` (which the six consumers genuinely
needed), `GateKind`/`BlockedReason` could not be left behind without
duplicating the single `GateKind -> TurnStatus` match table, and deleting
`ids.rs` forced retiring its `GateRef` alias rather than relocating it.

Two decisions confirmed outside the doc and never recorded:

- Strategy B (family dirs + focused crates) — confirmed 2026-07-31 by the
  owner, recorded retroactively; it was made in practice at program start.
- The `tools/` row's `default-members` trim — resolved as no trim.

Also surfaces #6963 on the WS0 blocking-prerequisite row's first line
(it was already cited mid-paragraph) and records the §11.2.2 exception
ratchet moving 20 -> 15.

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

* refactor(contracts): repoint touched imports to host_api and pin the TurnGateRef contract

CodeRabbit review round on #6967.

Import repoints (accepted): every `use` line this PR already rewrote now
names `ironclaw_host_api::turn` directly instead of routing through
`ironclaw_turns`' prelude — 57 files across extension_host, product,
composition, runner, loop_host, conversations, the integration harness,
and the stress tool, plus three inside `ironclaw_turns` itself so the
crate stops consuming its own facade. Import lines this PR did not touch
are left for their consumer's own repoint slot.

TurnGateRef contract pinned (refutation): two review comments claimed
`TurnGateRef::new` only accepts `gate:approval-`/`gate:auth-` prefixes
and that fixtures like "gate-alpha" and "stress-gate:{run_id}" fail
construction. They do not — `TurnGateRef` is `bounded_ref!` (non-empty,
<= 256 bytes, no control characters); `LoopGateRef` is the prefix-
validated family via `loop_ref!(.., "gate:")`. The misreading traces to
this PR's own AGENTS.md wording ("bounded `gate:`-prefixed routing
string"), which stated a minting convention as if it were validation.
That wording is corrected and the distinction is now pinned by a test.

Also: drop a stale cross-file line reference in a product test comment.

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

* docs(target-architecture): remove the row-97 self-contradiction

The `tools/` row resolved the `default-members` trim as "no trim" but
kept a trailing "The `tools/`/`default-members` half is still open."
from before that decision, so the row asserted both states. Drop the
stale sentence.

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

* ci(coverage): complete the WS1.1 changed-coverage exemptions

Finishes the remediation deferred last round, now that the settled run
(job 91246493989) provides authoritative line numbers. Manifest-only —
no .rs file changes, so the changed-line set the gate computes is
unchanged and these numbers stay valid for the next run.

Derived, not transcribed: the gate was replayed locally against its own
merged lcov from that run, reproducing CI's failure byte-identically
first (95.17%, 138/145, same 13 files, same 7 lines), then re-run after
each entry. Final local result: 100.00% (138/138), branch 100% (4/4),
exit 0. All 45 gate self-tests pass.

Two classes, both verified rather than asserted:

- 13 files x 20 lines - declaration lines (fn params, return types,
  struct fields) whose only edit is the type NAME: GateRef ->
  TurnGateRef, or ironclaw_turns::X -> ironclaw_host_api::turn::X.
  Declarations are not executable, so these files contribute a zero
  denominator and trip the fail-closed empty_denominator branch.

- 7 lines x 3 files - executable, instrumented, and genuinely not
  exercised by the integration tier. Each checked against the base
  merged lcov (main @ 67088a426, the PR's own base sha): identical 0
  hits before and after, so no coverage was lost. approval_prompt_
  context_view is uncovered across its whole signature at base
  (lines 505-511); the background spawn-mode arm and the invalid-gate-
  ref error path likewise.

This includes the two entries I refused to guess last round -
turn_events.rs (three identical candidate lines by text; the settled
run disambiguates it as 510) and await_edge/store.rs (no verbatim twin
after the repoint; authoritatively 268-272).

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

* refactor(contracts): extract ironclaw_loop_contracts and flip agent_loop (WS1.2)

Carve the loop tier's neutral contracts out of the turn kernel into a new
contracts-layer crate per PROPOSAL 6.1.4, and repoint every consumer.

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

* refactor(contracts): pin the loop-contract boundary and register the new crate

Enforcement, CI registration, and guidance for the WS1.2 extraction.

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

* docs(reborn): split the loop-exit contract's ownership claim across the two crates

The claim types moved to ironclaw_loop_contracts with WS1.2; the validator
policy and the trusted applier stayed in the turn kernel.

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

* docs(contracts): repoint the three intra-doc links the crate split broke

The two HostManagedLoop*Port impls stayed in ironclaw_turns, so same-crate
links to them no longer resolve; the TurnRunId link target became redundant
when the import repoint fully qualified it.

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

* fix(tests): repoint the loop-exit evidence imports the port rule moved

Removing the ironclaw_runner re-export (required by the new port-location
scan) left two workspace-root test-support files importing the turn kernel's
evidence types through it. They now import from ironclaw_turns::loop_exit
directly, which is the single sanctioned path.

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

* fix(collapse): reconcile the lock pin and the moved failure-category scan

Two artifacts of collapsing onto main:

- Cargo.lock pinned thiserror 2.0.18 for the new ironclaw_loop_contracts
  entry while main's dependency bump moved the workspace to 2.0.19. The
  auto-merge kept the stale pin because the bump predates the crate, so
  --locked builds failed.
- ironclaw_product's failure-summary test reaches into another crate's
  source with include_str! and scans it for 'impl LoopFailureKind'. WS1.2
  moved that impl to ironclaw_loop_contracts, so the include still resolved
  and matched nothing. Repointed to follow the code.

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

* refactor(contracts): extract ironclaw_extension_contracts and close the dual import paths (WS1.3)

Carve the extension tier's neutral contracts out of the host API into a new
contracts-layer crate per PROPOSAL 6.1.2, repoint every consumer, and pin the
boundary with the 11.2.3 purity allowlist and the 11.2.4 location scan.

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

* fix(ci): repoint the exact-test selector WS1.2 moved

scripts/reborn-e2e-rust.sh pins exact test names for the deterministic
gate. The capability-failure rehydration test moved from
ironclaw_turns::run_profile::host::capability to
ironclaw_loop_contracts::host::capability, so its selector matched zero
tests and the gate failed closed.

Swept all 10 pinned selectors in that script (4 lib + 6 integration
target); this was the only stale one. Each now resolves to exactly one
test, verified by running the selector.

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

* fix(collapse): reconcile the new crate's lock pins with main's dep bumps

The extension_contracts lock entry was generated before the parent branch
collapsed against main, so it pinned thiserror 2.0.18 and toml 1.1.2 — versions
that no longer have [[package]] blocks. --locked lanes would have failed to
resolve.

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

* fix(arch): register the new contracts crate in the two gates that enumerate deps

The composition pub-use snapshot still carried product's PreferenceTargetCodec
re-export, and the CLI's exact-dependency allowlist did not know the extension
tier's contracts crate. Both are enumerating gates, so both failed loudly rather
than passing vacuously.

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

* docs(contracts): record why the extension tier's traits are not sealed

The visibility kit's sealed-strategy template exists to close a strategy set;
every trait here exists to be implemented outside the crate. State that, so the
absence reads as a decision rather than an omission.

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

* fix(coverage): drop the exemption WS1.2 made stale

The changed-coverage manifest carried a WS1.1 exemption for
crates/ironclaw_turns/src/run_profile/runtime_context.rs:575-576. WS1.2
moved that file into ironclaw_loop_contracts, so the gate's fail-closed
path validator rejected the manifest before reaching its line-level
verdict.

Deleted rather than repointed: WS1.1 merged, so those lines are baseline
on main, and this PR's diff pairs the file as a 99%-similarity rename
whose only changed lines are imports. Repointing would re-exempt lines the
gate no longer flags. All 19 remaining entries verified to resolve.

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

* fix(coverage): merge main and derive the WS1.2 changed-coverage exemptions

Merge brings in tests/e2e/scenarios/test_reborn_webui_v2_custom_mcp.py,
added on main after the last merge-down; the WebUI-smoke and E2E roll-up
reds were purely the missing file.

Exemptions derived by replaying scripts/ci/reborn_changed_coverage.py
against this PR's own merged lcov artifact until it exits 0 (100% line
244/244, 100% branch 8/8) - never estimated. Three classes:

- type-path repoints on declaration/expression fragments;
- verbatim-moved bodies in the new crate. Explicitly NOT counter-attribution:
  those files are instrumented in this lcov and partially hit (loop_exit
  195/109, model 86/52, checkpoint_payload 32/16), which proves the crate is
  measured. The same bodies were equally unexercised by the integration tier
  before the move, when they sat in ironclaw_turns and simply were not
  changed lines;
- one crate-root inner attribute the uninstrumentable-line classifier does
  not recognise on a declaration-only facade.

Also adds the #6524 declaration-only facade entry for the new crate's
lib.rs to the informational per-crate coverage summary.

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

* refactor(contracts): extract ironclaw_product_contracts and land the WS1.3 adapter half (WS1.4)

Carve the product tier's neutral contracts out of `ironclaw_host_api` into
`crates/ironclaw_product_contracts`, and — in the same change, because it is
what unblocks them — move `ChannelAdapter`/`ToolAdapter`/`RestrictedEgress`
into `ironclaw_extension_contracts` where PROPOSAL §6.1.2 assigns them.

`host_api::product_adapter` is one connected component: `channel_adapter`
names `inbound::ProductTriggerReason`, `inbound` names both
`channel_adapter::ChannelAttachmentRef` and `outbound::ProjectionCursor`,
`projection` names inbound and outbound, `interaction_commands` names inbound,
and `product_surface` names all of them. Since `ironclaw_host_api` may hold no
internal dependency, the adapters could only leave once nothing that stays
behind names them — so the product DTO modules moved with them.

`git mv` moved 15 modules at 83-100% similarity, so the diff reads as a move.

Regression coverage: `reborn_product_contract_location_scan.rs` (7 tests, 2
real gates + 5 self-tests with positive and negative fixtures) pins one home
and one import path for the product tier's ports; it fails on the four
re-export chains this change deletes.

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

* docs(checklist): record the hosted-MCP merge-down resolution on the WS1.4 row

The WS1.4 ruling on package_lifecycle survived the parent's #6930
reconciliation; the LifecyclePackageId split is the recorded resolution,
now stated as what happened rather than what was recommended.

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

* refactor(contracts): consolidate sealed evidence minting behind witness grants (WS1.5)

CHECKLIST WS1's evidence-mint row: one sealed seam owns construction of
protocol-auth evidence, every other construction path is deleted, and the
refute-tests land with it.

The `host-auth-mint` cargo feature was not a seal. Cargo unifies features
across the packages selected in one invocation, so `ironclaw_webui`'s opt-in
(-> turns -> host_api) compiled `ironclaw_host_api` once with the gate ON for
every other crate in the same build. Measured before touching anything: a probe
in `ironclaw_agent_loop` — whose manifest names `ironclaw_host_api` with no
features — minted a verified bearer claim; it failed to compile alone and
passed as soon as `ironclaw_webui` joined the `cargo test`. Every workspace
build is the second case.

Replaced with the repo's existing witness-token idiom (`host_api::authorized`),
which no other crate's manifest can switch on:

- `HostAuthenticationGrant` <- `HostProtocolAuthenticator`, sole implementor
  `ironclaw_webui` (module-private `AuthLayerState`, trust stage T1).
- `VerifiedInboundGrant` <- `ChannelIngressVerifier`, sole implementor
  `ironclaw_extension_host` (`VerifiedEvidenceMint`, trust stage T2).
- Channel/webhook mint family -> `ironclaw_extension_contracts::verified_inbound`
  (§6.1.2); bearer/session family stays in `host_api` (§6.1.1). The evidence
  type does not move; `extension_contracts` reaches the private verified variant
  through the grant-gated `ProtocolAuthEvidence::seal_verified_inbound`.

Closed: the feature in 5 manifests + 1 CI recipe; four re-export chains
(`host_api::product_adapter`, `ironclaw_product` root, `ironclaw_product::auth`,
and composition's zero-consumer re-export).

Enforcement: `reborn_sealed_evidence_mint_ratchet` (10 tests, §11.2.5) plus
`host_api/tests/protocol_auth_evidence_seal.rs` (5) and
`extension_contracts/tests/verified_inbound_seal.rs` (4). +19 tests, 0 removed,
no surviving assertion edited. The production-struct dead-code baseline shrinks
81/282 -> 80/277: the five `dead_code` suppressions in `auth.rs` existed only
because the constructors were feature-gated.

* test(contracts): cover the extracted auth-prompt and lifecycle-id surface

The changed-coverage gate on #6980 flagged 134 uncovered lines and 22
uncovered branch arms, all in the two modules WS1.4 created. That was a real
regression, not an attribution artifact: the code moved out of
host_api::product_adapter::outbound and host_api::package_lifecycle, and the
owning crates' unit suites did not move with it.

Fourteen tests close every one of those lines: render_channel_auth_prompt in
both the DM and mention shapes and with/without a pairing deep link, the
AuthPromptContextView constructors and wire round-trip, each nested validator
arm rejecting independently, and the bounded-id accessor and rejection surface.
Verified by replaying reborn_changed_coverage.py against the PR's own merged
lcov (byte-identical to CI) and re-measuring with cargo llvm-cov at this head:
134 -> 0.

Three sites remain exempted with per-site evidence, all unreachable by test:
a declaration-only crate facade's inner attribute, and two pre-existing error
arms whose only change is a repointed type path.

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

* test(contracts): cover the newline/tab carve-out in bounded prompt text

The changed-coverage gate reached 100% line but left three branch arms on
validate_bounded_text's control-character predicate. Newline and tab are
deliberately legal in prompt copy — channels render multi-line instructions —
and every other control character is a rejection; both halves are now driven.

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

* test(coverage): exempt the four type-position lines the gate cannot instrument

With the auth-prompt and lifecycle-id holes tested, the gate reached 100%
line and 100% branch but still failed on four files 'contributing no
instrumented lines'. Each is a single type-position line — an enum variant
field, two parameter types, a struct field — whose only change is the
repointed path for a moved contract, wrapped onto its own line by rustfmt.
LLVM emits no coverage region for a type annotation, so no test can reach them.

Replayed against this PR's own merged lcov: exit 0, 285/285 lines, 38/38
branches.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 23:44:42 -04:00