Skip to content

fix: undo() peeks without popping, breaking repeated undo and leaking redo stack - #71

Merged
ilblackdragon merged 6 commits into
nearai:mainfrom
AI-Reviewer-QS:fix/undo-stack-never-pops
Feb 17, 2026
Merged

ilblackdragon merged 6 commits into
nearai:mainfrom
AI-Reviewer-QS:fix/undo-stack-never-pops

Conversation

@AI-Reviewer-QS

Copy link
Copy Markdown
Contributor

Summary

  • UndoManager::undo() calls self.undo_stack.back() which peeks without removing, so repeated undo always returns the same checkpoint while the redo stack grows unboundedly
  • UndoManager::redo() does not save the current state to the undo stack, so undo-after-redo loses the pre-redo state
  • Fixed undo() to use pop_back() and redo() to accept current state parameters and push to undo stack before popping redo
  • Updated caller in agent_loop.rs to match the new signatures

Test plan

  • test_repeated_undo_advances_through_stack — verifies two undos return different checkpoints and stack shrinks
  • test_undo_redo_cycle_preserves_state — verifies undo→redo→undo cycles correctly
  • test_undo_redo_stack_sizes_consistent — verifies undo_count + redo_count stays constant
  • All existing undo tests still pass
  • cargo clippy --all --all-features clean

… 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
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @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

  • Undo Functionality Fix: The undo() method in UndoManager was incorrectly peeking at the undo stack instead of popping, leading to repeated undo operations returning the same state and an unbounded growth of the redo stack. This has been corrected to use pop_back().
  • Redo Functionality Enhancement: The redo() method now correctly saves the current state to the undo stack before restoring a previously undone state, preventing loss of the pre-redo state during undo-after-redo cycles. It also accepts current state parameters.
  • API Signature Updates: The signatures of UndoManager::undo() and UndoManager::redo() have been updated to return an owned Checkpoint and accept current state parameters, respectively. Callers in agent_loop.rs have been updated to match these new signatures.
  • Comprehensive Test Coverage: New test cases have been added to validate repeated undo operations, ensure correct undo-redo cycles, and verify consistent stack sizes, alongside updates to existing tests.
Changelog
  • src/agent/agent_loop.rs
    • Updated the undo call to handle the new return type and removed an unnecessary clone.
    • Modified the redo call to pass the current turn number and messages as required by the updated UndoManager::redo signature.
  • src/agent/undo.rs
    • Modified undo method to pop the last checkpoint from the undo stack and return an owned Checkpoint.
    • Updated redo method to accept current state parameters, push the current state to the undo stack, and trim the undo stack if it exceeds its capacity.
    • Updated an existing test case to reflect the new undo and redo method signatures and behavior.
    • Added new test cases to verify repeated undo functionality, correct undo-redo cycles, and consistent stack sizes.
Activity
  • The author has provided a detailed summary of the changes and the rationale behind them.
  • A comprehensive test plan has been outlined and executed, including verification of new and existing undo tests, and cargo clippy checks.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@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 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.

Comment thread src/agent/agent_loop.rs Outdated
Comment thread src/agent/undo.rs Outdated
AI-Reviewer-QS and others added 4 commits February 14, 2026 10:35
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()
@AI-Reviewer-QS

Copy link
Copy Markdown
Contributor Author

@ilblackdragon can you help me do a code review for this PR? Thank you!

@tribendu

Copy link
Copy Markdown

Code Review: Fix undo() peeks without popping

PR Details


Summary

This PR fixes a critical bug in the undo/redo stack implementation where undo() was peeking at the checkpoint without popping it, causing two significant issues:

  1. Repeated undo broken: Multiple undos would always return the same checkpoint instead of walking backward through history
  2. Redo stack leak: The redo stack would grow indefinitely since undo never consumed from the undo stack

The fix properly changes undo() to pop from the stack (instead of peeking), changes the return type from reference to ownership (Option<&Checkpoint> → Option<Checkpoint>), and symmetrically updates redo() to push the current state to the undo stack. The PR also includes excellent test coverage for the new behavior and fixes a potential deadlock by reordering lock acquisition.


Pros

✅ Correct core fix: Changing self.undo_stack.back() to self.undo_stack.pop_back() correctly addresses the reported issue. The undo operation now properly consumes stack entries.

✅ 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:

  • test_repeated_undo_advances_through_stack - Validates that repeated undos return distinct checkpoints
  • test_undo_redo_cycle_preserves_state - Tests full undo/redo round-trip
  • test_undo_redo_stack_sizes_consistent - Validates invariant that total stack count remains constant

✅ Deadlock prevention: Reordered lock acquisition in both undo() and redo() to lock session first, then undo manager. This matches the pattern in process_user_input, preventing potential deadlocks.

✅ Reduced cloning: Removed unnecessary .clone() calls in checkpoint restoration (checkpoint.messages.clone() → checkpoint.messages), improving performance.

✅ Code cleanup: Extracted push_undo() helper method to eliminate duplication in checkpoint() and redo().

✅ Return type ownership: Changing from Option<&Checkpoint> to Option<Checkpoint> eliminates lifetime complexity and allows callers to own the returned checkpoint.


Concerns

⚠️ Potential deadlock risk with existing code: The lock ordering change (session first, then undo manager) is correct for consistency, but this needs to be verified against all other code that interacts with these locks. If there's any other location that locks in the opposite order without this fix, deadlocks could occur.

⚠️ Lifetime considerations with Vec<ChatMessage>: The current_messages: Vec<ChatMessage> parameter is moved into both undo() and redo(). This is fine for the current use case, but developers should be aware that calling code must clone if they need to retain the messages after calling these operations.

⚠️ Missing documentation for state ownership: The public API changes to undo() and redo() should mention that these methods now consume the current message vector. The existing docstring for undo() mentions "The current state should be saved to redo stack before calling" but doesn't explicitly note the move semantics.

⚠️ Potential test timing edge case: The test test_repeated_undo_advances_through_stack undos from turn 3 to turn 2, then from turn 2 to turn 1. However, the checkpoint at turn 2 was never explicitly created (only turns 0, 1, 2 were created via checkpoint()). This works because the first undo saves turn 3 state to redo and returns turn 2. The test is correct but relies on understanding that the first undo saves turn 3 implicitly.

⚠️ Removed pop_undo() method: The PR removes pop_undo() (used only for cleanup at session end). This is a good cleanup, but it should be verified that the removal sites are correctly handled and this method wasn't exposed in any public API.


Suggestions

  1. Update public API documentation: Add explicit notes to undo() and redo() docstrings about move semantics:

    /// Returns: The previous checkpoint, if one exists.
    /// Note: Consumes `current_messages` - callers must clone if needed after calling.
    
  2. Verify lock ordering across the codebase: Run a code search to ensure all locations that lock both Session and UndoManager use the same ordering (session first). Search for patterns like:

    • session.lock().await followed by undo_manager.lock().await ✓
    • Ensure no undo_manager.lock().await followed by session.lock().await sequences exist
  3. Consider adding an integration test: While the unit tests are excellent, consider adding a test that simulates the actual Agent::undo() / Agent::redo() flow to verify the lock ordering and end-to-end behavior work correctly.

  4. Document the stack invariant: Add a comment or doc to UndoManager explaining the invariant relationship between undo and redo stacks:

    // Invariant: undo_stack.len() + redo_stack.len() <= max_checkpoints * 2
    // Each operation pops from one stack and pushes to the other, preserving count
  5. Add benchmark comment: Consider adding a comment noting that the removal of .clone() is a performance optimization, especially for long conversation histories.

  6. Verify backward compatibility: Since this changes the public API of UndoManager::undo() and UndoManager::redo(), ensure any external callers (if this was a public crate) are updated. Since this appears to be internal API, this may not be applicable.


Conclusion

Status: ✅ 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.
@AI-Reviewer-QS

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @tribendu!

Addressed the actionable feedback in the latest push (6530614):

Done:

  • Move-semantics documentation — Added notes to both undo() and redo() doc comments stating they take ownership of current_messages and callers must clone if they need to retain a copy (suggestion Move whatsapp channel source to channels-src/ for consistency #1).
  • Stack invariant documented — Added a struct-level doc comment to UndoManager explaining that undo_count() + redo_count() stays constant across undo/redo cycles (suggestion feat: Sandbox jobs #4).

Already addressed in prior commit (05db445):

  • Lock ordering — Verified and standardized to always lock session first, then undo manager. Grep-searched the codebase to confirm no reverse ordering exists (suggestion feat: adding Web UI #2).
  • Code deduplication — Extracted push_undo() helper as suggested by the Gemini review, used in both checkpoint() and redo().

Out of scope / noted:

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review: Approve

This PR correctly identifies and fixes two real bugs in UndoManager:

Bug Fixes (Correct)

  1. 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 to pop_back() and returning an owned Checkpoint is the right fix.

  2. redo() did not save current state to undo stack -- After redoing, the pre-redo state was lost, breaking undo-after-redo cycles. Adding current_turn and current_messages parameters to redo() and pushing them onto the undo stack before popping redo mirrors the symmetric design of undo().

Lock Ordering Fix (Correct)

The PR also fixes a latent deadlock hazard in process_undo and process_redo. Previously:

  • process_undo/process_redo: locked undo_mgr first, then session
  • process_user_input (checkpoint code at line ~816): locked session first, then undo_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 in checkpoint(), reducing the chance of future divergence. Clean refactor.
  • Return type change from Option<&Checkpoint> to Option<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, calling redo() 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-trip
  • test_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)

  1. 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 the pub visibility).

  2. Trimming in push_undo during redo() -- When max_checkpoints is small and a user does many redo operations, push_undo will 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.

@tribendu

Copy link
Copy Markdown

GitHub PR Review Batch 3 - nearai/ironclaw

PR #71: Fix undo/redo deadlock and checkpoint behavior

Summary

This PR addresses critical concurrency issues in the undo/redo system. The main changes are:

  1. Reordering lock acquisitions in undo_turn() and redo_turn() to lock session before undo manager, matching process_user_input() pattern to prevent deadlocks
  2. Changing undo() to return owned Checkpoint instead of &Checkpoint, fixing a bug where repeated undos returned the same checkpoint reference instead of walking backwards through history
  3. Adding current_turn and current_messages parameters to redo() so it can save the current state before popping from redo stack
  4. Adding comprehensive tests to verify undo/redo stack invariants and state preservation

Pros

  • Critical bug fix: The deadlock prevention is well-reasoned and follows existing patterns in the codebase
  • Ownership fix: Returning owned Checkpoint from undo() eliminates the borrow/snapshot bug that prevented repeated undos
  • Better API design: The new redo() signature is more explicit about state management
  • Excellent test coverage: Added 3 new tests covering repeated undo, undo/redo cycles, and stack size consistency
  • Documentation: Added helpful docstrings explaining the stack behavior and invariants

Concerns

  • Missing error handling: No check if thread.restore_from_messages() succeeds/fails before returning success
  • Clone overhead: The undo() function now clones messages when saving to redo stack, but this is unavoidable with the API change
  • Test gap: No test for the deadlock scenario itself (would require concurrent test setup)
  • Migration note: The change from &Checkpoint to Checkpoint is a breaking API change for any external users of UndoManager

Suggestions

  • Consider returning Result<Checkpoint, ...> from undo() and propagate restoration errors
  • Add integration test with concurrent calls to verify deadlock is truly fixed
  • Document the breaking API change in upgrade notes if this is a public library

PR #66: Update README architecture diagram to use Unicode box drawing

Summary

This 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 ╭───╮, │, ● instead of +, -, |. The diagram structure and content remain unchanged.

Pros

  • Better aesthetics: Unicode box drawing creates cleaner, more professional diagrams
  • Improved readability: The visual distinction between components is clearer
  • Modern standard: Unicode box drawing is the standard for terminal-based diagrams in modern tools
  • Preserves structure: The logical diagram structure is unchanged

Concerns

  • Terminal compatibility: Some older terminals or Windows default fonts may not render Unicode box drawing correctly, showing replacement characters
  • Copy-paste issues: Unicode characters may cause issues in some markdown viewers or plain text contexts
  • Diff noise: This change makes future diff review harder since the diagram structure is visually the same but characters changed

Suggestions

  • Consider adding a note about terminal compatibility if users report issues
  • For CI environments, ensure the font/encoding is UTF-8 aware

PR #63: Memory Guardian and Cognitive Routines

Summary

This is a large PR (894 lines of new code) adding a comprehensive memory management system with two layers:

Cognitive Routines (Prompt-Level):

  • Pre-game routine (5-step checklist)
  • Checkpoint reminder templates
  • After-action review templates

Memory Guardian (System-Level):

  • Pre-compaction breadcrumbs (writes state snapshot before context loss)
  • Auto-breadcrumbs (every N tool calls, default 10)
  • Checkpoint gate (escalating pressure: gentle → firm → urgent at 12/20/30 calls)
  • Memory search tracking (reminds agent to search after 8 turns)

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

  • Comprehensive documentation: Excellent module-level docs explaining the two-layer design rationale
  • Design rationale: Clear explanation of why both layers exist (instructions vs. enforcement)
  • Good defaults: Sensible thresholds (breadcrumb_interval=10, gate_thresholds=[12,20,30])
  • Defense in depth: Escalating pressure vs. hard blocking is a thoughtful UX choice
  • Sanitization functions: sanitize_checkpoint_text() prevents markdown/prompt injection
  • Extensive testing: 15 tests covering the full functionality

Concerns

  • Large PR: 894 lines makes review difficult; could be split into smaller PRs
  • Config not wired up: CognitiveConfig is defined but not loaded from configuration files or used to enable/disable features
  • Session lock pattern: Multiple session lock acquisitions in the agent loop could become a performance bottleneck with high tool call volume
  • Breadcrumb performance: Async I/O in the tool execution path for every Nth tool call adds latency
  • Missing integration tests: No tests verifying the guardian works end-to-end with compaction/reset
  • Tracker not persisted: CheckpointTracker is in-memory only, resets on session restart

Suggestions

  • Split into smaller PRs: (1) cognitive module structure, (2) guardian integration, (3) database migration
  • Wire up CognitiveConfig from config files/CLI flags so features can be toggled
  • Consider making breadcrumb writing non-blocking (spawn without await)
  • Document the performance impact of breadcrumb I/O
  • Add integration test verifying pre-compaction breadcrumb actually writes
  • Add metrics/logging for guardian actions (breadcrumb writes, gate triggers)

PR #62: Add Tinfoil private inference provider

Summary

This PR adds support for Tinfoil as a new LLM backend. The changes include:

  • Add Tinfoil variant to LlmBackend enum with string parsing
  • Add TinfoilConfig struct with api_key and model fields
  • Add create_tinfoil_provider() function that uses Rig's OpenAI client with https://inference.tinfoil.sh/v1 as base URL
  • Explicitly use completions_api() for Tinfoil (they don't support the newer Responses API)
  • Wire up in create_llm_provider() dispatch
  • Update setup wizard to include tinfoil field in config

Pros

  • Clean implementation: Follows existing provider patterns (Ollama, OpenAI-compatible)
  • Proper error handling: Returns LlmError::AuthFailed when tinfoil config is missing
  • Environment variable support: Reads TINFOIL_API_KEY and TINFOIL_MODEL from env with defaults
  • Good documentation: Note about why completions_api() is explicitly used

Concerns

  • Hardcoded base URL: TINFOIL_BASE_URL constant is not configurable via environment variable
  • No provider documentation: No usage examples or caveats in the main docs
  • Secret exposure: Uses expose_secret() on api_key when passing to Rig - this is the correct approach but worth verifying Rig doesn't log it
  • No health check: No verification that the Tinfoil endpoint works on startup

Suggestions

  • Make TINFOIL_BASE_URL configurable via environment variable
  • Add a brief section in the README about supported LLM providers including Tinfoil
  • Consider adding a quick health check or connection test on startup when tinfoil is configured

PR #61: Add PostgreSQL test workflow for CI

Summary

This 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

  • Better test coverage: Enables testing against production database backend (PostgreSQL + pgvector)
  • Proper service configuration: Health checks, retries, and proper port mapping
  • Simple integration: Just adds the service and env var for DATABASE_URL

Concerns

  • No database fixtures: No test data setup or migrations for the test database
  • No test splitting: All tests still run against both backends; could be slow
  • Missing test matrix: Could benefit from matrix strategy testing libSQL and PostgreSQL in parallel
  • No cleanup between tests: Tests may interfere with each other's state
  • Flaky test risk: No fixture isolation or transaction rollback

Suggestions

  • Add a job matrix to test libSQL and PostgreSQL in parallel
  • Add migration step before running tests: cargo run -- database migrate
  • Consider wrapping tests in transactions with rollback for isolation
  • Add a separate integration test job vs. unit tests

PR #57: Sandbox job monitoring and credential grants persistence

Summary

This is a large PR (~6333 lines touched) introducing several features:

  1. Job Monitor System: New job_monitor.rs module spawns background tasks that watch for events from sandbox jobs (especially Claude Code) and inject assistant messages back into the agent loop via an injection channel in ChannelManager

  2. Credential Grants Persistence: Added credential_grants_json field to sandbox jobs for restoring credentials on job restart

  3. Claude Code OAuth Token Extraction: New logic to read OAuth tokens from macOS Keychain or Linux ~/.claude/.credentials.json for passing to containers via ANTHROPIC_API_KEY env var

  4. Database improvements: Changed LibSqlBackend::connect() to async and added PRAGMA busy_timeout = 5000 and PRAGMA journal_mode=WAL for better concurrency

  5. NEAR AI URL migration: Changed default from https://cloud-api.near.ai to https://private.near.ai

  6. Worker Docker image: Added GitHub CLI installation and improved layer caching

  7. Model command enhancement: /model now lists available models with the active one marked

Pros

  • Job monitoring is useful: Forwarding Claude Code output to the main agent enables better visibility into sub-agent work
  • Good test coverage: Job monitor has 4 unit tests for message forwarding, filtering, and lifecycle
  • Credential persistence: Restoring grants on job restart is a good UX improvement
  • Database concurrency: WAL mode and busy_timeout reduce lock contention in libSQL
  • OAuth extraction: Clever use of existing Claude auth rather than re-implementing auth

Concerns

  • Monolithic PR: Too many unrelated changes in one PR (job monitor, credentials, database, NEAR AI URLs, Docker, model command)
  • Cross-platform fragility: OAuth token extraction depends on macOS Keychain commands and Linux file paths - could break on different systems
  • Secret handling: OAuth token is passed via ANTHROPIC_API_KEY env var - this could be leaked in logs if the container or tool doesn't sanitize
  • Channel coupling: Job monitor couples Claude Code specifically to the agent loop - not generic for any job type
  • Database breaking change: Making connect() async is a breaking change for any code calling it synchronously
  • No rate limiting: Job monitor could spam the agent loop if the sub-agent produces many messages quickly
  • Missing test forOAuth: No tests for the token extraction logic

Suggestions

  • Split into smaller focused PRs: (1) job monitor, (2) credential grants, (3) database changes (4) Docker/URL migrations
  • Add rate limiting to job monitor (e.g., max 1 message/second)
  • Make job monitor generic (protocol-agnostic) instead of Claude-specific
  • Add tests for OAuth token extraction on both platforms (mocking the Keychain/file read)
  • Document the connect() async migration as a breaking change
  • Add warning logs when job monitor lags or drops messages
  • Verify ANTHROPIC_API_KEY doesn't leak in container logs

Summary

Highest Quality PRs

  1. PR fix: undo() peeks without popping, breaking repeated undo and leaking redo stack #71 - Well-scoped bug fix with excellent documentation and testing
  2. PR feat: add Tinfoil private inference provider #62 - Clean implementation following existing patterns

Needs Splitting

Minor Concerns

Recommendations

@ilblackdragon
ilblackdragon merged commit e499795 into nearai:main Feb 17, 2026
11 checks passed
jaswinder6991 pushed a commit to jaswinder6991/ironclaw that referenced this pull request Feb 26, 2026
… 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>
bkutasi pushed a commit to bkutasi/ironclaw that referenced this pull request Mar 28, 2026
… 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>
BenKurrek added a commit that referenced this pull request Aug 2, 2026
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>
ilblackdragon added a commit that referenced this pull request Aug 2, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants