test: trait-level contract harness scaffold (closes coverage gap class from #3890) - #3918
Conversation
Establishes a reusable harness for testing trait contracts across multiple impls in a single suite. Across recent PR review (#3890, #3887, #3908) the same antipattern recurred: a trait has multiple impls with isolation/durability/CAS invariants every impl must honor, but tests only cover one impl — often a mock that quietly implements its own invariants. Per .claude/rules/testing.md ("Test Through the Caller, Not Just the Helper"), a contract test against one impl proves only that impl, not the contract. This is a scaffolding change: it picks one trait (MemoryDocumentRepository, the trait at issue in #3890's HIGH finding) and wires both existing impls to a shared contract suite. The shape — not the breadth — is the point. Follow-ups can extend the suite and port other traits (IdempotencyLedger, CheckpointStateStore, ProcessStore, ApprovalRequestStore, ...) onto the same pattern. The harness lives in crates/ironclaw_memory/src/contract_tests.rs as pub async fn contracts taking a factory closure. The contract_test! macro expands to one #[tokio::test] per contract named <impl_label>::<contract_name> for clear failure attribution. Contracts covered in this scaffold: - round_trip_returns_written_bytes - writes_isolated_across_scopes (the #3890 invariant) - list_documents_honors_scope - search_documents_isolated_across_scopes (the exact #3890 HIGH) Existing tests in memory_backend_contract.rs and memory_filesystem_contract.rs are intentionally not deleted; deduplication is a follow-up.
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
henrypark133
left a comment
There was a problem hiding this comment.
Single-agent review. (Security · Bugs · Performance · Tests · Conventions · Design)
Verdict: COMMENT — 3 Medium findings, 1 Low finding. No Critical/High issues; the pattern established is sound and valuable. The Mediums below should be addressed before merge since two of them (vacuous search assertion, dead variant check) directly undermine the stated purpose of the PR (making isolation contracts load-bearing by construction).
Findings Summary
Tests (2 Medium)
M1 — search_documents_isolated_across_scopes passes vacuously for FilesystemMemoryDocumentRepository (contract_tests.rs:174–224)
The contract writes two documents but no chunk records. FilesystemMemoryDocumentRepository::search_documents queries the .chunks/ subtree over Filter::Fts, not raw document bodies. With no chunks present, the call returns Ok([]), the for-loop iterates zero times, and the scope-isolation assertion is trivially satisfied. This is precisely the vacuous-pass anti-pattern the PR aims to eliminate: the only impl where the Ok branch fires does so on an empty result set, so a broken isolation would never surface. Fix: add replace_document_chunks_if_current calls after the two write_document calls to seed indexed content, then assert that only scope-A hits are returned.
M2 — FilesystemError::Unsupported arm is dead; real error is Backend (contract_tests.rs:210–220)
memory_backend_unsupported() calls memory_error() which returns FilesystemError::Backend { reason: "memory backend does not support search" }, never FilesystemError::Unsupported. The matches!(err, FilesystemError::Unsupported { .. }) branch never fires; the assertion only passes because the string-fallback err.to_string().to_lowercase().contains("not support") catches it. The doc comment says the intent is to enforce "the documented unsupported variant" — but that variant is never actually emitted. Impls that correctly return FilesystemError::Unsupported would pass; current impls silently fall through. Fix: either change memory_backend_unsupported to emit FilesystemError::Unsupported, or replace the matches! arm with matches!(err, FilesystemError::Backend { .. }) and document the gap.
Conventions (1 Medium, 1 Low)
M3 — pub mod contract_tests is unconditionally in the production library (lib.rs:9)
Both ironclaw_llm and ironclaw_agent_loop gate their public test-helper modules behind #[cfg(any(test, feature = "test-support"))]. contract_tests ships into the production binary without a guard, which is inconsistent with the repo convention. Fix: add #[cfg(any(test, feature = "contract-tests"))] (or reuse a test-support feature) and declare it in Cargo.toml.
L1 — RepoFactory<R> type alias is dead code, narrower than the actual bound (contract_tests.rs:51)
The alias pub type RepoFactory<R> = fn() -> R (bare function pointer) is never referenced: contract functions use F: Fn() -> R (trait-bound accepting both fn pointers and closures), the macro uses $factory directly, and neither wiring file names the type. It will generate a dead-code warning and misleads callers into thinking bare fn-pointers are the canonical factory shape when the filesystem contract wires a closure. Remove or replace with a doc note explaining Fn() -> R is the expected bound.
What is clearly right
- The pattern itself — single harness, one
#[tokio::test]per contract per impl, factory-per-call isolation — is exactly the right shape. round_trip_returns_written_bytes,writes_isolated_across_scopes, andlist_documents_honors_scopeare load-bearing and correct.- Factory isolation (fresh repo per contract) is properly enforced.
- The existing unit tests in
repo/filesystem.rsare excellent and unaffected by this PR.
Approving once M1 and M2 are addressed — those two directly concern the stated goal of the PR.
|
|
||
| mod backend; | ||
| mod chunking; | ||
| pub mod contract_tests; |
There was a problem hiding this comment.
[Conventions/Medium] Both ironclaw_llm and ironclaw_agent_loop gate their public test-helper modules behind #[cfg(any(test, feature = "test-support"))]. This pub mod contract_tests ships into the production binary without a guard, inconsistent with repo convention. Add #[cfg(any(test, feature = "contract-tests"))] and declare the feature in Cargo.toml.
| /// | ||
| /// Must return a fresh, empty repository — contracts assume nothing | ||
| /// leaks between calls. | ||
| pub type RepoFactory<R> = fn() -> R; |
There was a problem hiding this comment.
[Conventions/Low] pub type RepoFactory<R> = fn() -> R is never referenced: contract functions use F: Fn() -> R (accepts both fn-pointers and closures), the macro uses $factory directly, and neither wiring file names this type. It will generate a dead-code warning and misleads callers. Remove or replace with a doc note explaining Fn() -> R is the expected bound.
| variant, got: {err:?}" | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
[Tests/Medium] memory_backend_unsupported() calls memory_error() which returns FilesystemError::Backend { reason: "..." }, never FilesystemError::Unsupported. The matches!(err, FilesystemError::Unsupported { .. }) arm never fires; the assertion only passes via the string-fallback contains("not support"). Fix: either update memory_backend_unsupported to emit FilesystemError::Unsupported, or swap the matches! arm to FilesystemError::Backend { .. }.
| .await | ||
| .unwrap(); | ||
|
|
||
| let request = MemorySearchRequest::new("needle").expect("valid search request"); |
There was a problem hiding this comment.
[Tests/Medium] This contract writes documents but no chunk records. FilesystemMemoryDocumentRepository::search_documents queries the .chunks/ subtree via Filter::Fts, not raw doc bodies. With no chunks, the search returns Ok([]), the for-loop below iterates zero times, and the scope-isolation assertion is trivially satisfied — vacuous pass. Add replace_document_chunks_if_current calls after the two write_document calls so the Ok branch actually validates isolation with real hits.
henrypark133
left a comment
There was a problem hiding this comment.
Approving with follow-up — prior COMMENT review (#pullrequestreview-4349702482) captured 3 Medium + 1 Low findings. No Critical/High. Not blocking merge per Med/Low-only rule. Items to address in follow-up:
- (Tests/M) Search isolation contract vacuously correct for
FilesystemMemoryDocumentRepository— no chunk records written before search - (Tests/M)
FilesystemError::Unsupportedarm is dead; actual error isBackend - (Conventions/M)
pub mod contract_testsunconditional in production binary; should gate behind#[cfg(any(test, feature = "contract-tests"))] - (Conventions/L)
RepoFactory<R>type alias is dead code
…oduction scan The `scripts/check_no_panics.py` scanner flagged 25 panic-style calls (`.expect`, `.unwrap`, `assert*!`) inside `crates/ironclaw_memory/src/contract_tests.rs`. These are intentional — it is a test harness — but the module was unconditionally `pub mod`, so the scanner (which walks added lines in changed files and only treats `#[cfg(...test...)]`-gated items as test context) saw them as production code. Fix: - Add a `contract-tests` cargo feature on `ironclaw_memory`. - Gate `pub mod contract_tests;` in `lib.rs` behind `#[cfg(any(test, feature = "contract-tests"))]`. - Gate every item inside `contract_tests.rs` with the same cfg so the no-panics scanner recognizes the file as test-only context (the scanner only sees the file's own attributes, not the module declaration in `lib.rs`). - Add a self dev-dependency on `ironclaw_memory` with the `contract-tests` feature enabled so the per-impl integration test files in `tests/` (which import the harness and the `contract_test!` macro) keep compiling. Production builds (`cargo build -p ironclaw_memory`) no longer compile the harness; `cargo test -p ironclaw_memory` continues to run all 4 contracts for both the in-memory and filesystem repository impls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tract (durable backend PR 1/4) Widen the predicate sliding-window backend contract from pub(crate) to a stable public surface that out-of-crate durable backends (Postgres, libSQL) can implement. This is PR 1 of the 4-PR durable-backend split; no durable impls land here. Surface changes: - Make `PredicateStateBackend`, `InvocationKey`, `ValueKey`, and `PredicateBackendError` `pub` (were `pub(crate)`). Replace the "intentionally pub(crate)" rustdoc with a stable-contract note. - Convert the trait to `#[async_trait]`. Both target durable backends are async; a sync trait would force `block_on` on the dispatch hot path. `PredicateEvaluator::evaluate`/`evaluate_at` and the sole production caller (`PredicateBackedBeforeCapabilityHook::evaluate`, already async) ripple to `.await` cleanly. - Change the clock from `std::time::Instant` to `chrono::DateTime<Utc>`. `Instant` is not serializable across processes; durable rows need a wall-clock timestamp. The in-memory backend stores `DateTime<Utc>` directly; window trimming stays age-based (`front_ts < now - window`). Contract-test harness scaffold: - Add `predicate_state::contract`, a feature-gated (`contract-tests`) module of `pub async fn` contracts each taking a backend factory closure, plus a `predicate_backend_contract_test!` macro — mirrors `ironclaw_memory::contract_tests` (#3918). The in-memory backend is wired through the suite, proving the harness shape works with one backend before PRs 2/3 drop in durable impls. In-memory backend behavior is unchanged apart from the clock-type adaptation; all prior unit tests are preserved (converted to async + `DateTime<Utc>` fixtures). Coordination with the in-flight #3635 bugfix: - The `Instant::checked_sub` underflow fix is SUPERSEDED here: with `DateTime<Utc>`, subtraction saturates rather than underflowing, so the hazard does not arise. Age-based trimming still holds. - The concurrent `hooks-predicate-state-bugfix` branch/PR is NOT yet on origin. If its cap-eviction overflow fix (e.g. a `WindowOverflow` variant on `PredicateBackendError`) lands first, this PR should be rebased onto it so the now-public error enum carries that variant — do not clobber the security fix. If this lands first, fold the overflow variant into the public enum in that PR. Blast radius confined to ironclaw_hooks + ironclaw_reborn; full workspace builds clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tract (durable backend PR 1/4) (#3927) Widen the predicate sliding-window backend contract from pub(crate) to a stable public surface that out-of-crate durable backends (Postgres, libSQL) can implement. This is PR 1 of the 4-PR durable-backend split; no durable impls land here. Surface changes: - Make `PredicateStateBackend`, `InvocationKey`, `ValueKey`, and `PredicateBackendError` `pub` (were `pub(crate)`). Replace the "intentionally pub(crate)" rustdoc with a stable-contract note. - Convert the trait to `#[async_trait]`. Both target durable backends are async; a sync trait would force `block_on` on the dispatch hot path. `PredicateEvaluator::evaluate`/`evaluate_at` and the sole production caller (`PredicateBackedBeforeCapabilityHook::evaluate`, already async) ripple to `.await` cleanly. - Change the clock from `std::time::Instant` to `chrono::DateTime<Utc>`. `Instant` is not serializable across processes; durable rows need a wall-clock timestamp. The in-memory backend stores `DateTime<Utc>` directly; window trimming stays age-based (`front_ts < now - window`). Contract-test harness scaffold: - Add `predicate_state::contract`, a feature-gated (`contract-tests`) module of `pub async fn` contracts each taking a backend factory closure, plus a `predicate_backend_contract_test!` macro — mirrors `ironclaw_memory::contract_tests` (#3918). The in-memory backend is wired through the suite, proving the harness shape works with one backend before PRs 2/3 drop in durable impls. In-memory backend behavior is unchanged apart from the clock-type adaptation; all prior unit tests are preserved (converted to async + `DateTime<Utc>` fixtures). Coordination with the in-flight #3635 bugfix: - The `Instant::checked_sub` underflow fix is SUPERSEDED here: with `DateTime<Utc>`, subtraction saturates rather than underflowing, so the hazard does not arise. Age-based trimming still holds. - The concurrent `hooks-predicate-state-bugfix` branch/PR is NOT yet on origin. If its cap-eviction overflow fix (e.g. a `WindowOverflow` variant on `PredicateBackendError`) lands first, this PR should be rebased onto it so the now-public error enum carries that variant — do not clobber the security fix. If this lands first, fold the overflow variant into the public enum in that PR. Blast radius confined to ironclaw_hooks + ironclaw_reborn; full workspace builds clean. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ead API, vacuous test, under-exposed const) (#3941) * fix(hooks): address dropped #3918/#3919 maintainability follow-ups (dead API, vacuous test, under-exposed const) henrypark133 approved-with-followup items on the merged contract-test harness (#3918) and SecurityAuditSink (#3919) PRs that were never followed up. Now in reborn-integration. - #3918: remove dead `RepoFactory<R>` type alias from contract_tests.rs (contracts use `F: Fn() -> R`; the alias was never referenced). - #3918 (henrypark M1, load-bearing): the scope-isolation search contract passed vacuously for the filesystem repo — it wrote documents but no CHUNK records, so `search_documents` (FTS over `.chunks/`) returned `Ok([])` and the isolation assertion was trivially satisfied. Now seeds chunk records via `replace_document_chunks_if_current` in two tenants with tenant-unique marker content, asserts scope-A search returns non-empty hits, only scope-A paths, and no scope-B snippet content. Verified the test FAILS when search ignores scope (the impl re-anchors result paths, so a per-path check alone could not catch a prefix leak; the snippet-marker assertion does). Split into a new `contract_test_indexed!` macro since only impls implementing `MemoryDocumentIndexRepository` can seed chunks. - #3918 (henrypark M2): the unsupported-search arm matched `FilesystemError::Unsupported` which `memory_backend_unsupported` never emits — it returns `FilesystemError::Backend` carrying a sanitized "...does not support search" reason (the `Unsupported` variant has no `reason` field and is reserved for mount-time capability mismatches). Changed the assertion to match `Backend { reason }` and documented the distinction; moved it to its own `search_documents_unsupported_is_documented` contract wired for opt-out impls. - #3919 (henrypark M1): `LEAK_REDACT_FAILED_CODE` is already `pub` and re-exported from the crate root in reborn-integration; no change needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(memory): use distinct paths per tenant in search-isolation contract fuse_memory_search_results keys its accumulator on relative_path alone (scope is not part of the key). The search-isolation contract seeded both tenants at notes/needle.md, so a leaked scope-B hit would collapse into the scope-A accumulator; since fusion keeps the first-inserted snippet (and_modify never replaces it), the "bravo" leak marker could be discarded before the assertions ran, masking the leak the test exists to catch. Give each tenant a distinct relative path so a leaked hit stays a separate fused result and its marker survives. Same query token, same assertions; only the key-collision blind spot is removed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(memory): document macro sync trap and wrong-macro hazard in contract tests (#3941) Addresses review findings on the contract-test macros: - M1: the base contract arms in `contract_test_indexed!` are duplicated verbatim from `contract_test!` with no compile-time guard against drift. Add paired `// SYNC:` markers on both macros stating that any base contract added to one must be added to the other. (Extracting a shared `contract_test_base!` is left as a follow-up.) - N1: correct the `contract_test_indexed!` doc that claimed it expands to "every base contract from contract_test!"; it deliberately excludes the opt-out search contract, replacing it with the real isolation contract. Adopt the reviewer's "same write/list/round-trip" wording. - L1: expand the `Ok(_)` arm comment in `search_documents_unsupported_is_documented` to warn that an indexed impl mistakenly wired with `contract_test!` passes vacuously here, and point at `contract_test_indexed!`. Comment/doc only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(memory): compose contract macros via shared contract_test_base! Address serrrfirat review on #3941. Extract the round-trip / write / list base contracts into an internal `contract_test_base!` macro that both `contract_test!` and `contract_test_indexed!` invoke inside their own `mod $label` block. The base arms now live in exactly one place, so a new base contract automatically flows into both suites and cannot drift — replacing the prior verbatim-duplication + SYNC-comment guard. The L1 explicit no-op comment on the indexed `Ok(_)` arm and the N1 doc wording were already present on the branch head; left as-is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…s from nearai#3890) (nearai#3918) * test: introduce trait-level contract test harness pattern Establishes a reusable harness for testing trait contracts across multiple impls in a single suite. Across recent PR review (nearai#3890, nearai#3887, nearai#3908) the same antipattern recurred: a trait has multiple impls with isolation/durability/CAS invariants every impl must honor, but tests only cover one impl — often a mock that quietly implements its own invariants. Per .claude/rules/testing.md ("Test Through the Caller, Not Just the Helper"), a contract test against one impl proves only that impl, not the contract. This is a scaffolding change: it picks one trait (MemoryDocumentRepository, the trait at issue in nearai#3890's HIGH finding) and wires both existing impls to a shared contract suite. The shape — not the breadth — is the point. Follow-ups can extend the suite and port other traits (IdempotencyLedger, CheckpointStateStore, ProcessStore, ApprovalRequestStore, ...) onto the same pattern. The harness lives in crates/ironclaw_memory/src/contract_tests.rs as pub async fn contracts taking a factory closure. The contract_test! macro expands to one #[tokio::test] per contract named <impl_label>::<contract_name> for clear failure attribution. Contracts covered in this scaffold: - round_trip_returns_written_bytes - writes_isolated_across_scopes (the nearai#3890 invariant) - list_documents_honors_scope - search_documents_isolated_across_scopes (the exact nearai#3890 HIGH) Existing tests in memory_backend_contract.rs and memory_filesystem_contract.rs are intentionally not deleted; deduplication is a follow-up. * ci: gate contract_tests behind feature to hide harness panics from production scan The `scripts/check_no_panics.py` scanner flagged 25 panic-style calls (`.expect`, `.unwrap`, `assert*!`) inside `crates/ironclaw_memory/src/contract_tests.rs`. These are intentional — it is a test harness — but the module was unconditionally `pub mod`, so the scanner (which walks added lines in changed files and only treats `#[cfg(...test...)]`-gated items as test context) saw them as production code. Fix: - Add a `contract-tests` cargo feature on `ironclaw_memory`. - Gate `pub mod contract_tests;` in `lib.rs` behind `#[cfg(any(test, feature = "contract-tests"))]`. - Gate every item inside `contract_tests.rs` with the same cfg so the no-panics scanner recognizes the file as test-only context (the scanner only sees the file's own attributes, not the module declaration in `lib.rs`). - Add a self dev-dependency on `ironclaw_memory` with the `contract-tests` feature enabled so the per-impl integration test files in `tests/` (which import the harness and the `contract_test!` macro) keep compiling. Production builds (`cargo build -p ironclaw_memory`) no longer compile the harness; `cargo test -p ironclaw_memory` continues to run all 4 contracts for both the in-memory and filesystem repository impls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tract (durable backend PR 1/4) (nearai#3927) Widen the predicate sliding-window backend contract from pub(crate) to a stable public surface that out-of-crate durable backends (Postgres, libSQL) can implement. This is PR 1 of the 4-PR durable-backend split; no durable impls land here. Surface changes: - Make `PredicateStateBackend`, `InvocationKey`, `ValueKey`, and `PredicateBackendError` `pub` (were `pub(crate)`). Replace the "intentionally pub(crate)" rustdoc with a stable-contract note. - Convert the trait to `#[async_trait]`. Both target durable backends are async; a sync trait would force `block_on` on the dispatch hot path. `PredicateEvaluator::evaluate`/`evaluate_at` and the sole production caller (`PredicateBackedBeforeCapabilityHook::evaluate`, already async) ripple to `.await` cleanly. - Change the clock from `std::time::Instant` to `chrono::DateTime<Utc>`. `Instant` is not serializable across processes; durable rows need a wall-clock timestamp. The in-memory backend stores `DateTime<Utc>` directly; window trimming stays age-based (`front_ts < now - window`). Contract-test harness scaffold: - Add `predicate_state::contract`, a feature-gated (`contract-tests`) module of `pub async fn` contracts each taking a backend factory closure, plus a `predicate_backend_contract_test!` macro — mirrors `ironclaw_memory::contract_tests` (nearai#3918). The in-memory backend is wired through the suite, proving the harness shape works with one backend before PRs 2/3 drop in durable impls. In-memory backend behavior is unchanged apart from the clock-type adaptation; all prior unit tests are preserved (converted to async + `DateTime<Utc>` fixtures). Coordination with the in-flight nearai#3635 bugfix: - The `Instant::checked_sub` underflow fix is SUPERSEDED here: with `DateTime<Utc>`, subtraction saturates rather than underflowing, so the hazard does not arise. Age-based trimming still holds. - The concurrent `hooks-predicate-state-bugfix` branch/PR is NOT yet on origin. If its cap-eviction overflow fix (e.g. a `WindowOverflow` variant on `PredicateBackendError`) lands first, this PR should be rebased onto it so the now-public error enum carries that variant — do not clobber the security fix. If this lands first, fold the overflow variant into the public enum in that PR. Blast radius confined to ironclaw_hooks + ironclaw_reborn; full workspace builds clean. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…ollow-ups (dead API, vacuous test, under-exposed const) (nearai#3941) * fix(hooks): address dropped nearai#3918/nearai#3919 maintainability follow-ups (dead API, vacuous test, under-exposed const) henrypark133 approved-with-followup items on the merged contract-test harness (nearai#3918) and SecurityAuditSink (nearai#3919) PRs that were never followed up. Now in reborn-integration. - nearai#3918: remove dead `RepoFactory<R>` type alias from contract_tests.rs (contracts use `F: Fn() -> R`; the alias was never referenced). - nearai#3918 (henrypark M1, load-bearing): the scope-isolation search contract passed vacuously for the filesystem repo — it wrote documents but no CHUNK records, so `search_documents` (FTS over `.chunks/`) returned `Ok([])` and the isolation assertion was trivially satisfied. Now seeds chunk records via `replace_document_chunks_if_current` in two tenants with tenant-unique marker content, asserts scope-A search returns non-empty hits, only scope-A paths, and no scope-B snippet content. Verified the test FAILS when search ignores scope (the impl re-anchors result paths, so a per-path check alone could not catch a prefix leak; the snippet-marker assertion does). Split into a new `contract_test_indexed!` macro since only impls implementing `MemoryDocumentIndexRepository` can seed chunks. - nearai#3918 (henrypark M2): the unsupported-search arm matched `FilesystemError::Unsupported` which `memory_backend_unsupported` never emits — it returns `FilesystemError::Backend` carrying a sanitized "...does not support search" reason (the `Unsupported` variant has no `reason` field and is reserved for mount-time capability mismatches). Changed the assertion to match `Backend { reason }` and documented the distinction; moved it to its own `search_documents_unsupported_is_documented` contract wired for opt-out impls. - nearai#3919 (henrypark M1): `LEAK_REDACT_FAILED_CODE` is already `pub` and re-exported from the crate root in reborn-integration; no change needed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * test(memory): use distinct paths per tenant in search-isolation contract fuse_memory_search_results keys its accumulator on relative_path alone (scope is not part of the key). The search-isolation contract seeded both tenants at notes/needle.md, so a leaked scope-B hit would collapse into the scope-A accumulator; since fusion keeps the first-inserted snippet (and_modify never replaces it), the "bravo" leak marker could be discarded before the assertions ran, masking the leak the test exists to catch. Give each tenant a distinct relative path so a leaked hit stays a separate fused result and its marker survives. Same query token, same assertions; only the key-collision blind spot is removed. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(memory): document macro sync trap and wrong-macro hazard in contract tests (nearai#3941) Addresses review findings on the contract-test macros: - M1: the base contract arms in `contract_test_indexed!` are duplicated verbatim from `contract_test!` with no compile-time guard against drift. Add paired `// SYNC:` markers on both macros stating that any base contract added to one must be added to the other. (Extracting a shared `contract_test_base!` is left as a follow-up.) - N1: correct the `contract_test_indexed!` doc that claimed it expands to "every base contract from contract_test!"; it deliberately excludes the opt-out search contract, replacing it with the real isolation contract. Adopt the reviewer's "same write/list/round-trip" wording. - L1: expand the `Ok(_)` arm comment in `search_documents_unsupported_is_documented` to warn that an indexed impl mistakenly wired with `contract_test!` passes vacuously here, and point at `contract_test_indexed!`. Comment/doc only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(memory): compose contract macros via shared contract_test_base! Address serrrfirat review on nearai#3941. Extract the round-trip / write / list base contracts into an internal `contract_test_base!` macro that both `contract_test!` and `contract_test_indexed!` invoke inside their own `mod $label` block. The base arms now live in exactly one place, so a new base contract automatically flows into both suites and cannot drift — replacing the prior verbatim-duplication + SYNC-comment guard. The L1 explicit no-op comment on the indexed `Ok(_)` arm and the N1 doc wording were already present on the branch head; left as-is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Summary
Introduces a trait-level contract test harness pattern. One trait, one shared suite of invariants, every impl wired in one line. Failures attribute to a specific impl by name.
This is a scaffolding PR — it establishes the macro/pattern with one concrete adoption (
MemoryDocumentRepository) to prove the shape. It does not try to migrate every trait in one go.Why
Across recent PR review (#3890, #3887, #3908) the same antipattern keeps recurring:
Per
.claude/rules/testing.md("Test Through the Caller, Not Just the Helper"), a contract test that runs against only one impl proves only that impl — not the contract. #3890 specifically surfaced a search-isolation HIGH onMemoryDocumentRepositorythat would have been impossible if every impl was forced through the same suite.Design
Contracts are plain
pub async fnincrates/ironclaw_memory/src/contract_tests.rsthat take a factory closureFn() -> R:Per-impl wiring is a single line via the exported
contract_test!macro:The macro expands to one
#[tokio::test]per contract, wrapped in a module named after the impl label. Failure output reads:— clear attribution, no shared mutable state across tests (factory produces a fresh repo per contract).
Contract invariants covered in this scaffold
round_trip_returns_written_byteswrites_isolated_across_scopeslist_documents_honors_scopelist_documents(scope)returns only documents in that scope.search_documents_isolated_across_scopessearch_documentseither returns the documentedUnsupported-shaped error, or returns only scope-local hits. This is the exact #3890 HIGH finding.Both
InMemoryMemoryDocumentRepositoryandFilesystemMemoryDocumentRepositorypass all four contracts cleanly. (If either had failed, that would be a real bug surfaced — none found in this scaffold pass.)Non-goals (explicitly)
IdempotencyLedger,CheckpointStateStore,ProcessStore,ApprovalRequestStore,MemoryDocumentIndexRepository, etc. onto the same shape.memory_backend_contract.rsandmemory_filesystem_contract.rsare untouched; deduplication of any tests subsumed by the new suite is a follow-up.MemoryDocumentRepository. The harness fits the trait as-is; the defaultsearch_documentsimpl returnsUnsupported, and the contract handles that branch.Test plan
cargo fmtcleancargo clippy -p ironclaw_memory --all-targets --all-featuresclean (no warnings, per CLAUDE.md zero-warning policy)cargo test -p ironclaw_memory— all existing tests still pass; 8 new contract tests pass (4 per impl)in_memory::…andfilesystem::…show up distinctly in test outputcontract_test!macro) is the one you want to standardize on before we start porting other traitsFollow-ups
MemoryDocumentRepositorycontract suite (CAS append semantics, metadata roundtrip, indexer-invariant 6 chunk invalidation).IdempotencyLedgerto the same shape (the trait at issue in [codex] Route Reborn production builders through factory #3887).ProcessStore/CheckpointStateStore(existing contract files already exist in those crates — easy ports).memory_backend_contract.rs/memory_filesystem_contract.rsthat are now subsumed by the trait-level suite.