Skip to content

fix(reborn): remove per-record lock convoys via shared cas_update helper - #5234

Merged
henrypark133 merged 22 commits into
mainfrom
fix/reborn-p1-cas-helper
Jul 1, 2026
Merged

henrypark133 merged 22 commits into
mainfrom
fix/reborn-p1-cas-helper

Conversation

@henrypark133

Copy link
Copy Markdown
Collaborator

Problem

Each Reborn persistence store wrapped its filesystem read-modify-write in a per-record tokio::sync::Mutex (FILESYSTEM_RECORD_LOCKS) held across .await — a redundant in-process serializer over backends that already do versioned CAS. Under burst, one writer stalled inside its critical section blocks every other writer for that scope (the convoy that contributed to the runtime wedge). PR #5142 removed exactly this from ironclaw_turns; this PR finishes the job for the remaining stores via one shared helper.

Change

Shared helper (ironclaw_filesystem::cas_update): one mutex-free, bounded read-modify-write loop — read versioned snapshot → idempotent apply closure → CAS put → on VersionMismatch re-read and retry (32×, jittered 2–50ms backoff, 15s timeout), with a fail-closed capability gate. Generic over record/outcome/error; leaks no store types.

Migrated all 5 stores onto it and deleted every per-record mutex:

  • ironclaw_turns — re-homed its local fix(turns): prevent turn-state write convoy #5142 copy onto the shared helper (one owner).
  • ironclaw_run_state, ironclaw_threads — dropped the mutex; route through the helper.
  • ironclaw_resources — gains a retry loop it never had (the lock was its only serializer).
  • ironclaw_secrets — deletes the Arc-keyed lock map → fixes an unbounded memory leak (it stored Arc, never pruned).

Post-condition: grep FILESYSTEM_RECORD_LOCKS / record_lock.lock().await / filesystem_secret_lock across the 5 store crates is empty.

Guardrail: .claude/rules/database.md records the invariant — filesystem read-modify-write must go through cas_update; never wrap it in a per-record mutex held across .await.

Tri-backend parity (no diverging experience by host)

The helper is RootFilesystem-level (backend-agnostic). Verified across all three ironclaw_filesystem backends:

  • in-memory (default): clippy 0 across the 6 crates; all store tests pass (turns/run_state/threads/resources/secrets).
  • postgres (--features postgres): CAS contract tests pass — postgres_native_put_cas_absent_rejects_existing_path, postgres_native_put_cas_any_increments_existing_version, postgres_transaction_rollback_discards_prior_put_after_later_cas_conflict.
  • libsql (--features libsql): CAS contract tests pass. (One unrelated append/dir contract test is flaky under parallel suite execution — passes 3/3 in isolation; db.rs is untouched by this PR, so it is pre-existing test-infra flakiness, not a regression.)

Notes

🤖 Generated with Claude Code

henrypark133 and others added 7 commits June 24, 2026 19:01
Extract the proven mutex-free CAS pattern from ironclaw_turns (PR #5142)
into one shared helper in ironclaw_filesystem so the remaining stores can
drop their per-record tokio::sync::Mutex convoys.

cas_update runs a bounded read-modify-write loop: read versioned snapshot,
run the caller's idempotent apply closure, CAS-put with the read version,
and on VersionMismatch re-read and retry with jittered exponential backoff
(2ms..50ms), capped at 32 retries and wrapped in a 15s timeout. A
fail-closed capability gate rejects backends that cannot CAS rather than
silently blind-overwriting. The helper is generic over the record type,
the outcome, and the caller's error; it never leaks store-specific types.

No store migrated yet — that follows in the next commits.

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

ironclaw_resources had NO CAS-retry loop — its per-record tokio::sync::Mutex
(FILESYSTEM_RECORD_LOCKS) held across the backend get/put awaits was the ONLY
serializer. Under burst, one writer stalled inside its critical section parked
every other same-scope writer (the convoy that contributed to the 2026-06-24
runtime wedge). Naively deleting that mutex without a retry loop loses updates:
a racing writer's single-attempt CAS detects the version mismatch and errors
out (cross-process CAS contention) instead of retrying.

Route update_snapshot through ironclaw_filesystem::cas_update (bounded CAS
retry + jittered backoff + 15s timeout + fail-closed capability gate) and
delete the per-record mutex, FilesystemRecordLock, the local PutError, and the
fail-open put_with_cas (CasExpectation::Any blind-overwrite fallback). The
helper fails closed instead; verified safe — these store aliases only ever
resolve to CAS-capable db/in-memory backends in production.

The public update API widens from FnOnce to FnMut because the helper re-runs
the closure against a freshly read snapshot on every CAS retry; leaf closures
that previously moved captured values now clone per invocation. Add PartialEq
to BudgetGateSnapshot (helper needs S: Clone + PartialEq).

Red->green regression: cas_snapshot::tests::concurrent_increments_have_no_lost_updates
proves RED on lock-removed-no-retry (lost updates / spurious contention) and
GREEN through the helper (every concurrent increment lands, no convoy).

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

run_state held BOTH a store-local CAS-retry loop AND a per-record
tokio::sync::Mutex (FILESYSTEM_RECORD_LOCKS) across the backend get/put awaits
— belt-and-suspenders where the mutex was a redundant in-process serializer
over a backend that already does versioned CAS. Under burst the mutex convoyed
same-scope writers behind one stalled writer (the 2026-06-24 wedge pattern).

Route apply_update, update_status, start, and save_pending through
ironclaw_filesystem::cas_update (one shared CAS implementation: bounded retry +
jittered backoff + 15s timeout + fail-closed capability gate). Delete the
store-local FILESYSTEM_CAS_RETRIES loop, the per-record lock + accessor + its
two unit tests, the local PutError, and the fail-open put_with_cas
(CasExpectation::Any blind-overwrite fallback). The scope-ownership check and
the approval Pending guard move inside the re-runnable apply closure; the
ApprovalStatus guard returns Err from apply. discard_pending keeps its
read-then-delete, only the lock is dropped.

The run_state contract tests previously ran against LocalFilesystem (byte-only,
no versioned CAS) where the OLD fail-open fallback masked the missing CAS.
Production never routes run_state to LocalFilesystem — only to CAS-capable
db/in-memory backends — so the tests now use InMemoryBackend, matching the
production capability shape (CAS) instead of a non-production blind-overwrite path.

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

ensure_thread held the only per-record tokio::sync::Mutex
(FILESYSTEM_RECORD_LOCKS) in this crate, across the backend get/put awaits, to
serialize the check-then-create against Unsupported(WriteFile)-fallback
backends. Route it through ironclaw_filesystem::cas_update: the apply closure
returns the existing record unchanged (no-op, no write) when the thread already
exists with a matching scope, or builds the fresh StoredThreadRecord when
absent. A concurrent create-if-absent loser hits VersionMismatch, the helper
re-reads and re-runs apply which now sees the winner and reconciles scope —
exactly the old single-reconcile semantics, now via the shared CAS-retry loop
without any lock. Delete the lock infra (Weak map + accessor); add PartialEq to
StoredThreadRecord (helper needs S: Clone + PartialEq). The three other
record/txn loops were already lock-free and are left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cord mutex (Arc leak fix)

The secrets filesystem store kept FILESYSTEM_RECORD_LOCKS as a HashMap of
*strong* Arc<Mutex<()>> (not Weak), one entry per (scope,lease)/session path
ever seen, NEVER pruned — an unbounded memory leak. The per-record mutex was
also held across the backend get/put awaits, convoying same-key writers behind
one stalled writer (the 2026-06-24 wedge pattern).

Route consume/revoke/consume_session_use through ironclaw_filesystem::cas_update
(one shared CAS implementation), mapping the local CasDecision onto CasApply:
Commit/BestEffortCommit -> changed snapshot; Settle(Ok) -> unchanged snapshot
(helper skips the write via PartialEq no-op); Settle(Err)/not-found -> apply
error. Crypto decrypt + use-count increment run inside the re-runnable apply
closure (pure / recomputed from the freshly read record each retry).
validate_session is a pure read — its lock is simply deleted. Delete the entire
lock map (leak gone), cas_mutate/CasDecision/CAS_RETRY_ATTEMPTS, and the
fail-open put_with_version_fallback (helper fails closed). Add PartialEq to
StoredLease and StoredSession.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PR #5142 removed the per-record mutex from ironclaw_turns but left a local copy
of the CAS read-modify-write loop (apply_with_retry, put_with_cas, PutError,
cas_retry_backoff + constants). Re-home it onto ironclaw_filesystem::cas_update
so there is ONE CAS implementation across the codebase. Behavior is preserved:
the 18 call sites and their closure shape (FnMut(InMemoryTurnStateStore) -> Fut)
are unchanged; the exact timeout/exhaustion error strings are preserved.

A BridgeError<T> sentinel carries the absent-record + default-snapshot no-op
through the helper's apply-error channel (the helper's own no-op check only
fires for Some(existing)==new; the turns store additionally must skip creating a
file for an empty default store, which the old new==old check covered because
read returned default on absent). The 500ms SNAPSHOT_READ_CACHE_TTL read cache
is kept as a separate layer: cleared around the CAS call and repopulated on the
next read (the helper does its own fresh get and does not surface the new
RecordVersion; every read_snapshot caller already discards the version, so
caching None is safe). Delete the now-unused local loop, put_with_cas, PutError,
cas_retry_backoff, and the duplicate CAS constants.

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

