Skip to content

test: trait-level contract harness scaffold (closes coverage gap class from #3890) - #3918

Merged
zmanian merged 2 commits into
reborn-integrationfrom
contract-test-harness-scaffold
May 23, 2026
Merged

zmanian merged 2 commits into
reborn-integrationfrom
contract-test-harness-scaffold

Conversation

@zmanian

@zmanian zmanian commented May 23, 2026

Copy link
Copy Markdown
Collaborator

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:

  • A trait has 2+ impls (in-memory, filesystem, libsql, postgres, …).
  • Every impl is supposed to honor the same isolation / durability / CAS invariants.
  • Tests only exercise one impl — often a mock that quietly implements its own invariants and never crosses the layer where a real bug lives.

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 on MemoryDocumentRepository that would have been impossible if every impl was forced through the same suite.

Design

Contracts are plain pub async fn in crates/ironclaw_memory/src/contract_tests.rs that take a factory closure Fn() -> R:

pub async fn writes_isolated_across_scopes<R, F>(factory: F)
where R: MemoryDocumentRepository, F: Fn() -> R { … }

Per-impl wiring is a single line via the exported contract_test! macro:

// crates/ironclaw_memory/tests/repo_in_memory_contract.rs
contract_test!(in_memory, InMemoryMemoryDocumentRepository::new);

// crates/ironclaw_memory/tests/repo_filesystem_contract.rs
contract_test!(filesystem, || {
    FilesystemMemoryDocumentRepository::new(Arc::new(InMemoryBackend::new()))
});

The macro expands to one #[tokio::test] per contract, wrapped in a module named after the impl label. Failure output reads:

test filesystem::search_documents_isolated_across_scopes ... FAILED

— clear attribution, no shared mutable state across tests (factory produces a fresh repo per contract).

Contract invariants covered in this scaffold

Contract Invariant
round_trip_returns_written_bytes Put then get returns the exact bytes written.
writes_isolated_across_scopes A write in scope A is invisible to reads in scope B — even at the identical relative path. (The class of bug behind #3890.)
list_documents_honors_scope list_documents(scope) returns only documents in that scope.
search_documents_isolated_across_scopes search_documents either returns the documented Unsupported-shaped error, or returns only scope-local hits. This is the exact #3890 HIGH finding.

Both InMemoryMemoryDocumentRepository and FilesystemMemoryDocumentRepository pass all four contracts cleanly. (If either had failed, that would be a real bug surfaced — none found in this scaffold pass.)

Non-goals (explicitly)

  • Not migrating every trait. Follow-ups can port IdempotencyLedger, CheckpointStateStore, ProcessStore, ApprovalRequestStore, MemoryDocumentIndexRepository, etc. onto the same shape.
  • Not deleting existing tests. memory_backend_contract.rs and memory_filesystem_contract.rs are untouched; deduplication of any tests subsumed by the new suite is a follow-up.
  • Not expanding the suite breadth. The first contract set is deliberately minimal — round-trip, scope isolation, list filter, search isolation. CAS, metadata, append-outcome, indexer-coupling contracts can be added as follow-ups once the shape lands.
  • Not reshaping MemoryDocumentRepository. The harness fits the trait as-is; the default search_documents impl returns Unsupported, and the contract handles that branch.

Test plan

  • cargo fmt clean
  • cargo clippy -p ironclaw_memory --all-targets --all-features clean (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)
  • New tests attribute by impl: in_memory::… and filesystem::… show up distinctly in test output
  • Reviewer: confirm the harness shape (closure factory + contract_test! macro) is the one you want to standardize on before we start porting other traits

Follow-ups

  1. Extend the MemoryDocumentRepository contract suite (CAS append semantics, metadata roundtrip, indexer-invariant 6 chunk invalidation).
  2. Port IdempotencyLedger to the same shape (the trait at issue in [codex] Route Reborn production builders through factory #3887).
  3. Port ProcessStore / CheckpointStateStore (existing contract files already exist in those crates — easy ports).
  4. Deduplicate single-impl tests in memory_backend_contract.rs / memory_filesystem_contract.rs that are now subsumed by the trait-level suite.

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

Copy link
Copy Markdown
Contributor

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@github-actions github-actions Bot added size: XS < 10 changed lines (excluding docs) risk: low Changes to docs, tests, or low-risk modules contributor: core 20+ merged PRs labels May 23, 2026

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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, and list_documents_honors_scope are load-bearing and correct.
  • Factory isolation (fresh repo per contract) is properly enforced.
  • The existing unit tests in repo/filesystem.rs are 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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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:?}"
);
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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::Unsupported arm is dead; actual error is Backend
  • (Conventions/M) pub mod contract_tests unconditional 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>
@github-actions github-actions Bot added scope: dependencies Dependency updates size: S 10-49 changed lines and removed size: XS < 10 changed lines (excluding docs) labels May 23, 2026
@zmanian
zmanian merged commit 8a19d41 into reborn-integration May 23, 2026
15 checks passed
@zmanian
zmanian deleted the contract-test-harness-scaffold branch May 23, 2026 04:10
zmanian added a commit that referenced this pull request May 23, 2026
…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>
zmanian added a commit that referenced this pull request May 23, 2026
…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>
zmanian added a commit that referenced this pull request Jun 6, 2026
…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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants