Files
ironclaw/scripts/ci/test-reborn-changed-coverage.sh
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

1224 lines
38 KiB
Bash
Executable File

#!/usr/bin/env bash
# Hermetic sabotage tests for the changed-line/changed-branch gate.
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
gate="${repo_root}/scripts/ci/reborn_changed_coverage.py"
work="$(mktemp -d "${TMPDIR:-/tmp}/ironclaw-changed-cov.XXXXXX")"
trap 'rm -rf "${work}"' EXIT
passes=0
failures=0
capture() {
set +e
CAP_OUT="$("$@" 2>&1)"
CAP_RC=$?
set -e
}
check_rc() {
local label="$1" expected="$2"
if [ "${CAP_RC}" -eq "${expected}" ]; then
echo " ok ${label}"
passes=$((passes + 1))
else
echo " FAIL ${label}: expected rc=${expected}, got ${CAP_RC}" >&2
printf '%s\n' "${CAP_OUT}" >&2
failures=$((failures + 1))
fi
}
check_text() {
local label="$1" needle="$2"
if grep -Fq -e "${needle}" <<<"${CAP_OUT}"; then
echo " ok ${label}"
passes=$((passes + 1))
else
echo " FAIL ${label}: missing ${needle}" >&2
printf '%s\n' "${CAP_OUT}" >&2
failures=$((failures + 1))
fi
}
check_report_text() {
local label="$1" needle="$2"
if grep -Fq -e "${needle}" "${work}/report.json"; then
echo " ok ${label}"
passes=$((passes + 1))
else
echo " FAIL ${label}: report missing ${needle}" >&2
cat "${work}/report.json" >&2
failures=$((failures + 1))
fi
}
check_no_report_text() {
local label="$1" needle="$2"
if grep -Fq -e "${needle}" "${work}/report.json"; then
echo " FAIL ${label}: report unexpectedly contains ${needle}" >&2
cat "${work}/report.json" >&2
failures=$((failures + 1))
else
echo " ok ${label}"
passes=$((passes + 1))
fi
}
case_root="${work}/repo"
source_path="crates/ironclaw_demo/src/lib.rs"
mkdir -p "${case_root}/crates/ironclaw_demo/src"
printf '%s\n' 'pub fn classify(value: bool) -> bool {' ' value' '}' >"${case_root}/${source_path}"
# The gate resolves "is this a Reborn production source?" from the crate
# inventory (scripts/ci/lib/crate_tree.py) rather than from a
# `crates/ironclaw_*` pattern, so the fixture has to be a real crate tree:
# a Cargo.toml per crate, and enough of them to clear crate_tree's
# MIN_CRATE_DIRECTORIES discovery floor. Padding the fixture up to the floor is
# deliberate — the alternative (lowering or bypassing the floor for tests) would
# retire the very fail-closed assertion these tests exist to pin.
write_crate_manifest() {
local crate_dir="$1"
mkdir -p "${case_root}/${crate_dir}/src"
printf '[package]\nname = "%s"\n' "$(basename "${crate_dir}")" \
>"${case_root}/${crate_dir}/Cargo.toml"
}
write_crate_manifest crates/ironclaw_demo
for pad_index in $(seq 1 24); do
write_crate_manifest "crates/ironclaw_pad${pad_index}"
done
cat >"${work}/policy.toml" <<'TOML'
[policy]
line_percent = 100.0
branch_percent = 100.0
TOML
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -0,0 +1,3 @@
+pub fn classify(value: bool) -> bool {
+ value
+}
DIFF
write_lcov() {
local line_hits="$1" first_branch="$2" second_branch="$3"
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:1,1
DA:2,${line_hits}
DA:3,1
BRDA:2,0,0,${first_branch}
BRDA:2,0,1,${second_branch}
LF:3
LH:3
BRF:2
BRH:2
end_of_record
EOF
}
run_gate() {
capture python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--diff-file "${work}/change.diff" \
--repo-root "${case_root}" \
--json "${work}/report.json"
}
echo "▶ changed coverage happy path"
write_lcov 1 1 1
run_gate
check_rc "fully covered changed lines and branches pass" 0
check_text "line denominator is reported" "Changed line coverage: 100.00% (3/3)"
check_text "branch denominator is reported" "Changed branch coverage: 100.00% (2/2)"
check_report_text "machine report preserves the branch denominator" '"instrumented_branches": 2'
echo "▶ restored original changed-line floor"
cat >"${work}/policy.toml" <<'TOML'
[policy]
line_percent = 90.0
branch_percent = 0.0
TOML
threshold_lines=20
: >"${case_root}/${source_path}"
: >"${work}/change.diff"
printf '%s\n' \
"diff --git a/${source_path} b/${source_path}" \
"--- /dev/null" \
"+++ b/${source_path}" \
"@@ -0,0 +1,${threshold_lines} @@" >>"${work}/change.diff"
for line in $(seq 1 "${threshold_lines}"); do
printf 'pub fn threshold_line_%s() {}\n' "${line}" >>"${case_root}/${source_path}"
printf '+pub fn threshold_line_%s() {}\n' "${line}" >>"${work}/change.diff"
done
write_threshold_lcov() {
local line_18_hits="$1"
printf 'SF:%s\n' "${case_root}/${source_path}" >"${work}/coverage.lcov"
for line in $(seq 1 17); do
printf 'DA:%s,1\n' "${line}" >>"${work}/coverage.lcov"
done
printf 'DA:18,%s\nDA:19,0\nDA:20,0\n' "${line_18_hits}" \
>>"${work}/coverage.lcov"
for line in $(seq 1 20); do
printf 'BRDA:%s,0,0,0\n' "${line}" >>"${work}/coverage.lcov"
done
printf 'LF:20\nBRF:20\nend_of_record\n' >>"${work}/coverage.lcov"
}
write_threshold_lcov 1
run_gate
check_rc "90% changed lines pass at the original floor" 0
check_text "line floor denominator is reported" "Changed line coverage: 90.00% (18/20)"
check_text "ungated branch coverage remains visible" "Changed branch coverage: 0.00% (0/20)"
check_text "uncovered branch detail remains visible" "${source_path}:1 branch 0/0"
check_report_text "machine report records the 90% line floor" '"threshold_percent": 90.0'
check_report_text "machine report records the zero branch floor" '"branch_threshold_percent": 0.0'
write_threshold_lcov 0
run_gate
check_rc "changed-line coverage below 90% fails" 1
check_text "line-floor failure names the original threshold" "line coverage 85.00% is below 90.0%"
printf '%s\n' 'pub fn classify(value: bool) -> bool {' ' value' '}' >"${case_root}/${source_path}"
cat >"${work}/policy.toml" <<'TOML'
[policy]
line_percent = 100.0
branch_percent = 100.0
TOML
echo "▶ diff markers inside hunk content are parsed by their first byte"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- a/crates/ironclaw_demo/src/lib.rs
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -1,1 +1,1 @@
---removed_content
+++added_content
DIFF
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:1,1
BRDA:1,0,0,1
BRDA:1,0,1,1
LF:1
LH:1
BRF:2
BRH:2
end_of_record
EOF
run_gate
check_rc "added content beginning with ++ is counted and removed -- content is skipped" 0
check_text "the marker-like added content contributes one line" "Changed line coverage: 100.00% (1/1)"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- a/crates/ironclaw_demo/src/lib.rs
+++ b/crates/ironclaw_demo/src/lib.rs
@@ malformed @@
+pub fn malformed() {}
DIFF
run_gate
check_rc "a malformed production hunk fails" 1
check_text "malformed hunk failure names its header" "malformed diff hunk header"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -0,0 +1,3 @@
+pub fn classify(value: bool) -> bool {
+ value
+}
DIFF
echo "▶ line and branch sabotage"
write_lcov 0 1 1
run_gate
check_rc "an uncovered changed line fails" 1
check_text "line sabotage names the exact source line" "${source_path}:2"
write_lcov 1 1 0
run_gate
check_rc "an uncovered changed branch fails" 1
check_text "branch sabotage names the exact branch" "${source_path}:2 branch 0/1"
echo "▶ missing branch instrumentation is loud"
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:1,1
DA:2,1
DA:3,1
LF:3
LH:3
end_of_record
EOF
run_gate
check_rc "LCOV without BRDA records fails" 1
check_text "missing BRDA explains instrumentation failure" "branch instrumentation is missing"
cat >"${work}/change.diff" <<'DIFF'
DIFF
run_gate
check_rc "missing branch instrumentation also fails for an empty production diff" 1
check_text "empty production diff cannot bypass BRDA validation" "branch instrumentation is missing"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -0,0 +1,3 @@
+pub fn classify(value: bool) -> bool {
+ value
+}
DIFF
echo "▶ explicit reviewed exemptions are exact-line only"
cat >"${work}/policy.toml" <<TOML
[policy]
line_percent = 100.0
branch_percent = 100.0
[[exemption]]
path = "${source_path}"
lines = [2]
branch_lines = [2]
owner = "@nearai/testing"
reason = "Synthetic self-test exemption."
issue = "https://github.com/nearai/ironclaw/issues/6524"
review_after = "2099-01-01"
TOML
write_lcov 0 0 0
run_gate
check_rc "an owned exact-line exemption removes only that denominator" 0
echo "▶ malformed and stale exemption fixtures fail"
cat >"${work}/policy.toml" <<'TOML'
[policy]
line_percent = 100.0
branch_percent = 100.0
[[exemption]]
path = "crates/ironclaw_demo/src/moved.rs"
lines = [2]
owner = "@nearai/testing"
reason = "Synthetic stale path."
issue = "https://github.com/nearai/ironclaw/issues/6524"
review_after = "2099-01-01"
TOML
write_lcov 1 1 1
run_gate
check_rc "a stale exemption path fails" 1
check_text "stale path is actionable" "names stale path"
cat >"${work}/policy.toml" <<'TOML'
[policy]
line_percent = 100.0
TOML
run_gate
check_rc "a malformed policy fails" 1
check_text "malformed policy names exact fields" "[policy] fields must be exactly"
echo "▶ missing production coverage cannot disappear from the denominator"
cat >"${work}/policy.toml" <<'TOML'
[policy]
line_percent = 100.0
branch_percent = 100.0
TOML
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/crates/ironclaw_other/src/lib.rs
DA:1,1
BRDA:1,0,0,1
LF:1
LH:1
BRF:1
BRH:1
end_of_record
EOF
run_gate
check_rc "a changed production file absent from LCOV fails" 1
check_text "missing file is named" "changed production files are absent from coverage"
check_report_text "machine report cannot turn a missing file into a pass" '"passed": false'
reexports_path="crates/ironclaw_demo/src/reexports.rs"
printf '%s\n' \
'pub use crate::service::Service;' \
'pub(crate) mod support;' >"${case_root}/${reexports_path}"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/reexports.rs b/crates/ironclaw_demo/src/reexports.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/reexports.rs
@@ -0,0 +1,2 @@
+pub use crate::service::Service;
+pub(crate) mod support;
DIFF
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:1,1
BRDA:1,0,0,1
LF:1
LH:1
BRF:1
BRH:1
end_of_record
EOF
run_gate
check_rc "an uninstrumentable re-export module in a measured crate passes" 0
check_text "re-export-only additions keep an explicit empty denominator" "Changed line coverage: 100.00% (0/0)"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -0,0 +1,3 @@
+pub fn classify(value: bool) -> bool {
+ value
+}
DIFF
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
end_of_record
SF:${case_root}/crates/ironclaw_other/src/lib.rs
DA:1,1
BRDA:1,0,0,1
LF:1
LH:1
BRF:1
BRH:1
end_of_record
EOF
run_gate
check_rc "an empty SF block for the changed file fails" 1
check_text "empty SF block is reported as uninstrumented" "contain no DA records"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- a/crates/ironclaw_demo/src/lib.rs
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -1,0 +2,1 @@
+const UNMEASURED: bool = true;
DIFF
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:1,1
DA:3,1
BRDA:3,0,0,1
LF:2
LH:2
BRF:1
BRH:1
end_of_record
EOF
run_gate
check_rc "a changed file with no measured changed lines fails" 1
check_text "zero per-file denominator is actionable" "contributed no instrumented lines"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- a/crates/ironclaw_demo/src/lib.rs
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -0,0 +1,2 @@
+/// Documents the executable item below.
+use std::fmt::Debug;
DIFF
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:5,1
DA:6,1
BRDA:6,0,0,1
LF:2
LH:2
BRF:1
BRH:1
end_of_record
EOF
run_gate
check_rc "imports and doc comments outside the executable span pass" 0
check_text "uninstrumentable-only additions keep an explicit empty denominator" "Changed line coverage: 100.00% (0/0)"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- a/crates/ironclaw_demo/src/lib.rs
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -1,0 +2,14 @@
+
+/// Documents an item inside the executable span.
+/*
+ * A multiline block comment is not executable.
+ */
+#[derive(
+ Debug,
+ Clone,
+)]
+pub use std::fmt::{
+ Debug,
+ Display,
+};
+}
DIFF
printf '%s\n' \
'pub fn before() {}' \
'' \
'/// Documents an item inside the executable span.' \
'/*' \
' * A multiline block comment is not executable.' \
' */' \
'#[derive(' \
' Debug,' \
' Clone,' \
')]' \
'pub use std::fmt::{' \
' Debug,' \
' Display,' \
'};' \
'}' \
'pub fn after() {}' >"${case_root}/${source_path}"
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:1,1
DA:16,1
BRDA:16,0,0,1
LF:2
LH:2
BRF:1
BRH:1
end_of_record
EOF
run_gate
check_rc "scaffolding-only additions inside the executable span pass" 0
check_text "in-span scaffolding cannot manufacture a denominator" "Changed line coverage: 100.00% (0/0)"
echo "▶ test-only Rust changes do not dilute the production denominator"
test_source="crates/ironclaw_demo/src/tests.rs"
e2e_test_source="crates/ironclaw_demo/src/channel_host/e2e_tests.rs"
printf '%s\n' '#[test]' 'fn helper_test() {}' >"${case_root}/${test_source}"
mkdir -p "$(dirname "${case_root}/${e2e_test_source}")"
printf '%s\n' '#[test]' 'fn e2e_helper_test() {}' >"${case_root}/${e2e_test_source}"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/tests.rs b/crates/ironclaw_demo/src/tests.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/tests.rs
@@ -0,0 +1,2 @@
+#[test]
+fn helper_test() {}
diff --git a/crates/ironclaw_demo/src/channel_host/e2e_tests.rs b/crates/ironclaw_demo/src/channel_host/e2e_tests.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/channel_host/e2e_tests.rs
@@ -0,0 +1,2 @@
+#[test]
+fn e2e_helper_test() {}
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- a/crates/ironclaw_demo/src/lib.rs
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -3,0 +4,4 @@
+#[cfg(test)]
+mod inline_tests {
+ fn helper() {}
+}
DIFF
printf '%s\n' \
'pub fn classify(value: bool) -> bool {' \
' value' \
'}' \
'#[cfg(test)]' \
'mod inline_tests {' \
' fn helper() {}' \
'}' >"${case_root}/${source_path}"
run_gate
check_rc "test modules are excluded mechanically" 0
check_text "test-only result is explicit" "no Reborn production lines added"
check_report_text "machine report records the empty production diff" '"changed_product_files": []'
echo "▶ cfg(test) span detection ignores braces in comments and literals"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -0,0 +1,11 @@
+#[cfg(test)]
+mod inline_tests {
+ // A comment brace must not end the module: }
+ const NORMAL: &str = "}";
+ const RAW: &str = r#"}"#;
+ const CHARACTER: char = '}';
+ fn helper() {}
+}
+pub fn production_after_test_module() -> bool {
+ true
+}
DIFF
printf '%s\n' \
'#[cfg(test)]' \
'mod inline_tests {' \
' // A comment brace must not end the module: }' \
' const NORMAL: &str = "}";' \
' const RAW: &str = r#"}"#;' \
" const CHARACTER: char = '}';" \
' fn helper() {}' \
'}' \
'pub fn production_after_test_module() -> bool {' \
' true' \
'}' >"${case_root}/${source_path}"
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:9,1
DA:10,1
DA:11,1
BRDA:10,0,0,1
LF:3
LH:3
BRF:1
BRH:1
end_of_record
EOF
run_gate
check_rc "literal and comment braces do not truncate cfg(test) exclusion" 0
check_text "production after cfg(test) remains in the denominator" "Changed line coverage: 100.00% (3/3)"
echo "▶ non-test cfg remains in the production denominator"
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/ironclaw_demo/src/lib.rs b/crates/ironclaw_demo/src/lib.rs
--- /dev/null
+++ b/crates/ironclaw_demo/src/lib.rs
@@ -0,0 +1,4 @@
+#[cfg(not(test))]
+pub fn production_only(value: bool) -> bool {
+ value
+}
DIFF
printf '%s\n' \
'#[cfg(not(test))]' \
'pub fn production_only(value: bool) -> bool {' \
' value' \
'}' >"${case_root}/${source_path}"
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${source_path}
DA:1,1
DA:2,1
DA:3,1
DA:4,1
BRDA:3,0,0,1
BRDA:3,0,1,1
LF:4
LH:4
BRF:2
BRH:2
end_of_record
EOF
run_gate
check_rc "cfg(not(test)) production code is gated" 0
check_text "production cfg lines remain counted" "Changed line coverage: 100.00% (4/4)"
echo "▶ renamed production files remain in the denominator"
renamed_path="crates/ironclaw_demo/src/renamed.rs"
printf '%s\n' \
'pub fn stable_one() -> bool { true }' \
'pub fn stable_two() -> bool { true }' \
'pub fn stable_three() -> bool { true }' \
'pub fn stable_four() -> bool { true }' \
'pub fn stable_five() -> bool { true }' \
'pub fn stable_six() -> bool { true }' \
'pub fn stable_seven() -> bool { true }' \
'pub fn stable_eight() -> bool { true }' \
'pub fn stable_nine() -> bool { true }' \
'pub fn stable_ten() -> bool { true }' >"${case_root}/${source_path}"
fixture_git() {
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \
git -c core.hooksPath=/dev/null -C "${case_root}" "$@"
}
fixture_git init -q --template=
fixture_git add "${source_path}"
fixture_git \
-c user.name=coverage-test -c user.email=coverage@example.invalid -c commit.gpgsign=false \
commit -qm baseline
base_commit="$(fixture_git rev-parse HEAD)"
fixture_git mv "${source_path}" "${renamed_path}"
printf '%s\n' 'pub fn renamed_branch(value: bool) -> bool { value }' \
>>"${case_root}/${renamed_path}"
fixture_git add "${renamed_path}"
fixture_git \
-c user.name=coverage-test -c user.email=coverage@example.invalid -c commit.gpgsign=false \
commit -qm renamed
head_commit="$(fixture_git rev-parse HEAD)"
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${renamed_path}
DA:11,0
BRDA:11,0,0,1
BRDA:11,0,1,0
LF:1
LH:0
BRF:2
BRH:1
end_of_record
EOF
capture python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--base "${base_commit}" \
--head "${head_commit}" \
--repo-root "${case_root}"
check_rc "an uncovered line added during a rename fails" 1
check_text "rename sabotage names the new source path" "${renamed_path}:11"
echo "▶ the changed-line denominator is computed with the histogram diff algorithm"
# Myers (git's default) anchors greedily. On a deletion-shaped diff it shreds one
# large removal into interleaved -/+ hunks, re-emitting surviving *unchanged* text
# as added lines — which this gate then demands coverage for. Found on #6964, where
# deleting the dead half of llm::reasoning made myers report 907 added lines in a
# file whose real change was 8 (all doc comments and imports, zero executable).
#
# This asserts the invocation rather than re-staging a myers pathology on purpose:
# 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. The
# flag is the actual contract, so pin the flag.
shim_bin="${work}/shim-bin"
mkdir -p "${shim_bin}"
real_git="$(command -v git)"
cat >"${shim_bin}/git" <<EOF
#!/usr/bin/env bash
printf '%s\n' "\$*" >>"${work}/git-argv.log"
exec "${real_git}" "\$@"
EOF
chmod +x "${shim_bin}/git"
: >"${work}/git-argv.log"
PATH="${shim_bin}:${PATH}" python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--base "${base_commit}" \
--head "${head_commit}" \
--repo-root "${case_root}" >/dev/null 2>&1 || true
capture grep -Fq -- "--diff-algorithm=histogram" "${work}/git-argv.log"
check_rc "the gate pins the histogram diff algorithm when it generates the diff" 0
capture grep -Eq -- "diff .*--unified=0" "${work}/git-argv.log"
check_rc "the gate still generates the diff with zero context" 0
echo "▶ discovery is tree-shape-agnostic and fails closed"
# The WS10 failure mode (docs/reborn/target-architecture/CHECKLIST.md, #6963):
# with the old `crates/ironclaw_*/src/**` keying, every case below reported
# "no Reborn production lines added" and exited 0 — a green gate that measured
# nothing. Coverage numbers here are deliberately identical to the flat-tree
# happy path, because the tree shape must not change what the gate measures.
nested_path="crates/substrates/ironclaw_nested/src/lib.rs"
write_crate_manifest crates/substrates/ironclaw_nested
printf '%s\n' 'pub fn classify(value: bool) -> bool {' ' value' '}' \
>"${case_root}/${nested_path}"
cat >"${work}/policy.toml" <<'TOML'
[policy]
line_percent = 100.0
branch_percent = 100.0
TOML
cat >"${work}/change.diff" <<DIFF
diff --git a/${nested_path} b/${nested_path}
--- /dev/null
+++ b/${nested_path}
@@ -0,0 +1,3 @@
+pub fn classify(value: bool) -> bool {
+ value
+}
DIFF
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${nested_path}
DA:1,1
DA:2,0
DA:3,1
BRDA:2,0,0,1
BRDA:2,0,1,1
LF:3
LH:2
BRF:2
BRH:2
end_of_record
EOF
run_gate
check_rc "an uncovered line in a family-nested crate still fails the gate" 1
check_text "the nested crate is measured, not skipped" "Changed line coverage: 66.67% (2/3)"
check_text "the nested uncovered line is named" "${nested_path}:2"
# A crate-owned path that is not the crate's own `src/` was outside the
# denominator under the old regex (`crates/<one-segment>/src/…`) and must stay
# outside it: `crates/ironclaw_safety/fuzz/src/main.rs` is the real instance.
# It is attributable — so it must be *excluded*, not *refused*.
fuzz_path="crates/ironclaw_demo/fuzz/src/main.rs"
mkdir -p "$(dirname "${case_root}/${fuzz_path}")"
printf '%s\n' 'fn main() {}' >"${case_root}/${fuzz_path}"
cat >"${work}/change.diff" <<DIFF
diff --git a/${fuzz_path} b/${fuzz_path}
--- /dev/null
+++ b/${fuzz_path}
@@ -0,0 +1,1 @@
+fn main() {}
DIFF
run_gate
check_rc "a nested non-src tree inside a crate stays out of the denominator" 0
check_text "the crate-owned non-src path is excluded, not refused" \
"no Reborn production lines added"
# The fail-closed half: a `crates/` Rust file no crate owns means the inventory
# and the tree disagree. Falling through to "not production" is precisely how a
# moved tree goes quiet, so it is refused instead.
cat >"${work}/change.diff" <<'DIFF'
diff --git a/crates/not_a_crate/src/lib.rs b/crates/not_a_crate/src/lib.rs
--- /dev/null
+++ b/crates/not_a_crate/src/lib.rs
@@ -0,0 +1,1 @@
+pub fn orphan() {}
DIFF
run_gate
check_rc "an unattributable crates/ Rust file fails closed" 1
check_text "the unattributable path is named" "belongs to no discovered crate"
# ...and the same refusal must reach the mode CI actually runs. `--diff-file`
# hands the gate an un-narrowed diff, but `--base/--head` narrows to per-crate
# `src/` pathspecs BEFORE `parse_diff` ever sees a path — so an unattributable
# file was filtered out of the diff text and the check above could not fire at
# all in production. Verified against this fixture: the gate printed
# "no Reborn production lines added" and exited 0. `screen_unattributable`
# closes that, and this case is the pin: a fail-closed check that cannot fail
# in the mode that matters is not a check (#6963).
orphan_path="crates/not_a_crate/src/lib.rs"
mkdir -p "$(dirname "${case_root}/${orphan_path}")"
printf '%s\n' 'pub fn orphan() {}' >"${case_root}/${orphan_path}"
fixture_git add "${orphan_path}"
fixture_git \
-c user.name=coverage-test -c user.email=coverage@example.invalid -c commit.gpgsign=false \
commit -qm orphan
orphan_commit="$(fixture_git rev-parse HEAD)"
capture python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--base "${head_commit}" \
--head "${orphan_commit}" \
--repo-root "${case_root}"
check_rc "an unattributable path is refused through --base/--head too" 1
check_text "the --base/--head refusal names the path" "${orphan_path}"
fixture_git rm -rq "$(dirname "${orphan_path}")"
fixture_git \
-c user.name=coverage-test -c user.email=coverage@example.invalid -c commit.gpgsign=false \
commit -qm drop-orphan
# A missing or truncated crate tree cannot read as "nothing changed".
empty_root="${work}/no-crates"
mkdir -p "${empty_root}"
capture python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--diff-file "${work}/change.diff" \
--repo-root "${empty_root}"
check_rc "a repo root with no crates/ tree fails closed" 1
check_text "missing crate tree is actionable" "crate discovery failed"
short_root="${work}/short-crates"
mkdir -p "${short_root}/crates/ironclaw_lonely/src"
printf '[package]\nname = "ironclaw_lonely"\n' \
>"${short_root}/crates/ironclaw_lonely/Cargo.toml"
capture python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--diff-file "${work}/change.diff" \
--repo-root "${short_root}"
check_rc "a crate inventory below the discovery floor fails closed" 1
check_text "short crate tree is actionable" "crate discovery failed"
echo "▶ lines already uncovered at base leave the denominator; nothing else does"
# The rule: a changed line whose pre-image was uncovered at the base commit is
# pre-existing debt, not debt this change introduced. Everything below pins one
# of the four ways a line can relate to base, because the value of the rule is
# entirely in what it refuses to forgive.
pre_crate="crates/ironclaw_preimage"
pre_path="${pre_crate}/src/lib.rs"
write_crate_manifest "${pre_crate}"
# Written here rather than inherited: every assertion below is an exact
# denominator, so an exemption left over from an earlier section would move the
# numbers without failing anything.
cat >"${work}/policy.toml" <<'TOML'
[policy]
line_percent = 100.0
branch_percent = 100.0
TOML
printf '%s\n' \
'pub fn alpha(value: bool) -> bool {' \
' value' \
'}' \
'pub fn beta(value: bool) -> bool {' \
' !value' \
'}' >"${case_root}/${pre_path}"
# Two lines modified in place, 1:1 — the rename / rustfmt-re-wrap shape that
# made 135 of PR #7000's 137 flagged lines pre-existing.
cat >"${work}/change.diff" <<DIFF
diff --git a/${pre_path} b/${pre_path}
--- a/${pre_path}
+++ b/${pre_path}
@@ -2 +2 @@
- old_value
+ value
@@ -5 +5 @@
- !old_value
+ !value
DIFF
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${pre_path}
DA:2,0
DA:5,1
BRDA:5,0,0,1
BRDA:5,0,1,1
LF:2
LH:1
BRF:2
BRH:2
end_of_record
EOF
# Line 2 uncovered at base; line 5 covered at base and still covered.
cat >"${work}/base.lcov" <<EOF
SF:${case_root}/${pre_path}
DA:2,0
DA:5,1
LF:2
LH:1
end_of_record
EOF
run_gate_base() {
capture python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--diff-file "${work}/change.diff" \
--repo-root "${case_root}" \
--json "${work}/report.json" \
"$@"
}
run_gate_base --base-lcov "${work}/base.lcov"
check_rc "a line uncovered at base and uncovered now is excluded, not gated" 0
check_text "the subtraction count is reported" \
"Pre-existing uncovered lines excluded from the denominator: 1"
check_text "the excluded line names its base pre-image for audit" \
"${pre_path}:2 (uncovered at base as ${pre_path}:2)"
check_text "only the genuinely measurable line remains in the denominator" \
"Changed line coverage: 100.00% (1/1)"
check_report_text "the machine report carries the exclusion count" \
'"preexisting_uncovered_excluded": 1'
check_report_text "the machine report records that base coverage was applied" \
'"base_coverage_applied": true'
# Uncovered at base but covered now: someone paid off the debt in this change.
# Excluding it would strip a hit from the numerator and shrink the denominator,
# i.e. quietly penalise adding the missing test, so only currently-uncovered
# lines are ever candidates for subtraction.
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${pre_path}
DA:2,3
DA:5,1
BRDA:5,0,0,1
BRDA:5,0,1,1
LF:2
LH:2
BRF:2
BRH:2
end_of_record
EOF
run_gate_base --base-lcov "${work}/base.lcov"
check_rc "a pre-existing hole this change filled still passes" 0
check_text "the newly covered line stays in the denominator" \
"Changed line coverage: 100.00% (2/2)"
check_text "a line that is covered now is never subtracted" \
"Pre-existing uncovered lines excluded from the denominator: 0"
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${pre_path}
DA:2,0
DA:5,1
BRDA:5,0,0,1
BRDA:5,0,1,1
LF:2
LH:1
BRF:2
BRH:2
end_of_record
EOF
# The regression this whole gate exists for: covered before, uncovered now.
cat >"${work}/base.lcov" <<EOF
SF:${case_root}/${pre_path}
DA:2,1
DA:5,1
LF:2
LH:2
end_of_record
EOF
run_gate_base --base-lcov "${work}/base.lcov"
check_rc "a line covered at base and uncovered now still fails" 1
check_text "the covered-at-base regression is named" "${pre_path}:2"
check_text "nothing is excluded when base coverage says the line was covered" \
"Pre-existing uncovered lines excluded from the denominator: 0"
# A pure addition has no pre-image, so it can inherit nothing. The base lcov
# deliberately marks the *same line number* uncovered: a gate that keyed on the
# line number rather than the diff's pre-image would wrongly forgive this.
cat >"${work}/change.diff" <<DIFF
diff --git a/${pre_path} b/${pre_path}
--- a/${pre_path}
+++ b/${pre_path}
@@ -1,0 +2 @@
+ value
DIFF
cat >"${work}/base.lcov" <<EOF
SF:${case_root}/${pre_path}
DA:2,0
DA:5,0
LF:2
LH:0
end_of_record
EOF
run_gate_base --base-lcov "${work}/base.lcov"
check_rc "a genuinely new uncovered line still fails" 1
check_text "the genuinely new line is named" "${pre_path}:2"
check_text "a line with no pre-image is never excluded" \
"Pre-existing uncovered lines excluded from the denominator: 0"
echo "▶ pre-images resolve across a rename"
moved_from="${pre_crate}/src/moved_from.rs"
moved_to="${pre_crate}/src/moved_to.rs"
printf '%s\n' \
'pub fn one(value: bool) -> bool {' \
' value' \
'}' \
'pub fn two(value: bool) -> bool {' \
' !value' \
'}' >"${case_root}/${moved_from}"
fixture_git add "${moved_from}"
fixture_git \
-c user.name=coverage-test -c user.email=coverage@example.invalid -c commit.gpgsign=false \
commit -qm preimage-baseline
preimage_base="$(fixture_git rev-parse HEAD)"
fixture_git mv "${moved_from}" "${moved_to}"
printf '%s\n' \
'pub fn one(value: bool) -> bool {' \
' value && true' \
'}' \
'pub fn two(value: bool) -> bool {' \
' !value || false' \
'}' >"${case_root}/${moved_to}"
fixture_git add "${moved_to}"
fixture_git \
-c user.name=coverage-test -c user.email=coverage@example.invalid -c commit.gpgsign=false \
commit -qm preimage-renamed
preimage_head="$(fixture_git rev-parse HEAD)"
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${moved_to}
DA:2,0
DA:5,0
BRDA:2,0,0,1
BRDA:2,0,1,1
LF:2
LH:0
BRF:2
BRH:2
end_of_record
EOF
# At the OLD path: line 2 covered, line 5 uncovered.
cat >"${work}/base.lcov" <<EOF
SF:${case_root}/${moved_from}
DA:2,1
DA:5,0
LF:2
LH:1
end_of_record
EOF
capture python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--base "${preimage_base}" \
--head "${preimage_head}" \
--repo-root "${case_root}" \
--json "${work}/report.json" \
--base-lcov "${work}/base.lcov"
check_rc "a renamed file's covered-at-base line still gates" 1
check_text "the renamed covered-at-base line is named" "${moved_to}:2"
check_report_text "the covered-at-base line is in the machine report's holes" \
"\"${moved_to}:2\""
# The uncovered-at-base sibling must be excluded, not merely absent because the
# rename lost its pre-image: the next assertion pins that it resolved to the old
# path, so the two together separate "forgiven" from "never seen".
check_no_report_text "the renamed uncovered-at-base line is not gated" \
"\"${moved_to}:5\""
check_text "the rename's pre-image is resolved to the old path" \
"${moved_to}:5 (uncovered at base as ${moved_from}:5)"
echo "▶ unobtainable base coverage falls back to counting every changed line"
# The fallback is the whole safety argument: the subtraction may only ever come
# from coverage the gate positively read. Every failure mode below must land on
# *current* behaviour — stricter, never looser — and say so out loud.
cat >"${work}/change.diff" <<DIFF
diff --git a/${pre_path} b/${pre_path}
--- a/${pre_path}
+++ b/${pre_path}
@@ -2 +2 @@
- old_value
+ value
@@ -5 +5 @@
- !old_value
+ !value
DIFF
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${pre_path}
DA:2,0
DA:5,1
BRDA:5,0,0,1
BRDA:5,0,1,1
LF:2
LH:1
BRF:2
BRH:2
end_of_record
EOF
run_gate_base --base-lcov "${work}/absent.lcov"
check_rc "a missing base lcov counts every changed line" 1
check_text "the missing base lcov is announced, not swallowed" "Base coverage: NOT APPLIED"
check_text "the fallback explains itself" "--base-lcov not found"
check_text "the fallback says every changed line counts" \
"every changed line counts, including lines that were already uncovered"
check_text "the previously excluded line is back in the denominator" "${pre_path}:2"
check_report_text "the machine report records the fallback" \
'"base_coverage_applied": false'
printf '%s\n' 'TN:' 'SF:/nowhere.rs' 'end_of_record' >"${work}/empty.lcov"
run_gate_base --base-lcov "${work}/empty.lcov"
check_rc "a base lcov with no DA records counts every changed line" 1
check_text "an lcov that measured nothing is refused as base coverage" \
"contains no DA records"
run_gate_base
check_rc "no base coverage requested is the strict denominator" 1
check_text "the un-requested case is still announced" "Base coverage: NOT APPLIED"
check_text "the subtraction count is reported even when nothing is subtracted" \
"Pre-existing uncovered lines excluded from the denominator: 0"
# Two sources of base coverage is an operator error, not a precedence question:
# silently preferring one would make which lcov was consulted unknowable.
run_gate_base --base-lcov "${work}/base.lcov" --fetch-base-coverage
check_rc "asking for two base-coverage sources is refused outright" 1
check_text "the conflicting flags are named" \
"--base-lcov cannot be combined with --fetch-base-coverage"
echo "▶ the artifact lookup is exercised, not just the local-file shortcut"
# `--fetch-base-coverage` is the mode CI runs. Testing only `--base-lcov` would
# leave the whole resolution path — the one that can silently stop working —
# unexercised, which is the "a check that cannot fail is not a check" trap.
fake_gh="${work}/fake-gh"
mkdir -p "${fake_gh}"
cat >"${fake_gh}/gh" <<EOF
#!/usr/bin/env bash
url="\$2"
case "\${url}" in
*"/actions/workflows/reborn-tests.yml/runs"*) cat "${work}/gh-runs.json" ;;
*"/actions/runs/"*"/artifacts"*) cat "${work}/gh-artifacts.json" ;;
*"/actions/artifacts/"*"/zip") cat "${work}/gh-artifact.zip" ;;
*) echo "unexpected gh call: \${url}" >&2; exit 9 ;;
esac
EOF
chmod +x "${fake_gh}/gh"
cat >"${work}/base.lcov" <<EOF
SF:${case_root}/${pre_path}
DA:2,0
DA:5,1
LF:2
LH:1
end_of_record
EOF
python3 - "${work}" <<'PY'
import pathlib, sys, zipfile
work = pathlib.Path(sys.argv[1])
with zipfile.ZipFile(work / "gh-artifact.zip", "w") as archive:
archive.write(work / "base.lcov", "reborn-integration-merged.lcov")
PY
printf '%s\n' '{"workflow_runs": [{"id": 4242, "event": "merge_group", "status": "completed"}]}' \
>"${work}/gh-runs.json"
printf '%s\n' '{"artifacts": [{"id": 77, "name": "reborn-integration-coverage-merged", "expired": false}]}' \
>"${work}/gh-artifacts.json"
run_gate_fetch() {
capture env PATH="${fake_gh}:${PATH}" GITHUB_REPOSITORY=nearai/ironclaw \
python3 "${gate}" \
--lcov "${work}/coverage.lcov" \
--manifest "${work}/policy.toml" \
--base "${preimage_base}" \
--head "${preimage_head}" \
--repo-root "${case_root}" \
--json "${work}/report.json" \
--fetch-base-coverage
}
# Drive the real diff/rename path against the fetched artifact, so the fetch is
# proved end to end rather than only up to the download.
cat >"${work}/coverage.lcov" <<EOF
SF:${case_root}/${moved_to}
DA:2,1
DA:5,0
BRDA:2,0,0,1
BRDA:2,0,1,1
LF:2
LH:1
BRF:2
BRH:2
end_of_record
EOF
cat >"${work}/base.lcov" <<EOF
SF:${case_root}/${moved_from}
DA:2,1
DA:5,0
LF:2
LH:1
end_of_record
EOF
python3 - "${work}" <<'PY'
import pathlib, sys, zipfile
work = pathlib.Path(sys.argv[1])
with zipfile.ZipFile(work / "gh-artifact.zip", "w") as archive:
archive.write(work / "base.lcov", "reborn-integration-merged.lcov")
PY
run_gate_fetch
check_rc "a downloaded base artifact excludes the pre-existing line" 0
check_text "the fetched run is named for audit" "run 4242"
check_text "the fetched artifact drives the same subtraction" \
"Pre-existing uncovered lines excluded from the denominator: 1"
printf '%s\n' '{"workflow_runs": []}' >"${work}/gh-runs.json"
run_gate_fetch
check_rc "no run for the base commit counts every changed line" 1
check_text "the empty run list is explained" "no completed reborn-tests.yml run exists"
printf '%s\n' '{"workflow_runs": [{"id": 4242, "event": "push", "status": "completed"}]}' \
>"${work}/gh-runs.json"
printf '%s\n' '{"artifacts": [{"id": 77, "name": "reborn-integration-coverage-merged", "expired": true}]}' \
>"${work}/gh-artifacts.json"
run_gate_fetch
check_rc "an expired artifact counts every changed line" 1
check_text "artifact expiry is explained" "no unexpired"
printf '%s\n' '{"artifacts": [{"id": 77, "name": "reborn-integration-coverage-merged", "expired": false}]}' \
>"${work}/gh-artifacts.json"
printf '%s' 'not a zip' >"${work}/gh-artifact.zip"
run_gate_fetch
check_rc "an unreadable artifact counts every changed line" 1
check_text "a corrupt artifact is explained" "unreadable"
cat >"${fake_gh}/gh" <<'EOF'
#!/usr/bin/env bash
echo "gh: HTTP 403" >&2
exit 1
EOF
chmod +x "${fake_gh}/gh"
run_gate_fetch
check_rc "an API failure counts every changed line" 1
check_text "the API failure is surfaced verbatim" "HTTP 403"
check_text "an API failure never silently subtracts" \
"Pre-existing uncovered lines excluded from the denominator: 0"
rm -f "${fake_gh}/gh"
run_gate_fetch
check_rc "a missing gh binary counts every changed line" 1
check_text "the missing binary is explained" "Base coverage: NOT APPLIED"
echo
if [ "${failures}" -ne 0 ]; then
echo "${failures} changed-coverage self-test(s) failed" >&2
exit 1
fi
echo "all ${passes} changed-coverage self-tests passed"