Skip to content

refactor(conversations,attachments): fix the conversations/threads naming trap and widen attachments (WS5) - #7005

Closed
BenKurrek wants to merge 11 commits into
mainfrom
ws2/naming-traps-attachments
Closed

BenKurrek wants to merge 11 commits into
mainfrom
ws2/naming-traps-attachments

Conversation

@BenKurrek

@BenKurrek BenKurrek commented Aug 1, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #7002 → #6998 → main; merges after them. Base is ws2/webui-openai-ports.

Wave 2, slot 6 — CHECKLIST WS5, the conversations/threads naming-trap row and the attachments widened row. Vocabulary renames plus one crate widening; behavior-free apart from one recorded, self-healing format change (finding 4).

The naming trap is the one the audit called the worst in the tree, and it was costing real money: exactly one file had to hold both vocabularies at once — composition's trusted-trigger submit — and it carried four as Canonical… aliases to say which accept_inbound_message it meant, plus a fifth in extension_host. All five are gone; nothing aliases these names anywhere in the workspace now.


1. The rename table

Every name renamed on the conversations side. ironclaw_threads — the canonical transcript, and the stronger claim to thread/message vocabulary — is untouched.

Old (ironclaw_conversations) New Why Consumers repointed
SessionThreadService InboundConversationService collided with ironclaw_threads::SessionThreadService, same method name and shape (accept_inbound_message(Request) -> Accepted), different store composition::trigger_poller_assembly (the one caller)
AcceptInboundMessageRequest AcceptConversationMessageRequest collided none outside the crate
AcceptedInboundMessage AcceptedConversationMessage collided composition::automation::trigger_poller_trusted_submit
AcceptedInboundMessageReplay AcceptedConversationMessageReplay collided none outside the crate
ThreadMessageRecord ConversationMessageRecord collided, and no row named it — finding 1 none outside the crate
AcceptedInboundMessageLookup AcceptedConversationMessageLookup never collided; renamed for family coherence none outside the crate

The consumer blast radius is two files. Measured, not assumed: a scan of the 72 files in crates/, tests/, tools/ that name any of the six found that 70 of them mean ironclaw_threads — which is exactly why the collision was dangerous and why it stayed invisible.

Alongside, the external-ref pair unified onto its one home:

Was Now Deleted
ironclaw_conversations::{ExternalActorRef, ExternalConversationRef} (own definitions) consumed from ironclaw_extension_contracts::external both definitions; not re-exported, so conversations never becomes a second import path
ironclaw_product::conversation_binding::{conversation_actor_ref, conversation_conversation_ref} the value passes through both field-by-field translators
ironclaw_product::workflow::delivered_route_conversation_ref envelope.external_conversation_ref().conversation_fingerprint() the rebuild and its error branch — the fingerprint already excludes the reply target
ExternalConversationRef::without_message_id (conversations) ExternalConversationRef::without_reply_target (contracts) one method, one home

14 files repointed to ironclaw_extension_contracts::external.

2. Attachments widening

ironclaw_attachments gained two modules and now declares:

Moved in From Note
InboundAttachmentLander, InboundAttachmentReader, AttachmentCleanupReport ironclaw_product::reborn_services §6.4.9's "the landing routine plus its ports"
ProjectScopedAttachmentLander (+ its 6 tests, bodies unedited) ironclaw_product::scoped_fs::attachment_landing the default impl over ScopedFilesystem
AttachmentCapabilities / attachment_capabilities() (was ProductAttachmentCapabilities / product_attachment_capabilities) ironclaw_product::product_surface_inbound "one home for size ceilings (webui/openai import them)"

ProjectScopedAttachmentReader stayed in product — finding 6.


3. Row corrections (re-verified against this PR's base ba009786d)

1. The "same-named DTO trio" is a quartet

The row names SessionThreadService "+ its same-named DTO trio" — four names. Comparing the two crates' pub use sets found five. The fifth is ThreadMessageRecord (crates/ironclaw_conversations/src/types.rs:243 against crates/ironclaw_threads/src/contract.rs), and nothing in CHECKLIST or PROPOSAL names it. It is renamed with the others, and the new gate discovers collisions rather than enumerating them, so the next one fails at introduction instead of at the next audit.

2. "unify … with host_api" is stale by one wave

host_api does not declare the pair any more: WS1.4 moved external.rs into ironclaw_extension_contracts (crates/ironclaw_extension_contracts/src/external.rs:69,144; the location scan's own module doc records the arrival at reborn_extension_contract_location_scan.rs:107-110). The unification landed on that copy. ironclaw_conversations gained the dep — substrates → contracts, a downward edge, no layer exception (LAYER_MATRIX_EXCEPTIONS stays 13).

3. The duplicate was field-divergent, and the divergence that mattered was equality

Not two copies of one shape. Conversations' copy spelled the last two fields thread_id / message_id where the canonical type says topic_id / reply_target_message_id, and carried no display_name. But the live difference was PartialEq / Hash: conversations' were derived, so they included the per-event message id; the canonical type excludes it deliberately ("the reply-target hint is deliberately excluded"). The same conversation route therefore compared equal or unequal depending on which copy the caller happened to hold, and ironclaw_product's two hand-written translators were the only bridge. That is the concrete argument for this row: a duplicated type is not just duplication, it is two answers to ==.

4. Delivered gate-route fingerprints change format — recorded, not mitigated

The two copies also had different conversation_fingerprint() algorithms (length-prefixed "{len}:{part}|" versus "space:{len}:{v};…"). Unifying picks the canonical one, so DeliveredGateRouteRecord.delivered_conversation_fingerprints is written and read in a new spelling.

Both sides traced: the writer (product::run_delivery::gate_routes) and the reader (product::workflow) both used the conversations copy and both flip together, so there is no read/write skew within a build. The exposure is across an upgrade and is bounded by DELIVERED_GATE_ROUTE_TTL = 48 hours (crates/ironclaw_outbound/src/delivered_gate_routes.rs:43): a gate prompt delivered before the upgrade and answered after it misses the delivered-route fallback, and the index self-heals inside the TTL. Dual-writing both formats is exactly the compatibility shim the type-placement rule forbids, so this is recorded rather than papered over.

5. …but the durable state that is not self-healing is preserved, and it would have failed silently

BindingRecord and ReplyTargetRecord embed the ref permanently, and serde fills a missing Option field with None without an error. Renaming thread_id → topic_id on the wire would therefore have loaded every existing threaded route as a conversation-root route — replies landing in the channel instead of the thread — with no log line anywhere.

ironclaw_conversations::stored_refs keeps that grammar, in the crate that owns the records, not in the contracts crate that owns the type.

✎ Corrected 2026-08-02 on review (@serrrfirat, stored_refs.rs:53; CodeRabbit, :38) — commit 4cd166f727. This finding originally shipped the compatibility one-way: write the canonical spelling, read either, with a # Rollback boundary section calling that deliberate. It was wrong, and the argument recorded for it refuted a proposal nobody made — it rejected dual-writing (emitting both spellings, which #[serde(alias)] makes self-defeating) when the fix is write-legacy / read-either, which that objection does not touch. Measured against origin/main, the released readers name thread_id/message_id with no aliases, so a record this build wrote read back as None on a rollback.

The severity was understated too. ExternalConversationIdentity keys BindingKey, and StoredConversationState::into_state rebuilds that map with Vec<(K, V)>::into_iter().collect() — so two threaded bindings in one conversation that both lose their topic become the same key and the earlier one is dropped. Binding loss, not a remap.

Now: conversation_ref::serialize writes {space_id, conversation_id, thread_id, message_id} through a borrowed representation, ExternalConversationIdentity has a matching hand-written Serialize, both readers keep their aliases, and a record carrying both spellings still fails closed. stored_refs::actor_ref was deleted as a genuine no-op (the actor change was additive, so the canonical impls already did the job).

The evidence moved tiers as well: filesystem_conversation_services_persist_external_refs_in_the_durable_grammar drives the real StoredConversationState through the services and walks every key of the persisted document. Under the mutation that makes the identity emit topic_id, the stored_refs surrogate tests stay green and only the store-level test fails — naming all six affected sites. That gap was @serrrfirat's separate memory.rs:1528 finding, and it was correct.

6. attachments — "their composition impls move in" is wrong twice

The impls were never in composition: both adapters lived in ironclaw_product::scoped_fs::attachment_landing, and composition only constructs them. And only one could move. ProjectScopedAttachmentReader cannot, for two independent reasons: it implements ironclaw_loop_host::LoopAttachmentReadPort as well as InboundAttachmentReader, so ironclaw_attachments (substrates) would need ironclaw_loop_host (loops) — an illegal upward edge — and even granted that, once the struct moved that impl would have neither side in ironclaw_product, so the orphan rule (#7002's finding) forbids leaving it behind too. The gate pins the reader in product and asserts ironclaw_attachments never acquires a loop_host dep, so "finishing the row" cannot quietly mean dragging the loop tier into a substrate. The row wants re-wording to exclude the read adapter, or the read port re-homed first.

7. [decision] — ironclaw_attachments now names ironclaw_product_contracts

The two ports keep erroring with ProductSurfaceError because the caller that fails is a product surface and the WebUI bytes endpoint maps NotFound/Forbidden straight onto 404/403. Layer-legal (contracts is the lowest layer, and this is the neutral product-tier contract crate, not ironclaw_product), but it does put product-tier error vocabulary in a substrate. The alternative — narrow the ports onto an attachments-owned error and map at the product boundary — moves an HTTP status mapping, which is behavior, not placement, and deserves its own slice the way WS2.2 gave ProductSurfaceFailure one. Recorded, not taken; carried on the CHECKLIST row.


4. New enforcement — crates/ironclaw_architecture/tests/reborn_conversations_threads_attachments.rs

Five tests, three rules:

  1. conversations_and_threads_declare_no_name_in_common — discovered, not enumerated: every type/trait/enum either crate declares, compared against the other's. Also pins that the five historic collisions stayed in ironclaw_threads and left ironclaw_conversations, so a later "fix" that renames the transcript crate instead (≈70 files, and the weaker claim) fails loudly. Non-vacuity: both sets asserted > 20.
  2. the_external_ref_pair_is_declared_only_by_the_extension_contracts_crate — the pair is declared by its owner and not by conversations, the two product translators are not re-declared, and conversations does not re-export the pair (the §11.2.4 re-export trap). The source-reading halves read stripped source, so a name surviving in prose does not count.
  3. the_attachment_ports_and_size_ceilings_live_in_the_attachments_crate — six names declared in ironclaw_attachments and not in ironclaw_product, attachment_capabilities() present, product_attachment_capabilities() absent, and the carve-out pinned in both directions (reader in product; no loop_host dep in the manifest).
  4. the_source_strip_removes_prose_and_literals_but_not_code — self-test over the four traps (doc comment, line comment, string literal, trailing comment).
  5. the_declaration_walk_finds_declarations_not_mentions — self-test: a type conversations really declares is found; ThreadScope, which it only names, is not.

Mutation-checked: re-adding pub struct ThreadMessageRecord + pub struct ExternalActorRef to conversations and pub trait InboundAttachmentLander to product made tests 1, 2 and 3 fail with the intended messages (declare 1 name(s) in common: ["ThreadMessageRecord"], declares its own ExternalActorRef again, is declared in ironclaw_product again); restoring returned 5/5 green.

5. Enumerating gates touched (all update-never-relax)

Gate Change
reborn_extension_contract_location_scan.rs COLLISION_EXEMPT 4 → 2, deleted not repointed. Its entries said the pair was "the same concept, declared twice … unifying them changes the conversations record grammar — a domain call". The call is taken, so the carve-out goes. Added in its place: the blind spot that remains — AdapterInstallationId/ExternalEventId are bounded_string_id! expansions no pub struct walk can resolve.
reborn_transport_product_boundary.rs WebUI residue 102 → 100, two rows deleted. They named this row as their owner ("CHECKLIST WS5 attachments widened … one home for size ceilings owns the move"), so the gate fired the moment the move landed — working exactly as #7002 designed it.
reborn_extension_specificity.rs allowlist untouched at 129. Its two reborn_services.rs entries still match after the ports left that file (verified by execution, not by reading).
reborn_struct_test_support_ratchet.rs untouched. Its one attachment_landing.rs entry is ProjectScopedAttachmentReader::with_max_bytes, which stayed with the reader.
reborn_service_method_freeze_ratchet.rs untouched — ProductSurface still 3 methods; reborn_services.rs shed a trait, it did not gain one.
LAYER_MATRIX_EXCEPTIONS 13, unchanged. Every new dep is downward (substrates → contracts, products → substrates).
docs/plans/composition-pubuse.snapshot untouched — composition's public surface is unchanged.
CLI exact-dep allowlist untouched — no CLI dependency changed.
reborn_cross_crate_include_scan.rs untouched — all seven files code was moved out of were swept for include_str!/include_bytes!; none carried one.
scripts/no_panics_reborn_baseline.txt untouched — the one baselined entry in a file this PR touches (conversation_state_store.rs, index_key_tenant_ids) is pre-existing and unmodified.

6. Un-masking

Full unfiltered --list inventory of all 18 crates this PR touches or could touch, --lib --tests, taken on a quiescent tree in this worktree at ba009786d and at this tip, compared name by name:

6702 → 6722. Six removed, twenty-six added, every one accounted for.

  • The six absent names are the six ProjectScopedAttachmentLander tests, which reappear as project_scoped::tests::<the same name>: the lander moved crates, so its module path changed and the diff cannot render it as a rename. All six bodies verified byte-identical by extracting each function from the base file and the destination file and comparing (6/6).
  • Twenty genuinely new, none written to make something pass:
    • 5 — the new architecture gate (§4).
    • 5 — conversations::stored_refs::tests: the durable record grammar. ✎ Reworked by the 2026-08-02 review correction above (4cd166f727), and grown to 7 plus 2 store-tier tests. A legacy conversation record keeps its topic and its reply target; the current record is written in the durable grammar and round-trips; a canonically spelled record still loads (the alias is not dead code); a record carrying both spellings is rejected; the actor pair proves no adapter is needed; all re-validate on load.
    • 4 — conversations::ids::tests: the route identity drops the reply target but keeps the topic (asserted both ways), a legacy thread_id binding key still resolves to the same route, deserialization re-validates a corrupted record, and the named error mapping keeps this crate's error vocabulary.
    • 4 — attachments::ports::tests: object safety for both Arc<dyn _> ports; per-method argument pass-through (scope + message id reach the impl, and rollback receives exactly the batch land returned); the cleanup snapshot; and a read whose answer differs by thread scope in both directions, against a double keyed on (scope, key) — a double that ignored the scope would have passed a one-directional check.
    • 2 — attachments::budgets::tests: the advertised ceiling is the enforced ceiling, the tokens come from the shared registry with no wildcards, and the budgets stay flattened on the wire (a nested budgets key would silently break every client reading max_file_bytes at the top level).
  • No test was deleted, disabled, or weakened. Exactly one existing body was edited, disclosed here: conversations::inbound_contract::serde_deserialization_revalidates_external_ref_invariants now spells its two JSON fixtures in the canonical field names, because that is what the unified type deserializes — and it gains an assertion that the route identity still reads the pre-unification thread_id key. All other test-file churn is mechanical (ExternalActorRef::new(a, b) → (a, b, None::<String>), qualified paths becoming imports); the largest such file, product_surface_contract.rs, was classified line by line: 20 identical call rewrites plus import churn, zero argument or assertion changes.

7. Verification

  • cargo fmt; cargo clippy -p ironclaw_conversations -p ironclaw_extension_contracts -p ironclaw_attachments -p ironclaw_product -p ironclaw_extension_host -p ironclaw_reborn_composition -p ironclaw_webui -p ironclaw_architecture --all-targets --all-features -- -D warnings → clean.
  • cargo test -p ironclaw_architecture → 142 passed / 0 failed (137 at the fork point + this PR's 5).
  • Per-touched-crate unfiltered --all-features tests → 2262 passed / 0 failed / 0 ignored across those eight crates, plus ironclaw_reborn_composition green on its own run.
  • cargo check --workspace --all-targets --all-features → 0 errors, 0 warnings (run last).
  • cargo metadata --locked clean; git status clean after every cargo run; the Cargo.lock delta is 8 lines (the four new path deps).
  • All 10 exact selectors in scripts/reborn-e2e-rust.sh executed under bash with < /dev/null; each matched exactly one test (10/10, SELECTOR-FAIL=0).

Changed-coverage: this is a move-shaped diff, so relocated bodies read as added production lines. Entries will be derived from this PR's own merged lcov via the replay method once CI publishes it — not guessed here. Written defensively for it: the pure-declaration module that moved (attachments::ports) carries real contract tests rather than a waiver — object safety for both Arc<dyn _> ports, argument pass-through recorded per method, and a read whose answer differs by thread scope in both directions against a double that keys on (scope, key); and the error re-wraps this PR introduces are named functions (map_external_ref_error, external_ref), not inline closures.

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2f38e922-64e6-497d-80a8-0068ce857941

📥 Commits

Reviewing files that changed from the base of the PR and between 5b85634 and 5f1867a.

📒 Files selected for processing (1)
  • crates/ironclaw_product/src/scoped_fs/attachment_reader.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added centralized attachment capabilities, size limits, scoped landing and reading, rollback, and stale cleanup.
    • WebUI attachment responses now include shared capability metadata.
    • Added compatibility for canonical and legacy conversation identity formats.
  • Refactor

    • Unified conversation terminology and service contracts.
    • Centralized external references and attachment ownership.
  • Documentation

    • Updated architecture and contract documentation.
  • Tests

    • Expanded coverage for attachment safety, persistence, routing, compatibility, and architectural boundaries.

Walkthrough

Changes

The PR renames conversation contracts, centralizes external references, and adds backward-compatible identity persistence. It moves attachment ports, capabilities, and project-scoped landing into ironclaw_attachments, while retaining the product-side reader. Architecture tests and documentation enforce the new boundaries.

Conversation and attachment migration

Layer / File(s) Summary
Conversation contracts and durable identity
crates/ironclaw_conversations/*, crates/ironclaw_extension_contracts/src/external.rs
Conversation services and DTOs use conversation-specific names. Route identity uses topic_id. Durable storage accepts legacy field names.
Attachment contracts and filesystem flow
crates/ironclaw_attachments/*, crates/ironclaw_product/src/scoped_fs/*
Attachment capabilities, ports, scoped landing, rollback, cleanup, and size ceilings are centralized. Product retains the loop-host reader.
Boundary wiring and validation
crates/ironclaw_product/*, crates/ironclaw_extension_host/*, crates/ironclaw_reborn_composition/*, crates/ironclaw_webui/*, tests/integration/*
Consumers use canonical external-reference and attachment APIs. Tests cover routing, persistence, attachment reads, and composition wiring.
Architecture enforcement and records
crates/ironclaw_architecture/tests/*, docs/reborn/target-architecture/*, crates/AGENTS.md, tests/integration/changed-coverage-exemptions.toml
Source scanners, dependency checks, ratchets, coverage policy, and architecture records encode the new ownership rules.

Estimated code review effort: 5 (Critical) | ~90 minutes

Possibly related issues

Possibly related PRs

  • nearai/ironclaw#6364 — Introduces the shared attachment budgets and durable attachment flow refined by this PR.
  • nearai/ironclaw#5851 — Modifies the same conversation contracts, external references, and binding APIs.
  • nearai/ironclaw#6977 — Enforces canonical ownership for shared extension-contract types.

Suggested reviewers: ilblackdragon, think-in-universe

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed and relevant but omits most required template sections, including change type, linked issue, security, database, rollback, and review follow-through. Add all required template headings and complete each field, using “None” or “N/A: ” where applicable.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commits style and accurately describes the conversation naming refactor and attachment ownership changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@railway-app

railway-app Bot commented Aug 1, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the ironclaw-pr-7005 environment in ironclaw-ci-preview

Service Status Web Updated (UTC)
ironclaw ✅ Success (View Logs) Web Aug 2, 2026 at 2:44 pm

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7005 August 1, 2026 08:13 Destroyed
@github-actions github-actions Bot added scope: docs Documentation scope: dependencies Dependency updates size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Aug 1, 2026
@ironloopai

ironloopai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Review · PR #7005

🔴 Failed

Execution result is invalid

The structured result could not be verified.

Automatic · PR opened · attempt 1 of 3 · failed after 2m 33s

Failure details
  • Repository: nearai/ironclaw
  • Base: ws2/webui-openai-ports at 256163c
  • Head: ws2/naming-traps-attachments at 2f78d5d
  • Created: Aug 1, 2026, 8:18 AM UTC
  • Updated: Aug 1, 2026, 8:21 AM UTC
  • Run: 118c97e0-ecde-4630-b7e1-f3d9896ecfd3
  • Latest attempt: 1 · Completed · 10dbe533-be1a-4ea0-8a5d-792ae770345b
  • Failed during: Verification
  • Retryable: No
  • Failure: 4b3f5df8-3142-4089-a0a0-406a8dd3ad5b

@BenKurrek

Copy link
Copy Markdown
Collaborator Author

Coordinator sign-off (Wave 2 slot 6 — WS2 naming traps + attachments widening).

This slice's author was terminated mid-verification by a session limit, and its own last message flagged that its test tallies came from an awk pattern later proven to silently under-count failures. I re-verified the load-bearing claims myself rather than accept them, with a regex-based parser and a name-by-name inventory comparison:

  • Zero failures across the crates this PR changes: ironclaw_conversations + ironclaw_attachments — 130 passed / 0 failed / 0 ignored (6 result lines); ironclaw_architecture + ironclaw_extension_contracts — 234 passed / 0 failed across 32 binaries, so the boundary rules and location scans hold.
  • Un-masking confirmed by inventory diff, not by counting: base (ws2/webui-openai-ports) 109 tests → branch 130. comm against sorted name lists shows 0 removed, 21 added. No test was dropped or renamed away.
  • CI on the stacked base: 12 checks, 0 failures.

Ready to merge in stack order (after #7002, its base). Two notes for whoever merges: the full 64-check set only attaches once it retargets to main, and its remaining un-run lanes are the usual stacked-gap ones rather than anything red.

@BenKurrek

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/ironclaw_conversations/src/inbound.rs (1)

39-48: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Field name still carries the collision the PR removes.

The bound is now InboundConversationService, but the field and constructor parameter are still session_thread_service (lines 42-46, and again at 309-319). SessionThreadService is the ironclaw_threads type this rename exists to stop colliding with, so the old name now points at the wrong crate's vocabulary. It is private, so this is readability only.

Rename to conversation_service in this crate while you are in the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_conversations/src/inbound.rs` around lines 39 - 48, Rename
the inbound service field and constructor parameter from session_thread_service
to conversation_service throughout the inbound conversation service
implementation, including the constructor near new and the later initialization
around the referenced lines. Update all internal references while preserving
behavior and the InboundConversationService type bound.
crates/ironclaw_reborn_composition/src/trigger_poller_assembly.rs (1)

59-67: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Do not silence the unused-field warning with a cfg-gated no-op read.

TriggerPollerServices::pairing_service is assigned in production but only read under #[cfg(any(test, feature = "test-support"))]; compile-time unused-field lints are not suppressed by that block. Per CLAUDE.md, avoid cfg-gated no-op field reads that hide lints: either gate the field off in production build shape or add the intended production reader.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_reborn_composition/src/trigger_poller_assembly.rs` around
lines 59 - 67, Remove the cfg-gated no-op read of
TriggerPollerServices::pairing_service and resolve the unused-field warning by
either conditionally excluding the field from the production build shape or
adding the intended production code path that reads it, while preserving its
test and test-support behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_attachments/src/project_scoped.rs`:
- Around line 56-59: Delete the stranded `///` documentation block immediately
before the `#[async_trait]` implementation of `InboundAttachmentLander`; it
incorrectly describes `ProjectScopedAttachmentReader` and is incomplete.
- Around line 464-508: Extend
stale_cleanup_with_empty_or_unowned_snapshot_deletes_nothing with an in-root
attachment reference whose relative path depth is not 3, then call cleanup_stale
and assert it returns the expected error and leaves the seeded attachment
intact. Reuse the existing fixture and verify one malformed reference aborts
reclamation for the entire thread.

In `@crates/ironclaw_conversations/src/ids.rs`:
- Around line 124-140: Choose and document one rollback policy for both
persistence adapters: either dual-write canonical and legacy field names during
a transition, or declare rollback unsupported after this commit. If
dual-writing, update crates/ironclaw_conversations/src/ids.rs:124-140 to replace
derived serialization and alias-based deserialization with explicit two-field
raw serialization/deserialization that prefers topic_id, and update
crates/ironclaw_conversations/src/stored_refs.rs:24-56 to cover both
topic_id/thread_id and reply_target_message_id/message_id; revise its test at
lines 133-136 accordingly. If rollback is unsupported, add the rollback boundary
to the ids module documentation and apply the same policy to stored_refs.rs
without changing the test to require legacy writes.

In `@crates/ironclaw_product/src/run_delivery/gate_routes.rs`:
- Around line 63-73: Update the source-conversation branch that builds
`ExternalConversationRef` to pass `None` for the reply target, preserving a
stable fingerprint independent of per-event message IDs. Also register the
topic-less variant used by the delivered-message loop, and add a caller-level
contract test covering recording with a reply target followed by resolution
without one.

In `@crates/ironclaw_product/tests/run_delivery_contract.rs`:
- Around line 1287-1289: Update the import used by the fixture around
source_fingerprint to import ExternalConversationRef from
ironclaw_extension_contracts::external instead of ironclaw_conversations, while
leaving the fingerprint assertion behavior unchanged.

In `@crates/ironclaw_webui/Cargo.toml`:
- Around line 36-39: Update
crates/ironclaw_webui/tests/reborn_dependency_boundaries.rs to explicitly allow
the ironclaw_attachments workspace dependency and document the existing
rationale about sharing the attachment size ceiling. Verify the WebUI imports
only the capability DTO and size constants from ironclaw_attachments, with no
attachment landing or filesystem types referenced under the WebUI source.

In `@docs/reborn/target-architecture/CHECKLIST.md`:
- Line 139: Add an explicit tracked follow-up for the
ProjectScopedAttachmentReader row’s wording or LoopAttachmentReadPort re-homing,
including an issue or plan link in the checklist. Keep the existing dependency
and orphan-rule blockers, but ensure the [~] row surfaces this obligation before
closure.

---

Outside diff comments:
In `@crates/ironclaw_conversations/src/inbound.rs`:
- Around line 39-48: Rename the inbound service field and constructor parameter
from session_thread_service to conversation_service throughout the inbound
conversation service implementation, including the constructor near new and the
later initialization around the referenced lines. Update all internal references
while preserving behavior and the InboundConversationService type bound.

In `@crates/ironclaw_reborn_composition/src/trigger_poller_assembly.rs`:
- Around line 59-67: Remove the cfg-gated no-op read of
TriggerPollerServices::pairing_service and resolve the unused-field warning by
either conditionally excluding the field from the production build shape or
adding the intended production code path that reads it, while preserving its
test and test-support behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6a61e139-6105-4db4-b1bd-81d8f78dcd00

📥 Commits

Reviewing files that changed from the base of the PR and between 256163c and 2f78d5d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (57)
  • crates/ironclaw_architecture/tests/reborn_conversations_threads_attachments.rs
  • crates/ironclaw_architecture/tests/reborn_extension_contract_location_scan.rs
  • crates/ironclaw_architecture/tests/reborn_transport_product_boundary.rs
  • crates/ironclaw_attachments/Cargo.toml
  • crates/ironclaw_attachments/src/budgets.rs
  • crates/ironclaw_attachments/src/lib.rs
  • crates/ironclaw_attachments/src/ports.rs
  • crates/ironclaw_attachments/src/project_scoped.rs
  • crates/ironclaw_conversations/Cargo.toml
  • crates/ironclaw_conversations/src/conversation_state_store.rs
  • crates/ironclaw_conversations/src/ids.rs
  • crates/ironclaw_conversations/src/inbound.rs
  • crates/ironclaw_conversations/src/lib.rs
  • crates/ironclaw_conversations/src/memory.rs
  • crates/ironclaw_conversations/src/stored_refs.rs
  • crates/ironclaw_conversations/src/traits.rs
  • crates/ironclaw_conversations/src/types.rs
  • crates/ironclaw_conversations/tests/conversation_state_store_contract.rs
  • crates/ironclaw_conversations/tests/inbound_contract.rs
  • crates/ironclaw_extension_contracts/src/external.rs
  • crates/ironclaw_extension_host/Cargo.toml
  • crates/ironclaw_extension_host/src/channel_host.rs
  • crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs
  • crates/ironclaw_extension_host/src/channel_pairing.rs
  • crates/ironclaw_extension_host/src/channel_pairing/tests.rs
  • crates/ironclaw_product/src/conversation_binding.rs
  • crates/ironclaw_product/src/inbound_turn.rs
  • crates/ironclaw_product/src/inbound_turn/tests/attachments.rs
  • crates/ironclaw_product/src/lib.rs
  • crates/ironclaw_product/src/product_surface_inbound.rs
  • crates/ironclaw_product/src/reborn_services.rs
  • crates/ironclaw_product/src/run_delivery/gate_routes.rs
  • crates/ironclaw_product/src/scoped_fs/attachment_landing.rs
  • crates/ironclaw_product/src/scoped_fs/mod.rs
  • crates/ironclaw_product/src/workflow.rs
  • crates/ironclaw_product/tests/product_surface_contract.rs
  • crates/ironclaw_product/tests/reborn_services_contract.rs
  • crates/ironclaw_product/tests/run_delivery_contract.rs
  • crates/ironclaw_reborn_composition/src/automation/trigger_poller_trusted_submit.rs
  • crates/ironclaw_reborn_composition/src/extension_host_assembly.rs
  • crates/ironclaw_reborn_composition/src/factory.rs
  • crates/ironclaw_reborn_composition/src/factory/test_support.rs
  • crates/ironclaw_reborn_composition/src/factory/trigger_creation_assembly.rs
  • crates/ironclaw_reborn_composition/src/product_surface.rs
  • crates/ironclaw_reborn_composition/src/runtime.rs
  • crates/ironclaw_reborn_composition/src/runtime/tests/core.rs
  • crates/ironclaw_reborn_composition/src/trigger_poller_assembly.rs
  • crates/ironclaw_reborn_composition/tests/production_runtime_trigger_poller.rs
  • crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs
  • crates/ironclaw_reborn_composition/tests/trigger_webui_timeline_e2e.rs
  • crates/ironclaw_webui/Cargo.toml
  • crates/ironclaw_webui/src/webui_v2/handlers.rs
  • crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs
  • docs/reborn/target-architecture/CHECKLIST.md
  • docs/reborn/target-architecture/PROPOSAL.md
  • tests/integration/support/harness/mod.rs
  • tests/integration/support/triggered_submit.rs

Comment thread crates/ironclaw_attachments/src/project_scoped.rs Outdated
Comment thread crates/ironclaw_attachments/src/project_scoped.rs
Comment thread crates/ironclaw_conversations/src/ids.rs Outdated
Comment thread crates/ironclaw_product/src/run_delivery/gate_routes.rs
Comment thread crates/ironclaw_product/tests/run_delivery_contract.rs Outdated
Comment on lines +36 to +39
# The advertised attachment ceilings the WebUI hands the browser: one home
# for a size ceiling means the transport reads the same constant the landing
# routine enforces (PROPOSAL §6.4.9).
ironclaw_attachments = { path = "../ironclaw_attachments" }

@coderabbitai coderabbitai Bot Aug 1, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

New workspace dependency requires an update to the WebUI dependency-boundary test.

ironclaw_webui/**/Cargo.toml states: "Keep workspace dependencies limited to the explicitly allowed crates; any other workspace-crate dependency requires updating tests/reborn_dependency_boundaries.rs with explicit rationale." This adds ironclaw_attachments, and crates/ironclaw_webui/tests/reborn_dependency_boundaries.rs is not in the change set.

The rationale in the manifest comment is sound — one home for the size ceiling beats a duplicated constant. Record it in the allowlist so the boundary stays enforced instead of silently widening. Confirm the dependency is import-limited to the capability DTO and the size constants, and does not pull attachment landing or filesystem behavior into the gateway.

#!/bin/bash
# Is ironclaw_attachments on the WebUI allowlist, and what does the gateway import from it?
set -uo pipefail

echo "=== the boundary test: allowlist entries ==="
fd -g 'reborn_dependency_boundaries.rs' crates/ironclaw_webui --exec cat -n {}

echo
echo "=== is ironclaw_attachments named anywhere in webui tests? ==="
rg -n 'ironclaw_attachments' crates/ironclaw_webui/

echo
echo "=== everything the gateway pulls from the attachments crate ==="
rg -n 'use ironclaw_attachments|ironclaw_attachments::' crates/ironclaw_webui/src/

echo
echo "=== must NOT appear: landing / filesystem / port types in the gateway ==="
rg -n 'InboundAttachmentLander|ProjectScopedAttachmentLander|ScopedFilesystem|RootFilesystem' \
  crates/ironclaw_webui/src/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_webui/Cargo.toml` around lines 36 - 39, Update
crates/ironclaw_webui/tests/reborn_dependency_boundaries.rs to explicitly allow
the ironclaw_attachments workspace dependency and document the existing
rationale about sharing the attachment size ceiling. Verify the WebUI imports
only the capability DTO and size constants from ironclaw_attachments, with no
attachment landing or filesystem types referenced under the WebUI source.

Source: Path instructions

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Refuted on the ask; the verification you requested was run and comes back clean.

The file you name does not exist. crates/ironclaw_webui/tests/ contains 20 test files; reborn_dependency_boundaries.rs is not among them. The only file by that name in the tree is crates/ironclaw_architecture/tests/reborn_dependency_boundaries.rs.

And it is a blocklist, not an allowlist. The webui rule there (:3063-3098) is BoundaryRule { crate_name: "ironclaw_webui", forbidden: vec![...] } — 33 named crates that must not appear. ironclaw_attachments is not on it, so the boundary did not "silently widen": there is no allowlist for ironclaw_webui to add to, and nothing was relaxed. (The allowlist-shaped checks in that file cover memory, reborn_identity, loop_contracts, extension_contracts, product_contracts only. The exact-dependency assertion is for the CLI crate, at :777.)

The substantive check — is the dependency import-limited? — passes. Exactly one import under src/, and it is the DTO plus the constants:

crates/ironclaw_webui/src/webui_v2/handlers.rs:33:
use ironclaw_attachments::{AttachmentCapabilities, attachment_capabilities};

InboundAttachmentLander, ProjectScopedAttachmentLander, ScopedFilesystem and RootFilesystem return zero matches across the whole crates/ironclaw_webui/ tree — no landing routine and no filesystem type reaches the gateway. Worth noting that ironclaw_filesystem is on webui's forbidden list (:3072), so the shape you were checking for would have failed CI on its own.

The rationale you call sound is already recorded where it takes effect, in the manifest comment above the dependency, and the size-ceiling clause is separately cashed out by reborn_transport_product_boundary.rs, whose two ProductAttachmentCapabilities residue rows this PR deletes (102 → 100) precisely because the move landed.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rate Limit Exceeded

@BenKurrek have exceeded the limit for the number of chat messages per hour. Please wait 43 minutes and 38 seconds before sending another message.

- [~] `attachments` widened: ports + composition impls move in; one home for size ceilings (webui/openai import them). **Landed with the WS5 naming-traps PR, minus one item that cannot land — see 3.** `ironclaw_attachments` now declares `InboundAttachmentLander`, `InboundAttachmentReader`, `AttachmentCleanupReport`, the default `ProjectScopedAttachmentLander`, and the advertised-ceiling pair `AttachmentCapabilities` / `attachment_capabilities()`; none of them is declared in `ironclaw_product` any more. Pinned by `reborn_conversations_threads_attachments.rs`.
1. **"composition impls" is wrong about where they were.** §6.4.9 says the impls "move in from … their composition impls"; they were never in composition. Both adapters lived in `ironclaw_product::scoped_fs::attachment_landing` (`crates/ironclaw_product/src/scoped_fs/attachment_landing.rs`), and composition only *constructs* them. The construction sites are unchanged — they now name `ironclaw_attachments::ProjectScopedAttachmentLander`.
2. **The size-ceiling clause was already cashed out by name in another gate.** `reborn_transport_product_boundary.rs` listed `ProductAttachmentCapabilities` / `product_attachment_capabilities` as WebUI residue with the reason "CHECKLIST WS5 `attachments widened … one home for size ceilings` owns the move". They moved, so those two rows are **deleted** and the shrink-only baseline goes **102 → 100** — WebUI reads the advertised ceilings from the crate that enforces them, which is the whole point of the clause.
3. **`ProjectScopedAttachmentReader` cannot move, for two independent reasons.** It implements `ironclaw_loop_host::LoopAttachmentReadPort` as well as `InboundAttachmentReader`; `ironclaw_loop_host` is a `loops`-layer crate and `ironclaw_attachments` is `substrates`, so the dep is an illegal upward edge — and even granting it, once the struct moved that impl would have neither side in `ironclaw_product` and the orphan rule forbids leaving it behind. It stays in product, and the gate pins it *there* (plus asserts `ironclaw_attachments` never acquires a `loop_host` dep) so "finishing the row" cannot quietly mean dragging the loop tier into a substrate. **The row should be re-worded to exclude the read adapter**, or the read port re-homed first.

@coderabbitai coderabbitai Bot Aug 1, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial

Track the row re-wording obligation.

This item states the row "should be re-worded to exclude the read adapter, or the read port re-homed first". The row stays [~] on that basis, but the obligation has no tracking issue or plan link, so nothing surfaces it when someone next tries to close the row. The two blockers are recorded well: the illegal substrates → loops edge and the orphan rule.

Do you want me to open an issue to track re-homing LoopAttachmentReadPort or re-wording §6.4.9?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/reborn/target-architecture/CHECKLIST.md` at line 139, Add an explicit
tracked follow-up for the ProjectScopedAttachmentReader row’s wording or
LoopAttachmentReadPort re-homing, including an issue or plan link in the
checklist. Keep the existing dependency and orphan-rule blockers, but ensure the
[~] row surfaces this obligation before closure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid — done, and thank you for asking rather than assuming. You are right that the two blockers are recorded and the obligation was not: the sentence stated work with no home, so nothing would have surfaced it when someone next tried to close the [~].

Opened #7010 and linked it from the row as a dated amendment that quotes the sentence it annotates, per this program's docs convention (the target-architecture docs are amended, never silently rewritten). The issue carries the two options — re-word §6.4.9 to exclude the read adapter, or re-home LoopAttachmentReadPort first — and records the one way it must not be closed: adding a loop_host dep to ironclaw_attachments, which the arch gate asserts never appears. The box stays [~] until #7010 closes.

Landed in 69dc88ac74.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Rate Limit Exceeded

@BenKurrek have exceeded the limit for the number of chat messages per hour. Please wait 43 minutes and 39 seconds before sending another message.

BenKurrek added a commit that referenced this pull request Aug 1, 2026
…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>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7005 August 1, 2026 15:06 Destroyed
@BenKurrek

Copy link
Copy Markdown
Collaborator Author

Review triage — the two outside-diff findings

CodeRabbit's review body carries two findings with no inline thread. Triaged here. The seven inline threads are answered in place; 69dc88ac74 carries the fixes.

1. crates/ironclaw_conversations/src/inbound.rs:39-48 — field still named session_thread_service — fixed

Correct, and squarely on this PR's charter rather than "readability only": SessionThreadService is the ironclaw_threads type the whole slice exists to stop colliding with, so a field named after it is exactly the residue the rename was meant to remove. The new gate (conversations_and_threads_declare_no_name_in_common) discovers declarations, so it could never have caught a field name.

13 occurrences renamed to conversation_service, all in inbound.rs, all private — no API surface, no consumer change. rg session_thread_service crates/ironclaw_conversations/ is now empty. (The remaining tree-wide hits are legitimate ironclaw_threads vocabulary: runtime.rs:1565's session_thread_service() accessor returns Arc<dyn ironclaw_threads::SessionThreadService>, which is the correct crate.)

2. crates/ironclaw_reborn_composition/src/trigger_poller_assembly.rs:59-67 — cfg-gated no-op read — substance agreed, deferred; two corrections

The cfg direction in the finding is inverted. The line is

#[cfg(not(any(test, feature = "test-support")))]
let _ = &services.pairing_service;

— gated on not(...), i.e. it exists only in production builds. The field is genuinely read under test/test-support; the no-op exists to stop the production build warning about it. The finding describes the opposite arrangement.

The rule cited is not in CLAUDE.md. I searched root CLAUDE.md, .claude/rules/, and the composition CLAUDE.md/AGENTS.md. The rule that actually applies is in .claude/rules/cargo-features.md:

Never gate on a feature to make a lint go away. #[cfg_attr(not(feature = "x"), allow(dead_code))] means the item is dead in some build — fix the build shape, don't silence it.

Different file, same substance, and it does condemn this shape.

Deferred because it is not this PR's, and neither remedy is a rename. git log -S 'let _ = &services.pairing_service' dates it to #6691; this PR's entire diff to that file is one trait bound:

-        + ironclaw_conversations::SessionThreadService
+        + ironclaw_conversations::InboundConversationService

Both remedies the finding offers — cfg the field out of the production build shape, or add the intended production reader — are composition-assembly changes to TriggerPollerServices' shape or to what the trigger poller does with a pairing service. Doing either inside a vocabulary-rename slice would smuggle a behavior or build-shape change into a PR whose claim is that it has none.

Named home: the composition god-crate row in docs/reborn/target-architecture/CHECKLIST.md, which already owns trigger_poller_assembly.rs — same pass that decides whether the poller should be reading a pairing service in production at all. That is the question underneath the lint, and it is not answerable from the lint.

@BenKurrek
BenKurrek force-pushed the ws2/webui-openai-ports branch from 4e2396c to 7e9805b Compare August 1, 2026 15:59
Base automatically changed from ws2/webui-openai-ports to main August 1, 2026 18:25
BenKurrek and others added 5 commits August 1, 2026 14:36
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>
…d ceilings (WS5)

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…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>
@BenKurrek
BenKurrek force-pushed the ws2/naming-traps-attachments branch from 69dc88a to bbdf1e8 Compare August 1, 2026 18:51
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7005 August 1, 2026 18:51 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_conversations/src/stored_refs.rs`:
- Around line 19-38: Preserve rollback compatibility for durable route records
by updating the stored record representation and its readers/writers around the
relevant serialization types and conversion methods to accept both legacy
thread_id/message_id and canonical topic_id/reply_target_message_id spellings,
while rejecting conflicting duplicate values. Add a cross-version regression
test covering records written by each representation and verified by the other,
ensuring threaded routes retain their original targets.

In `@crates/ironclaw_product/tests/run_delivery_contract.rs`:
- Around line 1321-1347: Add an independent assertion in the test around
source_fingerprint that compares the fingerprint for the topic-bearing
conversation with the same conversation created without topic_id, and requires
them to differ. Place this assertion before the
delivered_conversation_fingerprints membership check, while keeping the existing
reply-target invariance assertion unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f6d5e597-848f-46bd-ac24-300fc98884dd

📥 Commits

Reviewing files that changed from the base of the PR and between 2f78d5d and bbdf1e8.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (57)
  • crates/ironclaw_architecture/tests/reborn_conversations_threads_attachments.rs
  • crates/ironclaw_architecture/tests/reborn_extension_contract_location_scan.rs
  • crates/ironclaw_architecture/tests/reborn_transport_product_boundary.rs
  • crates/ironclaw_attachments/Cargo.toml
  • crates/ironclaw_attachments/src/budgets.rs
  • crates/ironclaw_attachments/src/lib.rs
  • crates/ironclaw_attachments/src/ports.rs
  • crates/ironclaw_attachments/src/project_scoped.rs
  • crates/ironclaw_conversations/Cargo.toml
  • crates/ironclaw_conversations/src/conversation_state_store.rs
  • crates/ironclaw_conversations/src/ids.rs
  • crates/ironclaw_conversations/src/inbound.rs
  • crates/ironclaw_conversations/src/lib.rs
  • crates/ironclaw_conversations/src/memory.rs
  • crates/ironclaw_conversations/src/stored_refs.rs
  • crates/ironclaw_conversations/src/traits.rs
  • crates/ironclaw_conversations/src/types.rs
  • crates/ironclaw_conversations/tests/conversation_state_store_contract.rs
  • crates/ironclaw_conversations/tests/inbound_contract.rs
  • crates/ironclaw_extension_contracts/src/external.rs
  • crates/ironclaw_extension_host/Cargo.toml
  • crates/ironclaw_extension_host/src/channel_host.rs
  • crates/ironclaw_extension_host/src/channel_host/e2e_tests.rs
  • crates/ironclaw_extension_host/src/channel_pairing.rs
  • crates/ironclaw_extension_host/src/channel_pairing/tests.rs
  • crates/ironclaw_product/src/conversation_binding.rs
  • crates/ironclaw_product/src/inbound_turn.rs
  • crates/ironclaw_product/src/inbound_turn/tests/attachments.rs
  • crates/ironclaw_product/src/lib.rs
  • crates/ironclaw_product/src/product_surface_inbound.rs
  • crates/ironclaw_product/src/reborn_services.rs
  • crates/ironclaw_product/src/run_delivery/gate_routes.rs
  • crates/ironclaw_product/src/scoped_fs/attachment_landing.rs
  • crates/ironclaw_product/src/scoped_fs/mod.rs
  • crates/ironclaw_product/src/workflow.rs
  • crates/ironclaw_product/tests/product_surface_contract.rs
  • crates/ironclaw_product/tests/reborn_services_contract.rs
  • crates/ironclaw_product/tests/run_delivery_contract.rs
  • crates/ironclaw_reborn_composition/src/automation/trigger_poller_trusted_submit.rs
  • crates/ironclaw_reborn_composition/src/extension_host_assembly.rs
  • crates/ironclaw_reborn_composition/src/factory.rs
  • crates/ironclaw_reborn_composition/src/factory/test_support.rs
  • crates/ironclaw_reborn_composition/src/factory/trigger_creation_assembly.rs
  • crates/ironclaw_reborn_composition/src/product_surface.rs
  • crates/ironclaw_reborn_composition/src/runtime.rs
  • crates/ironclaw_reborn_composition/src/runtime/tests/core.rs
  • crates/ironclaw_reborn_composition/src/trigger_poller_assembly.rs
  • crates/ironclaw_reborn_composition/tests/production_runtime_trigger_poller.rs
  • crates/ironclaw_reborn_composition/tests/trigger_poller_e2e.rs
  • crates/ironclaw_reborn_composition/tests/trigger_webui_timeline_e2e.rs
  • crates/ironclaw_webui/Cargo.toml
  • crates/ironclaw_webui/src/webui_v2/handlers.rs
  • crates/ironclaw_webui/tests/webui_v2_handlers_contract.rs
  • docs/reborn/target-architecture/CHECKLIST.md
  • docs/reborn/target-architecture/PROPOSAL.md
  • tests/integration/support/harness/mod.rs
  • tests/integration/support/triggered_submit.rs

Comment thread crates/ironclaw_conversations/src/stored_refs.rs Outdated
Comment thread crates/ironclaw_product/tests/run_delivery_contract.rs
@github-actions

github-actions Bot commented Aug 1, 2026 •

Copy link
Copy Markdown
Contributor

Coverage ratchet

Ratchet mode: ENFORCING

RATCHET PASS: global
  observed: 86% (325158 / 378092 lines)
  floor:    85.11% (tolerance 0.5pp -> effective floor 84.61%)
  denominator: 378092 lines now vs 375097 at floor capture (+2995 lines, +0.8%) — not a material change

RATCHET PASS: ironclaw_runner
  observed: 85.93% (14917 / 17359 lines)
  floor:    85.55% (tolerance 0.5pp -> effective floor 85.05%)
  floor_covered_lines: 14658 (tolerance 20 lines -> effective floor 14638)
  denominator: 17359 lines now vs 17133 at floor capture (+226 lines, +1.32%) — not a material change

RATCHET PASS: ironclaw_processes
  observed: 88.76% (5889 / 6635 lines)
  floor:    88.07% (tolerance 0.5pp -> effective floor 87.57%)
  floor_covered_lines: 5839 (tolerance 20 lines -> effective floor 5819)
  denominator: 6635 lines now vs 6630 at floor capture (+5 lines, +0.08%) — not a material change

RATCHET PASS: ironclaw_turns
  observed: 88.46% (3709 / 4193 lines)
  floor:    85.11% (tolerance 0.5pp -> effective floor 84.61%)

RATCHET PASS: ironclaw_authorization
  observed: 86.59% (723 / 835 lines)
  floor:    62.51% (tolerance 0.5pp -> effective floor 62.01%)
  floor_covered_lines: 612 (tolerance 20 lines -> effective floor 592)
  denominator: 835 lines now vs 979 at floor capture (-144 lines, -14.71%) — material change (>5%)

RATCHET PASS: ironclaw_approvals
  observed: 91.05% (1820 / 1999 lines)
  floor:    85.86% (tolerance 0.5pp -> effective floor 85.36%)
  floor_covered_lines: 1822 (tolerance 20 lines -> effective floor 1802)
  denominator: 1999 lines now vs 2122 at floor capture (-123 lines, -5.8%) — material change (>5%)

RATCHET PASS: ironclaw_secrets
  observed: 85.81% (2896 / 3375 lines)
  floor:    84.01% (tolerance 0.5pp -> effective floor 83.51%)
  floor_covered_lines: 2795 (tolerance 20 lines -> effective floor 2775)
  denominator: 3375 lines now vs 3327 at floor capture (+48 lines, +1.44%) — not a material change

RATCHET PASS: ironclaw_filesystem
  observed: 76.81% (5894 / 7673 lines)
  floor:    75.93% (tolerance 0.5pp -> effective floor 75.43%)
  floor_covered_lines: 5826 (tolerance 20 lines -> effective floor 5806)
  denominator: 7673 lines now vs 7673 at floor capture (+0 lines, +0%) — not a material change

RATCHET PASS: ironclaw_llm
  observed: 79.22% (20885 / 26364 lines)
  floor:    79.22% (tolerance 0.5pp -> effective floor 78.72%)
  floor_covered_lines: 20885 (tolerance 20 lines -> effective floor 20865)
  denominator: 26364 lines now vs 26364 at floor capture (+0 lines, +0%) — not a material change

RATCHET PASS: ironclaw_triggers
  observed: 94.68% (3134 / 3310 lines)
  floor:    86.04% (tolerance 0.5pp -> effective floor 85.54%)
  floor_covered_lines: 2804 (tolerance 20 lines -> effective floor 2784)
  denominator: 3310 lines now vs 3259 at floor capture (+51 lines, +1.56%) — not a material change

RATCHET PASS: ironclaw_product
  observed: 87.79% (22195 / 25281 lines)
  floor:    86.94% (tolerance 0.5pp -> effective floor 86.44%)
  floor_covered_lines: 21367 (tolerance 20 lines -> effective floor 21347)
  denominator: 25281 lines now vs 24576 at floor capture (+705 lines, +2.87%) — not a material change

RATCHET PASS: ironclaw_outbound
  observed: 94.68% (4271 / 4511 lines)
  floor:    93.49% (tolerance 0.5pp -> effective floor 92.99%)
  floor_covered_lines: 4105 (tolerance 20 lines -> effective floor 4085)
  denominator: 4511 lines now vs 4391 at floor capture (+120 lines, +2.73%) — not a material change

RATCHET PASS: ironclaw_extension_host
  observed: 85.06% (24454 / 28748 lines)
  floor:    83.82% (tolerance 0.5pp -> effective floor 83.32%)
  floor_covered_lines: 22271 (tolerance 20 lines -> effective floor 22251)
  denominator: 28748 lines now vs 26569 at floor capture (+2179 lines, +8.2%) — material change (>5%)

RATCHET PASS: ironclaw_events
  observed: 80.55% (1197 / 1486 lines)
  floor:    80.55% (tolerance 0.5pp -> effective floor 80.05%)
  floor_covered_lines: 1197 (tolerance 20 lines -> effective floor 1177)
  denominator: 1486 lines now vs 1486 at floor capture (+0 lines, +0%) — not a material change

RATCHET PASS: ironclaw_safety
  observed: 92.75% (4468 / 4817 lines)
  floor:    92.44% (tolerance 0.5pp -> effective floor 91.94%)
  floor_covered_lines: 3973 (tolerance 20 lines -> effective floor 3953)
  denominator: 4817 lines now vs 4298 at floor capture (+519 lines, +12.08%) — material change (>5%)

RATCHET PASS: ironclaw_host_runtime
  observed: 88.41% (21338 / 24135 lines)
  floor:    88.23% (tolerance 0.5pp -> effective floor 87.73%)
  floor_covered_lines: 20538 (tolerance 20 lines -> effective floor 20518)
  denominator: 24135 lines now vs 23277 at floor capture (+858 lines, +3.69%) — not a material change

Reborn integration-tier coverage

Line coverage (Reborn crates): 86% — 325158 / 378092 lines

Per-crate breakdown (63 crates, lowest-covered first)
Crate Line % Covered / Total
ironclaw_host_ingress 42.5% 17 / 40
ironclaw_memory 53.48% 630 / 1178
ironclaw_projects 72.36% 233 / 322
ironclaw_capabilities 74.59% 2876 / 3856
ironclaw_trust 75.79% 748 / 987
ironclaw_extractors 75.88% 538 / 709
ironclaw_reborn_cli 76.1% 11084 / 14566
ironclaw_observability 76.19% 32 / 42
ironclaw_filesystem 76.81% 5894 / 7673
ironclaw_wasm 78.84% 704 / 893
ironclaw_llm 79.22% 20885 / 26364
ironclaw_events 80.55% 1197 / 1486
ironclaw_loop_contracts 82.4% 5637 / 6841
ironclaw_first_party_extensions 82.57% 6784 / 8216
ironclaw_product_contracts 82.75% 3522 / 4256
ironclaw_memory_native 82.85% 2850 / 3440
ironclaw_libsql_runtime 83.3% 384 / 461
ironclaw_auth 83.95% 6699 / 7980
ironclaw_operator 84.47% 5309 / 6285
ironclaw_hooks 84.57% 9896 / 11702
ironclaw_event_projections 84.81% 854 / 1007
ironclaw_reborn_event_store 84.93% 1206 / 1420
ironclaw_extension_host 85.06% 24454 / 28748
ironclaw_reborn_config 85.29% 2110 / 2474
ironclaw_network 85.31% 894 / 1048
ironclaw_reborn_composition 85.72% 21956 / 25613
ironclaw_secrets 85.81% 2896 / 3375
ironclaw_extension_contracts 85.81% 2558 / 2981
ironclaw_runner 85.93% 14917 / 17359
ironclaw_host_api 86.05% 6367 / 7399
ironclaw_authorization 86.59% 723 / 835
ironclaw_webui 86.95% 11937 / 13729
ironclaw_wasm_limiter 87.06% 74 / 85
ironclaw_common 87.07% 1152 / 1323
ironclaw_reborn_traces 87.61% 11720 / 13377
ironclaw_product 87.79% 22195 / 25281
ironclaw_scripts 87.87% 420 / 478
ironclaw_threads 88.14% 5189 / 5887
ironclaw_host_runtime 88.41% 21338 / 24135
ironclaw_turns 88.46% 3709 / 4193
ironclaw_telegram_extension 88.55% 588 / 664
ironclaw_skills 88.61% 2785 / 3143
ironclaw_process_sandbox 88.64% 281 / 317
ironclaw_processes 88.76% 5889 / 6635
ironclaw_reborn_openai_compat 89.4% 3644 / 4076
ironclaw_telegram_v2_adapter 89.47% 1580 / 1766
ironclaw_extensions 89.55% 6249 / 6978
ironclaw_loop_host 90.47% 18043 / 19944
ironclaw_resources 90.76% 4084 / 4500
ironclaw_approvals 91.05% 1820 / 1999
ironclaw_reborn_identity 91.3% 451 / 494
ironclaw_mcp 92% 1426 / 1550
ironclaw_event_streams 92.5% 1048 / 1133
ironclaw_safety 92.75% 4468 / 4817
ironclaw_conversations 93.22% 2503 / 2685
ironclaw_agent_loop 93.52% 10430 / 11153
ironclaw_slack_extension 93.95% 3697 / 3935
ironclaw_first_party_extension_ports 94.66% 3758 / 3970
ironclaw_outbound 94.68% 4271 / 4511
ironclaw_triggers 94.68% 3134 / 3310
ironclaw_prompt_envelope 97.46% 192 / 197
ironclaw_runtime_policy 97.6% 855 / 876
ironclaw_attachments 98.49% 1374 / 1395

This table itself is informational and never gates the PR on its own — not the percentage, not the per-crate holes, not the 0-coverage callout. A separate coverage ratchet (dry-run until enforce=true; see tests/integration/coverage-floor.toml) can fail the build on specific configured floors.

Exemptions (18 entry/entries excluded from the accounting above)
Module / Crate Reason Issue
crate: ironclaw_gateway v1-only: consumed only by root ironclaw (src/channels/web/platform/static_files.rs, src/channels/web/handlers/frontend.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
crate: ironclaw_tui v1-only: consumed only by root ironclaw (src/main.rs, src/channels/tui.rs); no crates/* dependents. Crate's own doc comment confirms it bridges INTO v1, not Reborn. Covered by "Tests (Legacy)". #5657
crates/ironclaw_attachments/src/lib.rs Declarative crate facade: module declarations, constants, and re-exports only; executable attachment modules remain covered. #6524
crates/ironclaw_extension_host/src/ingress/mod.rs Declarative ingress module facade and documentation only; executable router modules remain covered. #6524
crates/ironclaw_host_api/src/lib.rs Declarative crate facade: module declarations and re-exports only; executable host API modules remain covered. #6524
crates/ironclaw_host_api/src/product_adapter/mod.rs Declarative product-adapter facade: module declarations and re-exports only; executable adapter modules remain covered. #6524
crates/ironclaw_llm/src/rig_adapter/tests/finish_reason_tests.rs Test-only module stored under src/ for private adapter access; cargo-llvm-cov omits test harness source from production LCOV while the exercised rig_adapter.rs production lines remain coverage-gated. #6284
crates/ironclaw_loop_contracts/src/lib.rs Declaration-only public facade with no executable Rust statements; rustc emits no LCOV source record. Executable loop-contract behavior remains covered in the owned implementation modules. #6524
crates/ironclaw_outbound/src/error.rs Declarative error vocabulary only; variants have no LLVM-instrumentable production statements. #6524
crates/ironclaw_outbound/src/lib.rs Declarative crate facade: module declarations and re-exports only; executable outbound modules remain covered. #6524
crates/ironclaw_product/src/lib.rs Declaration-only public facade with no executable Rust statements; rustc emits no LCOV source record. Executable product behavior remains covered in the owned implementation modules. #6524
crates/ironclaw_product/src/lib.rs Declarative crate facade: module declarations and re-exports only; executable product modules remain covered. #6524
crates/ironclaw_product/src/scoped_fs/mod.rs Declarative scoped-filesystem facade and documentation only; executable scoped filesystem modules remain covered. #6524
crates/ironclaw_reborn_composition/src/support/fs/mod.rs Declarative composition support facade: module declarations and re-exports only; executable filesystem adapters remain covered. #6524
crates/ironclaw_slack_extension/src/lib.rs Declarative Slack crate facade: module declarations and re-exports only; executable Slack modules remain covered. #6524
crates/ironclaw_telegram_extension/src/lib.rs Declarative Telegram crate facade: module declarations and re-exports only; executable Telegram modules remain covered. #6524
crates/ironclaw_threads/src/lib.rs Declaration-only public facade with no executable Rust statements; rustc emits no LCOV source record. Executable thread behavior remains covered in the owned implementation modules. #6524
crates/ironclaw_webui/src/webui_v2/mod.rs Declaration-only WebUI v2 facade with no executable Rust statements; rustc emits no LCOV source record. Executable route behavior remains covered in the owned implementation modules. #6524

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.
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7005 August 1, 2026 20:40 Destroyed
@BenKurrek
BenKurrek enabled auto-merge August 1, 2026 23:27

@serrrfirat serrrfirat left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Resolve conversations/threads naming collisions, centralize external reference contracts, and expand attachment landing APIs while preserving compatibility except for a bounded fingerprint transition.

Mode: normal — the shape preflight selected the standard eight-lens path for this 59-file PR. The required review packet is present in the PR body.

Coverage: diff source github; 59 files (production=33, tests=19, docs=2, generated_vendor=1, config=4, ci=0); diff_truncated=true. Each reviewer inspected the full detached worktree because the bundled unified diff was truncated to 40,000 characters. Limitations: the codebase graph was unavailable, and preflight's rename-aware summary could not resolve the PR branch names locally.

Stats: 12 findings (from 14 raw reviewer findings; 12 after false-positive filtering; 12 after overlap/same-line dedup) across 10 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 5.

Tests

  1. Medium Legacy serde adapters are not tested through the durable store (crates/ironclaw_conversations/src/memory.rs:1528-1601, confidence 100) — anchor: crates/ironclaw_conversations/src/memory.rs:1528
    The compatibility adapters are tested through surrogate structs, but no test reopens a real persisted conversation state using the legacy thread_id / message_id spelling, so misplaced record annotations could silently collapse a threaded route.

Conventions

  1. Medium Update the owning conversation contract after moving its API (crates/ironclaw_conversations/src/lib.rs:38-43, confidence 100) — anchor: AGENTS.md:159-163,193-194
    The owning contract and crate-local guidance still assign the external refs and old SessionThreadService vocabulary to ironclaw_conversations.

  2. Medium Reconcile guidance with the widened attachments boundary (crates/ironclaw_attachments/src/lib.rs:13-20, confidence 100) — anchor: AGENTS.md:159-163,193-194
    The crate map, product-contracts note, and WebUI dependency guidance still describe the pre-move attachments ownership boundary.

Local patterns

  1. Medium Rename the reader-only attachment_landing module (crates/ironclaw_product/src/scoped_fs/attachment_landing.rs:1, confidence 100) — anchor: crates/ironclaw_product/src/scoped_fs/project_filesystem_reader.rs:1
    The lander moved out, but the remaining reader-only module retains a landing-oriented path that now misdirects source discovery.

  2. Low Restore the reader documentation truncated by the move (crates/ironclaw_product/src/scoped_fs/attachment_landing.rs:24, confidence 100) — anchor: crates/ironclaw_product/src/scoped_fs/attachment_landing.rs:24 (no diff position — body only)
    The public reader rustdoc was reduced to a lowercase sentence fragment and no longer explains mount re-scoping or the loop-model use.

  3. Low Repoint the attachment lander rustdoc link (crates/ironclaw_product/src/workflow.rs:489, confidence 100) — anchor: crates/ironclaw_product/src/lib.rs:322 (no diff position — body only)
    The explicit link still targets the removed crate::InboundAttachmentLander re-export instead of ironclaw_attachments::InboundAttachmentLander.

  4. Medium Update the conversations guardrail to the new vocabulary (crates/ironclaw_conversations/CLAUDE.md:3-8, confidence 100) — anchor: crates/ironclaw_conversations/src/ids.rs:3 (no diff position — body only)
    The guide still claims ownership of the external refs and uses thread_id for the external sub-conversation identity.

  5. Medium Remove the obsolete attachment-capabilities ownership note (crates/ironclaw_product_contracts/src/inbound_requests.rs:15-17, confidence 100) — anchor: crates/ironclaw_product/src/product_surface_inbound.rs:14 (no diff position — body only)
    The comment says the deleted ProductAttachmentCapabilities remains in product instead of naming its new attachments owner.

  6. Low Refresh the attachments routing entry after widening (crates/AGENTS.md:87, confidence 100) — anchor: AGENTS.md:15 (no diff position — body only)
    The primary routing entry still describes only the landing routine and omits the ports, cleanup contract, default lander, and advertised/enforced budgets.

Maintainability

  1. Low Delete the no-op actor serde adapter (crates/ironclaw_conversations/src/stored_refs.rs:82-113, confidence 100) — anchor: crates/ironclaw_extension_contracts/src/external.rs:109
    The adapter duplicates the canonical actor serializer/deserializer, which already accepts a missing optional display name and revalidates through the constructor.

  2. Low Reuse the shared ratchet source lexer (crates/ironclaw_architecture/tests/reborn_conversations_threads_attachments.rs:135-170, confidence 100) — anchor: crates/ironclaw_architecture/tests/ratchet_support/mod.rs:215
    The new local lexer is weaker than the existing shared helper and can make architecture gates disagree on block comments, raw strings, byte strings, or character literals.

Approach

  1. Medium Keep emitting the rollback-compatible persisted field names (crates/ironclaw_conversations/src/stored_refs.rs:47-55, confidence 88) — anchor: crates/ironclaw_conversations/src/stored_refs.rs:47-55; crates/ironclaw_conversations/src/ids.rs:92-106; .claude/rules/type-placement.md:60-67
    Writing only the new field names creates a permanent downgrade boundary. The persistence adapters can emit the legacy grammar while accepting both spellings, preserving rollback compatibility without retaining duplicate domain types or dual-writing duplicate fields.

Security, bugs, and performance/concurrency returned no findings.

pub(crate) tenant_id: TenantId,
pub(crate) adapter_kind: AdapterKind,
pub(crate) adapter_installation_id: AdapterInstallationId,
#[serde(with = "crate::stored_refs::conversation_ref")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — Legacy serde adapters are not tested through the durable store.

The compatibility tests deserialize surrogate structs declared inside stored_refs.rs, but no test reopens an actual StoredConversationState containing pre-unification thread_id/message_id fields. Omitting or misplacing either new serde adapter annotation could leave current tests green while an upgrade silently loads a threaded route as its conversation root.

Fix: Add filesystem_conversation_services_reopen_pre_unification_external_refs covering a persisted binding and reply-target snapshot rewritten to legacy thread_id/message_id spelling, then reopen and assert the topic and reply target survive.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid, and the gap was real — the surrogate tests could not see a missing annotation. Fixed in 4cd166f727.

Two tests now drive the actual StoredConversationState through RebornFilesystemConversationServices, in crates/ironclaw_conversations/tests/conversation_state_store_contract.rs:

  • filesystem_conversation_services_persist_external_refs_in_the_durable_grammar — pairs an actor, resolves a threaded binding, accepts a message, then reads /conversations/state.json back and walks every key at every depth, asserting no canonical spelling appears anywhere and that the topic and reply target are present under the durable names. Because it is discovered rather than enumerated, a missing or misplaced annotation at any site fails it, which is the failure mode you described.
  • filesystem_conversation_services_reopen_pre_unification_external_refs (your name) — rewrites the persisted document's ref objects to the other spelling and reopens, asserting the topic survives as a topic: a second topic in the same conversation must still key a different binding, so the test cannot pass with the topic read as None.

That the surrogates were insufficient is not inference — it is measured. Mutating ExternalConversationIdentity::serialize to emit topic_id leaves every stored_refs.rs unit test green and fails only the store-level test, which names all six affected sites:

found the canonical spelling at ["topic_id", "topic_id", "topic_id",
                                "topic_id", "topic_id", "topic_id"]

Those six are BindingKey, BindingRecord.external_conversation_identity (twice — bindings and source_bindings), two external_event_routes entries, and StoredAcceptedMessageReplay. Exactly the "current tests green while an upgrade silently loads a threaded route as its conversation root" case.

This is the repo's own "test through the caller, not just the helper" rule, and the suite was on the wrong side of it. Thank you for catching it.


pub use conversation_state_store::{ConversationStateStore, RebornFilesystemConversationServices};
pub use error::InboundTurnError;
// `ExternalActorRef` / `ExternalConversationRef` are deliberately **not**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — Update the owning conversation contract after moving its API.

The diff removes the conversations re-export of ExternalActorRef/ExternalConversationRef and renames SessionThreadService plus its DTOs, but docs/reborn/contracts/conversation-binding.md and the crate-local AGENTS/CLAUDE guidance still assign those refs and the old service name to ironclaw_conversations. This contradicts the new one-home contract and directs future callers back to deleted names.

Fix: Update the conversation contract and crate-local AGENTS/CLAUDE files to name InboundConversationService, the renamed DTO family, topic_id, and ironclaw_extension_contracts::external as the external-ref owner.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid — the contract did still name the deleted API. Amended in 4cd166f727.

Confirmed each claim against the tree before editing:

  • docs/reborn/contracts/conversation-binding.md:34 — ownership table row keyed on SessionThreadService.
  • :45 — "ConversationBindingService, SessionThreadService, and InboundTurnService traits/DTOs".
  • :44 — assigned ExternalActorRef/ExternalConversationRef to this crate's "implemented semantic slice".
  • :63 — invariant 8: binding identity is (space_id, conversation_id, thread_id).
  • crates/ironclaw_conversations/CLAUDE.md:5 — "the SessionThreadService/TranscriptStore storage boundary".

All five corrected. The contract carries a dated amendment block at the head rather than a silent in-place rewrite, per this program's docs convention, because contract freeze makes these three of the answers an engineer is entitled to rely on (_contract-freeze-index.md §1: "which public traits/types are stable enough to implement against"). It records the service rename plus its DTO trio, the ref pair's move to ironclaw_extension_contracts::external, and topic_id; the table row and the slice list are corrected in place, and invariant 8 now reads (space_id, conversation_id, topic_id).

One thing I added beyond your list, because leaving it out would have made the contract newly misleading in the other direction. Invariant 8 now says the durable record still spells that field thread_id. That is deliberate and is the subject of @serrrfirat's stored_refs.rs:53 finding on this same PR: only the Rust spelling changed, so a rollback can still read persisted routes. A contract that said topic_id with no qualifier would have sent the next reader looking for a key that is not in the document.

The two rules the amendment points at are enforced, not just written down: reborn_conversations_threads_attachments.rs pins the no-shared-name rule by discovery rather than enumeration, and pins the ref pair's single home.

mod budgets;
mod inbound;
mod landing;
mod ports;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — Reconcile guidance with the widened attachments boundary.

The diff makes ironclaw_attachments own and export the ports, default lander, and advertised capabilities, while existing crate-map, product-contracts, and WebUI guidance still describes the previous ownership/dependency boundary. Those instructions conflict with the refactor's new boundary.

Fix: Update the crate map, product-contracts module documentation, and WebUI AGENTS/CLAUDE dependency guidance to describe the new attachment owner and explicitly allow the justified direct WebUI edge.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Two of the three were stale and are fixed; the third was already updated in this PR. 4cd166f727.

Fixed — the crate map. crates/AGENTS.md:87 described ironclaw_attachments as "the single inbound-attachment landing routine" and nothing else, which is the pre-widening boundary. It now names what the crate actually owns after this PR: the ports (InboundAttachmentLander, InboundAttachmentReader, AttachmentCleanupReport), the default ProjectScopedAttachmentLander, and the size ceilings + advertised AttachmentCapabilities that WebUI and the OpenAI-compatible adapter read. The "does not own" column now carries the reader carve-out, since that is the part of the row a reader is most likely to get wrong.

Fixed — the WebUI dependency guidance. crates/ironclaw_webui/AGENTS.md:68-75 enumerated the allowed workspace edges and closed with "any other workspace-crate edge requires an ironclaw_architecture boundary-test update plus explicit PR rationale". ironclaw_attachments was not in that list, so the guidance contradicted the manifest. It is now listed with the justification scoped to what the edge is actually for — the advertised ceilings only, so the transport reads the same constants the landing routine enforces instead of keeping a second copy.

Already correct — the product-contracts module documentation. crates/ironclaw_product_contracts/src/inbound_requests.rs:15-17 already points at the new owner:

decode_attachments and ProductAttachmentCapabilities stay there too, because the byte budgets are ironclaw_attachments types. (CHECKLIST WS5 "attachments widened … one home for size ceilings" owns that move.)

I re-read the crate's other attachment-touching modules (inbound.rs, product_wire.rs, workspace_views.rs, surface.rs) and found no ownership claim that survived the move, so no edit there. If you were pointing at a specific line I have not found, say which and I will take it.

Note this is a different ask from the earlier CodeRabbit thread on Cargo.toml:39, which I refuted: that one named a crates/ironclaw_webui/tests/reborn_dependency_boundaries.rs that does not exist and treated a blocklist as an allowlist. Yours is about the guidance being inconsistent with the new boundary, and it was.

@@ -1,57 +1,26 @@
//! Project-scoped inbound attachment landing for product adapters.
//! Project-scoped inbound attachment *read-back* for product adapters.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — Rename the reader-only attachment_landing module.

After moving the lander to ironclaw_attachments, this module contains only ProjectScopedAttachmentReader, but its path still says attachment_landing. Path-based discovery now sends readers looking for landing behavior to the wrong crate.

Fix: Rename the file/module to attachment_reader or project_scoped_attachment_reader, then update the declaration, re-export, tests, docs, and exact-path ratchets.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid — renamed. 4cd166f727.

The file's own module doc already opened with "Project-scoped inbound attachment read-back", so the path was the last thing still claiming otherwise. git mv to attachment_reader.rs, and every reference repointed:

  • crates/ironclaw_product/src/scoped_fs/mod.rs:14,17 — the pub mod and the pub use.
  • crates/ironclaw_architecture/tests/reborn_struct_test_support_ratchet.rs:456 — the exact-path ratchet entry you flagged.
  • docs/reborn/target-architecture/CHECKLIST.md:58 and PROPOSAL.md:66,611 — the three places that describe the current consumer of the LoopAttachmentReadPort/LoopAttachmentReadError pair, each now carrying "(renamed from attachment_landing.rs when the lander moved)" so the WS5/WS6 severing work still resolves.

Left alone deliberately: CHECKLIST.md:144 and PROPOSAL.md:546 say the impls "were never in composition, they were in ironclaw_product::scoped_fs::attachment_landing". That is a statement about where they were, and it is true with the old name. Rewriting it would make the record wrong.

The module doc now leads with why the name changed rather than only with what moved out, so the next reader gets the answer without having to reconstruct it from the CHECKLIST.

One thing worth flagging, since it is a cost your finding could not have known about. The rename moves this file's body into the changed-coverage gate's candidate set — git pairs the deleted attachment_landing.rs with ironclaw_attachments/src/project_scoped.rs (the lander's real destination) and then sees attachment_reader.rs as new. Replayed through the gate's own git_diff with CI's exact flags (--diff-algorithm=histogram --diff-filter=AMR, per-crate pathspecs), the net moves 2006 → 1745 candidate lines, so the rename reduces the total; the risk is only that it redistributes ~238 lines onto a file that was previously uncandidate. That file has an inline #[cfg(test)] module the gate excludes, and tests/integration/attach.rs drives the reader, so I expect it to hold — but I am watching that lane and will derive any needed exemption from CI's artifact rather than guessing at it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Follow-up on the coverage risk I flagged above: it did not hold, and chasing it down turned your rename into a real find.

The changed-coverage gate failed on 4cd166f727 at 90.87% (209/230), naming 21 lines — attachment_reader.rs:63, 101-117, 120, 121, 123. The mechanism is the one I predicted: git pairs 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 lines become changed-code candidates.

But they were not an attribution artifact. They were genuinely untested. Every test in that module drives read_attachment_bytes — the LoopAttachmentReadPort half. Nothing called InboundAttachmentReader::read, so the entire map_err taxonomy under it was dead to the suite:

LoopAttachmentReadError::NotFound  => 404 NotFound
LoopAttachmentReadError::Forbidden => 403 ParticipantDenied
LoopAttachmentReadError::Backend   => sanitized 500

That taxonomy is the bytes endpoint's contract — a caller separates "gone" from "not yours" from "broken" only by the status this closure picks — and none of it was pinned. So the rename did not merely move lines into a gate; it exposed that the reader's product-facing half had no coverage at all, which is exactly the thing a path-name that lied about the module's contents helps hide.

Fixed in 5b85634272 by covering them, not by exempting them — an exemption is for lines that cannot execute, and these can. Four tests appended to the existing inline module (extended, not a new file): the success path through the thread scope, 404, 403 (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.

Verified with the same instrumentation the gate uses rather than by assertion — cargo llvm-cov over that test binary reports non-zero hits on all 21 lines CI listed, none still uncovered:

   63: hits=1     101: hits=3    102-117: hits=1    120: hits=1
  121: hits=1     123: hits=3
STILL UNCOVERED: NONE

Product crate 1026 → 1030 tests, 0 removed. The exemption manifest is untouched and re-validated (73 entries, 316 coordinates, zero stranded).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correcting myself on one detail of the comment above, because it does not meet the bar I applied to the rest of this round.

I wrote that the malformed-key test asserts the 500 "does not echo the rejected value back". That assertion was vacuous — it could never have failed. ProductSurfaceError carries only {code, kind, status_code, retryable, field, validation_code}; there is no reason-bearing field, and internal_from logs its source through tracing::error! and then returns Self::internal(), discarding it. So !format!("{error:?}").contains("evil.example.com") was decoration, not coverage. The non-leak is structural, not behavioral.

Replaced in 5f1867ac87 with assert_eq!(error, ProductSurfaceError::internal()), which pins the entire sanitized shape — and unlike the string check, it would fail if a future variant added a detail-bearing field. The comment now states which property is structural and why no assertion is made for it, so the next reader does not re-add the vacuous form.

Mutation-proved this time rather than asserted: making ScopedPath::new's failure classify as NotFound instead of Backend fails exactly this test and no other. I went back and did the same for the sibling — collapsing Forbidden into the NotFound shape fails only the 403 test. The other three lines of the taxonomy were already pinned that way.

The coverage result is unchanged: the test executes the same paths, so all 21 lines stay covered. Product crate still 1030 tests, 0 removed.


pub(crate) fn serialize<S>(value: &ExternalActorRef, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low — Delete the no-op actor serde adapter.

The actor_ref adapter delegates serialization and reconstructs the same kind/id/display_name shape through the same constructor as ExternalActorRef's existing Deserialize implementation. Missing Option already deserializes as None, so the annotations add a second compatibility path without changing legacy behavior.

Fix: Delete stored_refs::actor_ref and remove its serde(with) annotations from ActorKey and AcceptedMessageReplayKey; use ExternalActorRef's canonical Serialize/Deserialize implementations.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid — deleted. 4cd166f727.

Checked rather than taken on faith, because the claim rests on ExternalActorRef's canonical impls being exactly equivalent, and they are:

  • Deserialize (ironclaw_extension_contracts/src/external.rs:116) reads ExternalActorRefWire { kind, id, display_name: Option<String> } and reconstructs through ExternalActorRef::new(...).map_err(serde::de::Error::custom) — the same three fields, the same constructor, the same error mapping the adapter used. Serde defaults a missing Option to None, so a released {kind, id} payload loads with no annotation at all.
  • Serialize is derived, and the adapter's serialize did nothing but value.serialize(serializer).

So both halves were a restatement. #[serde(with = "crate::stored_refs::actor_ref")] is gone from ActorKey and AcceptedMessageReplayKey, and the module is now conversation-ref only.

The two tests were retargeted, not deleted — they are the evidence for the deletion, so they now run against a record that carries no #[serde(with)]: actor_serde_needs_no_adapter and stored_actor_ref_revalidates_on_load.

Worth stating explicitly, because it is the asymmetry that decides which ref needs an adapter and which does not: the actor change was additive ({kind,id} → {kind,id,display_name}), and an added field is safe in both directions — serde ignores unknown fields, and a missing Option reads as None. The conversation ref's change was a rename, which is safe in neither. That contrast is now the # Scope: no actor adapter section of the module doc.

/// `//` line comments and double-quoted literals, which is what makes "the name
/// appears in a doc comment" and "the name appears in a panic message" not
/// count as a declaration.
fn strip_comments_and_strings(source: &str) -> String {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Low — Reuse the shared ratchet source lexer.

This test introduces a second strip_comments_and_strings implementation even though ratchet_support exposes the canonical helper. The local version handles only line comments and ordinary strings, while the shared lexer also handles nested block comments, raw/byte strings, and character literals, so architecture gates can disagree.

Fix: Import ratchet_support::strip_comments_and_strings and delete the local implementation; keep the self-test against the shared helper if desired.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Valid — and the local copy was not merely redundant, it was wrong in a way that mattered. Fixed in 4cd166f727.

ratchet_support::strip_comments_and_strings is imported and the local implementation is deleted. reborn_authorized_seal_ratchet.rs:28 already imported the shared one, so this file was the outlier, not the precedent.

The gap was real, not theoretical. Ran the deleted line-based implementation against a fixture containing a block comment:

OLD local lexer -> assertion `!stripped.contains("fn conversation_actor_ref")`: FAIL
leaked lines:
    '/* fn conversation_actor_ref in a block comment'
    '   /* fn conversation_actor_ref in a NESTED block comment */'

It breaks a line at // and tracks " per line, so it has no concept of a block comment at all. A prose mention of a type name inside /* … */ anywhere in ironclaw_conversations or ironclaw_threads would have registered as a declaration and failed rule 1 with a name nobody declared — the exact "architecture gates can disagree" outcome you named, arriving as a false positive on the most confusing possible failure message.

The self-test is kept and extended rather than dropped, per your "if desired" — it now covers the three shapes only the shared lexer handles (nested block comments, a raw string, a char literal holding a quote), so this file pins the behavior it actually depends on instead of trusting that another gate's self-test covers it.

All five tests in the file still pass, which is the non-vacuity check that matters here: the frozen name sets are unchanged, so the swap is behavior-preserving for this gate while removing the latent divergence.

One correction to the count, for the record: this was the third copy, not the second — reborn_deployment_mode_branching_ratchet.rs:89 carries one too. I left that one alone because it is pre-existing and outside this PR's diff; flagging it here so it is on the record rather than silently unaddressed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Follow-up, so the third copy I flagged above does not sit homeless: it now has one.

The same consolidation point was raised on #7003 and #7004 for the other scanner helpers, and #7004 (791c2379aa) records a WS10 CHECKLIST row that owns the whole family, with the measurement: rust_files ×3, strip_cfg_test_blocks ×4 plus a fifth spelling, balanced_angle_close ×2, implemented_trait_names ×2 — and it explicitly names reborn_deployment_mode_branching_ratchet.rs's shadow of ratchet_support::strip_comments_and_strings to be folded in the same pass.

So the split is: the copy this PR introduced is deleted here (it was mine to remove), and the pre-existing one is on that row rather than left as an unowned observation.

) -> Result<S::Ok, S::Error>
where
S: Serializer,
{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — Keep emitting the rollback-compatible persisted field names.

The compatibility adapter delegates serialization to the canonical ref, so subsequent state writes replace thread_id/message_id with topic_id/reply_target_message_id; ExternalConversationIdentity similarly emits the new spelling. Older binaries then silently load durable threaded routes as conversation-root routes. Dual-writing is unnecessary: the persistence adapter can keep writing the legacy grammar while accepting either spelling, preserving rollback without retaining a duplicate domain type.

Fix: Serialize conversation_ref through a borrowed stored representation that emits thread_id/message_id, and give ExternalConversationIdentity a custom serializer that emits thread_id; keep the aliases so both spellings remain readable.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You are right, and the reasoning in the module doc was wrong — it refuted a proposal you did not make. Fixed in 4cd166f727.

Where our argument failed. stored_refs.rs refused dual-writing — emitting both spellings — and that refusal is sound: #[serde(alias)] makes a dual-written record self-defeating, because a reader that accepts both spellings rejects a document carrying both as a duplicate field. But you asked for write-legacy / read-either, which that objection does not touch at all. One field goes out, thread_id; the alias accepts it. The module talked itself out of the fix by arguing against a different design.

Worse, the module's own opening sentence is the argument for your version — "a record grammar outlives the type that shaped it" — and then the code wrote the new type's spelling into the record anyway.

Your factual premises, verified against origin/main. The released readers are crates/ironclaw_conversations/src/ids.rs — RawExternalConversationIdentity { space_id, conversation_id, thread_id } and the ExternalConversationRef wire struct {space_id, conversation_id, thread_id, message_id}. Neither carries an alias, and neither sets deny_unknown_fields. So a document this build wrote read back as topic_id ignored + thread_id absent + None, silently.

And the consequence is one notch worse than "conversation-root routes". ExternalConversationIdentity keys BindingKey, and StoredConversationState::into_state rebuilds the map with Vec<(K, V)>::into_iter().collect() (conversation_state_store.rs:190). Two threaded bindings in one conversation that both lose their topic become the same key, and the later one silently wins. That is binding loss, not misrouting.

What landed — exactly your prescription.

  • conversation_ref::serialize now writes through a borrowed Written<'a> { space_id, conversation_id, thread_id, message_id }.
  • ExternalConversationIdentity gets a matching hand-written Serialize emitting thread_id (it already had a hand-written Deserialize, so it is now symmetric).
  • Both readers keep the aliases, so either spelling loads.
  • A record carrying both spellings still fails closed — kept deliberately, and now pinned by a_record_carrying_both_spellings_is_rejected.

Proof it is not just asserted. Reverting serialize to the canonical delegate fails both the unit test and the store-level test. Making the identity emit topic_id fails the store-level test with the six sites named:

durable records must keep the released grammar so a rollback can still read them;
found the canonical spelling at ["topic_id" x6]

and the fixed document reads:

"external_conversation_ref":{"conversation_id":"chat-threaded","message_id":"msg-1700.1",
                             "space_id":"space-1","thread_id":"topic-1"}

The # Rollback boundary section that declared the one-way compatibility "by decision" is gone; the module now documents why the write side matters as much as the read side.

… 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>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7005 August 2, 2026 13:41 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/ironclaw_conversations/CLAUDE.md`:
- Line 5: Update the binding identity guardrail in CLAUDE.md to use topic_id for
the external channel sub-conversation route identity, while reserving thread_id
for durable record grammar. Keep the surrounding
InboundConversationService/TranscriptStore storage-boundary guidance unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: f54fe063-db74-4b56-bfb5-7d917a051745

📥 Commits

Reviewing files that changed from the base of the PR and between 7fd3998 and 4cd166f.

📒 Files selected for processing (15)
  • crates/AGENTS.md
  • crates/ironclaw_architecture/tests/reborn_conversations_threads_attachments.rs
  • crates/ironclaw_architecture/tests/reborn_struct_test_support_ratchet.rs
  • crates/ironclaw_conversations/CLAUDE.md
  • crates/ironclaw_conversations/src/ids.rs
  • crates/ironclaw_conversations/src/memory.rs
  • crates/ironclaw_conversations/src/stored_refs.rs
  • crates/ironclaw_conversations/tests/conversation_state_store_contract.rs
  • crates/ironclaw_product/src/scoped_fs/attachment_reader.rs
  • crates/ironclaw_product/src/scoped_fs/mod.rs
  • crates/ironclaw_product/tests/run_delivery_contract.rs
  • crates/ironclaw_webui/AGENTS.md
  • docs/reborn/contracts/conversation-binding.md
  • docs/reborn/target-architecture/CHECKLIST.md
  • docs/reborn/target-architecture/PROPOSAL.md
💤 Files with no reviewable changes (1)
  • crates/ironclaw_conversations/src/memory.rs

Comment thread crates/ironclaw_conversations/CLAUDE.md
BenKurrek and others added 3 commits August 2, 2026 09:46
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>
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>
…xonomy

The changed-coverage gate failed on 4cd166f 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>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7005 August 2, 2026 14:06 Destroyed
…d shape

Self-review of 5b85634. 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>
@BenKurrek

Copy link
Copy Markdown
Collaborator Author

Superseded by #7018

This work is now carried by #7018 — consolidate the Wave 2 port-inversion stack (WS2.2, WS2.4, WS5), which merges #7000, #7003, #7004 and this PR into a single PR onto main.

What carried over: the conversations/threads naming trap and the ironclaw_attachments widening — including the rollback-grammar data-loss fix the human review caught, where the durable grammar keeps writing the legacy field names so that losing a topic drops a binding rather than merely misrouting it. Both files that carry your coverage exemptions (ironclaw_conversations/src/types.rs, run_delivery/gate_routes.rs) are byte-identical to this branch in the consolidated one, and all ten exempted lines were re-verified to still name exactly the declarations their reasons describe.

Three of the five merge conflicts were import lists where both this PR and #7004 removed different names — #7004 moving types into product_contracts, this PR moving them into ironclaw_attachments. Taking either side alone would have resurrected the other's deletions, so they were resolved by three-way set merge, (ours & theirs) | (ours - base) | (theirs - base), which honours both removal sets. Your PROPOSAL §6.9.1 edit survives, as does your LoopAttachmentReadPort re-homing row (still [~], tracked by #7010).

The coverage-exemption manifest was unioned rather than taking a side: main's WS2.2 "no entries, deliberately" rationale block is kept verbatim alongside your two new entries, and the result was validated through scripts/ci/reborn_changed_coverage.py's own load_manifest — 73 exemptions, zero stale paths, zero lines past EOF, zero duplicate claims.

Closing here so review continues in one place — closing is reversible.

@BenKurrek BenKurrek closed this Aug 2, 2026
auto-merge was automatically disabled August 2, 2026 19:27

Pull request was closed

BenKurrek added a commit that referenced this pull request Aug 2, 2026
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.
BenKurrek added a commit that referenced this pull request Aug 2, 2026
…ine 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.
pull Bot pushed a commit to bryanwills/ironclaw that referenced this pull request Aug 3, 2026
….2, WS2.4, WS5) (nearai#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 t…
BenKurrek added a commit that referenced this pull request Aug 3, 2026
`diff_pathspecs()` built one `<crate>/src/**` pair per crate in the CURRENT
inventory, which cannot name the SOURCE of a rename whose crate directory
moved: `crates/ironclaw_memory_native/` is not in the inventory once the
crate lives at `crates/extensions/packages/memory-native/`. `-M` therefore
had nothing to pair against and every surviving line of a moved file landed
in the denominator — measured on this PR, **33,026 added production lines**
across 95 files, none of them an actual edit, against a 90% changed-line
floor.

Same defect `-M` was added for in #7005, one level up: there a file moved
within a crate, here the crate itself moves. It fails in the expensive
direction, because the floor then demands coverage for a pure `git mv`.

The pathspec is now the crates root. Precision is unchanged — `parse_diff`
already classifies each destination path with `is_production()` and drops
everything else — so a non-move diff measures exactly what it measured
before. On this PR the denominator falls to 2,357, of which 1,187 are
test-path files the gate already excludes.

Pinned by two tests: the pathspec contract, and an end-to-end regression
that `git mv`s a whole crate into `extensions/packages/` and asserts zero
added production lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pull Bot pushed a commit to Stars1233/ironclaw that referenced this pull request Aug 3, 2026
nearai#7037)

* refactor(extensions): colocate packages under crates/extensions/ (WS2)

Physical colocation only — no behaviour change. `crates/extensions/` now
holds `ironclaw_extension_support/` (renamed from
`ironclaw_first_party_extensions`) beside `packages/`, which carries 14
self-contained package directories: the twelve extension packages plus the
two `[memory]` providers, each with its manifest, prompts, schemas,
committed `wasm/` and the `wasm-src/` guest that produced it.

`ironclaw_telegram_v2_adapter` is merged into `ironclaw_telegram_extension`,
giving Telegram the one-crate-per-package shape Slack already had and the
same four-crate contract-tier dependency set.

512 renames, 489 of them byte-identical; every content change in a moved
test file is a path or crate-name repoint.

Two silent registration traps this move created, both reproduced live
before being fixed:

* `classify-test-scope.sh` keys its arms on the crate directory basename,
  and package directories are named by extension identity, so all four
  moved package crates classified `has_reborn_tests=false` — the entire
  Reborn suite would have stopped running on a change to any of them, on a
  green PR.
* A data-only package has no `Cargo.toml`, so five `wasm-src/` guests
  became the outermost manifest on their path and were promoted to
  first-class crates, putting ~12k lines of workspace-excluded,
  never-compiled guest code into the changed-coverage and composition-budget
  denominators. Both inventories now agree that a manifest declaring its own
  `[workspace]` table roots a different workspace and is not a crate of this
  one; `nested_workspace_root()` keeps those paths attributable so the
  fail-closed unattributable-path guard still means what it says.

Adds the committed-`.wasm` freshness gate the tree never had: the rebuild
job overwrites artifacts in the working tree before testing them, so a
stale artifact shipped silently. It records a digest of each guest's
sources rather than of the artifact, because the guest builds are not
reproducible.

The memory-provider enforcement is re-pointed to PROPOSAL §8.2's amended
rule over both providers, with the three surviving dependents held as named
shrink-only residue. The layer flip and binary-only linking do not land
here: `host_runtime` constructs `NativeMemoryService` itself, so flipping
first would create a kernel→products edge. WS3 owns the unblock.

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

* fix(ci): make the classifier and the panic delta scan survive a move

Three CI-surfaced defects, all the same shape — a gate that assumed the
pre-move tree.

`classify-test-scope.sh` REFUSED on `packages/github/manifest.toml`: a
data-only package carries no `Cargo.toml` by design, so every file in it is
attributable to no crate, and the fail-closed arm fired. The refusal was
correct behaviour against an incomplete rule. A `package_asset_dir`
predicate — anchored on the support crate so `packages/` is found as its
sibling rather than by literal path — routes package data to the shared arm,
which is exactly where it landed before the move as
`first_party_extensions/assets/**`. The self-test had probed a package crate
and a `wasm-src` guest but not a data-only asset; all three are probed now.

`check_no_panics.py`'s delta scan had no rename detection, so a relocated
file had no pre-image on its destination path and every one of its lines
read as added: a pure `git mv` of `memory-native/src/repo/filesystem.rs`
contributed 1,754 spurious added lines and failed the scan on six
`unreachable!()` calls that are in the reviewed baseline. Both git
invocations now pass `-M`, the added-line map is computed once for the whole
range (pairing a rename needs both sides in one invocation), and the parser
is split out and pinned by three tests: pure rename contributes nothing,
rename-with-edits reports only the edited lines, creation still reports
every line.

`test-reborn-coverage.sh`'s M4 case had its fixture paths rewritten with the
move while its assertions still named the old crate directories.

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

* docs(checklist): record the rename-aware panic-scan fix

* fix(ci): let the changed-coverage gate pair a crate-directory rename

`diff_pathspecs()` built one `<crate>/src/**` pair per crate in the CURRENT
inventory, which cannot name the SOURCE of a rename whose crate directory
moved: `crates/ironclaw_memory_native/` is not in the inventory once the
crate lives at `crates/extensions/packages/memory-native/`. `-M` therefore
had nothing to pair against and every surviving line of a moved file landed
in the denominator — measured on this PR, **33,026 added production lines**
across 95 files, none of them an actual edit, against a 90% changed-line
floor.

Same defect `-M` was added for in nearai#7005, one level up: there a file moved
within a crate, here the crate itself moves. It fails in the expensive
direction, because the floor then demands coverage for a pure `git mv`.

The pathspec is now the crates root. Precision is unchanged — `parse_diff`
already classifies each destination path with `is_production()` and drops
everything else — so a non-move diff measures exactly what it measured
before. On this PR the denominator falls to 2,357, of which 1,187 are
test-path files the gate already excludes.

Pinned by two tests: the pathspec contract, and an end-to-end regression
that `git mv`s a whole crate into `extensions/packages/` and asserts zero
added production lines.

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

* docs(checklist): generalize the move-breaks-gates finding

* fix(ci): teach the coverage gate that crate-root scaffolding is uninstrumentable

Two classes of line that LLVM can never emit a coverage region for were not
recognized, and each is enough on its own to fail a pure relocation, because
one unclassified line defeats the `candidate_lines <= uninstrumentable_lines`
escape:

* **Inner attributes.** The classifier matched outer `#[...]` but not `#![...]`.
  Every crate root carries `#![forbid(unsafe_code)]`, so a moved `lib.rs` of
  pure `mod`/`pub use` declarations landed in "absent from coverage or contain
  no DA records" on the strength of that single line. Fixing it closes
  `packages/telegram/src/lib.rs` completely.
* **`const`/`static` items.** Their initializers are compile-time and carry no
  region — `const MANIFEST: &str = include_str!(…)` least of all. Repointing an
  asset path is the entire Rust content of a package move, so four inventory
  modules had nothing but const declarations changed and read as "contributed
  no instrumented lines". Spans to the terminating `;` like `use`, so
  multi-line initializers are covered too.

Both pinned by tests, including a negative assertion that a real function body
stays measurable.

What is left is exempted with per-site evidence rather than classified: nine
`include_bytes!(concat!(…))` lines sitting inside `macro_rules!` BODIES across
the same four modules. A macro body is template text — LLVM attributes the
expansion to the expansion site, so those line numbers can never carry a DA
record however many tests run. The expansions themselves are covered by
`ironclaw_extension_support`'s 152 tests, and the changed content is a string
literal inside `concat!`: a wrong path fails the build, which is stronger than
a coverage hit, and `check-include-str-paths.sh` independently asserts all 119
include targets exist. Classifying macro template text correctly needs a real
parser, not a lexer.

Whole manifest re-validated through the gate's own loader: 83 exemptions, no
stale path, no expired review date.

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

* test(telegram): cover the payload parser's fail-closed error paths

The package move made `payload.rs` unpairable for `git diff -M` — the
2,166-line original split into four files under the 999-line budget and no
destination clears the 50% rename threshold — so it reads as a new file with
zero pre-existing exclusions, and 88 uncovered instrumented lines surfaced.
They are almost all `PayloadParseError` construction sites and defensive
branches on an untrusted-input boundary, which is worth testing rather than
waiving.

18 tests in a new `src/tests/payload_errors.rs`, each driving a realistic
malformed or hostile webhook body through BOTH `parse_telegram_update` and
`normalize_telegram_update` so the two entry points cannot drift: missing
update_id, the full chat-kind classification table, out-of-range UTF-16
mention windows, anonymous-admin replies, negative media-group message ids,
attacker-authored media_group_id and display names, oversize and
control-char message bodies, unsliceable bot_command entities, the
leading-command rule, and every attachment slot.

Changed-line coverage of `payload.rs`: 83.64% -> 95.91% (516/538).
Production change is the four-line `#[path]` module declaration; no existing
test was weakened or removed (89 -> 107 lib tests).

Records one live defect rather than fixing it, since this is a move-only PR:
Telegram voice notes and stickers hard-fail the ENTIRE update today.
`collect_attachments` pairs voice with `audio/ogg` + `Voice` and sticker with
`image/webp` + `Sticker`, but `validate_attachment_kind`
(ironclaw_extension_contracts/src/external.rs:370) requires the kind to match
the MIME base type unless it is `Other` — so both are rejected and the `?`
aborts the parse. A well-formed voice message returns
`InvalidExternalRef { kind: "attachment_descriptor" }` instead of being
delivered. That is why those blocks had zero coverage. Pinned with an
explicit "contract pin, not an endorsement" comment.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
….2, WS2.4, WS5) (nearai#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 t…
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
nearai#7037)

* refactor(extensions): colocate packages under crates/extensions/ (WS2)

Physical colocation only — no behaviour change. `crates/extensions/` now
holds `ironclaw_extension_support/` (renamed from
`ironclaw_first_party_extensions`) beside `packages/`, which carries 14
self-contained package directories: the twelve extension packages plus the
two `[memory]` providers, each with its manifest, prompts, schemas,
committed `wasm/` and the `wasm-src/` guest that produced it.

`ironclaw_telegram_v2_adapter` is merged into `ironclaw_telegram_extension`,
giving Telegram the one-crate-per-package shape Slack already had and the
same four-crate contract-tier dependency set.

512 renames, 489 of them byte-identical; every content change in a moved
test file is a path or crate-name repoint.

Two silent registration traps this move created, both reproduced live
before being fixed:

* `classify-test-scope.sh` keys its arms on the crate directory basename,
  and package directories are named by extension identity, so all four
  moved package crates classified `has_reborn_tests=false` — the entire
  Reborn suite would have stopped running on a change to any of them, on a
  green PR.
* A data-only package has no `Cargo.toml`, so five `wasm-src/` guests
  became the outermost manifest on their path and were promoted to
  first-class crates, putting ~12k lines of workspace-excluded,
  never-compiled guest code into the changed-coverage and composition-budget
  denominators. Both inventories now agree that a manifest declaring its own
  `[workspace]` table roots a different workspace and is not a crate of this
  one; `nested_workspace_root()` keeps those paths attributable so the
  fail-closed unattributable-path guard still means what it says.

Adds the committed-`.wasm` freshness gate the tree never had: the rebuild
job overwrites artifacts in the working tree before testing them, so a
stale artifact shipped silently. It records a digest of each guest's
sources rather than of the artifact, because the guest builds are not
reproducible.

The memory-provider enforcement is re-pointed to PROPOSAL §8.2's amended
rule over both providers, with the three surviving dependents held as named
shrink-only residue. The layer flip and binary-only linking do not land
here: `host_runtime` constructs `NativeMemoryService` itself, so flipping
first would create a kernel→products edge. WS3 owns the unblock.

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

* fix(ci): make the classifier and the panic delta scan survive a move

Three CI-surfaced defects, all the same shape — a gate that assumed the
pre-move tree.

`classify-test-scope.sh` REFUSED on `packages/github/manifest.toml`: a
data-only package carries no `Cargo.toml` by design, so every file in it is
attributable to no crate, and the fail-closed arm fired. The refusal was
correct behaviour against an incomplete rule. A `package_asset_dir`
predicate — anchored on the support crate so `packages/` is found as its
sibling rather than by literal path — routes package data to the shared arm,
which is exactly where it landed before the move as
`first_party_extensions/assets/**`. The self-test had probed a package crate
and a `wasm-src` guest but not a data-only asset; all three are probed now.

`check_no_panics.py`'s delta scan had no rename detection, so a relocated
file had no pre-image on its destination path and every one of its lines
read as added: a pure `git mv` of `memory-native/src/repo/filesystem.rs`
contributed 1,754 spurious added lines and failed the scan on six
`unreachable!()` calls that are in the reviewed baseline. Both git
invocations now pass `-M`, the added-line map is computed once for the whole
range (pairing a rename needs both sides in one invocation), and the parser
is split out and pinned by three tests: pure rename contributes nothing,
rename-with-edits reports only the edited lines, creation still reports
every line.

`test-reborn-coverage.sh`'s M4 case had its fixture paths rewritten with the
move while its assertions still named the old crate directories.

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

* docs(checklist): record the rename-aware panic-scan fix

* fix(ci): let the changed-coverage gate pair a crate-directory rename

`diff_pathspecs()` built one `<crate>/src/**` pair per crate in the CURRENT
inventory, which cannot name the SOURCE of a rename whose crate directory
moved: `crates/ironclaw_memory_native/` is not in the inventory once the
crate lives at `crates/extensions/packages/memory-native/`. `-M` therefore
had nothing to pair against and every surviving line of a moved file landed
in the denominator — measured on this PR, **33,026 added production lines**
across 95 files, none of them an actual edit, against a 90% changed-line
floor.

Same defect `-M` was added for in nearai#7005, one level up: there a file moved
within a crate, here the crate itself moves. It fails in the expensive
direction, because the floor then demands coverage for a pure `git mv`.

The pathspec is now the crates root. Precision is unchanged — `parse_diff`
already classifies each destination path with `is_production()` and drops
everything else — so a non-move diff measures exactly what it measured
before. On this PR the denominator falls to 2,357, of which 1,187 are
test-path files the gate already excludes.

Pinned by two tests: the pathspec contract, and an end-to-end regression
that `git mv`s a whole crate into `extensions/packages/` and asserts zero
added production lines.

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

* docs(checklist): generalize the move-breaks-gates finding

* fix(ci): teach the coverage gate that crate-root scaffolding is uninstrumentable

Two classes of line that LLVM can never emit a coverage region for were not
recognized, and each is enough on its own to fail a pure relocation, because
one unclassified line defeats the `candidate_lines <= uninstrumentable_lines`
escape:

* **Inner attributes.** The classifier matched outer `#[...]` but not `#![...]`.
  Every crate root carries `#![forbid(unsafe_code)]`, so a moved `lib.rs` of
  pure `mod`/`pub use` declarations landed in "absent from coverage or contain
  no DA records" on the strength of that single line. Fixing it closes
  `packages/telegram/src/lib.rs` completely.
* **`const`/`static` items.** Their initializers are compile-time and carry no
  region — `const MANIFEST: &str = include_str!(…)` least of all. Repointing an
  asset path is the entire Rust content of a package move, so four inventory
  modules had nothing but const declarations changed and read as "contributed
  no instrumented lines". Spans to the terminating `;` like `use`, so
  multi-line initializers are covered too.

Both pinned by tests, including a negative assertion that a real function body
stays measurable.

What is left is exempted with per-site evidence rather than classified: nine
`include_bytes!(concat!(…))` lines sitting inside `macro_rules!` BODIES across
the same four modules. A macro body is template text — LLVM attributes the
expansion to the expansion site, so those line numbers can never carry a DA
record however many tests run. The expansions themselves are covered by
`ironclaw_extension_support`'s 152 tests, and the changed content is a string
literal inside `concat!`: a wrong path fails the build, which is stronger than
a coverage hit, and `check-include-str-paths.sh` independently asserts all 119
include targets exist. Classifying macro template text correctly needs a real
parser, not a lexer.

Whole manifest re-validated through the gate's own loader: 83 exemptions, no
stale path, no expired review date.

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

* test(telegram): cover the payload parser's fail-closed error paths

The package move made `payload.rs` unpairable for `git diff -M` — the
2,166-line original split into four files under the 999-line budget and no
destination clears the 50% rename threshold — so it reads as a new file with
zero pre-existing exclusions, and 88 uncovered instrumented lines surfaced.
They are almost all `PayloadParseError` construction sites and defensive
branches on an untrusted-input boundary, which is worth testing rather than
waiving.

18 tests in a new `src/tests/payload_errors.rs`, each driving a realistic
malformed or hostile webhook body through BOTH `parse_telegram_update` and
`normalize_telegram_update` so the two entry points cannot drift: missing
update_id, the full chat-kind classification table, out-of-range UTF-16
mention windows, anonymous-admin replies, negative media-group message ids,
attacker-authored media_group_id and display names, oversize and
control-char message bodies, unsliceable bot_command entities, the
leading-command rule, and every attachment slot.

Changed-line coverage of `payload.rs`: 83.64% -> 95.91% (516/538).
Production change is the four-line `#[path]` module declaration; no existing
test was weakened or removed (89 -> 107 lib tests).

Records one live defect rather than fixing it, since this is a move-only PR:
Telegram voice notes and stickers hard-fail the ENTIRE update today.
`collect_attachments` pairs voice with `audio/ogg` + `Voice` and sticker with
`image/webp` + `Sticker`, but `validate_attachment_kind`
(ironclaw_extension_contracts/src/external.rs:370) requires the kind to match
the MIME base type unless it is `Other` — so both are rejected and the `?`
aborts the parse. A well-formed voice message returns
`InvalidExternalRef { kind: "attachment_descriptor" }` instead of being
delivered. That is why those blocks had zero coverage. Pinned with an
explicit "contract pin, not an endorsement" comment.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-7005 — 5f1867ac Deployed Aug 2, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: low Changes to docs, tests, or low-risk modules scope: dependencies Dependency updates scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants