Skip to content

bug(store): #574 double-quote escaping fix not applied to sanitizeFTSCandidates (match_mode="any" still errors) #664

Description

@Jc-asastu

📋 Pre-flight Checks

  • I have searched existing issues and this is not a duplicate
  • I understand this issue needs status:approved before a PR can be opened

📝 Bug Description

The interior-double-quote fix from #574 was applied to sanitizeFTS, but not to its sibling sanitizeFTSCandidates — the builder used by match_mode: "any". So the exact bug #574 closed is still reachable, just through the other code path.

main @ 763a6ba4:

internal/store/store.go:6585 — fixed:

func sanitizeFTS(query string) string {
	words := strings.Fields(query)
	for i, w := range words {
		w = strings.Trim(w, `"`)
		// Double interior double-quotes: FTS5 escapes a literal " inside a
		// quoted phrase by doubling it (""). ... See #574.
		w = strings.ReplaceAll(w, `"`, `""`)
		words[i] = `"` + w + `"`
	}
	return strings.Join(words, " ")
}

internal/store/relations.go:1039 — not fixed:

func sanitizeFTSCandidates(title string) string {
	words := strings.Fields(title)
	// ...
	for _, w := range words {
		w = strings.Trim(w, `"`)          // <-- only trims the ENDS
		if w != "" {
			quoted = append(quoted, `"`+w+`"`)
		}
	}
	return strings.Join(quoted, " OR ")
}

strings.Trim only removes leading/trailing quotes, so an interior quote survives and produces an unterminated FTS5 string literal.

internal/store/store.go:3167 — where match_mode: "any" selects it:

if opts.MatchMode == "any" {
	ftsQuery = sanitizeFTSCandidates(query)
} else {
	ftsQuery = sanitizeFTS(query)
}

Given the query hello"world:

builder produced FTS5 query result
sanitizeFTS (match_mode: "all") "hello""world" ✅ valid
sanitizeFTSCandidates (match_mode: "any") "hello"world" unterminated string

sanitizeFTSCandidates is also called from the relation-scan path in relations.go, where observation titles become the query — so any saved title containing a " can trip the same error there.

🔄 Steps to Reproduce

Self-contained proof of the mechanism, no Go needed (any SQLite ≥ 3.9 with FTS5):

CREATE VIRTUAL TABLE t USING fts5(x);
INSERT INTO t VALUES ('hello world');

-- what sanitizeFTSCandidates produces for  hello"world
SELECT * FROM t WHERE t MATCH '"hello"world"';   -- Error: unterminated string

-- what sanitizeFTS produces for the same input
SELECT * FROM t WHERE t MATCH '"hello""world"';  -- OK, 1 row

Through the MCP tool on main:

mem_search(query: "hello\"world", match_mode: "any")
  1. Save any observation.
  2. Call mem_search with a query containing an interior double-quote and match_mode: "any".
  3. Search fails instead of returning results.

✅ Expected Behavior

match_mode: "any" handles interior double-quotes exactly like match_mode: "all" does after #574 — the quote is escaped by doubling, and the search returns results instead of erroring.

❌ Actual Behavior

Search error: search: SQL logic error: unterminated string (1). Try simpler keywords.

(Captured on 1.11.0, which predates #574 and therefore fails on both modes. On main the "all" path is fixed and only the "any" path still fails — that asymmetry is what this issue is about, and it is established from the source above plus the SQLite repro, not from running 1.11.0.)

🖥️ Environment

  • OS: Windows
  • Engram Version: 1.11.0 locally; bug analysis is against main @ 763a6ba4
  • Agent / Client: Claude Code

💡 Additional Context

The fix looks like one line — reusing the same escaping sanitizeFTS already does:

w = strings.Trim(w, `"`)
w = strings.ReplaceAll(w, `"`, `""`)   // same as sanitizeFTS, see #574

Two things worth considering alongside it, entirely your call:

  1. Factor the escaping into one helper used by both builders, so the two paths can't drift apart again. That drift is what produced this issue.
  2. A regression test on the shared helper covering foo"bar, "foo, foo", and " alone would pin it.

Happy to open the PR with the fix plus tests once this is approved — just let me know if you'd prefer the one-line version or the shared-helper refactor.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions