fix: undo() peeks without popping, breaking repeated undo and leaking redo stack - #71
Conversation
… redo stack undo() used self.undo_stack.back() (peek) instead of pop_back(), so repeated undo always returned the same checkpoint while pushing to the redo stack unboundedly. Additionally, redo() did not save the current state to the undo stack, breaking the undo/redo cycle. Changes: - undo(): change back() to pop_back(), return owned Checkpoint - redo(): accept current_turn/current_messages params, save current state to undo stack before popping from redo stack - Update process_undo/process_redo callers in agent_loop.rs - Add tests for repeated undo, undo/redo cycling, stack size invariant
Summary of ChangesHello @AI-Reviewer-QS, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses critical issues in the undo/redo mechanism, ensuring that these operations behave as expected. The changes prevent state loss, allow for proper traversal through historical states, and maintain the integrity of the undo and redo stacks. The core logic of state management for these features has been refactored and thoroughly tested to provide a robust and reliable experience. Highlights
Changelog
Activity
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request effectively addresses critical bugs in the undo/redo functionality, ensuring the undo operation correctly pops from the stack and the redo operation properly saves the current state. However, a security audit identified two significant issues: a high-severity deadlock risk in agent_loop.rs caused by inconsistent locking order between Session and UndoManager, and a critical IDOR vulnerability in the maybe_hydrate_thread method that allows unauthorized access to conversation threads. It is strongly recommended to implement ownership checks in maybe_hydrate_thread and standardize the locking order to prevent deadlocks. Additionally, there is a suggestion to refactor a small piece of duplicated code for improved maintainability.
Address review feedback: - Standardize lock order (Session before UndoManager) in process_undo and process_redo to match process_user_input and prevent deadlocks - Extract push_undo() helper to deduplicate push-and-trim logic shared by checkpoint() and redo()
|
@ilblackdragon can you help me do a code review for this PR? Thank you! |
Code Review: Fix undo() peeks without poppingPR Details
SummaryThis PR fixes a critical bug in the undo/redo stack implementation where
The fix properly changes Pros✅ Correct core fix: Changing ✅ Symmetric undo/redo design: Both operations now properly save current state before restoring from their respective stacks, maintaining a consistent state management approach. ✅ Excellent test coverage: Added three comprehensive tests:
✅ Deadlock prevention: Reordered lock acquisition in both ✅ Reduced cloning: Removed unnecessary ✅ Code cleanup: Extracted ✅ Return type ownership: Changing from Concerns
Suggestions
ConclusionStatus: ✅ Approve This is a well-structured PR that correctly fixes a critical bug with proper test coverage. The changes are well-thought-out, with good attention to deadlock prevention and performance optimization. The test additions significantly increase confidence in the fix and prevent regression. The minor concerns are mostly about documentation and verification rather than actual bugs. I recommend merging once the documentation updates are applied (or as a follow-up). |
Address review feedback requesting documentation about the ownership semantics of undo/redo parameters and the stack size invariant.
|
Thanks for the thorough review @tribendu! Addressed the actionable feedback in the latest push (6530614): Done:
Already addressed in prior commit (05db445):
Out of scope / noted:
|
ilblackdragon
left a comment
There was a problem hiding this comment.
Review: Approve
This PR correctly identifies and fixes two real bugs in UndoManager:
Bug Fixes (Correct)
-
undo()peeked instead of popping --undo_stack.back()returned a reference without removing the entry, so repeated undos always returned the same checkpoint and the redo stack grew unboundedly. Changing topop_back()and returning an ownedCheckpointis the right fix. -
redo()did not save current state to undo stack -- After redoing, the pre-redo state was lost, breaking undo-after-redo cycles. Addingcurrent_turnandcurrent_messagesparameters toredo()and pushing them onto the undo stack before popping redo mirrors the symmetric design ofundo().
Lock Ordering Fix (Correct)
The PR also fixes a latent deadlock hazard in process_undo and process_redo. Previously:
process_undo/process_redo: lockedundo_mgrfirst, thensessionprocess_user_input(checkpoint code at line ~816): lockedsessionfirst, thenundo_mgr
Opposite lock ordering on two mutexes is a classic deadlock setup. The PR moves session.lock().await before get_undo_manager + undo_mgr.lock().await in both functions, matching the order used in process_user_input. This is a valuable incidental fix.
Code Quality
- The
push_undo()helper consolidates the push-and-trim logic previously duplicated incheckpoint(), reducing the chance of future divergence. Clean refactor. - Return type change from
Option<&Checkpoint>toOption<Checkpoint>eliminates the need for.clone()in the caller and the awkward "extract values before consuming the reference" dance. Good simplification. - The
redo()early return guard (if self.redo_stack.is_empty()) is necessary -- without it, callingredo()on an empty redo stack would still push to the undo stack, silently corrupting state. - Doc comments on the struct and both methods clearly describe the invariant (
undo_count() + redo_count()stays constant across undo/redo cycles). Helpful for future maintainers.
Test Coverage
The three new tests are well-designed:
test_repeated_undo_advances_through_stack-- directly reproduces the original bug (two undos must return different checkpoints)test_undo_redo_cycle_preserves_state-- verifies the full undo->redo->undo round-triptest_undo_redo_stack_sizes_consistent-- verifies the count invariant documented on the struct
Existing tests are correctly updated for the new signatures.
Minor Observations (Non-blocking)
-
pop_undo()is now dead code -- It has zero callers in the codebase (before or after this PR). Consider removing it or adding#[allow(dead_code)]with a comment explaining its purpose if it's part of a public API contract. Not blocking since clippy CI passed (it may be suppressed by thepubvisibility). -
Trimming in
push_undoduringredo()-- Whenmax_checkpointsis small and a user does many redo operations,push_undowill trim the oldest undo entries. This is correct bounded-memory behavior, but it means redo can silently reduce how far back you can undo. The doc comment could mention this, but the current behavior is reasonable.
CI is green (fmt, clippy, tests all pass). The core fix is correct, the lock ordering improvement is a welcome bonus, and test coverage is thorough.
GitHub PR Review Batch 3 - nearai/ironclawPR #71: Fix undo/redo deadlock and checkpoint behaviorSummaryThis PR addresses critical concurrency issues in the undo/redo system. The main changes are:
Pros
Concerns
Suggestions
PR #66: Update README architecture diagram to use Unicode box drawingSummaryThis PR updates the README.md architecture diagram from ASCII art to Unicode box drawing characters. The change improves readability and visual appeal of the system architecture diagram, using characters like Pros
Concerns
Suggestions
PR #63: Memory Guardian and Cognitive RoutinesSummaryThis is a large PR (894 lines of new code) adding a comprehensive memory management system with two layers: Cognitive Routines (Prompt-Level):
Memory Guardian (System-Level):
The PR also adds line number tracking to memory chunks for citation support (V9 migration), checkpoint tracker to Thread struct, and extensive testing (15 new tests). Pros
Concerns
Suggestions
PR #62: Add Tinfoil private inference providerSummaryThis PR adds support for Tinfoil as a new LLM backend. The changes include:
Pros
Concerns
Suggestions
PR #61: Add PostgreSQL test workflow for CISummaryThis PR adds a PostgreSQL with pgvector service to the GitHub Actions test workflow. The changes enable running tests against a real PostgreSQL database instead of libSQL-only testing. The service is configured with pgvector/pgvector:pg16 image and health checks. Pros
Concerns
Suggestions
PR #57: Sandbox job monitoring and credential grants persistenceSummaryThis is a large PR (~6333 lines touched) introducing several features:
Pros
Concerns
Suggestions
SummaryHighest Quality PRs
Needs Splitting
Minor Concerns
Recommendations
|
… redo stack (nearai#71) * fix: undo() peeks without popping, breaking repeated undo and leaking redo stack undo() used self.undo_stack.back() (peek) instead of pop_back(), so repeated undo always returned the same checkpoint while pushing to the redo stack unboundedly. Additionally, redo() did not save the current state to the undo stack, breaking the undo/redo cycle. Changes: - undo(): change back() to pop_back(), return owned Checkpoint - redo(): accept current_turn/current_messages params, save current state to undo stack before popping from redo stack - Update process_undo/process_redo callers in agent_loop.rs - Add tests for repeated undo, undo/redo cycling, stack size invariant * fix: standardize lock ordering and extract push_undo helper Address review feedback: - Standardize lock order (Session before UndoManager) in process_undo and process_redo to match process_user_input and prevent deadlocks - Extract push_undo() helper to deduplicate push-and-trim logic shared by checkpoint() and redo() * docs: add move-semantics notes and stack invariant to UndoManager Address review feedback requesting documentation about the ownership semantics of undo/redo parameters and the stack size invariant. --------- Co-authored-by: Yi LIU <yi@quantstamp.com> Co-authored-by: firat.sertgoz <f@nuff.tech>
… redo stack (nearai#71) * fix: undo() peeks without popping, breaking repeated undo and leaking redo stack undo() used self.undo_stack.back() (peek) instead of pop_back(), so repeated undo always returned the same checkpoint while pushing to the redo stack unboundedly. Additionally, redo() did not save the current state to the undo stack, breaking the undo/redo cycle. Changes: - undo(): change back() to pop_back(), return owned Checkpoint - redo(): accept current_turn/current_messages params, save current state to undo stack before popping from redo stack - Update process_undo/process_redo callers in agent_loop.rs - Add tests for repeated undo, undo/redo cycling, stack size invariant * fix: standardize lock ordering and extract push_undo helper Address review feedback: - Standardize lock order (Session before UndoManager) in process_undo and process_redo to match process_user_input and prevent deadlocks - Extract push_undo() helper to deduplicate push-and-trim logic shared by checkpoint() and redo() * docs: add move-semantics notes and stack invariant to UndoManager Address review feedback requesting documentation about the ownership semantics of undo/redo parameters and the stack size invariant. --------- Co-authored-by: Yi LIU <yi@quantstamp.com> Co-authored-by: firat.sertgoz <f@nuff.tech>
Review findings from @serrrfirat on #7003. The first one was blocking CI outright. **The coverage exemption did not move with its file (HIGH).** `extension_lifecycle_capabilities.rs` left `ironclaw_extension_host` for `ironclaw_extension_manager` in this PR; its changed-coverage exemption kept naming the old path. That is not cosmetic staleness — the manifest validator is fail-closed on it, so the whole changed-coverage gate aborts with **no verdict at all** rather than reporting a number. Reproduced on this branch before the fix: GATE ERROR: exemption #71 names stale path: crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs exactly the entry index the reviewer named. Path repointed to the manager and the line corrected 217 -> 218 (217 was the `?error,` field, not the message literal the reason describes; the off-by-one was fixed on the parent). Whole manifest re-validated: **71 entries, no stale paths, no lines past EOF.** **Direct `#[cfg(test)]` module seeding was untested.** Confirmed empirically rather than by reading: deleting the seeding loop from `cfg_test_only_files` left the only in-tree pin green (9 passed), because its chain starts at `e2e_tests.rs` — already seeded by the `*_tests.rs` name rule — and reaches its child through an explicit `#[path]`. So neither the `cfg(test)` gate nor default `<dir>/<name>.rs` resolution was exercised, and a production-named file declared `#[cfg(test)] mod fixture;` could have become countable silently. Added `direct_cfg_test_module_and_default_child_are_test_only` on a synthetic tree covering both shapes plus the negative case; it goes red under that same deletion. **Crate contracts contradicted the move.** The CLI's exhaustive `[dependencies]` inventory omitted `ironclaw_extension_manager` (and, found while checking, `ironclaw_product_contracts` and `ironclaw_extension_contracts` — all three added by this layer). The product-contract docs still said `LifecycleProductService`, `ChannelConfigProductService` and `RebornViewProvider` are implemented by `ironclaw_extension_host`, while this branch's own `INVERTED_PORT_IMPLEMENTORS` says `ironclaw_extension_manager`. Reconciled toward the enforced pin in `reborn_cli/AGENTS.md`, `product_contracts/CLAUDE.md` (now a per-port implementor table, and citing the constant by its real name), `lifecycle_service.rs`, `views.rs`, `channel_config.rs`, and `crates/AGENTS.md` — the last of which the review did not flag but was stale the same way. **The production-source walker is centralized — for the two ratchets named.** `ratchet_support::production_rust_files` now owns the fatal walk, the name/directory exclusions and the `cfg_test_only_files` subtraction, and both `reborn_extension_host_port_inversion.rs` and `reborn_extension_manager_split.rs` delegate to it. The reviewer's concern was already realized rather than hypothetical: the two walkers **had** drifted — one skipped `node_modules` and the other did not. ~19 other ratchets still carry their own walk; migrating them belongs in a dedicated change against `ratchet_support`, not in a crate split, and that is recorded at the new helper and at the call site. Verification: `cargo fmt --check` clean; `cargo clippy -p ironclaw_architecture -p ironclaw_product_contracts -p ironclaw_extension_manager -p ironclaw_extension_host --all-targets --all-features -- -D warnings` clean; `cargo test -p ironclaw_architecture` 28 binaries green, 0 failed; `cargo check --workspace --all-targets --all-features` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on_host (WS2.4) (#7003) * refactor(contracts): invert extension_host's product-facing ports onto product_contracts (WS2.1) `ironclaw_extension_host` sits below product in the target tree, so a product-side port it satisfies must be declared at the product boundary and implemented downward — never declared inside `ironclaw_product` and reached upward. This moves every such port that `ironclaw_product_contracts` may legally name, and dissolves the product re-export facade for the extension host. Nine port families move (definitions only; every implementation stays with its owner, PROPOSAL §6.1.4): delivery resolution + reply context, account-connection status + setup descriptors, channel config, the view-provider conduit, command context + actor-role admission, gate-prompt enrichment, the lifecycle product service, the admin-user directory, and the operator tool catalog. Product keeps `DeliveryCoordinator`, `NoReplyContext`, `ExtensionAccountSetupRegistry`, `UnsupportedLifecycleProductService`, `RejectingAdminUserService`, `UnavailableRebornViewProvider`, `DirectConversationCommandAdmission`, the frozen `Reborn*` wire DTOs, and the inbound-action ledger. extension_host's product symbol usage drops 146 -> 62 across 46 -> 35 production files. The edge itself does not die here and could not: the survivors are `channel_host.rs`'s construction of product's concrete assembly, the `extension_manager` split inventory, `product::adapter_registry`, and the named strays — each owned by a later WS2 row. Six ports also could not move, all for one mechanical reason: `product_contracts` may depend only on `host_api` + `extension_contracts`, so a signature naming `ironclaw_auth`, `ironclaw_threads`, `ironclaw_turns`, or `ironclaw_conversations` cannot be declared there. `ProductSurfaceFailure` is the linchpin — extension_host uses product's *internal* workflow error as its own lifecycle error vocabulary in 19 files, and it carries `ironclaw_turns::TurnError`. Regression cover: `reborn_extension_host_port_inversion.rs` pins the nine moved ports where they landed and holds the six-entry residue shrink-only, with the per-entry reason each could not move; a new product-declared port implemented by extension_host fails the build. The moved typed-token tests travel with their code and `ActionFingerprintKey` gains the coverage it lacked. Enumerating gates, all update-never-relax: the composition pub-use snapshot gains one line (two names re-sourced from `product_contracts`, so one `pub use` splits into three); the extension-specificity allowlist, the struct/test-support ratchet, the §11.2.7 include inventory, the `ProductSurface` method freeze, and `LAYER_MATRIX_EXCEPTIONS` (13) are all untouched — extension_host carries no layer-matrix exception and never did, since both crates are `products`-layer. `secrecy` joins `product_contracts` with a manifest comment: `AdminUserService` takes secret material and `AdminCreatedUser` carries a one-time token, both `SecretString`. It is a value wrapper, not a framework/driver/runtime client. CHECKLIST WS2 row 1 ticked with the four dispositions the lead sheet did not predict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(contracts): cover the moved port surfaces and close the impl-scanner bracket hole Two follow-ups on the WS2.1 port inversion, both found by measuring rather than assuming. **Coverage of the surfaces this PR created.** `cargo llvm-cov` over `ironclaw_product_contracts` showed the relocated bodies had no crate-tier coverage of their own: `ProductCommandContext::from_envelope`, `AdminUserRole::is_admin`, `AccountConnectionStatusError::new`, `ChannelConnectionNoticePolicy::generic`, the bounded-token `TryFrom`/`AsRef`/`Display` arms, and — the one that matters most — the two `LifecycleProductService` **default** method bodies, which every production implementor overrides, so nothing exercised the fail-closed defaults. Each is now tested at its contract meaning, not for the line count: bundle import defaults to `InvalidRequest` rather than silently succeeding; activation errors default to none so the wire field stays absent; a non-command envelope is rejected as an invalid request rather than an internal error; a token that deserializes runs the same validation as its constructor; the generic notice policy names the channel in all five notices and does not collapse them into one string. Every added production line in the new modules is now covered. **The scanner had a hole the review caught, and it was real.** `implemented_trait_names` closed the impl's generic-parameter list at the first `>`. For `impl<T: Iterator<Item = X>> Port for Host<T>` that `>` closes `Iterator`, leaving `> Port` — not an identifier, so the impl was dropped and a new product-defined port could have entered `extension_host` without tripping the shrink-only gate. Now closed by balancing, with `->` inside a bound (`impl<F: Fn(&str) -> bool>`) excluded from the count, and both shapes added to the scanner self-test — which fails without the fix. Re-verified after the fix: the residue is still exactly the six frozen entries, so the wider scan found no previously hidden implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(arch): make the port-inversion scanner fail loud, and reconcile the doc counts Review triage on #6998. Four findings taken, four rejected with evidence in the thread; the taken ones are all about the gate telling the truth. **The scanner could pass on an incomplete scan.** `rust_files` returned early on a `read_dir` error and dropped per-entry errors through `.flatten()`, and `traits_implemented_by` skipped any file it could not read. A permission or transient I/O error in CI would have thinned the input and turned the ratchet green while enforcing nothing — the exact failure class this file exists to catch. Every I/O error is now fatal. **`#[cfg(test)]` blocks were located by raw brace bytes.** A `{` inside a comment or string literal in a gated block desynchronizes the depth count and either leaks a test-only `impl` into the production set or swallows the production code that follows it. Comments and strings are now stripped first; `cfg_test_stripping_survives_braces_in_comments_and_strings` is the pin, and it fails with the old composition (verified by reverting the order and watching it go red). The doc comment now also states why `#[cfg(feature = "test-support")]` is deliberately *not* stripped: that feature compiles into a real build, so an `impl` behind it is a genuine normal-dependency edge, unlike `#[cfg(test)]`. **The prose counts had drifted.** Eleven port declarations moved, not nine — nine that `extension_host` implements (the pinned `INVERTED_PORTS`) plus `AdminUserService` and `RebornOperatorToolCatalog`, which it only consumes and composition implements. CHECKLIST, both CLAUDE files, and the module-count line now agree and all defer to the architecture test as the enforced inventory. `families/contracts.md` also still listed `ironclaw_common` in the family-level dependency bullet; that is the second of the two places, now corrected too. **One mismatch recorded rather than fixed.** `LifecycleProductService:: import_extension_bundle`'s default said "unavailable" while returning `InvalidRequest`/400. The move carried both verbatim; changing the code changes an HTTP status on a live route, which does not belong in a move-shaped PR. The doc now describes what the code does, names the discrepancy, and points at the test that pins today's behavior so a silent flip is impossible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(contracts): state the module count as shipped-modules-plus-dev-seam The count line said 'seventeen modules' while `src/lib.rs` carries eighteen `pub mod` declarations — the difference is `test_support`, which is gated behind `#[cfg(any(test, feature = "test-support"))]` and is deliberately absent from the table above it. Saying 'seventeen shipped modules plus the dev-only test_support' makes the table and the manifest agree on inspection instead of looking like drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(contracts): resolve the ProductSurfaceFailure linchpin (WS2.2) `ironclaw_extension_host` used `ironclaw_product`'s internal workflow error as its own lifecycle error vocabulary across 19 production files — WS2.1's recorded linchpin, blocking half the port-inversion residue and the layer flip. Measured with `#[cfg(test)]` stripped, it constructs exactly six variants (150 sites), all plain-`String` or unit, and none of the two kernel-typed ones that kept the enum out of contracts. The boundary half is now `ironclaw_product_contracts::error::ProductOperationFailure`; `ironclaw_product` keeps `ProductSurfaceFailure` unchanged in shape and absorbs it with a total, payload-preserving `From`. The projection to `ProductSurfaceError` is defined once, in contracts, and product's `lifecycle_product_surface_error` delegates its six shared arms to it so the two paths cannot drift. Only the logging stayed with each caller — contracts may not log. Narrowing the enum instead was rejected on evidence: `auth_continuation.rs` matches all eight `TurnErrorCategory` values structurally and distinguishes two the sanitized projection collapses, and constructs by matching `TurnError` variants the projection cannot express — so narrowing is lossy in a live auth path. Unlocks `ProductConversationSubjectRouteResolver` (trait residue 6 -> 5, with its route key and request type) and takes extension_host's files naming the workflow error 19 -> 2. Corrects the two surviving residue reasons, which named the error rather than the real blocker. Regression coverage: nine crate-tier tests including the projection-agreement pin and the `From` totality pin, plus two new architecture gates (frozen residue files; the contract error names no kernel type), each verified by negative probe. Extension-specificity allowlist shrinks 130 -> 129. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(arch): apply the parent's scanner hardening to the WS2.2 half The merge brought in WS2.1's review fixes (I/O errors fatal, comments and strings stripped *before* `#[cfg(test)]` brace matching). Both apply verbatim to `production_files_naming`, which this branch added after that review: - An unreadable file was silently skipped, which is exactly how the frozen residue-file scan would go quietly vacuous. Now fatal, matching the three other readers in the file. - The strip order was backwards. A `{` inside a comment or string literal can desynchronise the `#[cfg(test)]` brace matcher, so comments and strings go first. Re-probed both directions afterwards: a code reference still trips the gate, a comment mentioning the type (now with an unbalanced brace) still does not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(contracts): close the changed-coverage holes the port move opened CI's changed-coverage gate failed on the WS2.1 move, exactly where a move-shaped diff is expected to: relocated bodies read as added production lines. Every hole is now closed with a test. One line is exempted, with its callers named. **Five relocated port modules had no LCOV record at all.** `delivery`, `channel_config`, `operator_tools`, `prompt_source`, and `views` are pure declarations, so rustc emitted no source record and the gate reported them absent. Each now carries a contract test rather than a waiver, and the properties they pin are the ones these ports actually owe: - **object safety** for all seven traits — every consumer holds them as `Arc<dyn _>`, so a signature change that breaks dyn-safety now fails at the contract instead of at the far-away wiring site; - **argument pass-through and ordering** for the delivery ports — `reply_context` takes extension id, installation id, and conversation fingerprint as three bare strings, so nothing but a test stops a transposition turning into a silent mis-delivery (this is the identity-mixup risk review raised; the types stay verbatim, the ordering is now pinned); - **absence without error** — an unresolved channel, an empty channel-config field set, an empty operator tool catalog, and a missing approval-prompt context are all normal outcomes that must not be expressible only as failures; - **caller scoping** on the operator catalog, whose `caller` parameter is the #5459 disclosure control; - **`next_cursor` omission** on an unpaginated view page — serializing `null` would make every unpaginated view look paginated to the browser. **Two genuinely untested error paths in `extension_host`, both fail-closed seams the move touched.** `AccountConnectionStatusSource::connected` now has coverage proving it fails *closed* on a pairing-backend outage (activation must not proceed on an unknown connection state) and *sanitized* (the test asserts the driver, host, and port do not appear in the product-facing error). The lifecycle output-serialization mapping moved out of an inline closure into a named `lifecycle_output_decode_error` so the mapping is reachable from a test: the failure is defensive, but *what it maps to* is a live contract — the model gets `OutputDecode` and never the serde error, which can quote projection contents. **A dead branch arm.** `validate_typed_token` guards `c == '\0' || c.is_control()` and only the second arm was exercised. NUL has its own arm because a token with an embedded NUL truncates at a C boundary rather than merely looking odd. **Diff shape.** The remaining reports were an artifact of relocating types inline: a fully-qualified `ironclaw_product_contracts::<mod>::<Item>` in a signature turns an untouched line into a changed one. Those 17 files now import the symbol like every other, which shrinks the diff, restores the crate's prevailing style, and drops the lines out of the gate's denominator because a `use` line is uninstrumentable by construction. **One exemption, with evidence.** `factory/test_support.rs`'s `channel_config_service` accessor: the repoint collapsed its signature onto one line, and the merged lcov does not attribute its two integration callers back to the composition bucket build. Both callers are named in the manifest, the service and the port contract are covered by tests added here, and it is filed under the same #6963 lane-attribution lane as the WS1 entries above it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(contracts): make the catalog and view doubles discriminate on their arguments Review caught two tests of mine that asserted the double's behavior rather than the contract, and it was right about both. `EmptyCatalog` ignored `caller` and always returned an empty vector, so `the_catalog_is_caller_scoped...` would have passed against a production catalog that disclosed every user's private installs — the exact leak the `caller` parameter exists to close (#5459 P1). It is now backed by an ownership-filtering double, two callers, one tenant-shared tool and one private tool each, asserting both directions of isolation and that the answer *can* differ by caller. `OneRowView::query` ignored `_caller` and `_params` and the test only checked the cursor; the provider now echoes all three conduit arguments and the test asserts all three. Both were verified red-then-green rather than assumed: dropping the caller filter fails the catalog tests, and dropping params from the echo fails the view test. (My first attempt at the view mutation substituted the expected literals and passed — a reminder that a mutation which doesn't fail proves nothing about the mutation, only about the mutant.) The over-claim went into the PR body too, and is corrected there: a contracts crate can pin that the port *hands the implementation the caller* and that its shape admits a per-caller answer. It cannot pin that production filters correctly — that is composition's implementation and composition's test. The doc comments now say so instead of implying the stronger claim. Also lands the CHECKLIST note this PR earned for the rest of Wave 2/3: a move-shaped PR fails the changed-coverage gate on its first CI run, in three distinct shapes needing three different answers, with the two mechanical habits that shrink all three. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * refactor(extensions): split ironclaw_extension_manager out of extension_host (WS2.4) The extension host held two jobs: lifecycle authority (the only writer of installation state, ingress verification, activation transactions) and the extension-management product face that arrived with #6616/#6669. PROPOSAL §6.8.3 splits the second into its own products-layer crate so the first can move below product in WS2's layer flip. Six of the nine inventory items moved; three are structurally blocked and each is recorded with its measurement. extension_host production files naming ironclaw_product: 20 -> 13. Port-inversion residue 5 -> 4. Behavior-free: modules move, imports repoint, one 100-line product projection is extracted from channel_config.rs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(contracts): close the coverage-gate shapes on the WS2.2 slice Applies the cross-slot lessons from WS2.1/WS2.3's coverage rounds to this row's own new code, before the gate has to ask. Pure-declaration modules gained real contract tests rather than waivers: - `subject_route`: the port is held as `Arc<dyn _>` in five places, so object safety is a contract; a resolver is handed every field unswapped (`adapter_id`/`installation_id` are both string newtypes, so a swap would otherwise be silent); and an unconfigured route is absence, not failure. The double is **route-keyed, not fixed-answer** — two configured routes resolve to *different* subjects and a third resolves to `None`, so a resolver that ignored its argument could not pass. A fixed-answer double would have made all three assertions vacuous. - `error`: `Display` is exercised for every variant, asserting each one keeps the text the LLM tool path forwards — `ProviderInstanceNotConfigured` carries the operator's exact `config set` remediation. - `lifecycle_surface_error`: pinned against the contract's own projection (drift guard) *and* against absolute statuses (so both drifting together still fails). `channel_config_unavailable` is extracted from a `map_err` closure because it sat on the one path unreachable in test without fault-injecting the concrete config service. Naming it makes the classification directly testable, and the classification matters: a store failure is transient (retryable 503), never a rejection (permanent 4xx) that would leave a correctly-configured channel looking broken. The other 44 closures in this crate are pre-existing bodies where only the type name changed (45 on the parent), so they are left alone rather than churned on speculation. Each new test was verified red-then-green by **mutating production code**, and every mutation compiles cleanly so the red is an assertion failure rather than the compiler catching the mutant: - route key stops discriminating by conversation -> two routes collapse to one subject (`left: eng-subject, right: support-subject`) - `Display` drops `{reason}` -> "rendered as ..., dropping ..." - `lifecycle_surface_error` stops delegating -> "projection drifted for ..." - store failure reclassified permanent -> "must be transient, got ..." Scope is calibrated in the doc comments: the contracts-crate test pins the port's shape and that it admits a per-route answer; it does not claim the production resolver filters correctly — `channel_subject_routes`' own tests (`foreign_adapter_or_installation_resolves_nothing`, `malformed_config_json_fails_closed`) already own that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(ws2.4): date the two row corrections and quote the text they replace The CHECKLIST disposition named the contradiction without quoting the inventory line it corrects or carrying a date; PROPOSAL §6.8.3 pointed at it without the verbatim text. Both now quote both sides. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(extension_host): cover the log-sanitization guard; exempt the type-position residue CI's second changed-coverage run came back at 99.32% line / 100% branch, with one uncovered line and six files reporting "contributed no instrumented lines". Two different problems, two different answers. **The uncovered line was coverable, so it is covered.** `lifecycle_output_decode_error`'s `tracing::debug!` body never ran under test: with no subscriber installed `tracing` short-circuits on the null dispatcher, so the message literal is a region that cannot be reached. The fix is not a waiver — it is the subscriber. The test now installs a DEBUG-level `tracing_subscriber::fmt` over a shared writer (the pattern `ironclaw_turns/tests/agent_loop_host_contract.rs` already uses) and asserts *both* halves of the guard's contract: the model gets `OutputDecode` and never the serde error, **and** the serde detail is not simply dropped — it reaches the debug log, which is where an operator diagnoses it from. Without the subscriber a test cannot tell "logged the detail" from "discarded it", which is the whole point. `tracing-subscriber` joins this crate's dev-dependencies for that, with a manifest comment saying why. **The six files are the type-position residue, and it is precedented.** Deleting `ironclaw_product`'s re-exports forced every signature naming a moved symbol to be rewritten; where the name sits in a *type* position — a struct field, a function parameter, a struct-literal field's enum path — the line changes but LLVM emits no coverage region, so it can never be covered. Nine exact lines across six files, each entry naming the construct, filed under the same #6963 lane the four WS1 entries use. Every line was re-read against the source before the entry was written; none is a guess. The balance for the PR as a whole: ten exemption lines, all type positions or one lane-attribution accessor, against ~30 tests written for surfaces that genuinely lacked them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(coverage): exempt the tracing message literal, with the evidence that it is an artifact Last line on the changed-coverage gate, and the obvious reading of it is wrong. `extension_lifecycle_capabilities.rs:217` is the message string inside a `tracing::debug!`. It reads as uncovered — but the event body demonstrably executes: the DEBUG-subscriber test added in the previous commit asserts the rendered log contains that exact message, and it passes, including in the `extension-operator` bucket, which is green. The proof it is an attribution artifact rather than a dead path comes from that bucket's own tracefile (run 30689416105, `bucket-extension-operator.lcov`): line 213 (fn signature) hits 1 line 214 (macro invocation) hits 1 line 217 (message literal) hits 0 line 219 (error construction) hits 1 line 220 (closing brace) hits 1 The function ran, the macro ran, the error was built. What LLVM does not count is the literal: `tracing` bakes the message into the callsite's `static` `Metadata`, so the region on that line belongs to a static initializer and is never attributed to an executed path. Nothing short of changing the log target moves that counter, and changing a log target is a behavior change this move-shaped PR will not make. Every `tracing::debug!` in the workspace has the same shape; they only escape this gate because their lines are not in a diff. Verified by replaying the gate locally against CI's own merged lcov with this entry in place: changed line coverage 100.00% (147/147), changed branch coverage 100.00% (10/10). The test stays. It is what proves the 0 is an artifact, and it still pins the guard's real contract: the model gets `OutputDecode` and never the serde error, and the detail reaches the debug log rather than being dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(extension-host): prove the transient cause survives the sanitized 503 The lifecycle warning is the entire reason this crate kept a local projection wrapper rather than calling the contract's `From` directly — and that claim was asserted in a doc comment and nowhere else. `tracing` short-circuits on the null dispatcher, so under a plain unit test the macro body never runs and a test cannot distinguish "logged the cause" from "dropped it" — which is exactly the distinction that matters when the 503 body is sanitized. Installing a scoped subscriber (`with_default`, so parallel tests are unaffected) over a shared writer, following the pattern `ironclaw_turns/tests/agent_loop_host_contract.rs` established, makes both halves of the guard's contract assertable, and both are asserted: - the caller's 503 is sanitized — the cause appears nowhere in the serialized `ProductSurfaceError`; and - the cause is not discarded — it reaches the warning, with its stable message. A second test pins the other direction: a rejection carries no operational cause and must not spend a warning, so "log everything" cannot satisfy the first test. Both verified red-then-green by mutating production code, compiling cleanly so the red is an assertion: - drop the warning -> "the transient cause must survive in the log, got \"\"" - warn on every variant -> "a rejection must not emit the transient warning, got ... invalid binding request: bad package ref" `tracing-subscriber` joins `[dev-dependencies]` and the `Cargo.lock` delta is **zero** — it was already resolved for the workspace. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(coverage): recapture the extension_host floor and ratchet the manager (WS2.4) Both numbers come from this PR's own merged coverage artifact (reborn-integration-coverage-merged, run 30689658637), read through the same aggregation that enforces the file. extension_host regains its covered-line floor at 19907/23467 = 84.83% (the ratio ROSE across the split); the manager is ratcheted from birth at 4602/5440 = 84.60%. Verified by running the enforcing ratchet against the artifact: both entries PASS, 17 crates pass, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(contracts,extension-host): preserve the acquire cause and pin every HostApiError projection Review triage for #7000. - `import_bundle`'s decode-limiter `map_err(|_| ...)` discarded the `AcquireError`. The mapping is now a named `map_import_decode_acquire_error` that logs the bound source before mapping. Named rather than inlined so it is reachable from a test: nothing in the workspace calls `Semaphore::close`, so an inline closure would be a permanently uncovered branch that the changed-line coverage gate could only accept as a standing exemption. New regression test builds a genuine `AcquireError` from a closed semaphore and asserts the failure is `Transient` (retryable), not a client mistake. - `From<HostApiError> for ProductOperationFailure` was pinned by one variant. It now enumerates all ten, asserts each carries its own rendering (so the cause cannot be flattened at the boundary) and projects to a 400, and adds an exhaustive `host_api_error_tag` match so a new `HostApiError` variant stops compiling the test instead of inheriting the blanket mapping silently. `InvariantViolation` is pinned as-is, not reclassified: the mapping mirrors product's pre-existing `From<HostApiError> for ProductSurfaceFailure` and changing it is a behavior change this slice does not own. Red-then-green proved by mutating the code under test: InvariantViolation -> Transient, flattening the reason text, and Transient -> InvalidBindingRequest each fail the corresponding assertion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(architecture,ci): close the review gaps on the extension_manager split Review triage for #7003. All four are artifacts this PR introduced, not moved code. - The new `ironclaw_extension_manager` boundary rule forbade `"ironclaw_reborn_cli"`, which is the crate DIRECTORY. `forbidden` entries are compared against `cargo metadata` package names and the CLI's package is `ironclaw`, so the entry could never fire — the edge it named was unguarded. Fixed, and pinned: `boundary_rule_names_are_package_names_not_crate_directories` flags any forbidden entry that is not a package but IS a directory under `crates/`. That discrimination matters — ~60 entries legitimately name retired v1 crates (`ironclaw_legacy`, `ironclaw_engine`, `ironclaw_gateway`, `ironclaw_tui`, `ironclaw_storage`) as reintroduction pins, and those have no directory. `ironclaw_reborn_cli` was the only entry in all 693 that had one. - `production_files_naming` took a flat `files.len() >= 10` to accommodate the manager, which silently dropped the host's vacuous-scan guard from >20 to 10. The same diff had already parameterized `traits_implemented_by` for exactly this reason. Parameterized to match: host 21, manager 10. - `classify-test-scope.sh` gained a `crates/ironclaw_extension_manager/*` arm with no self-test case, so a manager-only diff classifying `has_reborn_tests=false` would have gone unnoticed — the failure #6947 records for the stale `crates/ironclaw_product_*/*` arm. Case added. - `coverage-floor.toml`'s "9.7k lines moved" explained an instrumented-line delta of 3,102 with a source-line figure. Both units are now stated with their measurements (source: 57,464 -> 47,794 in the host, 9,979 in the manager; instrumented: 26,569 -> 23,467 against 5,440) and why they do not reconcile. Red-then-green proved by mutating the code under test: reverting the forbidden entry to the directory spelling fails the new meta-test with the fix-it message; removing the manager glob from the classifier fails the new self-test case (has_reborn_tests=false); raising the manager's file floor to 40 fails only the manager call site, proving the floor is per-call-site and consumed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(architecture,extensions): close the paranoid-architect review findings on the WS2.4 split Review pass over #7003 (four parallel deep reviews; no Critical/High — the move itself verified behavior-free). Everything found, fixed here: Gate hardening (crates/ironclaw_architecture/tests): - ratchet_support gains cfg_test_only_files: files reachable only through #[cfg(test)] mod chains (incl. #[path] overrides) are classified test code. channel_host/e2e_auth_challenge.rs — a fake AuthChallengeProvider impl wearing a production filename — no longer counts toward any residue row, implementor pin, or error-vocabulary floor. Pinned by a real-tree test that was red before the #[path] resolution landed. - Trait matching is qualified by a whole-token crate reference (names_crate), so a name-colliding local trait can no longer satisfy an implementor pin, and a manifest rename of ironclaw_product can no longer blind the manager residue scan (metadata tie: dep exists iff the residue list is non-empty, never renamed). - The manager gets its own product-defined-trait residue freeze (twin of the host's, frozen at ExtensionCredentialSetupService). - each_half_of_the_split_kept_its_own_job: authority checks are symmetric across file/directory spellings and back every module with a content witness, so an empty stub cannot satisfy retention. - untrusted_ingress_paths scan roots fail loudly on a missing root instead of silently dropping a tree from the guard. - Fork-check message names its two-crate scope. All new checks probed red-for-the-right-reason and reverted (hollow witness, product alias, stale scan root, authority-as-directory, unguarded secret). Manifest hygiene: - extension_host drops the ed25519-dalek dep orphaned when ironhub moved. - Ten manager deps used only by tests/the test_support fixture leave the production graph: fixture deps become test-support-gated optionals, pure test deps move to [dev-dependencies]. All three build shapes verified. Manager/host code: - channel_config: the pub resolved_manifest widening is narrowed to a declares_admin_configuration() boolean — the manifest read stays internal. - admin_configuration view: secret field values are redacted in render_group (same defense-in-depth as render_state), with a sentinel regression test; the service-error table test now pins code/kind beside status/retryable. Docs (single-source-of-truth): - families/extensions.md confesses the direct auth/host_runtime deps and the transitional dep tail the four-crate target does not name. - The residue characterization says what the list actually holds: DTOs, capability-id constants, and two port-inversion residues. - 20 -> 13 becomes 20 -> 12 (the 13th was the cfg(test)-only fixture); coverage-floor/CHECKLIST stale "recapture owed" drafts corrected to the shipped recapture; line counts de-precisioned; stale exemption comment repointed to the manager. Verification: architecture 143/0; manager 64/0 (--all-features); extension_host 388/0 (--all-features); cargo check --workspace --all-targets --all-features 0 errors / 0 warnings; clippy -D warnings clean on all three touched crates; both CI script self-tests pass; cargo metadata --locked clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(coverage,architecture): close the human review on the WS2.4 split Review findings from @serrrfirat on #7003. The first one was blocking CI outright. **The coverage exemption did not move with its file (HIGH).** `extension_lifecycle_capabilities.rs` left `ironclaw_extension_host` for `ironclaw_extension_manager` in this PR; its changed-coverage exemption kept naming the old path. That is not cosmetic staleness — the manifest validator is fail-closed on it, so the whole changed-coverage gate aborts with **no verdict at all** rather than reporting a number. Reproduced on this branch before the fix: GATE ERROR: exemption #71 names stale path: crates/ironclaw_extension_host/src/extension_lifecycle_capabilities.rs exactly the entry index the reviewer named. Path repointed to the manager and the line corrected 217 -> 218 (217 was the `?error,` field, not the message literal the reason describes; the off-by-one was fixed on the parent). Whole manifest re-validated: **71 entries, no stale paths, no lines past EOF.** **Direct `#[cfg(test)]` module seeding was untested.** Confirmed empirically rather than by reading: deleting the seeding loop from `cfg_test_only_files` left the only in-tree pin green (9 passed), because its chain starts at `e2e_tests.rs` — already seeded by the `*_tests.rs` name rule — and reaches its child through an explicit `#[path]`. So neither the `cfg(test)` gate nor default `<dir>/<name>.rs` resolution was exercised, and a production-named file declared `#[cfg(test)] mod fixture;` could have become countable silently. Added `direct_cfg_test_module_and_default_child_are_test_only` on a synthetic tree covering both shapes plus the negative case; it goes red under that same deletion. **Crate contracts contradicted the move.** The CLI's exhaustive `[dependencies]` inventory omitted `ironclaw_extension_manager` (and, found while checking, `ironclaw_product_contracts` and `ironclaw_extension_contracts` — all three added by this layer). The product-contract docs still said `LifecycleProductService`, `ChannelConfigProductService` and `RebornViewProvider` are implemented by `ironclaw_extension_host`, while this branch's own `INVERTED_PORT_IMPLEMENTORS` says `ironclaw_extension_manager`. Reconciled toward the enforced pin in `reborn_cli/AGENTS.md`, `product_contracts/CLAUDE.md` (now a per-port implementor table, and citing the constant by its real name), `lifecycle_service.rs`, `views.rs`, `channel_config.rs`, and `crates/AGENTS.md` — the last of which the review did not flag but was stale the same way. **The production-source walker is centralized — for the two ratchets named.** `ratchet_support::production_rust_files` now owns the fatal walk, the name/directory exclusions and the `cfg_test_only_files` subtraction, and both `reborn_extension_host_port_inversion.rs` and `reborn_extension_manager_split.rs` delegate to it. The reviewer's concern was already realized rather than hypothetical: the two walkers **had** drifted — one skipped `node_modules` and the other did not. ~19 other ratchets still carry their own walk; migrating them belongs in a dedicated change against `ratchet_support`, not in a crate split, and that is recorded at the new helper and at the call site. Verification: `cargo fmt --check` clean; `cargo clippy -p ironclaw_architecture -p ironclaw_product_contracts -p ironclaw_extension_manager -p ironclaw_extension_host --all-targets --all-features -- -D warnings` clean; `cargo test -p ironclaw_architecture` 28 binaries green, 0 failed; `cargo check --workspace --all-targets --all-features` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: BenKurrek <benjaminkurrek@gmail.com> Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Summary
UndoManager::undo()callsself.undo_stack.back()which peeks without removing, so repeated undo always returns the same checkpoint while the redo stack grows unboundedlyUndoManager::redo()does not save the current state to the undo stack, so undo-after-redo loses the pre-redo stateundo()to usepop_back()andredo()to accept current state parameters and push to undo stack before popping redoagent_loop.rsto match the new signaturesTest plan
test_repeated_undo_advances_through_stack— verifies two undos return different checkpoints and stack shrinkstest_undo_redo_cycle_preserves_state— verifies undo→redo→undo cycles correctlytest_undo_redo_stack_sizes_consistent— verifies undo_count + redo_count stays constantcargo clippy --all --all-featuresclean