Skip to content

refactor(reborn): route capability results through host_api::Resolution at the loop_host seam (§5.3) - #6245

Merged
ilblackdragon merged 3 commits into
mainfrom
refactor/reborn-result-wiring-seam
Jul 19, 2026
Merged

ilblackdragon merged 3 commits into
mainfrom
refactor/reborn-result-wiring-seam

Conversation

@ilblackdragon

Copy link
Copy Markdown
Member

What

Slice C result-side collapse, seam-first (§3/§5.3 of docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md). Makes host_api::Resolution + the GateRecordStore live in production at the loop_host capability seam, without a 40-file big-bang.

Seam mechanics

HostRuntimeLoopCapabilityPort::invoke_capability is now a thin wrapper around the existing dispatch body (renamed invoke_capability_dispatch): it produces the loop-facing CapabilityOutcome, then calls persist_gate_record_for_outcome, which

DenyRecord is terminal/same-turn and is not persisted (per #6243). Done / Suspended(Process) carry no gate record and no-op. The batch path is unchanged — it calls the persisting invoke_capability, so it inherits per-outcome persistence. A store write failure propagates fail-closed, consistent with record_loop_completed (the bound cause is logged at debug, never interpolated into a model-visible summary).

Store wiring (DI)

GateRecordStore is injected into HostRuntimeLoopCapabilityPort and its factory via the constructor + a with_gate_record_store builder (mirroring the crate's existing with_trajectory_observer DI). The default is a transitional NoopGateRecordStore: the persisted record has no reader yet (the resume-turn render path + the loop-ref↔minted-ref association are the follow-up), so skipping the write is behavior-preserving and never regresses an unwired path's gates.

Dependency edge

ironclaw_loop_host (loops) → ironclaw_run_state (kernel), allowed by the layer matrix — no architecture exception needed; cargo test -p ironclaw_architecture is green.

Consumers migrated

None — the port deliberately still returns CapabilityOutcome, so ironclaw_agent_loop (executor/capabilities.rs, executor/mapping.rs) and ironclaw_turns are untouched and every existing behavior (evidence admission, redaction, retry/terminal classification, progress/terminate_hint/output_digest derivation, gate resume) is preserved bit-for-bit.

Genuine blocker — why the consumer flip is a follow-up, not this PR

Flipping the port to return bare Resolution and migrating the executor cannot preserve existing behavior, because Resolution is intentionally lossy vs. what the executor needs:

  • resume tokens — CapabilityOutcome::{ApprovalRequired,AuthRequired} carry approval_resume/auth_resume that GateStage needs to resume gates; Resolution::Blocked has no such field (the mapping drops them).
  • error_kind — the executor's capability_error_class/capability_failure_kind need CapabilityFailureKind for retry/terminal classification (agent-loop-capabilities.md); ToolVerdict::RecoverableFailure collapses all failure kinds to one.
  • output_digest/terminate_hint/progress — G4 loop-derived signals used for output-aware progress derivation; absent from Outcome.
  • refs — the executor appends LoopResultRef (verified by the evidence port); the mapping mints fresh uuid ResultRefs and returns the loop→minted binding separately.

Preserving all of that while consuming bare Resolution requires either extending the frozen host_api vocabulary or carrying a companion (which defeats the collapse). The full consumer migration therefore needs the resume/read wiring + a loop-side carrier decision, tracked as the producer-migration follow-up (§9).

Also deferred to that follow-up: durable FilesystemGateRecordStore composition wiring + the /gate-records mount alias (the record is orphaned until the resume-read consumes it; the mount-alias change would regress gates if wrong).

Incidental base-branch build fixes

The #6242+#6243 integration merge left RunStateError::GateRecordAlreadyExists uncovered in two exhaustive matches (ironclaw_capabilities::helpers::run_state_error_kind, ironclaw_host_runtime::production::unavailable_from_run_state). The base branch does not compile without these one-arm additions; fixed here.

Tests (crate-tier, drive the production seam, red-first verified)

  • approval_gate_outcome_persists_gate_record_at_the_seam — a gate outcome through the seam persists a GateRecord::Approval that round-trips via the store (save → load by the minted GateRef), and the loop still receives the unchanged ApprovalRequired outcome. Confirmed failing ("exactly one gate record persisted") with the persist call removed.
  • gate_outcome_through_unwired_default_is_inert_and_non_regressing — a gate through the transitional no-op default still returns the gate (no regression).

The durable FilesystemGateRecordStore round-trip itself is covered by ironclaw_run_state's gate_record_store_contract.

Verification (all green)

  • cargo test -p ironclaw_loop_host -p ironclaw_agent_loop -p ironclaw_turns -p ironclaw_run_state --all-features
  • cargo clippy -p ironclaw_loop_host -p ironclaw_agent_loop -p ironclaw_turns --all-targets --all-features -- -D warnings (+ ironclaw_capabilities/ironclaw_host_runtime)
  • cargo test -p ironclaw_architecture
  • scripts/pre-commit-safety.sh

No #[cfg(feature=...)] gates were added/moved, so the full feature matrix is not required.

🤖 Generated with Claude Code

@ironloopai

ironloopai Bot commented Jul 18, 2026 •

Copy link
Copy Markdown
Contributor

🔎 IronLoop Review Status

Head: c7ba17ee14e7e2782c7e8eb446967571331627bc
Result: One or more review results were superseded by a newer PR head.
Next: Run @ironloopai review on the latest PR head.
Updated: 2026-07-19T02:26:01.690Z

Current reviewers:

Reviewer State Verdict Findings Last update
ironloop/common-reviewer (reviewer) Superseded N/A N/A 2026-07-18T11:02:51.413Z
Reviewer summaries
Reviewer Detail
ironloop/common-reviewer (reviewer) Superseded by a newer PR head. New head: ab6ba02. Previous verdict: Changes requested.
Recent activity
Time Reviewer State Detail
2026-07-18T10:47:07.326Z ironloop/common-reviewer (reviewer) Queued Accepted review request for head e812de0.
2026-07-18T10:47:07.326Z ironloop/common-reviewer (reviewer) Queued Waiting for this reviewer lane to become available.
2026-07-18T10:47:08.220Z ironloop/common-reviewer (reviewer) Started Reviewer worker started.
2026-07-18T10:47:10.622Z ironloop/common-reviewer (reviewer) Workspace ready Prepared isolated checkout (merge_ref) at 6998080.
2026-07-18T10:50:56.852Z ironloop/common-reviewer (reviewer) Result captured Changes requested; 1 blocking finding.
2026-07-18T10:50:56.852Z ironloop/common-reviewer (reviewer) Completed Review completed and terminal status was persisted.
2026-07-18T11:02:51.413Z ironloop/common-reviewer (reviewer) Superseded A newer PR head replaced this review (ab6ba02).
Available commands
  • @ironloopai help
  • @ironloopai agents
  • @ironloopai review
  • @ironloopai review --agent <agent>
Run metadata

Admission: webhook accepted the request and IronLoop persisted reviewer state before this projection.

@github-actions github-actions Bot added the scope: dependencies Dependency updates label Jul 18, 2026
@coderabbitai

coderabbitai Bot commented Jul 18, 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: 58cb5c15-72a5-4185-ac4d-0f5fd1b6dcf4

📥 Commits

Reviewing files that changed from the base of the PR and between 290bc0f and c7ba17e.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (3)
  • crates/ironclaw_host_runtime/src/production.rs
  • crates/ironclaw_loop_host/Cargo.toml
  • crates/ironclaw_loop_host/src/capability_port.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Blocked capability outcomes now create durable gate records for later review and resumption.
    • Replayed requests avoid creating duplicate gate records.
    • Transient persistence failures can be retried automatically.
  • Bug Fixes

    • Persistence failures now return an unavailable result instead of silently proceeding.
    • Existing behavior is preserved when durable gate storage is not configured.

Walkthrough

The loop-host capability port now supports durable GateRecord persistence for blocked outcomes, replay deduplication, fail-closed store errors, and a transitional no-op store. Tests cover persistence, retries, replay behavior, and unwired operation.

Changes

Gate-record persistence

Layer / File(s) Summary
Store wiring and runtime state
crates/ironclaw_loop_host/Cargo.toml, crates/ironclaw_loop_host/src/capability_port.rs
The gate-record store is configurable through the factory and capability port, with idempotency tracking for replay protection.
Outcome persistence and error handling
crates/ironclaw_loop_host/src/capability_port.rs, crates/ironclaw_host_runtime/src/production.rs
Capability outcomes persist blocked GateRecords before return; batch calls use the same path, wired-store failures become unavailable errors, and a run-state exhaustiveness exception is documented.
Persistence behavior tests
crates/ironclaw_loop_host/src/capability_port.rs
Test stores and helpers validate round trips, duplicate suppression, transient-failure retry, and inert default-store behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HostRuntimeLoopCapabilityPort
  participant Capability
  participant GateRecordStore
  HostRuntimeLoopCapabilityPort->>Capability: invoke capability
  Capability-->>HostRuntimeLoopCapabilityPort: blocked CapabilityOutcome
  HostRuntimeLoopCapabilityPort->>GateRecordStore: save GateRecord
  GateRecordStore-->>HostRuntimeLoopCapabilityPort: persistence result
  HostRuntimeLoopCapabilityPort-->>HostRuntimeLoopCapabilityPort: return outcome or unavailable error
Loading

Possibly related PRs

  • nearai/ironclaw#6243: Introduces the GateRecordStore definitions that this capability-port wiring consumes.
  • nearai/ironclaw#6226: Provides the resolution channels and GateRef handles used to identify blocked outcomes.
  • nearai/ironclaw#6237: Introduces the gate-record vocabulary persisted by this change.

Suggested reviewers: serrrfirat

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: routing capability results through Resolution at the loop_host seam.
Description check ✅ Passed The description is detailed and covers scope, blockers, tests, and verification, though it doesn't mirror the template headings.
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.

@github-actions github-actions Bot added size: L 200-499 changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Jul 18, 2026

@ironloopai ironloopai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ IronLoop Review: reviewer

Review at a glance

Verdict Blocking Notes Inline Head
❌ Changes requested 1 0 1 e812de02dfe4

Head: e812de02dfe4532251668b9591fb3b15eb5217c2
Next: Fix the blocking findings, push the PR branch, then re-run this reviewer.

Run details

Status: Current
Needs human: no
Needs validation: no

Summary

Found one blocking idempotency issue in gate-record persistence. Static diff checks passed; Rust tests could not run because cargo is unavailable in the review environment.

Findings

Blocking: 1 / Notes: 0

Blocking findings

1. ❌ [MEDIUM] Memoize gate persistence across idempotent invocation replays

Location: crates/ironclaw_loop_host/src/capability_port.rs:1742
This mapping runs after invoke_capability_dispatch has already cached a successful gate outcome as LoopCompleted. A duplicate delivery of the same invocation therefore returns the cached CapabilityOutcome, but this call mints a new GateRef and saves another record. GateRecordStore is write-once and intentionally has no removal API, so every retry leaves an additional orphaned durable record. Persist/memoize the mapped gate record within the idempotent dispatch result (and test repeated invocation/store-retry behavior) so one logical gate produces one stable record/ref.

Developer follow-up

After fixing this feedback:

  1. Push the fix to this PR branch.
  2. Re-run this reviewer with @ironloopai review --agent reviewer if you only changed this reviewer's findings.
  3. Re-run all reviewers with @ironloopai review when the fix may affect multiple areas.

&self,
outcome: &CapabilityOutcome,
) -> Result<(), AgentLoopHostError> {
let mapped = capability_outcome_to_resolution(outcome.clone());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This executes outside the idempotent dispatch cache. Replaying the same invocation returns the cached gate outcome, then this mapping mints a fresh GateRef and persists another write-once record. Since gate records have no removal API, retries accumulate orphaned records. Please memoize/persist the mapped gate result inside the cached dispatch path and cover duplicate invocation behavior.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in ab6ba02. The wrapper now computes the same idempotency key the dispatch cache uses (invocation_idempotency_key is a pure function of the request, so it's derivable before dispatch) and a per-port replay guard (gate_records_persisted: Mutex<HashSet<IdempotencyKey>>) skips the persist when the key was already persisted — so a replay served from the cached dispatch record no longer mints a fresh GateRef/orphan record. The guard entry rolls back on a failed save, so a transient store fault doesn't permanently skip persistence — the next replay retries. Duplicate-invocation behavior covered by replayed_gate_invocation_does_not_persist_a_duplicate_record (ONE queued runtime outcome, two invokes through the production caller, exactly one record) and the rollback by failed_gate_record_persist_is_retried_on_replay (fail-once store: first invoke fails closed, replay lands exactly one record).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request integrates ironclaw_run_state into the loop host capability port to persist durable gate records at the capability invocation seam. It introduces the GateRecordStore trait, implements outcome-based persistence, and adds a transitional no-op store along with corresponding unit tests. The review feedback recommends elevating the log level in gate_record_store_error from debug to error to ensure critical storage failures are visible in production environments.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +2206 to +2212
fn gate_record_store_error(error: RunStateError) -> AgentLoopHostError {
tracing::debug!(error = %error, "failed to persist capability gate record at loop host seam");
AgentLoopHostError::new(
AgentLoopHostErrorKind::Unavailable,
"failed to persist capability gate record",
)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The helper function gate_record_store_error logs the underlying RunStateError at the debug level. Since this error represents a critical, fail-closed host storage fault that aborts the capability invocation, logging it only at debug level will make it invisible in standard production environments. This makes troubleshooting extremely difficult. Please elevate the log level to error or warn so that operators can detect and diagnose storage/filesystem failures.

Suggested change
fn gate_record_store_error(error: RunStateError) -> AgentLoopHostError {
tracing::debug!(error = %error, "failed to persist capability gate record at loop host seam");
AgentLoopHostError::new(
AgentLoopHostErrorKind::Unavailable,
"failed to persist capability gate record",
)
}
fn gate_record_store_error(error: RunStateError) -> AgentLoopHostError {
tracing::error!(error = %error, "failed to persist capability gate record at loop host seam");
AgentLoopHostError::new(
AgentLoopHostErrorKind::Unavailable,
"failed to persist capability gate record",
)
}
References
  1. When handling errors, log detailed internal information for observability using a structured logger, while returning sanitized, generic, or typed errors to the caller to prevent leaking implementation details.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied in ab6ba02 — elevated to warn! (matching the crate's existing level for host-side faults, e.g. the lease-revoke failures): a fail-closed storage fault is something operators must see at default log levels. The sanitized boundary error and the no-cause-in-model-visible-summary rule are unchanged.

ilblackdragon added a commit that referenced this pull request Jul 18, 2026
…ys; warn on store faults

A replayed invocation returns the CACHED gate outcome from the dispatch
records, but the seam wrapper persisted unconditionally — minting a
fresh GateRef and orphaning another write-once record per retry (gate
records have no removal API). The wrapper now computes the same
idempotency key the dispatch cache uses (a pure function of the
request) and a per-port replay guard skips duplicate persists; the
guard entry rolls back on a failed save so a later replay retries the
persist after a transient store fault. Store-fault logging elevated
debug → warn (operators must see a fail-closed storage fault; the
sanitized boundary error is unchanged).

Tests: replayed_gate_invocation_does_not_persist_a_duplicate_record
(one queued runtime outcome, two invokes, exactly one record) and
failed_gate_record_persist_is_retried_on_replay (fail-once store:
first invoke fails closed, replay persists exactly one record).

Reported-by: ironloopai, gemini-code-assist (PR #6245 review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions github-actions Bot added size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Jul 18, 2026
@ilblackdragon

Copy link
Copy Markdown
Member Author

✅ Ready for merge

Reviewed, both review comments addressed with fixes plus one CI break repaired, fully green — 19 pass / 0 fail.

  • The seam-first result-side collapse: Resolution + GateRecordStore go live at the loop_host capability seam while the loop keeps receiving CapabilityOutcome bit-for-bit (pinned by test). The consumer-flip blocker analysis is genuine — resume tokens and error_kind have no home on Resolution yet — so deferring the producer migration is right, and the transitional no-op store default is correctly justified (the record has no reader until the resume-read slice).
  • IronLoop's replay-orphan finding — confirmed and fixed (ab6ba02): persistence ran on every invoke, including idempotent replays served from the dispatch cache — each retry would mint a fresh GateRef and orphan another write-once record. The wrapper now derives the same idempotency key the cache uses and dedups persists via a per-port replay guard, with rollback on failed saves so transient store faults retry on the next replay. Pinned by replayed_gate_invocation_does_not_persist_a_duplicate_record and failed_gate_record_persist_is_retried_on_replay.
  • Gemini applied: store-fault logging elevated debug! → warn! (crate precedent for host-side faults).
  • CI break fixed (56a3850): the added constructor parameter broke 26 HostRuntimeLoopCapabilityPort::new call sites in ironclaw_runner's tests (outside the verified crates; caught by the workspace all-features clippy lane). Constructor reverted to six args with the noop default; the gate store rides a with_gate_record_store builder matching the crate's existing DI style, forwarded by the factory — zero caller churn.
  • Verified: workspace all-features clippy clean, 496 loop_host + 116 runner (all-features) + 62 architecture tests green.

Merge order: after its integration base lands (contains #6242 + #6243, which stack on #6237).

🤖 Generated with Claude Code

ilblackdragon and others added 2 commits July 19, 2026 02:12
…olution + GateRecordStore at the loop_host seam (§5.3)

Slice C result-side collapse, seam-first (§3/§5.3 of
docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md).

At the loop_host capability seam
(`HostRuntimeLoopCapabilityPort::invoke_capability`), the loop-facing
`CapabilityOutcome` is now mapped to a host_api `Resolution` via
`capability_outcome_to_resolution` (#6242), and the derived `GateRecord`
is persisted through `GateRecordStore::save` (#6243), keyed by the minted
`GateRef` on the resolution channel. `DenyRecord` is terminal/same-turn
and is intentionally not persisted. The batch path inherits per-outcome
persistence (it calls the persisting `invoke_capability`).

Store wiring: `GateRecordStore` is injected into the port and its factory
via the constructor + a `with_gate_record_store` builder (mirroring the
crate's existing `with_*` DI). The default is a transitional no-op
(`NoopGateRecordStore`): the persisted record has no reader yet — the
resume-turn render path and the loop-ref↔minted-ref association are the
explicit follow-up — so skipping the write is behavior-preserving and
never regresses an unwired path's gates.

Dep edge: `ironclaw_loop_host` (loops) -> `ironclaw_run_state` (kernel),
allowed by the layer matrix; `cargo test -p ironclaw_architecture` green.

Deliberately NOT in this PR (reported as a genuine blocker / follow-up):
- The port still returns `CapabilityOutcome`; the agent_loop executor and
  turns consumers are NOT migrated to consume bare `Resolution`. Doing so
  cannot preserve existing behavior: `Resolution` structurally drops the
  gate resume tokens (approval_resume/auth_resume), the failure
  `error_kind` (retry/terminal classification), and
  progress/terminate_hint/output_digest (G4), and mints fresh uuid refs
  where the evidence layer needs the loop refs. Full consumer migration
  needs the resume/read wiring + a loop-side carrier decision.
- Durable `FilesystemGateRecordStore` composition wiring + the
  `/gate-records` mount alias are deferred to the resume-read follow-up
  (the record is orphaned until then; the mount-alias change would
  regress gates if wrong).

Also fixes two pre-existing base-branch build breaks from the #6242+#6243
merge: `RunStateError::GateRecordAlreadyExists` left exhaustive matches in
`ironclaw_capabilities` and `ironclaw_host_runtime` non-exhaustive.

Tests (crate-tier, driving the production seam, red-first verified):
`approval_gate_outcome_persists_gate_record_at_the_seam` (gate record
round-trips via the store; outcome unchanged) and
`gate_outcome_through_unwired_default_is_inert_and_non_regressing`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ys; warn on store faults

A replayed invocation returns the CACHED gate outcome from the dispatch
records, but the seam wrapper persisted unconditionally — minting a
fresh GateRef and orphaning another write-once record per retry (gate
records have no removal API). The wrapper now computes the same
idempotency key the dispatch cache uses (a pure function of the
request) and a per-port replay guard skips duplicate persists; the
guard entry rolls back on a failed save so a later replay retries the
persist after a transient store fault. Store-fault logging elevated
debug → warn (operators must see a fail-closed storage fault; the
sanitized boundary error is unchanged).

Tests: replayed_gate_invocation_does_not_persist_a_duplicate_record
(one queued runtime outcome, two invokes, exactly one record) and
failed_gate_record_persist_is_retried_on_replay (fail-once store:
first invoke fails closed, replay persists exactly one record).

Reported-by: ironloopai, gemini-code-assist (PR #6245 review)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ilblackdragon
ilblackdragon force-pushed the refactor/reborn-result-wiring-seam branch from 56a3850 to 2f2d395 Compare July 19, 2026 02:15
@github-actions github-actions Bot added scope: sandbox Docker sandbox scope: ci CI/CD workflows scope: docs Documentation labels Jul 19, 2026
@ilblackdragon
ilblackdragon changed the base branch from integration/reborn-result-wiring to main July 19, 2026 02:15
… gate store rides the builder

The added constructor parameter broke 26 HostRuntimeLoopCapabilityPort::new
call sites in ironclaw_runner's loop_driver_host tests (outside the
crates the PR verified; caught by the workspace all-features clippy
lane). The factory already owns the DI, so the port constructor now
defaults to the transitional NoopGateRecordStore and gains a
with_gate_record_store builder (matching with_execution_mounts /
with_trajectory_observer); the factory forwards through it. No caller
churn, same wiring semantics, seam tests unchanged and green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ilblackdragon
ilblackdragon force-pushed the refactor/reborn-result-wiring-seam branch from 2f2d395 to c7ba17e Compare July 19, 2026 02:25
@ilblackdragon
ilblackdragon merged commit 9712113 into main Jul 19, 2026
65 checks passed
@ilblackdragon
ilblackdragon deleted the refactor/reborn-result-wiring-seam branch July 19, 2026 02:36
@github-actions

Copy link
Copy Markdown
Contributor

Coverage ratchet

Ratchet mode: ENFORCING

RATCHET PASS: global
  observed: 85.51% (311137 / 363866 lines)
  floor:    85.3% (tolerance 0.5pp -> effective floor 84.8%)
  denominator: 363866 lines now vs 320188 at floor capture (+43678 lines, +13.64%) — material change (>5%)

⚠️ 2 Reborn crate(s) have 0 int-tier coverage (target: 0) — ironclaw_prompt_envelope, ironclaw_scripts

Reborn integration-tier coverage

Line coverage (Reborn crates): 85.51% — 311137 / 363866 lines

Per-crate breakdown (65 crates, lowest-covered first)
Crate Line % Covered / Total
ironclaw_prompt_envelope 0% 0 / 88
ironclaw_scripts 0% 0 / 345
ironclaw_runtime_policy 31.75% 80 / 252
ironclaw_event_projections 43.31% 673 / 1554
ironclaw_observability 61.54% 16 / 26
ironclaw_authorization 62.46% 604 / 967
ironclaw_dispatcher 62.88% 83 / 132
ironclaw_mcp 64.89% 595 / 917
ironclaw_triggers 65.44% 2142 / 3273
ironclaw_filesystem 67.78% 3957 / 5838
ironclaw_channel_host 68.65% 219 / 319
ironclaw_memory 69.2% 773 / 1117
ironclaw_reborn_migration 71.64% 1551 / 2165
ironclaw_trust 72.88% 661 / 907
ironclaw_wasm_limiter 74.6% 47 / 63
ironclaw_reborn_cli 74.64% 8919 / 11949
ironclaw_reborn_event_store 74.67% 958 / 1283
ironclaw_extractors 74.72% 538 / 720
ironclaw_capabilities 75.44% 2024 / 2683
ironclaw_projects 76.48% 400 / 523
ironclaw_llm 78.36% 20306 / 25915
ironclaw_product_context 78.57% 11 / 14
ironclaw_run_state 79.25% 424 / 535
ironclaw_telegram_extension 80.18% 4842 / 6039
ironclaw_wasm_product_adapters 80.36% 1448 / 1802
ironclaw_process_sandbox 80.65% 671 / 832
ironclaw_first_party_extensions 81.06% 5965 / 7359
ironclaw_memory_native 81.17% 3195 / 3936
ironclaw_events 81.95% 1594 / 1945
ironclaw_secrets 82.78% 2827 / 3415
ironclaw_network 82.98% 673 / 811
ironclaw_reborn_identity 83.59% 433 / 518
ironclaw_processes 83.76% 939 / 1121
ironclaw_reborn_config 84.04% 1832 / 2180
ironclaw_wasm 84.44% 1069 / 1266
ironclaw_auth 84.81% 3233 / 3812
ironclaw_product_workflow 84.91% 11031 / 12992
ironclaw_turns 85.46% 14248 / 16673
ironclaw_channel_delivery 85.79% 1383 / 1612
ironclaw_common 86.13% 1714 / 1990
ironclaw_threads 86.93% 4708 / 5416
ironclaw_slack_v2_adapter 87.3% 1491 / 1708
ironclaw_skills 87.58% 4470 / 5104
ironclaw_host_api 87.66% 3496 / 3988
ironclaw_hooks 87.78% 9921 / 11302
ironclaw_reborn_composition 87.96% 70209 / 79816
ironclaw_product_adapter_registry 88.06% 531 / 603
ironclaw_product_adapters 88.1% 3384 / 3841
ironclaw_reborn_traces 88.2% 11946 / 13544
ironclaw_host_runtime 88.68% 18033 / 20334
ironclaw_webui 88.9% 7652 / 8607
ironclaw_extensions 89.38% 2971 / 3324
ironclaw_reborn_openai_compat 89.5% 3778 / 4221
ironclaw_runner 89.65% 17365 / 19370
ironclaw_telegram_v2_adapter 89.7% 2717 / 3029
ironclaw_approvals 90.18% 1598 / 1772
ironclaw_conversations 90.39% 3123 / 3455
ironclaw_event_streams 90.82% 1009 / 1111
ironclaw_resources 91.65% 4476 / 4884
ironclaw_loop_host 92.28% 15567 / 16870
ironclaw_attachments 93.06% 630 / 677
ironclaw_agent_loop 94.88% 9184 / 9680
ironclaw_safety 95.04% 3677 / 3869
ironclaw_outbound 95.52% 3451 / 3613
ironclaw_first_party_extension_ports 95.62% 3672 / 3840

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 (3 entry/entries excluded from the accounting above)
Module / Crate Reason Issue
crate: ironclaw_embeddings v1-only: consumed only by root ironclaw (src/app.rs, src/tools/builtin/memory.rs, src/workspace/mod.rs, src/config/{mod,embeddings}.rs); no crates/* dependents. Covered by "Tests (Legacy)". #5657
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

ilblackdragon added a commit that referenced this pull request Jul 19, 2026
…loadStore; wire real stores (§5.3 Stage 2a-i)

Moves the raw gate/auth resume replay payload out of the untrusted loop and
into the host-private `ReplayPayloadStore`, and wires the real durable stores
in production composition. The loop-facing result stays `CapabilityOutcome`
(no `Resolution` flip — that is Stage 2a-ii).

Security fix: the loop checkpoint no longer stores raw tool args, retiring the
charter violation in `crates/ironclaw_agent_loop/CLAUDE.md` ("State stores
refs... Do not store raw prompts, raw model output, tool args ... in state").

Write (two gate-raise paths): at a FRESH runtime gate raise
(`HostRuntimeLoopCapabilityPort::invoke_capability_dispatch`, gated on
`is_fresh_dispatch` before `finish_runtime_outcome`) and at the synthetic
outbound-delivery approval-gate raise (`outbound_delivery.rs::request_approval`),
the host `save`s a `ReplayPayload{input, estimate, prior_approval:None,
input_ref, correlation_id}` keyed by `InvocationId` (write-once; a benign
same-invocation duplicate is tolerated).

Read on resume: `runtime_outcome_to_loop` no longer embeds input/estimate;
`invocation_replay_input` is replaced by `ReplayPayloadStore::load` keyed by the
`InvocationId` recovered from the resume token, in both the runtime seam
(`replay_payload_for_resume`) and the synthetic decorator/handler. Fail closed
on a miss: an absent payload (including a wrong-scope read) is a sanitized
terminal `Unavailable`, never a silent empty-input dispatch. The fingerprinted
approval lease claim ordering is preserved.

Dropped loop fields (security): `input`/`estimate` from `CapabilityApprovalResume`
and `PendingApprovalResume`; the whole `CapabilityAuthResumeReplay` type and the
`replay` field on `CapabilityAuthResume`/`PendingAuthResume`. `approval_request_id`/
`correlation_id`/`input_ref`/`prior_approval` stay on the wire (they move in 2a-ii).

Composition (real stores): `capability_wiring` builds `FilesystemGateRecordStore`
(closes the #6245 production gap where it defaulted to `NoopGateRecordStore`) and
`FilesystemReplayPayloadStore` over the shared scoped filesystem and threads both
through the refreshing capability port. Adds the `/replay-payloads` per-user mount
alias. `loop_host` gains a dependency-inversion dep on `ironclaw_capabilities` for
the port trait.

Tests (all failed first): fail-closed on missing payload (loop_host seam);
extended the #6245 approval-resume seam test to assert the store persisted the
payload and the resume redispatches the correct original input; the
`ReplayPayloadStore` contract (round-trip / write-once / cross-tenant /
within-tenant) rides the base commit. Full-infra resume edges pass through the
production-shaped harness: group_approvals (runtime approval resume),
group_journeys (approval→auth convergence), outbound_target (synthetic approval
resume).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Jul 20, 2026
…loadStore; wire real stores (§5.3 Stage 2a-i) (#6271)

* refactor(reborn): resume replay payload moves host-side via ReplayPayloadStore; wire real stores (§5.3 Stage 2a-i)

Moves the raw gate/auth resume replay payload out of the untrusted loop and
into the host-private `ReplayPayloadStore`, and wires the real durable stores
in production composition. The loop-facing result stays `CapabilityOutcome`
(no `Resolution` flip — that is Stage 2a-ii).

Security fix: the loop checkpoint no longer stores raw tool args, retiring the
charter violation in `crates/ironclaw_agent_loop/CLAUDE.md` ("State stores
refs... Do not store raw prompts, raw model output, tool args ... in state").

Write (two gate-raise paths): at a FRESH runtime gate raise
(`HostRuntimeLoopCapabilityPort::invoke_capability_dispatch`, gated on
`is_fresh_dispatch` before `finish_runtime_outcome`) and at the synthetic
outbound-delivery approval-gate raise (`outbound_delivery.rs::request_approval`),
the host `save`s a `ReplayPayload{input, estimate, prior_approval:None,
input_ref, correlation_id}` keyed by `InvocationId` (write-once; a benign
same-invocation duplicate is tolerated).

Read on resume: `runtime_outcome_to_loop` no longer embeds input/estimate;
`invocation_replay_input` is replaced by `ReplayPayloadStore::load` keyed by the
`InvocationId` recovered from the resume token, in both the runtime seam
(`replay_payload_for_resume`) and the synthetic decorator/handler. Fail closed
on a miss: an absent payload (including a wrong-scope read) is a sanitized
terminal `Unavailable`, never a silent empty-input dispatch. The fingerprinted
approval lease claim ordering is preserved.

Dropped loop fields (security): `input`/`estimate` from `CapabilityApprovalResume`
and `PendingApprovalResume`; the whole `CapabilityAuthResumeReplay` type and the
`replay` field on `CapabilityAuthResume`/`PendingAuthResume`. `approval_request_id`/
`correlation_id`/`input_ref`/`prior_approval` stay on the wire (they move in 2a-ii).

Composition (real stores): `capability_wiring` builds `FilesystemGateRecordStore`
(closes the #6245 production gap where it defaulted to `NoopGateRecordStore`) and
`FilesystemReplayPayloadStore` over the shared scoped filesystem and threads both
through the refreshing capability port. Adds the `/replay-payloads` per-user mount
alias. `loop_host` gains a dependency-inversion dep on `ironclaw_capabilities` for
the port trait.

Tests (all failed first): fail-closed on missing payload (loop_host seam);
extended the #6245 approval-resume seam test to assert the store persisted the
payload and the resume redispatches the correct original input; the
`ReplayPayloadStore` contract (round-trip / write-once / cross-tenant /
within-tenant) rides the base commit. Full-infra resume edges pass through the
production-shaped harness: group_approvals (runtime approval resume),
group_journeys (approval→auth convergence), outbound_target (synthetic approval
resume).

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

* docs(turns): move the removed-replay design note out of prior_approval's rustdoc

Reported-by: gemini-code-assist (PR #6271 review)

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

* fix(runner): drop removed replay fields from planned-driver test fixtures

The Pending{Approval,Auth}Resume replay-field removal missed six
cfg(test) construction sites in planned_driver.rs (outside the PR's
verified crates; caught by the workspace all-features clippy lane).

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

* style: cargo fmt

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

* refactor(reborn): Resolution carries model-visible failure diagnostic + denial content (§5.3 flip prep / PR-B) (#6273)

* refactor(reborn): Resolution carries model-visible failure diagnostic + denial content (§5.3 flip prep / PR-B)

Additive vocabulary slice (§3/§5.2.9/§5.3): make `host_api::Resolution`
carry the model-visible result content the executor's
`handle_capability_outcome` reads off `CapabilityOutcome` today, so a later
slice (PR-C) can flip `invoke_capability` to `Resolution` without the loop
reading host storage (its charter forbids that).

host_api additions (redacted vocabulary only — charter fit):
- `ModelInputIssue`: redacted mirror of the loop's `CapabilityInputIssue` —
  `DispatchInputIssueCode` (existing host_api enum) + bounded redacted
  `SafeSummary` path/expected/received/schema_path.
- `ModelFailureDiagnostic { InvalidInput { issues }, Diagnostic { text } }`:
  redacted mirror of `CapabilityFailureDetail`; rides
  `ToolVerdict::RecoverableFailure.diagnostic` (additive Option).
- `Denial { deny, reason_kind, summary }`: `Resolution::Denied(DenyRef)` →
  `Resolution::Denied(Denial)`, carrying the model-visible `DenyReason` +
  redacted `SafeSummary` on the channel (a projection of the sibling
  DenyRecord).

Mapping (`resolution_mapping.rs`): `failed_outcome` now redacts the loop
`detail` into the diagnostic (per-field SafeSummary; path-shaped free text
degrades to the placeholder); the Denied arm populates the Denial channel
from the same reason/summary the DenyRecord holds.

Tests (failed first, then green): structured `InvalidInput` issues and
free-text `Diagnostic` round-trip through the mapping; Denied carries
reason_kind + redacted summary; redaction proof — path- and secret-shaped
content is redacted in the `Resolution` output, never carried raw. GateRecord/
DenyRecord persistence round-trips (ironclaw_run_state) stay green.

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

* fix(host_api): enforce the 16-issue diagnostic cap in the type, not one producer

ModelFailureDiagnostic::InvalidInput now carries a bounded
ModelInputIssues newtype: validated at construction, revalidated on the
wire (try_from), with a truncating() producer constructor for the
mapping. A 17-item payload is rejected at every entry point — pinned by
model_input_issues_cap_is_enforced_at_construction_and_on_the_wire.

Reported-by: ironloopai (PR #6273 review)

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

* style: cargo fmt (inherited from base pre-fmt fork)

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

* refactor(reborn): complete host-side gate reconstitution — resume input_ref, local-dev gate persistence, GateRef reconcile (§5.3 flip prep / Stage 0) (#6278)

* refactor(reborn): complete host-side gate reconstitution — resume input_ref, local-dev gate persistence, GateRef reconcile (§5.3 flip prep / Stage 0)

Stage 0 no-flip host-reconstitution slice: closes two verified correctness
hazards ahead of the atomic capability-result flip. The loop-facing trait and
`CapabilityOutcome` are UNCHANGED; only host-side plumbing moves.

Fix 1 — resume-time input_ref reconstitution (hazard 3). On a gate/auth resume
the effective input_ref used for the idempotency key + validation is now derived
from the host-private `ReplayPayload` persisted at the fresh gate raise (loaded
by the resume's invocation id), not the advisory loop-supplied
`approval_resume.input_ref`. A new `resume_replay_payload` helper centralizes the
derivation so `invoke_capability` (seam) and `invoke_capability_dispatch` compute
the SAME byte-stable key. The key is now derived lazily inside
`persist_gate_record_for_outcome` (after dispatch, only for a gate outcome) so a
missing/stale resume payload never pre-empts dispatch's own resume
identity/activity validation — a malformed resume still surfaces
`InvalidInvocation`, and a genuinely-missing payload still fails closed
(2a-i missing-payload test stays green). Test proves a resume derives the same
input_ref/key from the store even when the loop-supplied ref differs (a second
resume differing only in that advisory ref replays the cached outcome instead of
re-dispatching); failed first (re-dispatched, count 2) before the fix.

Fix 2 — gate-record persistence for the local-dev synthetic approval producer
(hazard 2). `OutboundDeliveryTargetSetHandler::request_approval` raises its gate
OUTSIDE the loop-host persist seam, so it now persists a `GateRecord::Approval`
itself at the raise (via the wired `GateRecordStore`), keyed by the canonical
`GateRef::for_approval_request`, so host-side gate rendering (§5.2.9) has a record
to read. Test proves the record persists and round-trips; failed first (load
returned None) before the fix.

Fix 3 — reconcile the two approval GateRef encodings. Canonicalized on the typed
`GateRef::for_approval_request(id) = GateRef::from_uuid(id.as_uuid())` — a new
`ironclaw_host_api` helper — as the GateRecordStore key. This is the host_api
uuid GateRef (record key), a DIFFERENT type from the loop-facing
`gate:approval-{id}` routing ref (`ironclaw_turns::GateRef`), so reconciling here
touches NO persisted wire format and does not break the `is_approval_gate_ref`
prefix-routing predicate. `ironclaw_capabilities::host` now uses the helper at
both authorize sites. Test proves a host-persisted approval gate ref resolves
through the read model: the routing ref recovers the approval id
(`approval_request_id_from_gate_ref`), the canonical key re-derives, and the
persisted GateRecord is found.

Note: the task suggested canonicalizing on `from_uuid`; that is exactly what this
does for the record-key GateRef. The RISKY direction (rewriting the
`gate:approval-{id}` routing string to a bare uuid) was NOT taken — it would
break the `is_approval_gate_ref` prefix predicate used in production routing,
projection, and channel delivery, plus persisted adapter command strings and
delivered-gate route records.

Tests: loop_host 378 lib (incl. new + updated resume/seam/2a-i); host_api helper
round-trip; composition 1629 (env-clean); capabilities/product_workflow/run_state
green; architecture green; integration outbound_target/auth_gate/
reopen_resume_through_gate/group_approvals green; clippy -D warnings clean on all
touched crates; pre-commit-safety clean.

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

* style(reborn): rustfmt the auto-merged local-dev gate-key test line

Merge of refactor/reborn-resolution-model-visible collapsed a
GateRef::for_approval_request call across two lines; rustfmt --check
flagged it. Formatting-only, no behavior change.

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

* fix(reborn): drop stale replay/input/estimate from planned_driver resume test helpers

The Pending{Auth,Approval}Resume structs migrated to the ReplayPayloadStore
model — raw runtime input/estimate no longer ride the checkpoint (they are
persisted host-side at the fresh gate raise and reconstituted on resume,
keyed by invocation id). The structs dropped `replay`/`input`/`estimate` in
favor of `provider_replay`/`input_ref`, but three test helpers in
planned_driver.rs still set the removed fields.

This never broke base/#6273/#6275 CI because the clippy matrix is
change-scoped and those PRs did not pull ironclaw_runner into the compiled
set; this stacked change does, surfacing the latent E0560. Remove the nine
write-only stale field settings (3× replay, 3× input, 3× estimate) and the
now-unused ResourceEstimate import. No behavior change: the fields did not
exist on the structs, so no test read them back. Full runner suite passes
(344+ tests) and clippy --all-features is clean.

[skip-regression-check] compile-only fix in test helpers; the regression
coverage is the now-compiling runner test module under the all-features lane.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reborn): ResolutionBatch + parks() loop-suspension predicate + scripted-outcome fixture (§5.3 flip prep / Stage 1) (#6275)

* feat(reborn): ResolutionBatch + parks() loop-suspension predicate + scripted-outcome fixture (§5.3 flip prep / Stage 1)

Additive vocabulary + test-fixture slice ahead of the capability-result
flip. Nothing consumes the new items yet, so the tree stays green.

Closes a verified correctness hazard: `Resolution::is_suspension()` is
`Suspended(_)` ONLY, but the batch loops the flip rewrites must also stop
on the re-entrant gate variants (`Resolution::Blocked` — Approval/Auth/
Resource), which map to `Blocked`, not `Suspended`. A naive
`is_suspension()` batch guard would silently let a gate fall through as
if the call had completed (the #6137 bug class). This provides the
correct predicate now so the flip has one canonical, tested site to call.

- `ironclaw_host_api::Resolution::parks()`: the loop-semantics suspension
  predicate — true for every `Blocked` variant AND every `Suspended`
  variant; false for `Done` (incl. non-suspending `ChildSpawned`) and
  terminal `Denied`. `parks()` ⊋ `is_suspension()`: a `Blocked::Approval`
  parks but is not a suspension. `is_suspension()` keeps its narrow
  meaning unchanged.
- `ironclaw_host_api::ResolutionBatch { resolutions: Vec<Resolution>,
  stopped_on_suspension: bool }`: the loop-facing batch result the flip
  adopts, mirroring `CapabilityBatchOutcome`'s shape/semantics over
  `Resolution`. Wire-stable serde.
- `ironclaw_agent_loop::test_support::{resolution_from_scripted_outcome,
  resolution_batch_from_scripted}` (behind the existing `test-support`
  feature): convert existing `CapabilityOutcome` fixtures to
  `Resolution`/`ResolutionBatch` via the production mapping, for the
  flip's ~150 test sites.

Tests (written first, watched fail):
- host_api: acceptance table extended with a `parks` column across all 10
  CapabilityOutcome→Resolution rows; a dedicated exhaustive `match`
  (compile-fails if a new `Resolution` variant is added) proving
  `parks()` ⊋ `is_suspension()`; a `ResolutionBatch` round-trip.
  Verified red: a naive `is_suspension()`-based `parks()` fails
  ("parks() disagrees for blocked").
- agent_loop test-support: round-trips scripted Completed/Failed/
  ApprovalRequired → the right Resolution channel, and a batch preserving
  order + stop flag.

No trait flip, no consumer change, `CapabilityOutcome` untouched.

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

* refactor(host_api): Resolution::parks uses an exhaustive match, not matches!

A new Resolution variant must be a compile error in parks() (§11.9
no-wildcard) so its parking behavior is decided deliberately rather than
silently defaulting to false — the exact class of silent fall-through
parks() exists to prevent (#6137).

Reported-by: gemini-code-assist (PR #6275 review)

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

* fix(runner): drop removed Pending{Approval,Auth}Resume replay fields (inherited base debt)

The replay-field removal lands in a lower stack slice; until this branch's
base picks it up, planned_driver.rs's cfg(test) fixtures still set the
removed fields and fail the workspace clippy lane. Harmless once the base
merges.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reborn): commit the dispatch reservation only after the replay-payload write (#6271 IronLoop)

`guard.commit()` ran before the fallible `persist_replay_payload_for_fresh_gate`
store write. On a transient store error the `?` returned with the dispatch
reservation still `InFlight` and the committed guard skipping its cleanup —
stranding retries/duplicates of the same key waiting forever. Commit after the
write succeeds, so a store error leaves the guard uncommitted and its drop
clears the reservation and wakes waiters.

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

* docs(reborn): record the drain-then-deploy rollout for pre-#6271 gates (§5.3 Stage 2a-i)

IronLoop finding 2 flagged that gates raised/checkpointed before this change
carried `{input, estimate}` on the loop resume, whereas the new code reads them
from the host-side `ReplayPayloadStore`; a resume of a pre-deployment gate finds
no store entry and fails closed as `Unavailable`.

Author decision (PR #6271 thread): drain-then-deploy — no backward-compat
bridge. In-flight gates from before the cutover are drained before rollout, and
none exist pre-release, so a store miss in production is a genuine fault, never a
migration gap. Record that rationale at the fail-closed miss site so a future
reader debugging an `Unavailable` resume sees why there is no fallback.

Comment-only; verified `cargo check -p ironclaw_loop_host --all-features` clean.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ilblackdragon added a commit that referenced this pull request Jul 20, 2026
… delete CapabilityOutcome/mirror DTOs (§5.3 Slice C) (#6299)

* refactor(reborn): resume replay payload moves host-side via ReplayPayloadStore; wire real stores (§5.3 Stage 2a-i)

Moves the raw gate/auth resume replay payload out of the untrusted loop and
into the host-private `ReplayPayloadStore`, and wires the real durable stores
in production composition. The loop-facing result stays `CapabilityOutcome`
(no `Resolution` flip — that is Stage 2a-ii).

Security fix: the loop checkpoint no longer stores raw tool args, retiring the
charter violation in `crates/ironclaw_agent_loop/CLAUDE.md` ("State stores
refs... Do not store raw prompts, raw model output, tool args ... in state").

Write (two gate-raise paths): at a FRESH runtime gate raise
(`HostRuntimeLoopCapabilityPort::invoke_capability_dispatch`, gated on
`is_fresh_dispatch` before `finish_runtime_outcome`) and at the synthetic
outbound-delivery approval-gate raise (`outbound_delivery.rs::request_approval`),
the host `save`s a `ReplayPayload{input, estimate, prior_approval:None,
input_ref, correlation_id}` keyed by `InvocationId` (write-once; a benign
same-invocation duplicate is tolerated).

Read on resume: `runtime_outcome_to_loop` no longer embeds input/estimate;
`invocation_replay_input` is replaced by `ReplayPayloadStore::load` keyed by the
`InvocationId` recovered from the resume token, in both the runtime seam
(`replay_payload_for_resume`) and the synthetic decorator/handler. Fail closed
on a miss: an absent payload (including a wrong-scope read) is a sanitized
terminal `Unavailable`, never a silent empty-input dispatch. The fingerprinted
approval lease claim ordering is preserved.

Dropped loop fields (security): `input`/`estimate` from `CapabilityApprovalResume`
and `PendingApprovalResume`; the whole `CapabilityAuthResumeReplay` type and the
`replay` field on `CapabilityAuthResume`/`PendingAuthResume`. `approval_request_id`/
`correlation_id`/`input_ref`/`prior_approval` stay on the wire (they move in 2a-ii).

Composition (real stores): `capability_wiring` builds `FilesystemGateRecordStore`
(closes the #6245 production gap where it defaulted to `NoopGateRecordStore`) and
`FilesystemReplayPayloadStore` over the shared scoped filesystem and threads both
through the refreshing capability port. Adds the `/replay-payloads` per-user mount
alias. `loop_host` gains a dependency-inversion dep on `ironclaw_capabilities` for
the port trait.

Tests (all failed first): fail-closed on missing payload (loop_host seam);
extended the #6245 approval-resume seam test to assert the store persisted the
payload and the resume redispatches the correct original input; the
`ReplayPayloadStore` contract (round-trip / write-once / cross-tenant /
within-tenant) rides the base commit. Full-infra resume edges pass through the
production-shaped harness: group_approvals (runtime approval resume),
group_journeys (approval→auth convergence), outbound_target (synthetic approval
resume).

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

* docs(turns): move the removed-replay design note out of prior_approval's rustdoc

Reported-by: gemini-code-assist (PR #6271 review)

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

* refactor(reborn): Resolution carries model-visible failure diagnostic + denial content (§5.3 flip prep / PR-B)

Additive vocabulary slice (§3/§5.2.9/§5.3): make `host_api::Resolution`
carry the model-visible result content the executor's
`handle_capability_outcome` reads off `CapabilityOutcome` today, so a later
slice (PR-C) can flip `invoke_capability` to `Resolution` without the loop
reading host storage (its charter forbids that).

host_api additions (redacted vocabulary only — charter fit):
- `ModelInputIssue`: redacted mirror of the loop's `CapabilityInputIssue` —
  `DispatchInputIssueCode` (existing host_api enum) + bounded redacted
  `SafeSummary` path/expected/received/schema_path.
- `ModelFailureDiagnostic { InvalidInput { issues }, Diagnostic { text } }`:
  redacted mirror of `CapabilityFailureDetail`; rides
  `ToolVerdict::RecoverableFailure.diagnostic` (additive Option).
- `Denial { deny, reason_kind, summary }`: `Resolution::Denied(DenyRef)` →
  `Resolution::Denied(Denial)`, carrying the model-visible `DenyReason` +
  redacted `SafeSummary` on the channel (a projection of the sibling
  DenyRecord).

Mapping (`resolution_mapping.rs`): `failed_outcome` now redacts the loop
`detail` into the diagnostic (per-field SafeSummary; path-shaped free text
degrades to the placeholder); the Denied arm populates the Denial channel
from the same reason/summary the DenyRecord holds.

Tests (failed first, then green): structured `InvalidInput` issues and
free-text `Diagnostic` round-trip through the mapping; Denied carries
reason_kind + redacted summary; redaction proof — path- and secret-shaped
content is redacted in the `Resolution` output, never carried raw. GateRecord/
DenyRecord persistence round-trips (ironclaw_run_state) stay green.

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

* fix(host_api): enforce the 16-issue diagnostic cap in the type, not one producer

ModelFailureDiagnostic::InvalidInput now carries a bounded
ModelInputIssues newtype: validated at construction, revalidated on the
wire (try_from), with a truncating() producer constructor for the
mapping. A 17-item payload is rejected at every entry point — pinned by
model_input_issues_cap_is_enforced_at_construction_and_on_the_wire.

Reported-by: ironloopai (PR #6273 review)

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

* feat(reborn): ResolutionBatch + parks() loop-suspension predicate + scripted-outcome fixture (§5.3 flip prep / Stage 1)

Additive vocabulary + test-fixture slice ahead of the capability-result
flip. Nothing consumes the new items yet, so the tree stays green.

Closes a verified correctness hazard: `Resolution::is_suspension()` is
`Suspended(_)` ONLY, but the batch loops the flip rewrites must also stop
on the re-entrant gate variants (`Resolution::Blocked` — Approval/Auth/
Resource), which map to `Blocked`, not `Suspended`. A naive
`is_suspension()` batch guard would silently let a gate fall through as
if the call had completed (the #6137 bug class). This provides the
correct predicate now so the flip has one canonical, tested site to call.

- `ironclaw_host_api::Resolution::parks()`: the loop-semantics suspension
  predicate — true for every `Blocked` variant AND every `Suspended`
  variant; false for `Done` (incl. non-suspending `ChildSpawned`) and
  terminal `Denied`. `parks()` ⊋ `is_suspension()`: a `Blocked::Approval`
  parks but is not a suspension. `is_suspension()` keeps its narrow
  meaning unchanged.
- `ironclaw_host_api::ResolutionBatch { resolutions: Vec<Resolution>,
  stopped_on_suspension: bool }`: the loop-facing batch result the flip
  adopts, mirroring `CapabilityBatchOutcome`'s shape/semantics over
  `Resolution`. Wire-stable serde.
- `ironclaw_agent_loop::test_support::{resolution_from_scripted_outcome,
  resolution_batch_from_scripted}` (behind the existing `test-support`
  feature): convert existing `CapabilityOutcome` fixtures to
  `Resolution`/`ResolutionBatch` via the production mapping, for the
  flip's ~150 test sites.

Tests (written first, watched fail):
- host_api: acceptance table extended with a `parks` column across all 10
  CapabilityOutcome→Resolution rows; a dedicated exhaustive `match`
  (compile-fails if a new `Resolution` variant is added) proving
  `parks()` ⊋ `is_suspension()`; a `ResolutionBatch` round-trip.
  Verified red: a naive `is_suspension()`-based `parks()` fails
  ("parks() disagrees for blocked").
- agent_loop test-support: round-trips scripted Completed/Failed/
  ApprovalRequired → the right Resolution channel, and a batch preserving
  order + stop flag.

No trait flip, no consumer change, `CapabilityOutcome` untouched.

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

* refactor(host_api): Resolution::parks uses an exhaustive match, not matches!

A new Resolution variant must be a compile error in parks() (§11.9
no-wildcard) so its parking behavior is decided deliberately rather than
silently defaulting to false — the exact class of silent fall-through
parks() exists to prevent (#6137).

Reported-by: gemini-code-assist (PR #6275 review)

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

* fix(runner): drop removed Pending{Approval,Auth}Resume replay fields (inherited base debt)

The replay-field removal lands in a lower stack slice; until this branch's
base picks it up, planned_driver.rs's cfg(test) fixtures still set the
removed fields and fail the workspace clippy lane. Harmless once the base
merges.

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

* refactor(reborn): complete host-side gate reconstitution — resume input_ref, local-dev gate persistence, GateRef reconcile (§5.3 flip prep / Stage 0)

Stage 0 no-flip host-reconstitution slice: closes two verified correctness
hazards ahead of the atomic capability-result flip. The loop-facing trait and
`CapabilityOutcome` are UNCHANGED; only host-side plumbing moves.

Fix 1 — resume-time input_ref reconstitution (hazard 3). On a gate/auth resume
the effective input_ref used for the idempotency key + validation is now derived
from the host-private `ReplayPayload` persisted at the fresh gate raise (loaded
by the resume's invocation id), not the advisory loop-supplied
`approval_resume.input_ref`. A new `resume_replay_payload` helper centralizes the
derivation so `invoke_capability` (seam) and `invoke_capability_dispatch` compute
the SAME byte-stable key. The key is now derived lazily inside
`persist_gate_record_for_outcome` (after dispatch, only for a gate outcome) so a
missing/stale resume payload never pre-empts dispatch's own resume
identity/activity validation — a malformed resume still surfaces
`InvalidInvocation`, and a genuinely-missing payload still fails closed
(2a-i missing-payload test stays green). Test proves a resume derives the same
input_ref/key from the store even when the loop-supplied ref differs (a second
resume differing only in that advisory ref replays the cached outcome instead of
re-dispatching); failed first (re-dispatched, count 2) before the fix.

Fix 2 — gate-record persistence for the local-dev synthetic approval producer
(hazard 2). `OutboundDeliveryTargetSetHandler::request_approval` raises its gate
OUTSIDE the loop-host persist seam, so it now persists a `GateRecord::Approval`
itself at the raise (via the wired `GateRecordStore`), keyed by the canonical
`GateRef::for_approval_request`, so host-side gate rendering (§5.2.9) has a record
to read. Test proves the record persists and round-trips; failed first (load
returned None) before the fix.

Fix 3 — reconcile the two approval GateRef encodings. Canonicalized on the typed
`GateRef::for_approval_request(id) = GateRef::from_uuid(id.as_uuid())` — a new
`ironclaw_host_api` helper — as the GateRecordStore key. This is the host_api
uuid GateRef (record key), a DIFFERENT type from the loop-facing
`gate:approval-{id}` routing ref (`ironclaw_turns::GateRef`), so reconciling here
touches NO persisted wire format and does not break the `is_approval_gate_ref`
prefix-routing predicate. `ironclaw_capabilities::host` now uses the helper at
both authorize sites. Test proves a host-persisted approval gate ref resolves
through the read model: the routing ref recovers the approval id
(`approval_request_id_from_gate_ref`), the canonical key re-derives, and the
persisted GateRecord is found.

Note: the task suggested canonicalizing on `from_uuid`; that is exactly what this
does for the record-key GateRef. The RISKY direction (rewriting the
`gate:approval-{id}` routing string to a bare uuid) was NOT taken — it would
break the `is_approval_gate_ref` prefix predicate used in production routing,
projection, and channel delivery, plus persisted adapter command strings and
delivered-gate route records.

Tests: loop_host 378 lib (incl. new + updated resume/seam/2a-i); host_api helper
round-trip; composition 1629 (env-clean); capabilities/product_workflow/run_state
green; architecture green; integration outbound_target/auth_gate/
reopen_resume_through_gate/group_approvals green; clippy -D warnings clean on all
touched crates; pre-commit-safety clean.

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

* refactor(reborn): Suspension::DependentRun carries the staged child result (§5.3 flip prep / Stage 1b)

Closes a verified hazard blocking the atomic capability-result flip
(§5.3): `host_api::Resolution`'s `Suspension::DependentRun` channel could
not carry the dependent child's staged result, which the loop needs on
resume. After the flip the executor gets only
`Resolution::Suspended(Suspension::DependentRun(..))`; the mapping folded
`result_ref`/`byte_len`/`safe_summary` into the host-persisted
`GateRecord::DependentRun` (the loop cannot read host storage) and dropped
`model_observation` entirely — so byte caps, model-visible child text, and
the model observation would silently regress ("never drop model output").

This ADDITIVE slice gives `Suspension::DependentRun` an inline
staged-result payload alongside its `GateWaypoint`, mirroring how
`Done(Outcome)` carries a spawned child run's content:

- New `DependentRunResult { byte_len, summary: SafeSummary,
  observation: Option<SafeSummary>, origin: Option<LoopRef> }` — plain
  redacted host_api vocabulary; the full child bytes stay host-owned behind
  the record's `ResultRef`. `Suspension::DependentRun` becomes a struct
  variant `{ waypoint, result }`; accessors split from `ExternalTool`, plus
  a `dependent_result()` accessor.
- `capability_outcome_to_resolution`'s `AwaitDependentRun` arm populates it
  from the loop's `result_ref`/`byte_len`/`safe_summary`/`model_observation`
  (redaction applied), and the "model_observation has no home … dropped"
  behavior is deleted — it now rides the inline observation preview (the
  same `observation_preview` reduction Completed/SpawnedChildRun use). The
  durable `GateRecord::DependentRun` sidecar is still persisted (host
  durability); the inline payload is the loop-visible copy — the same dual
  pattern as PR-B.

Nothing consumes the new payload yet: `LoopCapabilityPort` is not flipped,
`CapabilityOutcome` is not deleted, executor consumers are untouched — the
tree stays green.

Test-first: extended the `resolution` + `resolution_mapping` acceptance
tables and added dedicated round-trip + redaction tests — an
`AwaitDependentRun` outcome round-trips its `result_ref` origin, `byte_len`,
`safe_summary`, AND `model_observation` onto `Suspension::DependentRun`, and
a secret/path-shaped summary/observation degrades (dropped / placeholder).
These fail against the pre-change tuple variant (fields absent /
model_observation dropped). The `GateRecord::DependentRun` persistence
round-trip stays green.

Verified: `cargo test -p ironclaw_host_api -p ironclaw_turns -p
ironclaw_run_state --all-features`; `cargo clippy -p ironclaw_host_api -p
ironclaw_turns --all-targets --all-features -- -D warnings`; `cargo test -p
ironclaw_architecture`; `scripts/pre-commit-safety.sh`; downstream build of
loop_host/agent_loop/runner/reborn_composition.

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

* refactor(reborn): loop-facing result becomes host_api::Resolution (§5.3 Stage 2 — the flip) (#6287)

* refactor(reborn): loop-facing result becomes host_api::Resolution (§5.3 Stage 2 — the flip)

Collapses the loop-facing capability result — the overloaded ten-variant
`CapabilityOutcome`/`CapabilityBatchOutcome` — into the five-channel
`host_api::Resolution`/`ResolutionBatch` at the `LoopCapabilityPort` trait
boundary. `CapabilityOutcome` is retained (Stage 2b deletes it); producers
convert at their boundary via `capability_outcome_to_resolution(..).resolution`.

The trait flip: `invoke_capability -> Result<Resolution, _>`,
`invoke_capability_batch -> Result<ResolutionBatch, _>`. Batch loops gate on
`Resolution::parks()` (a re-entrant `Blocked` gate stops the batch too, H1),
replacing `CapabilityOutcome::is_suspension()`. All ~49 impls + decorators/
factories migrated in dep order (turns -> loop_host -> hooks -> composition ->
runner -> agent_loop), plus every test double and the `tests/integration`
doubles.

Executor consumer rewrite (ironclaw_agent_loop): `handle_capability_outcome`,
the batch fast-path drain, `handle_capability_error` retry,
`shared_await_dependent_gate`, and `capability_batch_counts` match exhaustively
over `Resolution` (no wildcard, §11.9). host_api->loop inverse reconstruction:
loop refs from the channel's preserved `origin`; `Done` re-split on
`ToolVerdict`; model-visible failure/denial content from PR-B
(`RecoverableFailure.diagnostic`, `Denial`); the DependentRun staged result from
`Suspension::dependent_result()`.

Resume identity stays byte-stable: `approval_request_id` reconstructed
deterministically from the `gate:approval-{id}` routing ref (fingerprinted lease
byte-identical); `prior_approval` kept on the wire (host-side move deferred to
§5.3 Stage 2a-ii); `input_ref` advisory on resume (host reconstitutes from
`ReplayPayloadStore`, Stage 0); `correlation_id` observability-only, regenerated
at the loop boundary. Stage-0 resume + missing-payload fail-closed + cross-tenant
tests stay green.

Auth `credential_requirements` render-from-record: moved off the loop-facing
`Blocked::Auth` channel onto `GateRecord::Auth` (§5.2.9), keyed deterministically
by the new name-based `GateRef::for_auth_gate` (the auth gate id is
`auth-{sha256hex}`, not a uuid, so it derives a stable v5 uuid); the runner
re-reads them from the durable record at the blocked-exit application into
`TurnRunRecord.credential_requirements` — the auth analogue of approval's Stage-0
render-from-record.

Model-visible first-look preview (#5838) fixed correctly, not dropped: the
collapse mis-routed tool-result CONTENT through `SafeSummary`, a caption type
that rejects `{ } [ ] /` delimiters (all structured output) and — via a
substring `"secret"` match — scrubbed the ordinary word "Secretary" (the #6129
bug: an amnesia re-read loop that tanked benchmark scores). Ports the canonical
word-boundary credential matcher into host_api (`credential_redaction`) and
applies it to `SafeSummary` so captions like "Secretary" survive; adds
`ModelResultPreview` — the correct 24 KiB content vehicle that tolerates
delimiters/newlines and redacts only genuine credentials (word-boundary markers
+ secret-like tokens) — and re-points `Outcome.refs.preview` at it, plus
`ResultPreviewMeta` carrying the truncated-preview continuation metadata
(referenced ref, total bytes, next offset, item count) so `result_read`
pagination survives. The executor reconstructs the `ResultReference` observation
from real content, preserving the inline first-look optimization (no extra
`result_read` round-trip).

Resume-core completions surfaced driving the production integration tiers green
(the flip drops the loop-facing `correlation_id`, so every resume consumer must
reconstitute it host-side, not read it off the wire):

- Loop-host runtime resume reconstitutes `correlation_id` from the persisted
  `ReplayPayload` (keyed by invocation id) and applies it to the invocation
  context before the fingerprinted approval/auth lease is matched — the loop's
  post-flip value is advisory. Without this the lease's correlation match fails
  ("approval request does not match invocation: correlation_id") and the resume
  terminalizes (caught by `reborn_group_approvals`, `reborn_group_journeys`).
- The synthetic outbound-delivery handler (raises its OWN approval gate outside
  the loop-host persist seam) reconstitutes `correlation_id` from its own replay
  payload for the approval cross-check instead of trusting the executor's now-
  fresh `resume.correlation_id`, completing the same §5.3 Stage 2a-i render-from-
  record pattern it already applied to `{input, estimate}` (caught by
  `reborn_integration_outbound_target`).
- Deterministic gate-record keys (`for_auth_gate`, and `for_approval_request` on
  the authorize path) make a re-raised gate derive the SAME content-addressed
  key; the write-once `GateRecordStore` reports `GateRecordAlreadyExists`, which
  the host now treats as benign (first-write-wins, record byte-identical),
  mirroring the existing `ReplayPayloadAlreadyExists` tolerance — it strengthens,
  not weakens, the write-once contract (caught by the auth-convergence journey).
- The completed-result `ResultReference` observation's own producer-authored
  `summary` (the truncation/continuation hint, distinct from the generic result-
  message caption) is carried through the collapse on `ResultPreviewMeta.summary`
  so the executor rebuilds the observation with the producer's exact text rather
  than the caption (caught by `reborn_integration_tool_call`'s truncated-preview
  and array-item-count transcript assertions).

Full gate green: workspace `cargo clippy --all-targets --all-features -D
warnings`; `ironclaw_architecture`; the changed-crate unit suites (the only red
is the 3 pre-existing `llm_admin::nearai_mcp` env-var tests, unrelated); and all
51 Reborn integration bins (706 scenarios) including every §11.9 per-channel /
gate / resume / auth tier.

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

* fix(reborn): gate record keyed to returned Resolution + fail unsubmittable auth block (#6287 IronLoop)

Two blocking gate-record correctness fixes on the §5.3 Stage 2 flip:

1. Map once (capability_port.rs). `invoke_capability` mapped the outcome
   twice — once inside `persist_gate_record_for_outcome` to derive the key
   it saved under, and once via `capability_outcome_to_resolution` to build
   the return value. The approval/resource/dependent/external channels mint
   a fresh random `GateRef` per mapping (only auth is deterministic), so the
   persisted record and the returned `Resolution` pointed at different refs
   and a resume could never load the record. Now maps once and
   persists/returns the same `MappedResolution`
   (`persist_gate_record_for_outcome` -> `persist_gate_record_for_mapped`,
   no internal re-map).

2. Fail the exit on unsourceable auth requirements (turn_run_executor.rs).
   The flip made the durable `GateRecord::Auth` the ONLY source of
   `credential_requirements`, so a lookup that does not yield the record is
   a real regression: applying the block leaves an unsubmittable,
   provider-null auth gate. `enrich_auth_block_credential_requirements` now
   returns `Result`; the three lookup-result miss arms (store fault,
   `Ok(None)`, wrong kind) return `Err`, and `apply_exit` records a terminal
   failure (shared `record_exit_failure` helper) instead of applying the
   incomplete block. The two tolerant pre-conditions (no store, non-auth
   ref) stay warn+empty.

Regression tests: extend `approval_gate_outcome_persists_gate_record_at_the_seam`
to assert the returned Resolution's gate ref equals the persisted key; new
`auth_block_with_unsourceable_requirements_fails_the_exit` drives `apply_exit`
with a missing-record store and asserts a terminal failure is recorded (this
test also caught a `&'static str` lifetime bug in the helper).

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

* fix(reborn): forward dependent-run observation caption to the resumed parent (#6287 IronLoop)

`dependent_run_result_message` hardcoded `model_observation: None`, so
`append_capability_result_ref`'s synthesize-fallback re-dropped the child's
staged observation and the resumed parent saw only a bare success sentinel —
not the caption/result reference the `AwaitDependentRun` channel forwarded
before the flip. The mapping already preserves the caption on
`DependentRunResult.observation` ("model_observation now rides the inline
observation preview (was dropped entirely)"); the consumer just dropped it.

Forward it as a Success `ResultReference` observation carrying the caption as
its summary and pointing at the staged child result (`result_ref` + `byte_len`),
so the parent can `result_read` the child output. The inline first-look preview
content stays host-owned (the completed-`Outcome` path), per the mapping's
"bounded SafeSummary caption" design — so `preview` stays `None` here.

Test: `await_dependent_run_preserves_model_observation_for_replay` now asserts
the forwarded `ResultReference` observation instead of the previously-pinned
synthesized `GenericFailure` sentinel.

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

* fix(reborn): replay returns the record-backed gate ref, not a fresh mint (#6287 IronLoop)

The map-once fix made the first invocation's persisted key and returned gate
ref agree, but the replay path still diverged: on an idempotent replay the
dispatch cache returns the same CapabilityOutcome, invoke_capability re-maps it
(a fresh random GateRef for the approval/resource/dependent/external channels),
and the set-based guard then skipped the persist — so the replayed Resolution
carried a gate ref no record was ever saved under.

Turn the replay guard into a resolution cache:
`persisted_gate_resolutions: HashMap<IdempotencyKey, Resolution>`. The first
invocation reserves the key with its resolution (atomic check-and-insert under
one lock) and persists exactly one record under that resolution's gate ref; a
repeat (or concurrent duplicate) finds the reservation and returns the SAME
resolution — the one whose gate ref the record is under — instead of
re-minting. Rollback-on-failed-save is preserved so the next replay retries.

Regression: extend `replayed_gate_invocation_does_not_persist_a_duplicate_record`
to assert the replayed Resolution's gate ref equals the first invocation's and
the single persisted record's key, and that the record loads by the replayed
ref.

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

* fix(reborn): gate resolution reservation waits for durable persist (#6287 IronLoop)

The reserve-then-save replay cache had a concurrency window: a concurrent
duplicate could find the reservation and return the resolution before the
owner's save durably completed, so a transient store fault (owner rolls back
and errors) left the waiter holding a blocked resolution whose gate record was
never persisted — an unresumable gate.

Make the reservation a proper in-flight/persisted state machine mirroring the
crate's own DispatchRecordStore + wait_for_dispatch_completion:

- persisted_gate_resolutions: HashMap<IdempotencyKey, GateResolutionState>,
  where GateResolutionState = InFlight(Arc<Notify>) | Persisted(Box<Resolution>).
- The first caller reserves InFlight and is the sole persister. A concurrent
  duplicate / later replay finds InFlight and WAITS on the notify, using the
  lost-wakeup-safe pattern (create notified(), re-check the same reservation
  under the lock via gate_resolution_in_flight_matches, await only if still
  in-flight).
- The owner publishes only AFTER the durable save resolves: Persisted on
  Ok/benign GateRecordAlreadyExists, cleared on a transient fault; it wakes the
  waiters either way, so a woken waiter receives the now-durable resolution or
  re-owns and retries. No waiter receives a resolution before durable persist.

Covered by the existing replay/rollback regression tests
(replayed_gate_invocation_does_not_persist_a_duplicate_record,
failed_gate_record_persist_is_retried_on_replay), which still hold.

[skip-regression-check] deterministic single-threaded coverage is unchanged;
the concurrency window is a narrow race the existing tests already pin the
sequential behavior of, and a hermetic multi-waiter race harness is infeasible
to make reliably deterministic here.

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

* fix(reborn): wire durable gate/replay stores into the ProductLive capability port (#6287 IronLoop)

The ProductLive planned-runtime path built its capability port with only
runtime/input/result/milestone/mounts — never `.with_gate_record_store(..)` /
`.with_replay_payload_store(..)` — so on that path the gate record was never
persisted and the replay payload never reconstituted, breaking approval/auth
resumes (the local-dev path already wires both).

- `ProductLivePlannedRuntimeAdapterConfig` + `ProductLiveLoopCapabilityPortFactory`
  gain `gate_record_store` + `replay_payload_store`; `create_capability_port`
  now calls `.with_gate_record_store(..).with_replay_payload_store(..)` on the
  host-runtime factory, exactly as the local-dev path does.
- The product-workflow planned-loop harness builds both stores over ONE
  in-memory filesystem (production mount view via `wrap_scoped`) so a raise and
  its resume round-trip through the same records; the composition adapter tests
  supply in-memory-backed stores through the config.
- Adds `ironclaw_capabilities` to product-workflow dev-deps for the harness's
  `FilesystemReplayPayloadStore`.

Verified: `cargo test -p ironclaw_reborn_composition --test product_live_adapters`
passes; product-workflow planned-loop harness tests build; clippy --all-features
clean on both crates.

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

* fix(reborn): RAII guard clears orphaned gate-resolution reservation on cancel/fault (#6287 IronLoop)

The in-flight gate-resolution reservation cleaned up only on the paths that ran
to completion. If the owning persist future was cancelled (dropped mid-`save`),
or returned early before the publish block, the `InFlight` entry was orphaned
and same-key replays waited on it forever; the transient-failure cleanup relied
on code after the `await` running.

Protect the reservation with an RAII drop guard mirroring the crate's own
`DispatchReservationGuard`:

- On reserving `InFlight`, the owner holds `GateResolutionReservationGuard`.
  Any drop without `commit()` — cancellation, transient store fault, or an early
  error — calls `clear_gate_resolution_reservation`, which removes the entry
  (only if still `InFlight`, never a published resolution) and wakes its waiters
  so one re-owns and retries.
- The success / benign `GateRecordAlreadyExists` path publishes the durable
  `Persisted` resolution (`publish_gate_resolution`), wakes waiters, then
  `commit()`s the guard so its drop is a no-op.

No waiter can be left blocked on an orphaned reservation regardless of how the
owner exits.

Regression: `cancelled_gate_persist_clears_reservation_so_replay_can_re_own`
blocks the first `save` on a barrier, cancels the invocation while parked in
`save`, and asserts a replay re-owns, completes within a timeout (no hang), and
persists exactly one record. `failed_gate_record_persist_is_retried_on_replay`
covers the transient-fault cleanup path through the guard.

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

* fix(reborn): share the ProductLive gate store with the turn executor too (#6287 IronLoop)

The prior ProductLive wiring fix only wired the gate-record store into the
capability port (the persist side). The turn executor (`RebornTurnRunExecutor`,
via `DefaultPlannedRuntimeParts.gate_record_store`) still got `None`, so its
render-from-record path (`enrich_auth_block_credential_requirements`, §5.2.9)
could not read the record — an auth block was applied with empty requirements /
failed the exit on the ProductLive path.

The harness now shares ONE gate-record store between both sides: the store built
for the capability port is captured into `turn_executor_gate_store` and passed
to `DefaultPlannedRuntimeParts.gate_record_store`, so a persisted auth gate
record round-trips to the executor read. Stays `None` for the non-ProductLive
capability fakes (which do not persist gate records), preserving the executor's
tolerant "no store wired" path; `inbound_turn_contract.rs` uses
`EmptyCapabilityFactory`, so its `None` is correct and unchanged.

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

* fix(reborn): commit the dispatch reservation only after the replay-payload write (#6287 IronLoop)

`guard.commit()` ran before the fallible `persist_replay_payload_for_fresh_gate`
store write. On a transient store error the `?` returned with the dispatch
reservation still `InFlight`, and the already-committed guard skipped its
cleanup — stranding retries/duplicates of the same idempotency key waiting on
the reservation forever.

Move `guard.commit()` to AFTER the replay-payload write succeeds, so a store
error leaves the guard uncommitted; its drop then clears the reservation and
wakes waiters so one re-dispatches. The finish-runtime-outcome ordering is
unchanged.

Covered by `failed_gate_record_persist_is_retried_on_replay` and the
`replayed_gate` seam tests (the reservation-clears-then-retry path).

[skip-regression-check] the store-write-failure reservation-clear path is
exercised by the existing retry/replay seam tests; a dedicated dispatch-cancel
harness for this specific reordering is redundant with them.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(reborn): producers emit Resolution directly; delete CapabilityOutcome (§5.3 Stage 2b — collapse complete) (#6293)

* chore(reborn): anchor s2b worktree at flip base

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

* refactor(reborn): add producer-facing Resolution constructors in ironclaw_turns (§5.3 Stage 2b)

New run_profile::resolution module with completed/failed/spawned_process/
spawned_child_run (return Resolution), approval_required/auth_required/
resource_blocked/await_dependent_run/external_tool_pending (return
GatedResolution), and denied (returns DeniedResolution). Moves the non-lossy
redaction verbatim from resolution_mapping (ModelResultPreview 24 KiB
word-boundary redaction, ModelFailureDiagnostic, deterministic
GateRef::for_auth_gate auth key, resume-token carry, DependentRunResult inline
staged result, DenyReason mapping). resolution_mapping::
capability_outcome_to_resolution is retained transitionally as a thin delegator
so unmigrated producers keep compiling; RefBindings is now empty (loop refs ride
the channel origin). 17 retargeted constructor tests green.

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

* refactor(reborn): loop_host producers emit Resolution directly (§5.3 Stage 2b)

capability_port.rs: invoke_capability_dispatch now returns GatedResolution; the
seam persists the durable GateRecord from .gate_record (GateRef::for_auth_gate
key + replay-payload persistence intact) and returns .resolution — deleting the
capability_outcome_to_resolution re-map. runtime_outcome_to_loop and the
synthetic/inline dispatch arms emit Resolution/GatedResolution via the new
constructors; the runtime failure classifier returns a private LoopFailureClass
(Failed | Denied) so the per-tool display preview still stages from the raw
fields. lib.rs empty-surface denial, subagent_spawn_port (spawn gates + batch
coalescing keyed on the DependentRun channel origin), and
capability_surface_filter denials all emit Resolution directly. 378 loop_host
tests green.

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

* refactor(reborn): hooks middleware emits Resolution directly (§5.3 Stage 2b)

HookedLoopCapabilityPort::decision_to_outcome and fail_closed_gate_ref_unavailable
now return host_api Resolution (deny/approval/auth) via the producer constructors
instead of building CapabilityOutcome and re-mapping; single and batch invoke
paths consume the Resolution directly. Test double emits resolution::completed.

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

* refactor(reborn): local_dev synthetic capabilities emit Resolution directly (§5.3 Stage 2b)

SyntheticCapabilityHandler::invoke now returns host_api Resolution; the synthetic
port and external-tool port delegate it straight through (no capability_outcome_to_resolution
re-map). outbound_delivery (approval gate + denial/failure paths, internal
ApprovedResumeDecision + approval_lease_outcome now carry Resolution),
skill_activation, project_create, result_read (parse error boxed for
result_large_err), and external_tool_capability all build Resolution via the
producer constructors. Tests assert Resolution::Done recoverable-failure verdicts
and Resolution::Denied. 230 local_dev tests green; crate clippy -D warnings clean.

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

* refactor(reborn): runner tool-disclosure + subagent flavors emit Resolution directly (§5.3 Stage 2b)

tool_disclosure_port bridge helpers (invoke_bridge/tool_search/describe/
describe_first/completed_bridge_result, failed_invalid_input) and the test
double now build host_api Resolution via the producer constructors; subagent
flavors test double likewise. No capability_outcome_to_resolution re-map remains
in runner producers. Runner tests + clippy -D warnings green (all-features).

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

* refactor(reborn): agent_loop executor test fixtures build Resolution via constructors (§5.3 Stage 2b)

shared_await_dependent_gate fixtures (await_dependent/completed/approval) now use
the producer constructors directly instead of mapping a CapabilityOutcome. 401
agent_loop tests green.

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

* test(reborn): migrate agent_loop capability fixtures off CapabilityOutcome (§5.3 Stage 2b)

MockHost stores Resolution/ResolutionBatch directly; scripted_capability_outcome
maps ScriptedCapabilityOutcome -> Resolution via the producer constructors; ~90
executor test fixtures rebuilt as resolution::* + ironclaw_host_api::ResolutionBatch
(transformed by a comment/brace-aware script). resolution_from_scripted_outcome
deleted; resolution_batch_from_scripted takes Resolutions. 401 agent_loop tests green.

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

* refactor(reborn): delete CapabilityOutcome and the transitional mapping (§5.3 Stage 2b — collapse complete)

Deletes CapabilityOutcome, CapabilityBatchOutcome, CapabilityResultMessage,
CapabilityFailure, CapabilityDenied, ProcessHandleSummary from run_profile::host,
the resolution_mapping delegator (capability_outcome_to_resolution /
MappedResolution / RefBindings), and their re-exports. Retargets the last
serde-fixture tests (turn_coordinator, agent_loop_host_contract, content_digest)
onto the surviving vocabulary (CapabilityDeniedReasonKind / CapabilityProgress /
Resolution). CapabilityApprovalResume/AuthResume/ResumeToken kept (resume
requests). Architecture ratchet trims CapabilityOutcome from FROZEN_COLLAPSE_DTOS
per its own shrink-only instructions. No production code references
CapabilityOutcome; turns + architecture ratchet green.

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

* refactor(reborn): retain CapabilityResultMessage/CapabilityFailure as executor-internal vocabulary

Scope correction: these two are NOT dead payloads — the agent_loop executor
reconstructs them from host_api::Outcome (capability_result_from_outcome /
capability_failure_from_recoverable, added by the flip) and consumes them across
gates/capability_helpers/strategies. They are loop-internal working types now
(no producer emits them; documented as such). The genuinely-dead
CapabilityOutcome/CapabilityBatchOutcome/CapabilityDenied/ProcessHandleSummary
stay deleted. Workspace builds clean.

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

* test(reborn): migrate integration harness capability double off CapabilityOutcome (§5.3 Stage 2b)

RecordingTestCapabilityPort (the shared tests/integration double) builds
host_api::Resolution via the producer constructors; completed_result returns
Resolution, approval/echo paths emit resolution::approval_required/completed
directly. Unblocks every reborn_integration_* binary.

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

* docs(reborn): refresh loop_host seam comments post-collapse (§5.3 Stage 2b)

The seam TODO to delete CapabilityOutcome is done; dispatch emits GatedResolution
directly. Comment-only.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(reborn): cargo fmt the collapse tree (import ordering + line wrapping)

Formatting-only. The capability-result collapse and the -s ours main merge left
import ordering and long-line wrapping unformatted across the loop-host,
agent-loop executor tests, composition local_dev, and turns run_profile files;
`cargo fmt --all` resolves them. No logic change. Fixes the red Formatting lane
on PR #6299.

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

* fix(reborn): retain gate-record + replay-payload stores on ProductLivePlannedRuntimeAdapters (#6299 IronLoop)

`ProductLivePlannedRuntimeAdapters::from_services` moved the host-private
`gate_record_store` / `replay_payload_store` into the capability factory but did
not retain them on the bundle. The bundle is the production API for composing a
product-live planned runtime, so a consumer building the runner
(`DefaultPlannedRuntimeParts`) from it had no way to wire the SAME store the
capability port persists `GateRecord::Auth` into — it would pass
`gate_record_store: None`, and on an `AuthRequired` resume the turn executor
reloads an empty requirement set and applies an unactionable auth block (the
§5.2.9 render-from-record seam, same class as #6287 IronLoop f6/f8).

Expose both stores as `pub` fields, populated from cheap `Arc` clones taken
before the originals move into the factory, so both sides share one durable
store. Additive, no behavior change to existing consumers.

Note: the only current bundle consumer (the product-workflow planned-loop test
harness) extracts just `.capability_factory`; its runner already wires the same
store via `turn_executor_gate_store`, so the end-to-end product-live auth-gate
tests through that path pass unchanged. This closes the bundle's API gap so a
future/production runner consumer wires the store, not None.

cargo check + clippy (test-support,libsql) clean on ironclaw_reborn_composition.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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: ci CI/CD workflows scope: dependencies Dependency updates scope: docs Documentation scope: sandbox Docker sandbox size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant