Skip to content

fix(memory): sanitize FTS queries so natural-language recall works on libSQL (#7275) - #7281

Closed
serrrfirat wants to merge 1 commit into
nearai:mainfrom
serrrfirat:implement-issue-7275-test
Closed

serrrfirat wants to merge 1 commit into
nearai:mainfrom
serrrfirat:implement-issue-7275-test

Conversation

@serrrfirat

Copy link
Copy Markdown
Collaborator

Summary

  • Closes Reborn: verify explicit persistent memory recall across conversations in production #7275: caller-level verification of explicit persistent-memory recall across conversations on the production composition path (standalone build over the embedded-libSQL root filesystem — the active shipping backend), not an in-memory harness.
  • Building that verification exposed a real production defect: the libSQL backend passes the memory search query verbatim to FTS5, which treats ? ! ( ) " + - * : ^ as operators. Any natural-language query containing them makes the MATCH expression invalid: memory_search fails with OperationFailed, and the proactive prompt-memory lane (whose query is the user's latest message) degrades to empty. User messages almost always carry punctuation — so explicit recall across conversations was unreliable in production. This is the likely root cause of the Memory not reliably recalled across conversations #7185 feedback.
  • Fix: sanitize queries in MemorySearchRequest::new (memory-native) to whitespace-joined alphanumeric tokens — the same treatment the unicode61 tokenizer applies to indexed content, so it is faithful, not lossy. Punctuation-only queries still fail as invalid input.

Change Type

  • Bug fix
  • Documentation (test evidence maps issue acceptance criteria)

Linked Issue

Closes #7275

Validation

  • cargo fmt --all -- --check
  • cargo clippy -p ironclaw_composition -p ironclaw_memory_native --tests (clean)
  • cargo test -p ironclaw_composition --lib — 505 passed
  • cargo test -p ironclaw_memory_native — all suites passed (52+39+13+25+4+4)
  • cargo test -p ironclaw_host_runtime --test memory_prompt_context + lib memory_* — passed
  • cargo test -p ironclaw_integration_tests --test reborn_group_memory — passed
  • cargo test -p ironclaw_integration_tests --test reborn_integration_wiring_parity — passed
  • Regression discipline: the new composition test fails without the sanitizer fix (verified by temporary revert)
  • Note: reborn_group_multiuser stack-overflows on the base commit too (pre-existing, reproduced with changes stashed)

Acceptance-criteria evidence (issue #7275)

Criterion Evidence
1. Write in conv A + provider-issued/durable write evidence memory_recall_across_conversations_on_production_path: tool response carries status:"written", path, append, content_length; tree read-back; full runtime teardown + rebuild over the same libSQL root
2. Conv B (different thread, same tenant/user/agent/project) finds marker via memory_search same test: result_count: 1, marker in content, search_scope marker
3. Conv B receives marker via proactive prompt-memory lane without a tool call same test: production memory_context_service (exact memory_lifecycle_consumers derivation build_reborn_runtime wires) returns the marker snippet for conv B's run scope; request mirrors the loop's own builder
4. Fails closed for different user + isolated scope axis memory_recall_fails_closed_for_other_users_and_isolated_scope_axes: cross-user and cross-project both return successful result_count: 0 and empty prompt lane; non-vacuous same-scope control finds the marker
5. Failure distinguishable from no-match memory_retrieval_failure_is_distinct_from_no_matching_memory + memory_prompt_lane_failure_emits_operator_diagnostic: no-match = successful invocation with result_count: 0; empty query = failed invocation (InputEncode); backend Unavailable = typed Err(Unavailable), prompt lane degrades to empty while the memory context lane retrieval failed operator diagnostic is emitted (captured via thread-local subscriber)
6. Verified on production composition path + active shipping backend all tests build build_runtime_substrate(local_filesystem_build_input(..)) (embedded libSQL, native memory provider)
7. Version documentation Complaint reported on a pre-#6345 build (#7185). Punctuation-triggered FTS failure is present on current main (post-#6345) and fixed by this PR. Verified on head 2e2439af4

Test Strategy

User behavior: recall of explicitly saved memory across conversations must not depend on whether the user's phrasing carries punctuation.

Risk areas:

  • Persistence
  • Cross-component behavior (memory provider ↔ prompt lane ↔ loop host ↔ tool surface)
  • Model behavior — Not applicable: no prompt/model behavior changed; the query normalization is provider-side
  • Browser — Not applicable: no frontend change
  • Security or permissions — Not applicable: scope isolation semantics unchanged (fail-closed assertions added)
  • External provider — Not applicable: native provider only

Tests added or updated:

  • Unit or contract: MemorySearchRequest sanitizer tests (memory-native search.rs)
  • Reborn integration: none added (existing reborn_group_memory + wiring_parity re-run green)
  • Recorded fixture: Not applicable
  • Browser E2E: Not applicable
  • Backend or runtime: composition factory caller-level tests over embedded libSQL
  • Live canary: Not applicable

What the tests prove: the full issue #7275 acceptance-criteria matrix above, on the production composition path; plus the FTS5 punctuation defect and its fix.

Commands run: see Validation.

… libSQL (nearai#7275)

Issue nearai#7275 asks for caller-level verification that explicit persistent
memory written in conversation A is searchable and proactively recalled
in conversation B on the production composition path. Building that
verification on the real embedded-libSQL standalone composition exposed
a genuine production defect: the libSQL backend passes the memory search
query verbatim to FTS5, which treats ?, !, (, ), quotes, and + - * : ^
as operators — any natural-language query containing them makes the
MATCH expression invalid, so memory_search fails with OperationFailed
and the proactive prompt-memory lane (query = the user's latest message)
degrades to empty. User messages almost always carry punctuation, so
explicit recall across conversations was unreliable in production.

- memory-native: sanitize queries in MemorySearchRequest::new to
  whitespace-joined alphanumeric tokens (the same treatment the
  unicode61 tokenizer applies to indexed content); punctuation-only
  queries still fail as invalid input.
- composition factory tests (issue nearai#7275 acceptance criteria 1-6, on the
  production composition path with the shipping embedded-libSQL backend,
  not an in-memory harness):
  - conv A write with provider-issued write evidence (status/path/
    content_length), durable across a full runtime teardown/rebuild;
  - conv B (different thread, same tenant/user/agent/project) finds the
    marker via memory_search and receives it through the production
    prompt-memory lane (same derivation build_reborn_runtime wires)
    without any memory tool call, including natural-language queries
    with punctuation;
  - fail-closed for a different user and for an isolated project axis
    (successful empty results on both surfaces, with non-vacuous
    same-scope controls);
  - retrieval failure distinguishable from no-match: no-match is a
    successful invocation with result_count 0, invalid input fails with
    InputEncode, backend Unavailable errors degrade the prompt lane to
    empty while emitting the operator diagnostic (captured via a
    thread-local subscriber).
  - regression tests fail without the sanitizer fix.
@github-actions github-actions Bot added size: XL 500+ changed lines risk: low Changes to docs, tests, or low-risk modules labels Aug 6, 2026
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Improved memory recall across conversations while keeping results isolated by context.
    • Added clearer handling when memory is unavailable, including operator diagnostics.
    • Improved search for punctuation, hyphenated terms, Unicode text, and natural-language queries.
  • Bug Fixes

    • Punctuation-only searches are now rejected cleanly.
    • Empty search results are distinguished from retrieval failures.

Walkthrough

The changes add FTS query sanitization and production-path memory tests. The tests cover durable cross-conversation recall, scope isolation, prompt-lane retrieval, empty results, backend failures, and operator diagnostics.

Changes

Persistent memory recall validation

Layer / File(s) Summary
FTS query sanitization
crates/extensions/packages/memory-native/src/search.rs
Search queries now convert punctuation and operators into whitespace-separated tokens before validation and backend matching. Tests cover Unicode, hyphenated identifiers, natural-language queries, and empty sanitized queries.
Production-path recall and scope tests
crates/app/ironclaw_composition/src/factory/tests.rs
Helpers and production-derived test contexts support durable writes, cross-conversation searches, prompt-lane retrieval, snippet references, and caller/project isolation.
Retrieval outcomes and diagnostics
crates/app/ironclaw_composition/src/factory/tests.rs
Tests distinguish successful no-match results, invalid input, unavailable backends, and prompt-lane degradation with captured operator diagnostics.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConversationA
  participant MemoryService
  participant MemoryStore
  participant ConversationB
  participant PromptContextService
  ConversationA->>MemoryService: durable memory write
  MemoryService->>MemoryStore: persist memory
  ConversationB->>MemoryService: explicit memory search
  MemoryService->>MemoryStore: search saved memory
  MemoryStore-->>MemoryService: matching snippets
  ConversationB->>PromptContextService: prompt-lane request
  PromptContextService->>MemoryStore: retrieve scoped memory
  MemoryStore-->>PromptContextService: matching snippets
Loading

Suggested reviewers: benkurrek

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description documents the fix and validation, but it omits several required template sections for database, trust-boundary, blast-radius, rollback, and review details. Complete the missing Database Impact, Reborn Trust-Boundary Checklist, Security Impact, Blast Radius, Rollback Plan, Review Follow-Through, and Review track sections.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title follows Conventional Commits style and accurately describes the FTS query sanitization fix.
Linked Issues check ✅ Passed The changes and evidence address all coding objectives and acceptance criteria in [#7275], including production persistence, recall, isolation, diagnostics, and the root-cause fix.
Out of Scope Changes check ✅ Passed The sanitizer and production-path regression tests directly support [#7275]; no unrelated code changes are identified.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the contributor: core 20+ merged PRs label Aug 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

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

⚠️ Outside diff range comments (1)
crates/extensions/packages/memory-native/src/search.rs (1)

79-86: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Distinguish "empty query" from "no searchable tokens" in the error.

After sanitization, value is the empty string for any punctuation-only input. The caller receives value: "" and reason: "query must not be empty", so an operator cannot tell an empty query from a query that lost every token to the tokenizer. Repo guidance requires errors to carry useful context.

🐛 Proposed fix: report the token-loss case explicitly
-        let query = sanitize_fts_query(query);
-        if query.trim().is_empty() {
+        let raw: String = query.into();
+        let query = sanitize_fts_query(&raw);
+        if query.is_empty() {
             return Err(HostApiError::InvalidId {
                 kind: "memory search query",
-                value: query,
-                reason: "query must not be empty".to_string(),
+                value: raw,
+                reason: "query has no alphanumeric search tokens".to_string(),
             });
         }

sanitize_fts_query already returns a whitespace-trimmed value, so trim() is no longer required.

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

In `@crates/extensions/packages/memory-native/src/search.rs` around lines 79 - 86,
Update the validation around sanitize_fts_query so genuinely empty input and
punctuation-only input produce distinct InvalidId errors with useful
original-query context. Remove the redundant trim check because
sanitize_fts_query already returns a trimmed value, and preserve the existing
non-empty searchable-query flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/app/ironclaw_composition/src/factory/tests.rs`:
- Around line 871-877: Extract the self-contained `#7275` memory-recall
block—including its helpers, four tests, CaptureWriter, and
UnavailableMemoryService—from the factory tests file into a sibling
memory_recall_tests.rs module. Declare it from the parent with #[cfg(test)] mod
memory_recall_tests;, and preserve the existing test behavior and required
imports while reducing the size of the original file.
- Around line 1279-1294: Update the invalid-input case in the invoke_json test
to send a syntactically valid request whose query contains only punctuation, so
it reaches MemorySearchRequest::new and exercises sanitizer rejection before
retrieval. Assert the caller-visible failure kind mapped from
HostApiError::InvalidId, replacing the current InputEncode expectation while
preserving the public invocation path.
- Around line 1151-1218: Add a positive prompt-lane control in this test before
the cross-user and isolated-project negative checks, using the existing
same-scope user/project context and the written “isolation marker” data. Assert
that the lane returns a non-empty snippet containing the marker, then retain the
existing assertions proving different users and projects return empty results;
anchor the change to the prompt_lane_service flow and
prompt_lane_turn_scope/prompt_lane_request helpers.

---

Outside diff comments:
In `@crates/extensions/packages/memory-native/src/search.rs`:
- Around line 79-86: Update the validation around sanitize_fts_query so
genuinely empty input and punctuation-only input produce distinct InvalidId
errors with useful original-query context. Remove the redundant trim check
because sanitize_fts_query already returns a trimmed value, and preserve the
existing non-empty searchable-query flow.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 364d2872-308d-44bc-a840-56f8858e6225

📥 Commits

Reviewing files that changed from the base of the PR and between c69ed2d and 2e2439a.

📒 Files selected for processing (2)
  • crates/app/ironclaw_composition/src/factory/tests.rs
  • crates/extensions/packages/memory-native/src/search.rs

Comment on lines +871 to +877
// ────────────────────────────────────────────────────────────────────────────
// Issue #7275 — verify explicit persistent memory recall across conversations
// on the PRODUCTION composition path (standalone build over the embedded
// libSQL root filesystem — the active shipping backend of the local-dev
// deployment), not an in-memory harness. Each assertion maps to one
// acceptance criterion of the issue; see the PR body for the criterion table.
// ────────────────────────────────────────────────────────────────────────────

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Move the #7275 memory-recall block into its own test module file.

This block adds about 530 lines to a file that is already over 3,000 lines. The repo rule for crates/**/*.rs requires a decomposition tracking issue for files over 3,000 lines, and inline justification for additions over 200 lines. The block is self-contained: helpers, four tests, CaptureWriter, and UnavailableMemoryService. Extract it to a sibling module, for example crates/app/ironclaw_composition/src/factory/memory_recall_tests.rs, and declare it with #[cfg(test)] mod memory_recall_tests;.

As per coding guidelines: "Keep new Rust files below 800 lines where possible. Existing files over 1,500 lines should become shorter when touched … files over 3,000 lines require a decomposition tracking issue, and additions exceeding 200 lines require inline justification."

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

In `@crates/app/ironclaw_composition/src/factory/tests.rs` around lines 871 - 877,
Extract the self-contained `#7275` memory-recall block—including its helpers, four
tests, CaptureWriter, and UnavailableMemoryService—from the factory tests file
into a sibling memory_recall_tests.rs module. Declare it from the parent with
#[cfg(test)] mod memory_recall_tests;, and preserve the existing test behavior
and required imports while reducing the size of the original file.

Source: Coding guidelines

Comment on lines +1151 to +1218
let lane = prompt_lane_service(&services).expect("native binding wires the prompt lane");

// Different USER: search succeeds with zero results (no leak), and the
// prompt lane returns no snippet — both are SUCCESSFUL empty outcomes,
// the fail-closed shape, not errors.
let other_user_search = invoke_json(
&services,
MEMORY_SEARCH_CAPABILITY_ID,
memory_context_for(
MEMORY_SEARCH_CAPABILITY_ID,
"issue-7275-other-user",
"conv-c",
None,
),
serde_json::json!({"query": "isolation marker", "limit": 5}),
)
.await
.expect("cross-user search succeeds (fail-closed, not an error)");
assert_eq!(other_user_search["result_count"], serde_json::json!(0));
assert_eq!(
other_user_search["search_scope"],
serde_json::json!("reborn_internal_persistent_memory")
);
let other_scope = prompt_lane_turn_scope("issue-7275-other-user", "conv-c", None);
let other_snippets = lane
.load_memory_snippets(prompt_lane_request(
&other_scope,
"issue-7275-other-user",
"isolation marker?",
))
.await
.expect("cross-user prompt lane retrieval succeeds");
assert!(
other_snippets.is_empty(),
"different user must not receive the marker through the prompt lane"
);

// Isolated scope axis: same user, DIFFERENT project → same fail-closed
// shape on both surfaces (the memory document scope is
// tenant/user/agent/project, so a different project is a different
// memory partition).
let isolated_search = invoke_json(
&services,
MEMORY_SEARCH_CAPABILITY_ID,
memory_context_for(
MEMORY_SEARCH_CAPABILITY_ID,
user,
"conv-d",
Some("isolated-project"),
),
serde_json::json!({"query": "isolation marker", "limit": 5}),
)
.await
.expect("isolated-project search succeeds (fail-closed, not an error)");
assert_eq!(isolated_search["result_count"], serde_json::json!(0));
let isolated_scope = prompt_lane_turn_scope(user, "conv-d", Some("isolated-project"));
let isolated_snippets = lane
.load_memory_snippets(prompt_lane_request(
&isolated_scope,
user,
"isolation marker?",
))
.await
.expect("isolated-project prompt lane retrieval succeeds");
assert!(
isolated_snippets.is_empty(),
"an isolated scope axis must not receive the marker through the prompt lane"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a positive prompt-lane control so the isolation assertions are non-vacuous.

The control at Line 1141 exercises the tool surface only. Both prompt-lane assertions here are negative. If prompt_lane_turn_scope derived a tenant or agent that never matches the write path, other_snippets.is_empty() and isolated_snippets.is_empty() would still pass and prove nothing. The positive lane proof lives in memory_recall_across_conversations_on_production_path, which uses a different tempdir, owner, and user, so it does not cover this test.

💚 Proposed fix: assert the same-scope lane hit first
     let lane = prompt_lane_service(&services).expect("native binding wires the prompt lane");
 
+    // Non-vacuity control for the lane: same user, same project, different
+    // thread must receive the marker, so the negative lane assertions below
+    // cannot pass because of a mis-derived scope.
+    let control_scope = prompt_lane_turn_scope(user, "conv-b", None);
+    let control_snippets = lane
+        .load_memory_snippets(prompt_lane_request(&control_scope, user, "isolation marker?"))
+        .await
+        .expect("same-scope prompt lane retrieval succeeds");
+    assert!(
+        control_snippets
+            .iter()
+            .any(|snippet| snippet.safe_summary.contains(MARKER)),
+        "lane control must find the marker: {control_snippets:?}"
+    );
+
     // Different USER: search succeeds with zero results (no leak), and the

As per coding guidelines: "Destructive operations require an explicit product contract, authorization, and tests proving scope isolation."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let lane = prompt_lane_service(&services).expect("native binding wires the prompt lane");
// Different USER: search succeeds with zero results (no leak), and the
// prompt lane returns no snippet — both are SUCCESSFUL empty outcomes,
// the fail-closed shape, not errors.
let other_user_search = invoke_json(
&services,
MEMORY_SEARCH_CAPABILITY_ID,
memory_context_for(
MEMORY_SEARCH_CAPABILITY_ID,
"issue-7275-other-user",
"conv-c",
None,
),
serde_json::json!({"query": "isolation marker", "limit": 5}),
)
.await
.expect("cross-user search succeeds (fail-closed, not an error)");
assert_eq!(other_user_search["result_count"], serde_json::json!(0));
assert_eq!(
other_user_search["search_scope"],
serde_json::json!("reborn_internal_persistent_memory")
);
let other_scope = prompt_lane_turn_scope("issue-7275-other-user", "conv-c", None);
let other_snippets = lane
.load_memory_snippets(prompt_lane_request(
&other_scope,
"issue-7275-other-user",
"isolation marker?",
))
.await
.expect("cross-user prompt lane retrieval succeeds");
assert!(
other_snippets.is_empty(),
"different user must not receive the marker through the prompt lane"
);
// Isolated scope axis: same user, DIFFERENT project → same fail-closed
// shape on both surfaces (the memory document scope is
// tenant/user/agent/project, so a different project is a different
// memory partition).
let isolated_search = invoke_json(
&services,
MEMORY_SEARCH_CAPABILITY_ID,
memory_context_for(
MEMORY_SEARCH_CAPABILITY_ID,
user,
"conv-d",
Some("isolated-project"),
),
serde_json::json!({"query": "isolation marker", "limit": 5}),
)
.await
.expect("isolated-project search succeeds (fail-closed, not an error)");
assert_eq!(isolated_search["result_count"], serde_json::json!(0));
let isolated_scope = prompt_lane_turn_scope(user, "conv-d", Some("isolated-project"));
let isolated_snippets = lane
.load_memory_snippets(prompt_lane_request(
&isolated_scope,
user,
"isolation marker?",
))
.await
.expect("isolated-project prompt lane retrieval succeeds");
assert!(
isolated_snippets.is_empty(),
"an isolated scope axis must not receive the marker through the prompt lane"
);
let lane = prompt_lane_service(&services).expect("native binding wires the prompt lane");
// Non-vacuity control for the lane: same user, same project, different
// thread must receive the marker, so the negative lane assertions below
// cannot pass because of a mis-derived scope.
let control_scope = prompt_lane_turn_scope(user, "conv-b", None);
let control_snippets = lane
.load_memory_snippets(prompt_lane_request(&control_scope, user, "isolation marker?"))
.await
.expect("same-scope prompt lane retrieval succeeds");
assert!(
control_snippets
.iter()
.any(|snippet| snippet.safe_summary.contains(MARKER)),
"lane control must find the marker: {control_snippets:?}"
);
// Different USER: search succeeds with zero results (no leak), and the
// prompt lane returns no snippet — both are SUCCESSFUL empty outcomes,
// the fail-closed shape, not errors.
let other_user_search = invoke_json(
&services,
MEMORY_SEARCH_CAPABILITY_ID,
memory_context_for(
MEMORY_SEARCH_CAPABILITY_ID,
"issue-7275-other-user",
"conv-c",
None,
),
serde_json::json!({"query": "isolation marker", "limit": 5}),
)
.await
.expect("cross-user search succeeds (fail-closed, not an error)");
assert_eq!(other_user_search["result_count"], serde_json::json!(0));
assert_eq!(
other_user_search["search_scope"],
serde_json::json!("reborn_internal_persistent_memory")
);
let other_scope = prompt_lane_turn_scope("issue-7275-other-user", "conv-c", None);
let other_snippets = lane
.load_memory_snippets(prompt_lane_request(
&other_scope,
"issue-7275-other-user",
"isolation marker?",
))
.await
.expect("cross-user prompt lane retrieval succeeds");
assert!(
other_snippets.is_empty(),
"different user must not receive the marker through the prompt lane"
);
// Isolated scope axis: same user, DIFFERENT project → same fail-closed
// shape on both surfaces (the memory document scope is
// tenant/user/agent/project, so a different project is a different
// memory partition).
let isolated_search = invoke_json(
&services,
MEMORY_SEARCH_CAPABILITY_ID,
memory_context_for(
MEMORY_SEARCH_CAPABILITY_ID,
user,
"conv-d",
Some("isolated-project"),
),
serde_json::json!({"query": "isolation marker", "limit": 5}),
)
.await
.expect("isolated-project search succeeds (fail-closed, not an error)");
assert_eq!(isolated_search["result_count"], serde_json::json!(0));
let isolated_scope = prompt_lane_turn_scope(user, "conv-d", Some("isolated-project"));
let isolated_snippets = lane
.load_memory_snippets(prompt_lane_request(
&isolated_scope,
user,
"isolation marker?",
))
.await
.expect("isolated-project prompt lane retrieval succeeds");
assert!(
isolated_snippets.is_empty(),
"an isolated scope axis must not receive the marker through the prompt lane"
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/app/ironclaw_composition/src/factory/tests.rs` around lines 1151 -
1218, Add a positive prompt-lane control in this test before the cross-user and
isolated-project negative checks, using the existing same-scope user/project
context and the written “isolation marker” data. Assert that the lane returns a
non-empty snippet containing the marker, then retain the existing assertions
proving different users and projects return empty results; anchor the change to
the prompt_lane_service flow and prompt_lane_turn_scope/prompt_lane_request
helpers.

Source: Coding guidelines

Comment on lines +1279 to +1294
// Retrieval failure (invalid input on the same surface) → the invocation
// FAILS with a distinct failure kind instead of fabricating an empty
// result set: failure is observable at the caller.
let input_failure = invoke_json(
&services,
MEMORY_SEARCH_CAPABILITY_ID,
memory_context_for(MEMORY_SEARCH_CAPABILITY_ID, user, "conv-c", None),
serde_json::json!({}),
)
.await
.expect_err("an empty query must fail the search invocation, not return empty results");
assert_eq!(
input_failure,
FailureKind::InputEncode,
"invalid search input must surface as a distinct failure kind"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The invalid-input case does not exercise the changed rejection path.

The payload {} omits query, so the failure originates in request deserialization, not in MemorySearchRequest::new. The comment states the empty query "is rejected before any retrieval", which describes a different mechanism. This PR makes punctuation-only queries newly invalid, and no caller-level test covers that. Add a probe with a punctuation-only query.

💚 Proposed fix: cover the sanitizer rejection at the caller
+    // Punctuation-only query: the sanitizer leaves no tokens, so the
+    // invocation FAILS instead of returning an empty result set.
+    let punctuation_failure = invoke_json(
+        &services,
+        MEMORY_SEARCH_CAPABILITY_ID,
+        memory_context_for(MEMORY_SEARCH_CAPABILITY_ID, user, "conv-e", None),
+        serde_json::json!({"query": "!!!???", "limit": 5}),
+    )
+    .await
+    .expect_err("a punctuation-only query must fail the search invocation");
+    assert_ne!(
+        punctuation_failure,
+        FailureKind::InputEncode,
+        "placeholder: assert the kind the tool surface maps the sanitizer rejection to"
+    );

Replace the placeholder assertion with the failure kind the tool surface actually maps HostApiError::InvalidId to.

As per coding guidelines: "Test through the public caller when a predicate, classifier, or transform gates a side effect and wrappers or computed inputs intervene; helper-only tests are insufficient."

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

In `@crates/app/ironclaw_composition/src/factory/tests.rs` around lines 1279 -
1294, Update the invalid-input case in the invoke_json test to send a
syntactically valid request whose query contains only punctuation, so it reaches
MemorySearchRequest::new and exercises sanitizer rejection before retrieval.
Assert the caller-visible failure kind mapped from HostApiError::InvalidId,
replacing the current InputEncode expectation while preserving the public
invocation path.

Source: Coding guidelines

@ironloopai

ironloopai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Review · PR #7281

🟢 Completed · Review submitted

1 actionable findings →

Reviewed the complete trusted base-to-head comparison. The punctuation normalization improves several queries, but it does not fully make input safe for FTS5: alphabetic FTS operators remain executable and can still fail or alter natural-language recall.

Automatic · PR opened · attempt 1 of 3 · completed in 1m 42s

Run details
  • Repository: nearai/ironclaw
  • Base: main at c69ed2d
  • Head: implement-issue-7275-test at 2e2439a
  • Created: Aug 6, 2026, 11:56 AM UTC
  • Updated: Aug 6, 2026, 11:57 AM UTC
  • Run: c07e1127-3872-454e-82af-c971c19d6dd9
  • Latest attempt: 1 · Completed · 894064b6-7040-4645-a2eb-6b4a531bf5b0

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

🔍 Review complete · PR #7281

⚠️ 1 finding · 1 blocking

Reviewed the complete trusted base-to-head comparison. The punctuation normalization improves several queries, but it does not fully make input safe for FTS5: alphabetic FTS operators remain executable and can still fail or alter natural-language recall.

Findings

  1. 🟠 Medium · FTS5 keyword operators survive query sanitization — crates/extensions/packages/memory-native/src/search.rs:79
    Details are attached to the relevant diff.
Validation and technical details
  • Verified refs/ironloop/base and refs/ironloop/head resolve exactly to c69ed2d and 2e2439a.
  • Inspected both changed files and surrounding memory service, repository, composition, and libSQL MATCH translation paths.
  • Reproduced FTS5 behavior with SQLite: AND and alpha OR raise syntax errors; alpha OR beta is interpreted as an operator expression.
  • git diff --check refs/ironloop/base..refs/ironloop/head completed cleanly.
  • Rust tests could not be executed because cargo is unavailable in the review environment.
  • Base: main
  • Head: implement-issue-7275-test at 2e2439a
  • Run: c07e1127-3872-454e-82af-c971c19d6dd9

// `plainto_tsquery`. Content is tokenized by unicode61 the same
// way, so sanitizing the query to whitespace-joined alphanumeric
// tokens is faithful, not lossy.
let query = sanitize_fts_query(query);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 Medium · FTS5 keyword operators survive query sanitization

sanitize_fts_query removes punctuation but preserves alphabetic FTS5 operators such as uppercase AND, OR, and NOT. Because the libSQL backend passes this value directly to MATCH, queries such as "AND" or "alpha OR" still produce FTS5 syntax errors, while "alpha OR beta" changes the intended all-token search into an OR expression. This leaves the reported production failure reproducible for valid natural-language/boolean-looking input and can broaden recall unexpectedly. Encode the sanitized tokens as FTS5 literals at the libSQL boundary (or otherwise use a parser-safe backend representation), and add production-backend regression cases for these keywords.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Railway preview QA — BLOCKED

Tested head: 2e2439af478fa489f92ef408996d639f361ac17a (PR head, unchanged throughout the run)
Railway state: missing — no Railway status or ci-preview check-run exists on the head commit after ~15 minutes of polling (20 attempts at 40s); the fallback preview URL https://ironclaw-ironclaw-pr-7281.up.railway.app resolves but returns HTTP 404 (no deployment attached to this PR).
Preview URL: https://ironclaw-ironclaw-pr-7281.up.railway.app

Required cases (blocked)

The PR's user-visible contract — cross-conversation recall of an explicitly saved memory when conversation B asks in natural language with punctuation (?, !) — can only be exercised on the exact deployed head. No deployment exists, so no required browser case could be executed against the intended build:

Case Acceptance Intended contract Actual contract Status
Conv A: "remember the staging rollback codename is osprey-meridian-7" → assistant saves to persistent memory Required memory_write succeeds; durable evidence — (no live preview) BLOCKED
Conv B (new conversation): "what's the staging rollback codename?" → assistant recalls from memory without re-explaining Required Proactive prompt-memory lane returns the marker; no hard FTS failure — (no live preview) BLOCKED
Conv B asks with punctuation in a fresh phrasing → recall still works (the #7275 regression claim) Required Sanitized FTS query; search succeeds instead of OperationFailed — (no live preview) BLOCKED

Status derivation

  • FAIL? No — no required case ran against a contradicting observation.
  • PASS? No — no required case executed.
  • BLOCKED — every required case is unexecuted because the intended build is not live: no Railway status/check on the head, preview URL returns 404.

Supplemental evidence (does not upgrade BLOCKED)

  • CI on the exact tested head is fully green: Tests (Reborn), Reborn E2E, Reborn WebUI v2 E2E (browser), Code Style (fmt + clippy) all success (commit-scoped check-runs).
  • Caller-level verification of the same contract landed in this PR on the production composition path (embedded libSQL backend, native memory provider), including the FTS5-punctuation regression that previously failed memory_search with OperationFailed and degraded the proactive prompt lane to empty:
    • memory_recall_across_conversations_on_production_path — write evidence, durable reopen, cross-thread memory_search, proactive prompt-lane recall with ?/!/( queries;
    • memory_recall_fails_closed_for_other_users_and_isolated_scope_axes;
    • memory_retrieval_failure_is_distinct_from_no_matching_memory + memory_prompt_lane_failure_emits_operator_diagnostic.
  • Browser driver and preview bearer were available locally (playwright harness assembled; token read from the user's preview-tokens store, never printed); the only blocker is the absent deployment.

Remaining risks / how to unblock

  • Deploy PR 7281 to Railway (or attach the ci-preview/Railway check) and re-run /railway-test — the browser matrix above is ready to execute against the live build.
  • Residual risk while blocked: live-model recall behavior (model tool choice for memory_write, natural-language recall in conversation B) is not yet observed on a deployed build; the deterministic caller-level tests cover the provider/prompt-lane path but not the model's tool-use behavior.

@serrrfirat

Copy link
Copy Markdown
Collaborator Author

Superseded by #7285 — reopening from the upstream branch (nearai/ironclaw) so the Railway preview deployment attaches to the PR head. Same commit 2e2439a, same content.

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-7285 — 2e2439af Deployed Aug 6, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reborn: verify explicit persistent memory recall across conversations in production

1 participant