Add the invariant to crates/ironclaw_filesystem/CLAUDE.md (#2 "CAS is the
floor") and a pointer in .claude/rules/database.md: every filesystem
read-modify-write must go through the one shared ironclaw_filesystem::cas_update
helper; never wrap it in a per-record tokio::sync::Mutex held across the backend
.await (redundant serializer + convoy/wedge risk + leak). Also commit Cargo.lock
(async-trait dev-dep added to ironclaw_resources for the convoy regression test).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5234 June 25, 2026 11:53 Destroyed
@github-actions github-actions Bot added scope: docs Documentation scope: dependencies Dependency updates size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels Jun 25, 2026

@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 migrates multiple persistence stores (ironclaw_resources, ironclaw_run_state, ironclaw_threads, ironclaw_secrets, and ironclaw_turns) to use a shared, lock-free compare-and-swap (CAS) helper (cas_update). This eliminates redundant per-record mutexes held across .await boundaries, preventing runtime-wedging convoys under high contention. Feedback on the changes highlights an opportunity to improve the jitter calculation in cas_retry_backoff using RandomState to avoid zero-jitter scenarios in low clock resolution environments (such as VMs or Windows). Additionally, it is recommended to explicitly add Send bounds to the closure generic constraints in cas_update and cas_update_loop to catch any non-Send regressions at the helper's definition site.

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 +311 to +325
async fn cas_retry_backoff(attempt: usize) {
let shift = attempt.min(8) as u32;
let multiplier = 1_u32.checked_shl(shift).unwrap_or(u32::MAX);
let base_delay = FILESYSTEM_CAS_BACKOFF_BASE
.saturating_mul(multiplier)
.min(FILESYSTEM_CAS_BACKOFF_MAX);
let jitter = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| {
let jitter_ceiling = base_delay.as_millis().max(1);
Duration::from_millis((elapsed.as_nanos() % jitter_ceiling) as u64)
})
.unwrap_or_default();
tokio::time::sleep(base_delay.saturating_add(jitter)).await;
}

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.

high

Using SystemTime::now() for jitter calculation can lead to zero jitter on systems with low clock resolution (e.g., 1ms resolution in VMs, containers, or Windows). Since base_delay.as_millis() is at most 50, and 1,000,000 (1ms in nanoseconds) is divisible by all common backoff ceilings (2, 4, 8, 16, 32, 50), the modulo operation elapsed.as_nanos() % jitter_ceiling will always evaluate to exactly 0. This completely defeats the purpose of jitter under high contention.

Instead, use std::collections::hash_map::RandomState to obtain a high-quality, thread-safe, and un-correlated pseudo-random value seeded from the OS without adding external dependencies.

Suggested change
async fn cas_retry_backoff(attempt: usize) {
let shift = attempt.min(8) as u32;
let multiplier = 1_u32.checked_shl(shift).unwrap_or(u32::MAX);
let base_delay = FILESYSTEM_CAS_BACKOFF_BASE
.saturating_mul(multiplier)
.min(FILESYSTEM_CAS_BACKOFF_MAX);
let jitter = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| {
let jitter_ceiling = base_delay.as_millis().max(1);
Duration::from_millis((elapsed.as_nanos() % jitter_ceiling) as u64)
})
.unwrap_or_default();
tokio::time::sleep(base_delay.saturating_add(jitter)).await;
}
async fn cas_retry_backoff(attempt: usize) {
let shift = attempt.min(8) as u32;
let multiplier = 1_u32.checked_shl(shift).unwrap_or(u32::MAX);
let base_delay = FILESYSTEM_CAS_BACKOFF_BASE
.saturating_mul(multiplier)
.min(FILESYSTEM_CAS_BACKOFF_MAX);
let jitter = {
use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hash, Hasher};
let mut hasher = RandomState::new().build_hasher();
attempt.hash(&mut hasher);
let hash = hasher.finish();
let jitter_ceiling = base_delay.as_millis().max(1) as u64;
Duration::from_millis(hash % jitter_ceiling)
};
tokio::time::sleep(base_delay.saturating_add(jitter)).await;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 0eaf69d — jitter now derives from a RandomState-seeded hash (clock-independent), so it no longer collapses to 0 on coarse-resolution clocks. See cas_retry_backoff in crates/ironclaw_filesystem/src/cas.rs.

Comment on lines +194 to +208
pub async fn cas_update<F, S, T, E, D, N, A, Fut>(
filesystem: &ScopedFilesystem<F>,
scope: &ResourceScope,
path: &ScopedPath,
decode: D,
encode: N,
mut apply: A,
) -> Result<T, CasUpdateError<E>>
where
F: RootFilesystem + ?Sized,
S: PartialEq + Clone,
D: Fn(&[u8]) -> Result<S, E>,
N: Fn(&S) -> Result<Entry, E>,
A: FnMut(Option<S>) -> Fut,
Fut: Future<Output = Result<CasApply<S, T>, E>>,

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

According to the repository's general rules, async helper functions that return futures intended to be Send should explicitly add Send bounds to their closure generic constraints. This ensures that any non-Send regressions are caught at the helper's definition site rather than at the call site.

pub async fn cas_update<F, S, T, E, D, N, A, Fut>(
    filesystem: &ScopedFilesystem<F>,
    scope: &ResourceScope,
    path: &ScopedPath,
    decode: D,
    encode: N,
    mut apply: A,
) -> Result<T, CasUpdateError<E>>
where
    F: RootFilesystem + ?Sized,
    S: PartialEq + Clone + Send,
    T: Send,
    E: Send,
    D: Fn(&[u8]) -> Result<S, E> + Send,
    N: Fn(&S) -> Result<Entry, E> + Send,
    A: FnMut(Option<S>) -> Fut + Send,
    Fut: Future<Output = Result<CasApply<S, T>, E>> + Send,
References
  1. In Rust, explicitly add Send bounds to closure generic constraints in async helper functions that return futures intended to be Send. This ensures that non-Send regressions are caught at the helper's definition site rather than at the call site, even if the future is already bound by Send at the call site.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Considered but declined. Adding Send to the helper generics forces propagating Send across consumer-crate generics (run_state mutate: M, turns persistence T) — threading a bound through multiple layers for marginal "catch at the def site" benefit, which .claude/rules/architecture.md flags as bound-creep. The futures are already Send-constrained where awaited/spawned.

Comment on lines +226 to +240
async fn cas_update_loop<F, S, T, E, D, N, A, Fut>(
filesystem: &ScopedFilesystem<F>,
scope: &ResourceScope,
path: &ScopedPath,
decode: &D,
encode: &N,
apply: &mut A,
) -> Result<T, CasUpdateError<E>>
where
F: RootFilesystem + ?Sized,
S: PartialEq + Clone,
D: Fn(&[u8]) -> Result<S, E>,
N: Fn(&S) -> Result<Entry, E>,
A: FnMut(Option<S>) -> Fut,
Fut: Future<Output = Result<CasApply<S, T>, E>>,

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

According to the repository's general rules, async helper functions that return futures intended to be Send should explicitly add Send bounds to their closure generic constraints. This ensures that any non-Send regressions are caught at the helper's definition site rather than at the call site.

async fn cas_update_loop<F, S, T, E, D, N, A, Fut>(
    filesystem: &ScopedFilesystem<F>,
    scope: &ResourceScope,
    path: &ScopedPath,
    decode: &D,
    encode: &N,
    apply: &mut A,
) -> Result<T, CasUpdateError<E>>
where
    F: RootFilesystem + ?Sized,
    S: PartialEq + Clone + Send,
    T: Send,
    E: Send,
    D: Fn(&[u8]) -> Result<S, E> + Send,
    N: Fn(&S) -> Result<Entry, E> + Send,
    A: FnMut(Option<S>) -> Fut + Send,
    Fut: Future<Output = Result<CasApply<S, T>, E>> + Send,
References
  1. In Rust, explicitly add Send bounds to closure generic constraints in async helper functions that return futures intended to be Send. This ensures that non-Send regressions are caught at the helper's definition site rather than at the call site, even if the future is already bound by Send at the call site.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Same decision as the cas_update bound above — declined to avoid propagating Send across consumer-crate generics for marginal benefit.

@coderabbitai

coderabbitai Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Filesystem-backed read-modify-write paths now use cas_update with fail-closed gating, bounded retries, and timeout handling. Run-state, resources, secrets, threads, and turns were migrated off local lock/CAS loops, and discarded approval-state handling was updated.

Changes

Shared filesystem CAS migration

Layer / File(s) Summary
Contract and public API surface
.claude/rules/database.md, crates/ironclaw_filesystem/CLAUDE.md, docs/plans/2026-06-25-cas-migration.md, crates/ironclaw_filesystem/src/scoped.rs, crates/ironclaw_filesystem/src/lib.rs, crates/ironclaw_filesystem/Cargo.toml, crates/ironclaw_resources/Cargo.toml, crates/ironclaw_run_state/tests/approval_resolution_contract.rs, crates/ironclaw_capabilities/src/helpers.rs
Filesystem invariants and migration notes now require cas_update; ScopedFilesystem exposes capabilities; the crate root exports the CAS helper, types, and timing constants; and discarded approval-state mapping is added at the capability layer.
cas_update loop and tests
crates/ironclaw_filesystem/src/cas.rs, crates/ironclaw_filesystem/src/cas/tests.rs
The shared helper performs capability-gated retries, timeout, backoff, and mapped error handling, with behavior covered by tests.
CasSnapshotStore and resource retries
crates/ironclaw_resources/src/cas_snapshot.rs, crates/ironclaw_resources/src/filesystem_store.rs, crates/ironclaw_resources/src/lib.rs
CasSnapshotStore, resource-governor updates, and budget-gate updates switch to shared CAS retries and re-runnable closures, and the affected snapshot types derive PartialEq.
Run-state record updates
crates/ironclaw_run_state/src/lib.rs, crates/ironclaw_run_state/tests/*, crates/ironclaw_product_workflow/src/approval_interaction/service.rs
FilesystemRunStateStore and approval-request storage move creation, mutation, discard tombstones, and record filtering onto cas_update while removing the local lock map and legacy CAS write helpers; discarded-state handling and tests align to the new status.
Secret, broker, and thread CAS updates
crates/ironclaw_secrets/src/filesystem_store.rs, crates/ironclaw_threads/src/filesystem_service.rs, crates/ironclaw_host_runtime/tests/reborn_durable_restart_integration.rs
Secret, credential-broker, and thread persistence paths now use shared CAS retries, with updated derives, record kinds, error mapping, and in-memory backend wiring for durable test coverage.
Turn-state CAS update and cache handling
crates/ironclaw_turns/src/filesystem_store.rs, crates/ironclaw_turns/src/filesystem_store/tests.rs, crates/ironclaw_turns/tests/filesystem_turn_state_contract.rs, tests/support/reborn/*
Turn-state writes route through cas_update with cache clearing, overlay preservation, and no-op handling, and the regression tests and harness wiring cover the resulting cache/version behavior.

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

  • nearai/ironclaw#5002: Direct overlap in crates/ironclaw_threads/src/filesystem_service.rs, including thread record persistence and update flow changes.
  • nearai/ironclaw#5142: Same turn-state snapshot write path migration to bounded CAS-style retry behavior in crates/ironclaw_turns/src/filesystem_store.rs.
  • nearai/ironclaw#5232: Adjacent ironclaw_turns runner-lease sidecar and CAS/heartbeat overlay behavior.

Suggested reviewers

  • think-in-universe

Poem

A rabbit hopped through retry light,
Where CAS kept each old write in sight.
Discarded gates went soft and still,
While snapshots danced with bounded will.
🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the change, but it omits most required template sections like change type, linked issue, validation, rollback, and review track. Reformat it to the repo template and add the missing sections: Summary, Change Type, Linked Issue, Validation, Security Impact, Trust-Boundary Checklist, Database Impact, Blast Radius, Rollback Plan, Review Follow-Through, and Review track.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: replacing per-record lock convoys with the shared cas_update helper.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

self.filesystem.delete(scope, &path).await?;

P1 Badge Guard discard deletes with CAS

When discard_pending races in the same process with approve or deny for the same request, this unconditional delete can now run after the resolver's CAS-protected status update and remove the terminal approval record. The removed per-record lock used to serialize that get/check/delete sequence with status updates; without either that lock or a CAS/tombstone transition here, a user approval can be lost and later look like UnknownApprovalRequest rather than Approved.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

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

⚠️ Outside diff range comments (1)
crates/ironclaw_run_state/src/lib.rs (1)

922-932: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make discard_pending atomic before dropping the lock.

This path reads Pending, then deletes later with no CAS expectation. A concurrent approve()/deny() can win the CAS update between Line 922 and Line 932, then this delete removes a non-pending approval record. Use a CAS-aware delete/transactional transition, or model discard as a CAS status update instead of read-then-delete. As per path instructions: “Fail loud” and filesystem read-modify-write paths must use the CAS invariant rather than split multi-step state changes.

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

In `@crates/ironclaw_run_state/src/lib.rs` around lines 922 - 932, The
discard_pending flow in RunState::discard_pending is currently split into a
read/validate step and a later filesystem delete, which can race with
approve()/deny() and remove a record after its status has already changed.
Update this path to be atomic by using a CAS-aware transition or transactional
delete that enforces the same status invariant at the point of mutation, rather
than relying on a prior Pending check; keep the failure path loud if the
expected state no longer matches.

Sources: Coding guidelines, Path instructions

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

Inline comments:
In `@crates/ironclaw_filesystem/src/cas.rs`:
- Line 193: The new Clippy allow on cas.rs needs the required arch-exempt
rationale comment immediately above #[allow(clippy::too_many_arguments)]. Update
the same site near the cas:: function signature that triggered the lint by
adding the mandatory // arch-exempt: too_many_args, <reason naming the missing
aggregation>, plan `#NNNN` annotation, and make sure the reason justifies why this
many parameters is unavoidable until the missing aggregation is introduced.
- Around line 295-306: The CAS preflight is treating
BackendCapabilities::default() as “unknown,” which lets a backend with no
advertised capabilities bypass the fail-closed gate. Update capabilities_known()
in cas.rs to only treat an explicit unknown state as unknown and not overload
BackendCapabilities::empty()/default(), then make the cas_update path rely on
that check so unsupported backends still return CasUpdateError::CasUnsupported
instead of falling back to op-time behavior.
- Around line 262-265: The no-op shortcut in cas_update_loop currently only
returns early when current is Some(existing) and matches the snapshot, while
absent records still fall through to encoding and writing; update the logic to
either treat None plus an unchanged/default snapshot as a true no-op or adjust
the CasApply/cas_update contract and surrounding comments to state the shortcut
only applies to existing records. Use the existing cas_update_loop and CasApply
symbols to keep the behavior and docs aligned, and remove any need for
downstream NoOp suppression if you choose to enforce it here.

In `@crates/ironclaw_filesystem/src/cas/tests.rs`:
- Around line 184-399: Add a regression test in cas/tests.rs for the timeout
path in cas_update, since the current tests cover retries, CAS rejection, and
apply errors but never exercise CasUpdateError::Timeout. Introduce a tiny wedged
backend or stub that blocks one of the CAS operations (get, put, or apply), and
use paused Tokio time so the outer timeout in cas_update deterministically
fires. Reference cas_update and CasUpdateError::Timeout in the new test, and
assert that the timeout branch wins over the hung backend behavior.

In `@crates/ironclaw_resources/src/cas_snapshot.rs`:
- Line 212: The CasUpdateError::Backend handling in cas_snapshot.rs currently
forwards the backend error string directly via E::storage_from(inner), which can
leak virtual paths and raw backend details into StorageError. Update this
mapping to return a stable sanitized public message from the error conversion
path, and preserve the detailed backend error only in internal diagnostics or
source context associated with E::storage_from.

In `@crates/ironclaw_run_state/src/lib.rs`:
- Line 1109: The `CasUpdateError::Backend` branch in `RunStateError` currently
converts the filesystem failure to a string, which drops the structured
`FilesystemError` context. Update this match arm in `lib.rs` to preserve the
typed error by using the existing `?`-style conversion path or direct typed
`From`/`Into` conversion used elsewhere in this file, so the `FilesystemError`
variant and its path/operation details continue to propagate through
`RunStateError::Filesystem`.

In `@crates/ironclaw_secrets/src/filesystem_store.rs`:
- Around line 479-483: The no-op CAS comment in filesystem_store.rs is
inaccurate: the `already_marked` branch in the lease update path returns
`Err(SecretStoreError::LeaseExpired { lease_id })` and goes through
`CasUpdateError::Apply`, not the unchanged record/`PartialEq` skip path. Update
the comment to describe the actual intent, or change the branch in the relevant
lease/CAS helper to return `CasApply::new(lease, Err(...))` if that is the
desired behavior; use the `already_marked`, `SecretLeaseStatus::Expired`, and
`CasUpdateError::Apply` symbols to locate and align the code with the documented
contract.

In `@crates/ironclaw_turns/src/filesystem_store.rs`:
- Around line 378-385: The CAS error mapper in map_cas_error should not panic on
BridgeError::NoOp, since this is production error-handling code. Replace the
unreachable! fallback in map_cas_error with a typed unavailable TurnError that
preserves context about the unexpected NoOp leak, while keeping the
BridgeError::Real(inner) path unchanged and consistent with CasUpdateError
handling.

In `@docs/plans/2026-06-25-cas-migration.md`:
- Around line 137-140: The quality gate in the migration plan is outdated and
should match the repo’s required backend validation for persistence changes.
Update the “Quality gate” section to include the feature-isolation `cargo check`
matrix for the default/postgres build, `--no-default-features --features
libsql`, and `--all-features`, and strengthen the clippy step to the full `cargo
clippy --all --benches --tests --examples --all-features -- -D warnings`. Keep
the existing formatting/tests coverage, but ensure the plan explicitly reflects
the required checks for the legacy persistence migration.

---

Outside diff comments:
In `@crates/ironclaw_run_state/src/lib.rs`:
- Around line 922-932: The discard_pending flow in RunState::discard_pending is
currently split into a read/validate step and a later filesystem delete, which
can race with approve()/deny() and remove a record after its status has already
changed. Update this path to be atomic by using a CAS-aware transition or
transactional delete that enforces the same status invariant at the point of
mutation, rather than relying on a prior Pending check; keep the failure path
loud if the expected state no longer matches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1c594037-4929-451d-97ad-73e096ed325d

📥 Commits

Reviewing files that changed from the base of the PR and between 44f063d and 3223540.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (17)
  • .claude/rules/database.md
  • crates/ironclaw_filesystem/CLAUDE.md
  • crates/ironclaw_filesystem/src/cas.rs
  • crates/ironclaw_filesystem/src/cas/tests.rs
  • crates/ironclaw_filesystem/src/lib.rs
  • crates/ironclaw_filesystem/src/scoped.rs
  • crates/ironclaw_resources/Cargo.toml
  • crates/ironclaw_resources/src/cas_snapshot.rs
  • crates/ironclaw_resources/src/filesystem_store.rs
  • crates/ironclaw_resources/src/lib.rs
  • crates/ironclaw_run_state/src/lib.rs
  • crates/ironclaw_run_state/tests/approval_resolution_contract.rs
  • crates/ironclaw_run_state/tests/run_state_contract.rs
  • crates/ironclaw_secrets/src/filesystem_store.rs
  • crates/ironclaw_threads/src/filesystem_service.rs
  • crates/ironclaw_turns/src/filesystem_store.rs
  • docs/plans/2026-06-25-cas-migration.md

Comment thread crates/ironclaw_filesystem/src/cas.rs Outdated
Comment thread crates/ironclaw_filesystem/src/cas.rs Outdated
Comment thread crates/ironclaw_filesystem/src/cas.rs
Comment thread crates/ironclaw_filesystem/src/cas/tests.rs
Comment thread crates/ironclaw_resources/src/cas_snapshot.rs
Comment thread crates/ironclaw_run_state/src/lib.rs
Comment thread crates/ironclaw_secrets/src/filesystem_store.rs
Comment thread crates/ironclaw_turns/src/filesystem_store.rs Outdated
Comment thread docs/plans/2026-06-25-cas-migration.md Outdated
@railway-app

railway-app Bot commented Jun 25, 2026 •

Copy link
Copy Markdown

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

Service Status Web Updated (UTC)
ironclaw ✅ Success (View Logs) Web Jun 30, 2026 at 11:37 pm

serrrfirat added a commit that referenced this pull request Jun 25, 2026
Fold in PR review (henrypark133) + a durable-write hot-path audit. Adds §12
framing the deployed lease-expiry cascade as three independent layers:
- Layer A: in-process lock convoy → #5234 (open); write-behind composes on the
  post-#5234 CAS path.
- Layer B: pool starvation — DEFAULT_POSTGRES_POOL_MAX_SIZE=2 shared across all
  Postgres FS I/O. Notes the existing 30s checkout guard (closes the infinite
  hang) but flags pool-too-small + checkout(30s)>apply(15s); cheap mitigations
  (raise pool, reserve a critical connection, align checkout<apply) prior to and
  complementary with write-behind.
- Layer C: the synchronous hot-path write map (events / governor / thread-append
  / lease / memory) with the write-behind-vs-batch-coalesce distinction. Events
  are the top batch-coalesce target (highest churn, O(1) INSERT no CAS); must
  stay DURABLE (source of truth), per-step flush to preserve live SSE. Memory
  stays synchronous (FTS-only, no embedding write; agent-initiated, low churn).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve PR #5234 review comments across the shared cas_update helper
and its consumers:

- cas: replace SystemTime-based jitter (collapses to 0 on coarse clocks)
  with a RandomState-seeded value; drop the now-unnecessary
  too_many_arguments allow (helper has 6 args, below clippy threshold).
- cas: add explicit CasApply::no_op so absent-record no-ops are signalled
  directly; make the contract doc true. Deletes the BridgeError::NoOp
  "success-through-an-error-channel" hack in ironclaw_turns (E is now
  TurnError directly) and simplifies map_cas_error.
- run_state: fix a TOCTOU race in FilesystemApprovalRequestStore::
  discard_pending (Codex P1). The non-atomic get->check->delete could
  remove a record an approve()/deny() CAS had just transitioned. Route
  discard through cas_update as a version-checked tombstone transition
  (new ApprovalStatus::Discarded); get/records_for_scope filter it so it
  still reads as gone. Adds a no-clobber regression test.
- turns/secrets: correct inaccurate CAS no-op comment; replace an
  unreachable!() panic in map_cas_error with a typed Unavailable error.
- add a deterministic CasUpdateError::Timeout regression test (paused
  Tokio time + hanging backend).
- docs: restore the feature-isolation cargo check matrix and full clippy
  gate in the migration plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5234 June 25, 2026 22:24 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
crates/ironclaw_product_workflow/src/approval_interaction/service.rs (1)

158-164: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Classify discarded approvals as stale before the persistent branch.

For persistent == true, ApprovalStatus::Discarded hits Line 163 and returns AlwaysAllowUnsupported, so the new stale arm at Line 199 is unreachable for that path.

Proposed fix
-        if matches!(status, ApprovalStatus::Denied | ApprovalStatus::Expired) {
+        if matches!(
+            status,
+            ApprovalStatus::Denied | ApprovalStatus::Expired | ApprovalStatus::Discarded
+        ) {

As per coding guidelines, WebUI-facing facade errors must expose the stable sanitized taxonomy for blocked approval/auth/resource and stale/conflict states.

Also applies to: 199-199

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

In `@crates/ironclaw_product_workflow/src/approval_interaction/service.rs` around
lines 158 - 164, The approval gating in
approval_interaction::service::ApprovalInteractionService should classify
ApprovalStatus::Discarded as stale before the persistent-only unsupported
branch. Update the early status checks around the approval_rejected flow so the
stale rejection path is taken for Discarded regardless of persistent, and ensure
the later stale arm remains reachable with the sanitized StaleGate taxonomy
instead of falling through to AlwaysAllowUnsupported.

Source: Coding guidelines

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

Inline comments:
In `@crates/ironclaw_filesystem/src/cas.rs`:
- Around line 102-114: The no-op early returns in cas_update can return a
snapshot that is no longer current, which then gets cached by
filesystem_store::apply_from_snapshot and similar callers. Update cas_update so
the equality and explicit no_op branches either revalidate the record/version
before returning or avoid exposing a cacheable “final” snapshot from those
paths; use the cas_update, CasApply::new, and CasApply::no_op branches as the
main touchpoints. Also adjust the surrounding contract/comments so they only
promise what is actually enforced after concurrent writes.

In `@crates/ironclaw_run_state/tests/run_state_contract.rs`:
- Around line 491-510: The regression test in
filesystem_discard_does_not_clobber_resolved_approval is still sequential and
does not force the TOCTOU interleaving it describes. Update this test to use a
store/filesystem harness that can pause discard_pending after it reads a Pending
record, then let approve run on the same request before discard_pending resumes
its write/CAS path. Use FilesystemApprovalRequestStore and
discard_pending/approve to verify the resolved approval is preserved and the
terminal record is not clobbered.

In `@docs/plans/2026-06-25-cas-migration.md`:
- Around line 141-145: The quality gate currently uses cargo fmt --all, which
reformats files instead of validating CI-style formatting; update the checklist
entry in the plan to use the repo’s check-mode formatting command instead. Make
this change in the validation list alongside the other cargo check/clippy steps
so the migration plan matches the “Before You Open a PR” requirement and remains
authoritative.

---

Outside diff comments:
In `@crates/ironclaw_product_workflow/src/approval_interaction/service.rs`:
- Around line 158-164: The approval gating in
approval_interaction::service::ApprovalInteractionService should classify
ApprovalStatus::Discarded as stale before the persistent-only unsupported
branch. Update the early status checks around the approval_rejected flow so the
stale rejection path is taken for Discarded regardless of persistent, and ensure
the later stale arm remains reachable with the sanitized StaleGate taxonomy
instead of falling through to AlwaysAllowUnsupported.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e8a5fca8-3068-48d7-92df-53d101912fa5

📥 Commits

Reviewing files that changed from the base of the PR and between 3223540 and 0eaf69d.

📒 Files selected for processing (10)
  • crates/ironclaw_capabilities/src/helpers.rs
  • crates/ironclaw_filesystem/Cargo.toml
  • crates/ironclaw_filesystem/src/cas.rs
  • crates/ironclaw_filesystem/src/cas/tests.rs
  • crates/ironclaw_product_workflow/src/approval_interaction/service.rs
  • crates/ironclaw_run_state/src/lib.rs
  • crates/ironclaw_run_state/tests/run_state_contract.rs
  • crates/ironclaw_secrets/src/filesystem_store.rs
  • crates/ironclaw_turns/src/filesystem_store.rs
  • docs/plans/2026-06-25-cas-migration.md

Comment thread crates/ironclaw_filesystem/src/cas.rs
Comment thread crates/ironclaw_run_state/tests/run_state_contract.rs
Comment thread docs/plans/2026-06-25-cas-migration.md Outdated
Reconcile the CAS-migration work with main, notably PR #5232 (durable
runner-lease sidecar) which independently rewrote
ironclaw_turns/filesystem_store.rs — splitting it into io/profile_resolver/
projection/runner_lease submodules and adding a per-run lease sidecar.

Resolution of crates/ironclaw_turns/src/filesystem_store.rs:
- Keep main's module split + runner-lease sidecar feature.
- Keep this PR's routing of the turn-state snapshot RMW through the shared
  ironclaw_filesystem::cas_update helper (no inline CAS loop, no BridgeError;
  E is TurnError directly; CasApply::no_op for the inert-snapshot no-op).
- The cas_update apply callback applies the runner-lease overlay per retry and
  uses the OVERLAID snapshot as the no-op baseline, matching main's pre-merge
  `new_snapshot == old_snapshot` semantics so an active lease overlay does not
  force a spurious write each apply. Covered by a new regression test
  (filesystem_turn_state_store_no_op_under_active_lease_overlay_does_not_rewrite_snapshot).
- service.rs: add ApprovalStatus::Discarded to the early stale-gate guard for
  consistency with the exhaustive match arms.

Known scoped exception, tracked in #5274: the runner-lease sidecar still uses a
local put_with_cas + cas_retry_backoff loop rather than cas_update. Documented
in crates/ironclaw_filesystem/CLAUDE.md; the main snapshot RMW does go through
cas_update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5234 June 25, 2026 23:30 Destroyed
@henrypark133

Copy link
Copy Markdown
Collaborator Author

Addressed the Codex P1 (and CodeRabbit's matching outside-diff note) on discard_pending racing approve()/deny().

Fixed in 0eaf69d: the non-atomic get → check Pending → delete is replaced with a version-checked CAS transition through cas_update to a new ApprovalStatus::Discarded tombstone (mirrors update_status). If an approve()/deny() wins the CAS first, the discard's put hits VersionMismatch, re-reads, sees a non-Pending status, and returns ApprovalNotPending — so a resolved approval is never clobbered. get and records_for_scope filter Discarded, so a discarded request still reads as gone (contract unchanged). The in-memory store was left as-is — its get→check→remove is already atomic under one mutex.

Regression test added: filesystem_discard_does_not_clobber_resolved_approval (approve, then assert discard_pending returns ApprovalNotPending and the record stays Approved).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (2)
crates/ironclaw_product_workflow/src/approval_interaction/service.rs (2)

312-332: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Use debug! for these fallback diagnostics.

These new warn! calls are internal diagnostics on a best-effort path. In this repo, warn!/info! from runtime flows are reserved out because they corrupt the interactive REPL/TUI display. As per path instructions, REPL/TUI logging: info!/warn! corrupt the terminal UI — internal diagnostics use debug!.

Suggested diff
-            tracing::warn!(
+            tracing::debug!(
                 error = %error,
                 capability_id = %policy.key.capability_id,
                 action = ?policy.key.action,
                 "persistent approval policy write failed after approval resolution"
             );
@@
-            tracing::warn!(
+            tracing::debug!(
                 error = %error,
                 capability_id = %override_key.capability_id,
                 "tool permission override clear failed after persistent approval"
             );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/ironclaw_product_workflow/src/approval_interaction/service.rs` around
lines 312 - 332, These fallback diagnostics in approval_interaction::service,
specifically the tracing calls inside the persistent_policies.allow and
tool_permission_overrides.clear error paths, should use debug! instead of warn!
because runtime warn/info logging can disrupt the REPL/TUI. Update both logging
sites to debug! while keeping the same error and capability context so the
internal best-effort failure reporting remains available without corrupting
terminal UI.

Source: Path instructions


306-333: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Route persistent-approval side effects through the approval layer.

This service now calls PersistentApprovalPolicyStore::allow() and ToolPermissionOverrideStore::clear() directly from ironclaw_product_workflow. That bypasses the approval boundary and leaves the durable allow + override-clear sequence owned by product code instead of the approval layer that already owns resolution semantics. Move this behind ApprovalResolutionPort (or a dedicated approval-layer port) and invoke that here. As per path instructions, Approve/deny decisions must go through ApprovalResolutionPort and TurnCoordinator; product/WebUI code must not directly execute tools or mutate approval stores ad hoc.

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

In `@crates/ironclaw_product_workflow/src/approval_interaction/service.rs` around
lines 306 - 333, Route the durable approval side effects out of
approval_interaction::service::persist_allow_policy and behind the approval
boundary instead of calling PersistentApprovalPolicyStore::allow() and
ToolPermissionOverrideStore::clear() directly. Introduce or use an
ApprovalResolutionPort-owned method that performs the allow-then-clear sequence,
and have persist_allow_policy delegate to that port so the approval layer
remains the single owner of resolution semantics and store mutation.

Source: Path instructions

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

Outside diff comments:
In `@crates/ironclaw_product_workflow/src/approval_interaction/service.rs`:
- Around line 312-332: These fallback diagnostics in
approval_interaction::service, specifically the tracing calls inside the
persistent_policies.allow and tool_permission_overrides.clear error paths,
should use debug! instead of warn! because runtime warn/info logging can disrupt
the REPL/TUI. Update both logging sites to debug! while keeping the same error
and capability context so the internal best-effort failure reporting remains
available without corrupting terminal UI.
- Around line 306-333: Route the durable approval side effects out of
approval_interaction::service::persist_allow_policy and behind the approval
boundary instead of calling PersistentApprovalPolicyStore::allow() and
ToolPermissionOverrideStore::clear() directly. Introduce or use an
ApprovalResolutionPort-owned method that performs the allow-then-clear sequence,
and have persist_allow_policy delegate to that port so the approval layer
remains the single owner of resolution semantics and store mutation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a098d8a1-20cb-419f-920f-fe4f83865273

📥 Commits

Reviewing files that changed from the base of the PR and between 0eaf69d and 2097c6d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (5)
  • crates/ironclaw_filesystem/CLAUDE.md
  • crates/ironclaw_product_workflow/src/approval_interaction/service.rs
  • crates/ironclaw_turns/src/filesystem_store.rs
  • crates/ironclaw_turns/src/filesystem_store/tests.rs
  • crates/ironclaw_turns/tests/filesystem_turn_state_contract.rs
💤 Files with no reviewable changes (3)
  • crates/ironclaw_turns/src/filesystem_store/tests.rs
  • crates/ironclaw_turns/tests/filesystem_turn_state_contract.rs
  • crates/ironclaw_turns/src/filesystem_store.rs

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Replace per-record filesystem mutexes with a shared CAS update helper across Reborn stores to eliminate lock convoys and related leaks.

Stats: 6 findings kept (from 8 raw, 6 after filtering/dedup) across 3 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 1. Filtered: 2 low-signal local-pattern doc notes.

All surviving findings are Medium severity, so I am leaving this as a comment review rather than requesting changes.

Security

  1. Medium Discarded approvals now keep the full request payload on disk (crates/ironclaw_run_state/src/lib.rs:947-956, confidence 88) — anchor: crates/ironclaw_run_state/src/lib.rs:947
    discard_pending now rewrites the filesystem record as Discarded instead of deleting it, but it keeps the full original ApprovalRequest body in the tombstone. That means a cancelled approval's action, reason, requester, and fingerprint remain recoverable from disk/backups even though public APIs hide discarded records. This is a data-minimization regression on an approval control-plane record.
    Fix: Store only the minimal tombstone data needed to reserve the request ID, or keep the discarded-ID reservation in a compact side index instead of retaining the full approval payload.

Tests

  1. Medium Missing test for op-time unsupported-write fail-closed path (crates/ironclaw_filesystem/src/cas.rs:337-340, confidence 95) — anchor: crates/ironclaw_filesystem/src/cas.rs:337
    The helper covers the pre-flight non-CAS rejection, but the second fail-closed path is untested: an unknown/default-capability backend can read successfully and then return FilesystemError::Unsupported { operation: WriteFile } from put. That branch maps to CasUpdateError::CasUnsupported and should be protected because it is the composite-router fallback path.
    Fix: Add tests::cas::unsupported_write_file_maps_to_cas_unsupported using a default-capabilities backend whose read succeeds and whose CAS write returns Unsupported(WriteFile).

Performance / Concurrency

  1. Medium CAS helper clones the full snapshot on every retry (crates/ironclaw_filesystem/src/cas.rs:299-307, confidence 86) — anchor: crates/ironclaw_filesystem/src/cas.rs:306
    cas_update clones the decoded snapshot before every apply, and several migrated call sites clone again when building CasApply::new(...). For large snapshots such as turn state, each write now copies the whole record at least twice, and contention multiplies that cost across the retry loop. That is avoidable churn on the hot persistence path this PR is trying to improve.
    Fix: Pass apply a borrowed snapshot or split the helper API so the helper retains one owned copy for equality/write checks while call sites clone only when they truly need ownership.

Conventions

  1. Medium Filesystem discard now blocks ID reuse, but the in-memory store does not (crates/ironclaw_run_state/src/lib.rs:947-956, confidence 82) — anchor: crates/ironclaw_run_state/tests/run_state_contract.rs:459 (no diff position — body only)
    The filesystem store now writes a Discarded tombstone, so a later save_pending with the same request ID is rejected even though get and records_for_scope hide the record. The in-memory store still removes the record outright, so tests and early wiring can accept a reused approval ID that production rejects. That diverges the store contract introduced by this PR.
    Fix: Make InMemoryApprovalRequestStore::discard_pending preserve a discarded tombstone, or separately track discarded IDs so save_pending rejects reused IDs in both backends.

Maintainability

  1. Medium Turn-state apply has become a 1k-line orchestration knot (crates/ironclaw_turns/src/filesystem_store.rs:381-533, confidence 95) — anchor: crates/ironclaw_turns/src/filesystem_store.rs:381
    This refactor pushes FilesystemTurnStateStore from 961 to 1051 lines, and the new apply flow now mixes cache invalidation, runner-lease overlay, transient store construction, FnMut adaptation, CAS no-op detection, timeout policy, and cache update/rollback in one method. That is a lot of state to hold in one control flow, especially in the turn-state persistence path.
    Fix: Split the CAS-specific orchestration into smaller helpers, or move runner-lease overlay/cache bookkeeping behind a focused helper so apply coordinates the transition at a higher level.
  2. Medium The apply timeout is now defined in two places (crates/ironclaw_turns/src/filesystem_store.rs:77-77, confidence 86) — anchor: crates/ironclaw_turns/src/filesystem_store.rs:77
    The turn store keeps its own 15-second FILESYSTEM_APPLY_TIMEOUT, while the shared CAS helper now owns the same 15-second loop timeout. The outer timeout comment says this may be shorter for tests, but the default value is still duplicated, so a future timeout-policy change can drift the turn store away from the helper contract.
    Fix: Use the shared ironclaw_filesystem::FILESYSTEM_APPLY_TIMEOUT as the turn-store default, or remove the outer default when the helper deadline is the only intended policy.

status: record.status,
});
}
// Write a Discarded tombstone so the file still exists (preventing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Discarded approvals now keep the full request payload on disk.

discard_pending now rewrites the filesystem record as Discarded instead of deleting it, but it keeps the full original ApprovalRequest body in the tombstone. That means a cancelled approval's action, reason, requester, and fingerprint remain recoverable from disk/backups even though public APIs hide discarded records. This is a data-minimization regression on an approval control-plane record.

Fix: Store only the minimal tombstone data needed to reserve the request ID, or keep the discarded-ID reservation in a compact side index instead of retaining the full approval payload.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferred (consistency, not a discard-specific bug). ApprovalRecord.request is non-optional and Approved/Denied records already retain the identical full payload on disk — the Discarded tombstone is consistent with every other terminal state. Singling out discard for minimization would be the inconsistency. If approval-payload retention matters it's a cross-cutting policy (pruning/redaction of all terminal approval records) plus a schema change (optional/redacted request), which is out of scope for this PR. Tracking as a retention follow-up.

cas_retry_backoff(attempt).await;
}
// 5b. Backend cannot CAS-write — fail closed (no blind overwrite).
Err(FilesystemError::Unsupported {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Missing test for op-time unsupported-write fail-closed path.

The helper covers the pre-flight non-CAS rejection, but the second fail-closed path is untested: an unknown/default-capability backend can read successfully and then return FilesystemError::Unsupported { operation: WriteFile } from put. That branch maps to CasUpdateError::CasUnsupported and should be protected because it is the composite-router fallback path.

Fix: Add tests::cas::unsupported_write_file_maps_to_cas_unsupported using a default-capabilities backend whose read succeeds and whose CAS write returns Unsupported(WriteFile).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 578e971 — added unsupported_write_file_maps_to_cas_unsupported: a default/unknown-capability backend whose get succeeds and whose put returns Unsupported{WriteFile}, asserting cas_update maps it to CasUpdateError::CasUnsupported. This exercises the op-time fallback path, distinct from the already-tested pre-flight rejection.

snapshot,
outcome,
write,
} = apply(current.clone())

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — CAS helper clones the full snapshot on every retry.

cas_update clones the decoded snapshot before every apply, and several migrated call sites clone again when building CasApply::new(...). For large snapshots such as turn state, each write now copies the whole record at least twice, and contention multiplies that cost across the retry loop. That is avoidable churn on the hot persistence path this PR is trying to improve.

Fix: Pass apply a borrowed snapshot or split the helper API so the helper retains one owned copy for equality/write checks while call sites clone only when they truly need ownership.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferred (low-value perf). Confirmed two in-memory clones per attempt: cas_update clones current (needed — retained for the equality check after apply consumes its copy) and the turns call site clones new_snapshot (threaded into outcome to populate the read cache). Both are struct copies, not IO, and the loop only re-clones on VersionMismatch (rare). Eliminating them requires an API change (return the final snapshot from cas_update, or hash-based equality) for marginal gain — premature without a profile showing turn-state writes are hot. Will revisit with a benchmark.

// there is no lock contention in practice.
let apply = Arc::new(Mutex::new(apply));

let cas_future = cas_update(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — Turn-state apply has become a 1k-line orchestration knot.

This refactor pushes FilesystemTurnStateStore from 961 to 1051 lines, and the new apply flow now mixes cache invalidation, runner-lease overlay, transient store construction, FnMut adaptation, CAS no-op detection, timeout policy, and cache update/rollback in one method. That is a lot of state to hold in one control flow, especially in the turn-state persistence path.

Fix: Split the CAS-specific orchestration into smaller helpers, or move runner-lease overlay/cache bookkeeping behind a focused helper so apply coordinates the transition at a higher level.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed it's doing a lot, deferred to #5274. The clean move is extracting the per-retry transform (overlay + store build + no-op detection) into a named helper so apply reads as orchestration. Follow-up #5274 (migrate the runner-lease sidecar onto cas_update) already rewrites this exact apply/CAS path, so the decomposition lands there — once — rather than re-churning a just-merged file twice. Added to #5274's scope. (Note: most of the 961→1051 growth is main's own module split landing via the merge; net the file is smaller than this branch's pre-merge 1455.)

/// unrelated callers behind one wedged operation.
pub const FILESYSTEM_CAS_RETRIES: usize = 32;
/// Deadline for the entire read-modify-write loop (including all retries).
pub const FILESYSTEM_APPLY_TIMEOUT: Duration = Duration::from_secs(15);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Medium — The apply timeout is now defined in two places.

The turn store keeps its own 15-second FILESYSTEM_APPLY_TIMEOUT, while the shared CAS helper now owns the same 15-second loop timeout. The outer timeout comment says this may be shorter for tests, but the default value is still duplicated, so a future timeout-policy change can drift the turn store away from the helper contract.

Fix: Use the shared ironclaw_filesystem::FILESYSTEM_APPLY_TIMEOUT as the turn-store default, or remove the outer default when the helper deadline is the only intended policy.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 578e971 — the turn store now uses the shared ironclaw_filesystem::FILESYSTEM_APPLY_TIMEOUT instead of a duplicated local constant; the per-store override via with_apply_timeout (used in tests) is unchanged.

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5234 June 30, 2026 21:23 Destroyed
Resolve import-union conflict in ironclaw_threads/filesystem_service.rs vs
#5455 (row-native sequence primitive): keep this branch's cas_update imports
(CasApply/CasUpdateError/RecordKind/cas_update for the ensure_thread migration)
plus main's SeqNo (row-native sequence). Rest auto-merged: main's row-native
reserve_sequence coexists with this branch's cas_update ensure_thread.

Refresh the put_with_cas exception note (comment + filesystem CLAUDE.md): #5455
made reserve_sequence row-native, so the local CAS loop's callers are now
write_new_message / reserve_sequence_via_thread_record (legacy fallback) /
apply_message_update / append_capability_display_preview / create_summary_artifact
+ message indices. Tracked by #5469.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5234 June 30, 2026 21:40 Destroyed

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Remove Reborn per-record filesystem mutex convoys by migrating stores to a shared bounded CAS update helper.

Stats: 6 findings kept (from 8 raw final-head reviewer candidates, 6 after dedupe/filter) across 6 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 1.

tests

  1. Medium Discarded approval gates lack interaction coverage (crates/ironclaw_product_workflow/src/approval_interaction/service.rs:183-189, confidence 100) — anchor: crates/ironclaw_product_workflow/src/approval_interaction/service.rs:183
    This diff adds ApprovalStatus::Discarded to the approve/deny stale-gate handling, but approval_interaction_contract has no Discarded fixture coverage. A regression could route a discarded tombstone through approval, persistent allow, or denial side effects instead of returning StaleGate.
    Fix: Add approval_interaction_contract coverage such as discarded_gate_returns_stale_without_resolution for ApproveOnce, AlwaysAllow, and Deny on ApprovalStatus::Discarded.

  2. Medium Resource CAS retry exhaustion is untested through callers (crates/ironclaw_resources/src/cas_snapshot.rs:263-267, confidence 75) — anchor: crates/ironclaw_resources/src/cas_snapshot.rs:265
    CasSnapshotStore now maps CasUpdateError::RetriesExhausted into storage errors, and resources gained a retry loop it did not previously have. The helper test covers persistent VersionMismatch, and resource tests cover concurrency and CasUnsupported, but no resource caller test drives persistent VersionMismatch through FilesystemResourceGovernorStore or FilesystemBudgetGateStore to pin the caller-level error mapping.
    Fix: Add a resource_governor_contract test such as filesystem_resource_governor_store_exhausts_cas_retries that uses a CAS-capable backend returning persistent VersionMismatch and asserts the ResourceError::Storage path.

  3. Medium Durable restart test no longer uses durable run-state storage (crates/ironclaw_host_runtime/tests/reborn_durable_restart_integration.rs:51-55, confidence 75) — anchor: crates/ironclaw_host_runtime/tests/reborn_durable_restart_integration.rs:54
    The approval restart test now shares one InMemoryBackend across service-graph rebuilds. That still covers service reconstruction, but it no longer proves the CAS-migrated run-state and approval-request records survive a real durable backend reopen like production libSQL/Postgres storage.
    Fix: Add or retarget a feature-gated integration test that runs this approval resume flow over a CAS-capable durable backend reopen, for example libSQL, instead of only sharing an in-memory backend Arc.

conventions

  1. Low Resource store docs overstate writer overlap (crates/ironclaw_resources/src/filesystem_store.rs:15-20, confidence 75) — anchor: AGENTS.md:84
    The module docs say same-scope writers overlap instead of convoying, but this store still routes updates through CasSnapshotStore's single-consumer AsyncStorageWorker when handles are cloned. The lower-level CasSnapshotStore docs correctly say same-process writers sharing one handle still serialize, so this top-level guarantee is too broad.
    Fix: Soften the docs to say backend CAS is lock-free and removes the per-record mutex, while the current sync worker bridge still serializes cloned-handle resource updates until the async-store follow-up.

local-patterns

  1. Low CAS test comments point at stale source line numbers (crates/ironclaw_filesystem/src/cas/tests.rs:578-578, confidence 100) — anchor: crates/ironclaw_filesystem/src/cas/tests.rs:578
    The new test comment says the Unsupported-to-CasUnsupported arm is at cas.rs ~lines 337-340, but those lines now cover the VersionMismatch backoff branch. Other nearby regression comments use the same brittle approximate anchors, which sends future readers to the wrong branch while debugging the helper.
    Fix: Drop the numeric cas.rs line references and describe the exercised branch by symbol or match arm instead.

  2. Low submit_turn comment still references the removed async lock (crates/ironclaw_turns/src/filesystem_store.rs:612-614, confidence 100) (no diff position — body only) — anchor: crates/ironclaw_turns/src/filesystem_store.rs:612
    This PR routes turn writes through cas_update, but submit_turn still says profile resolution runs early so the resolver future is not held across the per-path async lock. That stale mental model makes the current retry behavior harder to reason about.
    Fix: Rewrite the comment around the current reason: resolve the profile once before the retryable CAS closure so retries reuse a pre-resolved resolver and do not repeat provider/profile resolution.

Comment thread crates/ironclaw_resources/src/cas_snapshot.rs
Comment thread crates/ironclaw_filesystem/src/cas/tests.rs Outdated
Comment thread crates/ironclaw_resources/src/filesystem_store.rs Outdated
…mment accuracy

Five reviewer comments (henrypark133, 2026-06-30 22:32):

Tests (pin existing behavior; mutation-verified):
- product_workflow: discarded_gate_returns_stale_without_resolution +
  expired_gate_returns_stale_without_resolution — ApprovalStatus::Discarded/
  Expired gates return StaleGate for ApproveOnce/AlwaysAllow/Deny with zero
  resolution side effects (approval/denial/resume counts all 0). Neither status
  was fixtured before.
- resources: filesystem_resource_governor_store_surfaces_storage_error_on_
  persistent_version_mismatch — drives FilesystemResourceGovernorStore through a
  PersistentVersionMismatchBackend (ported from secrets' AlwaysRacingBackend) so
  cas_update exhausts all 32 retries, pinning CasUpdateError::RetriesExhausted ->
  ResourceError::Storage at the caller boundary.
- host_runtime: approval_resume_survives_durable_libsql_reopen_and_consumes_
  lease_once (#[cfg(feature = "libsql")]) — reopens a real on-disk LibSqlRoot
  Filesystem across each service-graph rebuild (vs the prior shared-InMemory
  backend), proving CAS-migrated run-state/approval records survive a durable
  reopen. DurableServices/mount helper generified over F: RootFilesystem; the
  in-memory test is unchanged.

Docs/comments:
- filesystem cas/tests.rs: replace all 8 brittle/stale numeric `cas.rs ~line N`
  anchors in regression comments with match-arm/symbol descriptions (3 were
  stale, pointing at the VersionMismatch backoff arm after line drift).
- resources filesystem_store.rs module doc: scope the lock-free "overlap" claim
  to cross-process CAS contention; note same-process cloned-handle writers still
  serialize on AsyncStorageWorker until #5470 makes the store async.

No production logic changed. Full --all-features clippy clean (pre-existing
criterion bench deprecations only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-5234 June 30, 2026 23:31 Destroyed

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Code Review (multi-agent)

Intent: Remove Reborn per-record persistence lock convoys by migrating remaining stores to a shared mutex-free CAS update helper.

Stats: 0 findings (from 0 raw, 0 after dedup) across 0 files. Reviewers run: security, bugs, performance, tests, conventions, local-patterns, maintainability, approach. Reviewers failed: none. Body-only: 0.

No new issues survived review against head 1b0e97a30d14dd5f44eda97364365ad603a71151. Existing live review threads were treated as prior discussion and not duplicated; the current-head pass found the previously flagged fail-closed, no-op, retry, docs, and caller-level test gaps either fixed, explicitly deferred with rationale, or already covered by existing review comments.

@henrypark133
henrypark133 merged commit 41f6c57 into main Jul 1, 2026
108 checks passed
@henrypark133
henrypark133 deleted the fix/reborn-p1-cas-helper branch July 1, 2026 00:13
henrypark133 added a commit that referenced this pull request Jul 7, 2026
…InMemory store parity (#5661)

* fix(run_state): InMemoryApprovalRequestStore tombstones discard, not deletes (#5467)

discard_pending removed the record outright, so a later save_pending
for the same request id succeeded silently — diverging from
FilesystemApprovalRequestStore's already-shipped (#5234) tombstone
semantics. Mutate to ApprovalStatus::Discarded in place instead,
filter Discarded out of get/records_for_scope, matching the
filesystem store exactly.

InMemoryApprovalRequestStore is LocalOnly per
production_wiring.rs::classify_component_type, reachable only via a
cargo build/run of ironclaw_reborn_cli with neither --features libsql
nor --features postgres (Dockerfile.reborn always passes both, and
tests/integration/ always compiles ironclaw_reborn_composition with
libsql per root Cargo.toml, so it wires FilesystemApprovalRequestStore
regardless of StorageMode). This fixes a genuine cross-backend
divergence for the unfeatured local-dev path and crate-tier tests; it
does not change any tests/integration/ or Docker-deployed behavior.

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

* test(capabilities): pin discard-reuse invariant through the real rollback caller

capability_host_does_not_orphan_approval_when_run_block_fails already
drives host.rs's real save_pending-then-block_approval rollback path
(the only production caller of discard_pending). Extend it to also
prove the reuse-blocking invariant: after rollback, a fresh
save_pending for the discarded request's id must fail closed with
ApprovalRequestAlreadyExists, not silently succeed.

Adds a RecordingApprovalStore spy (records the id at save_pending
time, since run_state.fail() clears RunRecord.approval_request_id
before the test can read it back).

Confirmed RED pre-fix (temporarily reverted the prior commit's
InMemoryApprovalRequestStore::discard_pending diff, restoring the old
records.remove(&key) behavior): save_pending after the rollback
returned Ok(..) instead of Err(ApprovalRequestAlreadyExists). Restored
the fix and re-verified GREEN before committing.

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

* test(integration): discard-then-resubmit coverage over the real approval store

No int-tier test drove discard_pending before this (grep -rl discard
tests/integration/ only matched a doubles-file doc comment). The only
production caller (host.rs's rollback-on-partial-failure path) fires
on a crash-window race a scripted submit_turn can't naturally trigger
without new fault-injection harness surface, so this drives
save_pending -> discard_pending -> save_pending directly against the
group's real wired FilesystemApprovalRequestStore (reached via
HostRuntimeCapabilityHarness::approval_requests_store(), mirroring how
local_dev_approval_test_parts() exposes the same store elsewhere)
rather than a hand-built store.

Expected to pass today: documents already-correct filesystem-parity
behavior shipped in #5234, does not pin a currently-broken invariant.
Wired into approvals_group_e2e only (StorageMode::InMemory) -- the
approval-request store is always on-disk regardless of StorageMode,
same rationale as approval_request_persists_after_reopen's C-DURABLE
note, so no StorageMode::LibSql variant is needed.

Mutation-verified both assertions independently:
- Commented out the discard_pending call (no-op-discard stub): the
  get()-must-be-None assertion failed with "discarded record must not
  be readable via get()".
- Temporarily made FilesystemApprovalRequestStore::discard_pending
  delete the file after its CAS tombstone write (simulating the
  old in-memory-style delete bug): the reuse-block assertion failed
  with "expected save_pending to fail closed on a discarded request
  id". Reverted both mutations before committing; git diff against
  crates/ironclaw_run_state/src/lib.rs is clean (byte-identical to the
  prior commit).

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

* docs(run_state): trim discard-tombstone comments to lean form

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

* docs(capabilities): trim discard-reuse spy/test comments to lean form

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

* docs(integration): trim discard-then-resubmit comments to lean form

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

* test(integration): real parallel CAS-contention scenario (#5466)

scenario_concurrent_dual_gate_resume.rs's own module doc deferred this:
"truly parallel read-modify-write turns against the shared CAS
turn-state store is a separate, prod-relevant concern orthogonal to
what this scenario proves." This is that follow-up: two threads
tokio::join!ed through submit -> gate-resolve -> terminal-wait against
the group's ONE shared FilesystemTurnStateStore<InMemoryBackend> (the
same concrete CAS-over-RootFilesystem mechanism prod uses regardless
of StorageMode).

#5466 measured ~10% of single-attempt real-parallel exchanges landing
on a sanitized exit_application_failed catch-all instead of Completed.
Bounded retry (3 attempts, fresh run_ids each) plus a category
allow-list keeps steady-state CI green without silently swallowing a
different, unreported failure mode under the same category. Every
tolerated retry emits a grep-able stdout line so a drift in the real
flake rate is visible in CI logs, not just invisible until the retry
bound itself starts failing.

wait_for_terminal (builder.rs) extracts a shared private poll loop
(poll_until_terminal_condition) also used by wait_for_status, so the
30s/10ms literals have one home instead of two copies to drift.

StorageMode::LibSql is deliberately excluded: #5466 reports the libsql
variant SIGABRTs the whole test process (consistent with
SQLITE_MISUSE unwinding across an extern "C" boundary), which would
crash every other test in this [[test]] binary.

Verification:
- Mutation-verified the cross-bleed assertion: swapped thread B's
  file-absence check to read thread A's path (which legitimately gets
  written) and confirmed the test fails with "workspace file
  parallel_a_1.txt exists ... but should not"; reverted.
- Ran the scenario 20x locally (10x default, 10x --test-threads=1): all
  20 passed cleanly with zero occurrences of the tolerated-flake log
  line. The local InMemoryBackend + fast filesystem does not reproduce
  #5466's race in this environment, so the retry path is unverified
  empirically here; its correctness rests on code review + the
  cross-bleed mutation-verify above, not an observed tolerated-flake
  run.

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

* fix(integration): restore byte-identical wait_for_status timeout text

The poll_until_terminal_condition extraction (#5466 lane) lost
wait_for_status's original "timed out waiting for {expected:?}"
timeout wording, generalizing it to "timed out waiting for terminal
condition" for both callers. No test parsed the exact text, but the
lane's own review bar required byte-identical wait_for_status
semantics across the extraction. Parameterize the shared loop with a
timeout_context so wait_for_status keeps its original message and
wait_for_terminal gets its own descriptive one.

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

* docs(integration): review fixes — doc exception + poll helper rename

Document discard_then_resubmit as a store-direct exception in the
group_approvals module doc, and rename poll_until_terminal_condition
to poll_run_state_until since wait_for_status also uses it for
non-terminal BlockedApproval/BlockedAuth waits.

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

* test: relocate discard-tombstone coverage to store contract tier

Drop the store-direct discard_then_resubmit scenario from
group_approvals: it carried a second execution model in a suite whose
value is turn-driven E2E purity, and its assertions were already pinned
at the store contract tier plus the real CapabilityHost rollback caller.
Extend the run_state tombstone contract tests with the missing
resolution path: approve()/deny() on a discarded id must reject with
ApprovalNotPending(Discarded) on both stores (mutation-verified RED).
Narrow the group module doc's gate-path guarantee accordingly.

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

---------

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

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-5234 — 1b0e97a3 Deployed Jun 30, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant