fix(memory): v0.1 CRIT cluster — facts_vec / replay correlation / FTS5 sanitize / search_memory preamble #153

Merged
KofTwentyTwo merged 5 commits from fix/memory-crit-v0.1 into develop 2026-05-13 11:30:35 +00:00
13 changed files with 701 additions and 107 deletions

No files matched your search

+101 -31
View File
@@ -383,6 +383,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
/// (audit trail: Track-D D-1; semantics unchanged after D-5/D-6.)
var memorySearchAvailable: Bool = false
/// Audit 2026-05-12 / M-2 (Issue #13): per-process sentinel TurnID for
/// memory-mutation / memory-retrieval events that don't have a real
/// per-turn TurnID (the extraction orchestrator runs after `.turnEnd`
/// and isn't pinned to a specific turn). Generated once at delegate
/// construction; `installAgent` writes the matching row into
/// `replay.sqlite/turns` so `events.turn_id REFERENCES turns(turn_id)`
/// resolves. Pre-fix the sink synthesized a fake TurnID per call which
/// broke the FK on every emission.
let memorySentinelTurnID: TurnID = TurnID.fresh()
/// Background memory-extraction orchestrator. Drains a bounded
/// `AsyncChannel(capacity: 32, .dropOldest)` one job at a time so a
/// stalled 32B-model extraction never back-pressures `.turnEnd`
@@ -1554,12 +1564,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
.deletingLastPathComponent()
.appendingPathComponent("jarvis.db")
// 2. MemoryStore. Hard-block on failure per D-5/D-6 — see method
// 2a. Embedder. Constructed BEFORE MemoryStore so both the writer
// side (Issue #12: populating `facts_vec` after every fact
// INSERT) and the reader side (HybridSearch) share one
// instance. Embedder construction is permissive — only fails
// on a non-loopback URL — but if the constructor somehow
// throws, we proceed with a nil embedder so the store still
// opens (degraded mode: FTS5-only search). Loopback host is
// hardcoded; the OllamaEmbeddingClient.assertLoopback check
// will never reject `127.0.0.1`.
let memoryEmbedder: OllamaEmbeddingClient?
do {
memoryEmbedder = try OllamaEmbeddingClient(
baseURL: URL(string: "http://127.0.0.1:11434")!
)
} catch {
systemLogger?.warning(
"installMemory: embedder init failed — facts_vec writes will be skipped (degraded mode): \(String(describing: error))"
)
memoryEmbedder = nil
}
// 2b. MemoryStore. Hard-block on failure per D-5/D-6 — see method
// docstring above for the policy + the prior Track-D D-1 era
// that this reversed.
let store: MemoryStore
do {
store = try MemoryStore(databaseURL: dbURL)
store = try MemoryStore(databaseURL: dbURL, embedder: memoryEmbedder)
} catch {
let description = String(describing: error)
systemLogger?.critical(
@@ -1580,9 +1611,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let searchAvailable = true
// 3. Wire the replay sink. `store` is non-optional after D-5/D-6
// (we'd have terminated above if init had failed).
// (we'd have terminated above if init had failed). Issue #13:
// the sink attributes every memory event to the per-process
// sentinel TurnID (memorySentinelTurnID); `installAgent` writes
// the matching `turns` row after `beginSession`. Until that row
// exists, sink emissions still flow but fail the FK and get
// dropped via the batch-split path — chain stays uncorrupted.
if let log = replayLog {
await store.setReplayLog(AppDelegateMemoryReplaySink(replayLog: log))
await store.setReplayLog(AppDelegateMemoryReplaySink(
replayLog: log,
sentinelTurnID: memorySentinelTurnID
))
} else {
systemLogger?.warning("installMemory: replayLog absent — memory mutation rows won't persist")
}
@@ -1635,18 +1674,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// `MemoryStore.forgetFact`. (audit trail: Track-D D-2.)
let forgetDispatcher: any ForgetFactDispatching = ForgetFactStoreAdapter(store: store)
var searchDispatcher: (any HybridSearchDispatching)? = nil
if searchAvailable {
do {
let embedder = try OllamaEmbeddingClient(
baseURL: URL(string: "http://127.0.0.1:11434")!
)
let hybrid = HybridSearch(store: store, embedder: embedder)
searchDispatcher = HybridSearchAdapter(hybrid: hybrid)
} catch {
systemLogger?.warning(
"installMemory: embedder init failed — search_memory not registered: \(String(describing: error))"
)
}
if searchAvailable, let embedder = memoryEmbedder {
// Audit 2026-05-12 / M-1 (Issue #12): the SAME embedder
// instance powers both writer-side (facts_vec inserts) and
// reader-side (HybridSearch.searchFacts) so the vector space
// stays consistent. Pre-fix this constructed a second
// OllamaEmbeddingClient for search only; the writer side
// never had one, which is why facts_vec was empty.
let hybrid = HybridSearch(store: store, embedder: embedder)
searchDispatcher = HybridSearchAdapter(hybrid: hybrid)
} else {
systemLogger?.warning(
"installMemory: search_memory not registered — embedder absent (degraded mode)"
)
}
// Register INTO the shared `inProcessToolRegistry` — never
@@ -1814,6 +1854,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
)
return
}
// Audit 2026-05-12 / M-2 (Issue #13): write the memory-sentinel turn
// row so subsequent memory-mutation / memory-retrieval events emit
// against a turn_id that exists in `turns`. Without this row, every
// memory event would FK-fail and (pre-Issue-#13-batch-split-fix) take
// every batch-sibling down with it. The sentinel never `endTurn`s —
// its `ended_at` stays NULL for the life of the session.
do {
try await replayLog.startTurn(
turnId: memorySentinelTurnID,
sessionId: sessionId,
retryOf: nil,
turnNonce: "memory-sentinel",
source: .text,
provider: "memory-system",
modelId: "memory-system"
)
} catch {
systemLogger?.warning(
"installAgent: memory-sentinel startTurn failed — memory events will FK-fail and be dropped via batch-split: \(String(describing: error))"
)
}
// Plan 10-02b / B-01: source the tool catalog lazily from BOTH
// the in-process registry (memory + self-knowledge tools) AND
// the stdio MCPClient (mcp-time / mcp-clipboard /
@@ -3045,25 +3107,33 @@ struct DormantVoiceBusEmitter: BusOutboundEmitter {
/// Bridges `Memory.MemoryReplaySink` → `Replay.ReplayLog`.
///
/// The Memory module deliberately does not import `Replay.ReplayLog` directly
/// (07-02 design); AppDelegate is the only place that bridges the two. The
/// adapter forwards a recorded `ReplayEvent` from MemoryStore.applyOp into the
/// on-disk replay log. The log records are TurnID-keyed; the orchestrator
/// records `ReplayEvent.memoryMutation` against an Int64 turnId hash, but the
/// replay log indexes on TurnID. This adapter generates a synthetic TurnID
/// stub from the Int64 hash — once AgentOrchestrator wiring lands the real
/// turn-id correlation will be in place.
/// (07-02 design); AppDelegate is the only place that bridges the two.
///
/// Audit 2026-05-12 / M-2 (Issue #13): pre-fix this sink synthesized a fake
/// TurnID per call (`"memory-trigger-<int>"`) from the Int64 triggerTurnId
/// the Memory module passed in. That synthetic TurnID had no row in the
/// replay-DB's `turns` table, so EVERY emission fired the foreign-key
/// constraint on `events.turn_id REFERENCES turns(turn_id)`. The whole
/// batch transaction rolled back, the log re-buffered, and consecutive
/// failures climbed to chronic-critical within minutes. Real turn events
/// queued behind the poison were silently lost.
///
/// New design: the sink holds a single optional **sentinel TurnID** that
/// `installAgent` writes into the `turns` table once per session (a turn
/// row with `ended_at=NULL`, `stop_reason=NULL`, source = `.text`). Memory
/// emissions use this sentinel — the FK resolves, the events land, and the
/// Int64 triggerTurnId travels in the JSON payload for forensic queries.
struct AppDelegateMemoryReplaySink: MemoryReplaySink {
let replayLog: ReplayLog
let sentinelTurnID: TurnID
func record(_ event: ReplayEvent, forTriggerTurnId triggerTurnId: Int64) {
// The replay log's `record(_:for:)` is async; fire-and-forget through
// a detached Task so the synchronous MemoryReplaySink contract stays
// intact. The Int64 trigger-turn-id is encoded as a TurnID's UUID
// string deterministically (FNV-1a hash → reverse stub) so replay
// queries can correlate later.
let synthetic = TurnID(rawValue: "memory-trigger-\(triggerTurnId)")
Task.detached { [replayLog] in
await replayLog.record(event, for: synthetic)
// Fire-and-forget through a detached Task so the synchronous
// MemoryReplaySink contract stays intact. The Int64 triggerTurnId
// is already encoded into the event's JSON payload by MemoryStore;
// we attribute the row to the sentinel turn so the FK resolves.
Task.detached { [replayLog, sentinelTurnID] in
await replayLog.record(event, for: sentinelTurnID)
}
}
}
@@ -70,7 +70,11 @@ extension ContextBuilder {
get_self_state, list_camera_devices, get_time, get_clipboard, \
run_applescript) over giving the user generic instructions like \
"open System Settings" or "I don't have access to your hardware." \
You are not a text-only assistant.
You are not a text-only assistant. \
You also have a long-term memory store. When the user references \
something they may have told you before, or asks "do you remember…", \
call search_memory FIRST. Never claim to remember without searching. \
Use forget_fact when the user explicitly asks to forget something.
"""
/// Compose the full system prompt for one turn.
@@ -135,4 +135,31 @@ final class CacheHintsEligibilityTests: XCTestCase {
"ContextBuilder.systemPrompt(for: .empty) must start with selfAwarePreamble (D-12 — preamble FIRST, then per-turn enrichments)."
)
}
// MARK: - Audit 2026-05-12 / M-3 — proactive memory recall discoverability
//
// Issue #14: agent never called `search_memory` in production because the
// preamble didn't name it — model preference is shaped by the preamble's
// explicit "always prefer calling those tools" rule. Without a name in the
// preamble, the tool falls into "tools the agent technically has access to
// but won't think to use." Regression guard: the preamble MUST cue
// `search_memory` and `forget_fact` so proactive recall is alive.
/// test_preambleNamesMemoryTools — Issue #14 regression guard.
///
/// Asserts both memory tools are named in the preamble. Without naming,
/// the model has no preamble-level nudge to call them, so the live
/// 2026-05-12 session reproduced the failure: user asks "what is my dog's
/// name?" and the agent answers from the void without searching.
func test_preambleNamesMemoryTools() {
let preamble = ContextBuilder.selfAwarePreamble
XCTAssertTrue(
preamble.contains("search_memory"),
"Issue #14: selfAwarePreamble must name `search_memory` so the model is cued to call it when the user references prior context."
)
XCTAssertTrue(
preamble.contains("forget_fact"),
"Issue #14: selfAwarePreamble must name `forget_fact` alongside `search_memory` — the read/write pair of the memory surface."
)
}
}
@@ -31,8 +31,18 @@ public actor HybridSearch {
public func searchFacts(query: String, k: Int = 10, triggerTurnId: Int64) async throws -> [FactRef] {
guard !query.isEmpty else { return [] }
// Audit 2026-05-12 / M-5 (Issue #16): FTS5 MATCH grammar rejects raw
// natural-language punctuation. Sanitize before binding to the
// positional parameter; short-circuit if no usable tokens remain
// (binding an empty MATCH is itself a syntax error). Embedding still
// runs on the raw query so semantic recall isn't degraded by the
// FTS-safe rewrite.
guard let safeQuery = MemoryQueries.sanitizeFTS5Query(query) else {
logger.debug("searchFacts: sanitize returned no tokens; short-circuit queryLen=\(query.count) k=\(k) triggerTurnId=\(triggerTurnId)")
return []
}
let embedding = try await embedder.embed(query)
let rows = try await store.runHybridSearchSQL(query: query, embedding: embedding, k: k)
let rows = try await store.runHybridSearchSQL(query: safeQuery, embedding: embedding, k: k)
let now = Int64(Date().timeIntervalSince1970 * 1000)
var refs: [FactRef] = []
refs.reserveCapacity(rows.count)
@@ -119,13 +119,19 @@ public actor MemoryExtractionOrchestrator {
assistantText: job.assistantText,
priorActiveFacts: priorFacts
)
// Stable, but currently best-effort: TurnID is a UUID-string
// wrapper; we hash it to Int64 for the DB column. Plan 07-03's
// search path can use the hash to correlate facts back to turns.
let turnIdInt: Int64 = stableHash(job.turnId.rawValue)
// Audit 2026-05-12 / M-4 (Issue #15): `facts.source_turn_id` is
// no longer derived from an FNV-1a hash of `TurnID.rawValue`.
// The hash was Phase-7-era and conflated two disjoint number
// systems (TurnID UUID strings vs. turns.id INTEGER rowid),
// storing a value that joined nothing. We now pass 0 as a
// sentinel meaning "no correlated turn rowid"; fact-to-turn
// correlation is best done via `valid_from` timestamp proximity
// until a future change threads the real `turns.id` rowid back
// from `appendTurn`. Schema comment was updated to match.
let sentinelSourceTurnId: Int64 = 0
for op in ops {
do {
try await applyOp(op, turnIdInt)
try await applyOp(op, sentinelSourceTurnId)
} catch {
logger.error("memory applyOp failed: \(error)")
}
@@ -134,20 +140,4 @@ public actor MemoryExtractionOrchestrator {
logger.error("memory extraction failed for turnId=\(job.turnId.rawValue): \(error)")
}
}
/// Stable 64-bit hash of an arbitrary string. We use FNV-1a so the
/// hash is identical across process launches (Swift's built-in
/// `String.hashValue` is randomized per process and can't be persisted
/// to a SQLite column for correlation).
private func stableHash(_ s: String) -> Int64 {
var hash: UInt64 = 0xcbf29ce484222325
let prime: UInt64 = 0x100000001b3
for byte in s.utf8 {
hash ^= UInt64(byte)
hash = hash &* prime
}
// Fold to signed Int64 — drop the sign bit so the value stays
// positive (SQLite Int64 column indexes nicer when always positive).
return Int64(hash & 0x7FFFFFFFFFFFFFFF)
}
}
@@ -115,4 +115,60 @@ public enum MemoryQueries {
ORDER BY created_at DESC
LIMIT ?;
"""
/// Audit 2026-05-12 / M-5 (Issue #16) — FTS5-safe query rewriter.
///
/// `hybridSearchSQL` binds the query text directly to `facts_fts MATCH ?`.
/// FTS5's MATCH grammar accepts terms, AND/OR/NOT/NEAR, column filters,
/// and phrase quoting — but raw natural language with `?`, `'`, `:`, `-`,
/// `*`, or other reserved characters throws `fts5: syntax error` at the
/// SQLite layer. Live probe before this fix:
/// sqlite3 jarvis.db "SELECT rowid FROM facts_fts
/// WHERE facts_fts MATCH 'what is my dog name?'"
/// → Error: stepping, fts5: syntax error near "?"
///
/// Rewriter strategy (simplest defensible):
/// 1. Replace anything that isn't a unicode letter or digit with a
/// space. This drops `?`, `'`, `:`, `-`, `*`, `(`, `)`, `"`, etc.
/// 2. Split on whitespace.
/// 3. Wrap each remaining token in double quotes, escaping any
/// embedded `"` per FTS5 grammar (doubled `""`). The class-1
/// filter strips `"` characters, so step (3) escape is belt-and-
/// suspenders against future relaxation of the class filter.
/// 4. Join tokens with spaces.
///
/// Turns `"what is my dog's name?"` into
/// `"what" "is" "my" "dog" "s" "name"` — a FTS5-safe phrase-OR query.
/// Returns nil if no usable tokens remain (caller short-circuits to
/// empty results rather than binding an empty MATCH that would itself
/// be a syntax error).
///
/// Long-term we want a real query AST; this is the minimum fix that
/// stops the footgun #14 (preamble add) is about to wire up.
public static func sanitizeFTS5Query(_ raw: String) -> String? {
// Step 1: replace non-alphanumeric (per Unicode) with space.
let scalarsKept = raw.unicodeScalars.map { scalar -> Character in
if CharacterSet.alphanumerics.contains(scalar) {
return Character(scalar)
}
return " "
}
let scrubbed = String(scalarsKept)
// Step 2: split on whitespace, drop empties.
let tokens = scrubbed
.split(whereSeparator: { $0.isWhitespace })
.map { String($0) }
.filter { !$0.isEmpty }
guard !tokens.isEmpty else { return nil }
// Step 3 + 4: double-quote each token (escape any internal " per
// FTS5 — doubled), join with spaces.
let quoted = tokens.map { token -> String in
let escaped = token.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
}
return quoted.joined(separator: " ")
}
}
@@ -32,18 +32,17 @@ public enum MemorySchema {
// Facts table — temporal validity + supersede chain + soft delete.
//
// `source_turn_id` is a CORRELATION TAG, NOT a foreign key. The
// memory-extraction writer (`MemoryExtractionOrchestrator.process`)
// FNV-1a-hashes the originating `TurnID` UUID to an Int64 because
// `TurnID` is a string-backed UUID and the writer needs an integer
// column. The hash space and `turns.id` (AUTOINCREMENT) never
// collide by design — they're disjoint number systems — so any
// `REFERENCES turns(id)` on this column would fail every insert
// with SQLITE_CONSTRAINT_FOREIGNKEY (code 19). The constraint was
// present in the original schema and silently broke fact persistence
// for the entire history of the project until the 2026-05-12 dogfood
// surfaced it. Reintroducing the FK without first wiring the writer
// to use real `turns.id` rowids would re-break persistence.
// `source_turn_id` is a CORRELATION TAG, NOT a foreign key, and as
// of audit 2026-05-12 / M-4 (Issue #15) it is effectively unused.
// The pre-fix writer FNV-1a-hashed the originating `TurnID` UUID to
// an Int64, but the hash space and `turns.id` (AUTOINCREMENT) were
// disjoint number systems — joining `facts.source_turn_id` against
// `turns.id` returned nothing on every query. Reintroducing a real
// `REFERENCES turns(id)` on this column would require threading the
// real rowid from `appendTurn` through the extraction pipeline; until
// that lands, the writer passes 0 as a sentinel meaning "no
// correlated turn rowid." Fact-to-turn correlation is via
// `valid_from` timestamp proximity.
//
// `superseded_by REFERENCES facts(id)` IS a real FK — the supersede
// chain uses `priorFact.id` from a previous `applyOp` return value,
@@ -64,10 +64,26 @@ public actor MemoryStore {
private let conn: SQLiteConnection
private let logger: Logger
/// Audit 2026-05-12 / M-1 (Issue #12): writer-side embedder. When
/// non-nil, `insertNewFact` runs the embedder against the subject/
/// predicate/object triple and writes a matching `facts_vec` row after
/// the facts row is committed. When nil, the writer skips the vec
/// insert entirely — degraded-but-searchable mode (FTS5 still works).
/// Embedder failures during the vec write are logged but never roll
/// back the facts row, per audit recommendation: "log + leave the FTS
/// row (degraded but searchable) rather than rolling back the fact row."
private let embedder: (any EmbeddingProviding)?
/// Open jarvis.db at databaseURL. Creates parent dir if absent.
/// Registers vec0 as auto-extension (once per process). Applies pragmas
/// + schema migration + vec_version sanity probe. Throws on any failure.
public init(databaseURL: URL) throws {
///
/// `embedder` (audit 2026-05-12 / Issue #12): optional embedding
/// provider used by `applyOp` to populate `facts_vec` alongside `facts`.
/// When nil, facts_vec is never written; hybrid search degrades to
/// FTS5-only ranking (no semantic recall). Production wires
/// `OllamaEmbeddingClient` here in `installMemory`; tests inject stubs.
public init(databaseURL: URL, embedder: (any EmbeddingProviding)? = nil) throws {
// Ensure parent directory exists.
let parent = databaseURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
@@ -81,6 +97,7 @@ public actor MemoryStore {
self.conn = try SQLiteConnection.open(at: databaseURL)
self.logger = Logger(label: Self.logChannel)
self.embedder = embedder
// 1. Pragmas (WAL + synchronous + busy_timeout + foreign_keys + temp_store).
for sql in MemorySchema.pragmas {
@@ -128,14 +145,18 @@ public actor MemoryStore {
/// Apply a single mem0 operation. The ONLY emission site for
/// `ReplayEvent.memoryMutation` (MEM-06).
///
/// Audit 2026-05-12 / M-1 (Issue #12): now `async` because `insertNewFact`
/// awaits the optional embedder to populate `facts_vec` alongside `facts`.
/// Pre-fix this was sync and `facts_vec` was never written.
@discardableResult
public func applyOp(_ op: MemoryOp, sourceTurnId: Int64) throws -> Fact? {
public func applyOp(_ op: MemoryOp, sourceTurnId: Int64) async throws -> Fact? {
switch op {
case .noop:
logger.debug("memory NOOP for turnId=\(sourceTurnId)")
return nil
case .add(let subject, let predicate, let object, _):
return try insertNewFact(
return try await insertNewFact(
subject: subject,
predicate: predicate,
object: object,
@@ -143,7 +164,7 @@ public actor MemoryStore {
sourceTurnId: sourceTurnId
)
case .update(let supersedes, let subject, let predicate, let object, _):
return try insertNewFact(
return try await insertNewFact(
subject: subject,
predicate: predicate,
object: object,
@@ -160,7 +181,7 @@ public actor MemoryStore {
object: String,
supersedesFactId: Int64?,
sourceTurnId: Int64
) throws -> Fact {
) async throws -> Fact {
let now = Int64(Date().timeIntervalSince1970 * 1000)
do {
try conn.beginTransaction()
@@ -228,6 +249,13 @@ public actor MemoryStore {
createdAt: now
)
// Audit 2026-05-12 / M-1 (Issue #12): write the matching
// facts_vec row so semantic search has a cohort. Done AFTER
// commit so embedder failure (network, model unavailable,
// dimension mismatch) leaves the facts row + FTS5 row intact —
// degraded-but-searchable per audit recommendation.
await writeFactsVecRow(for: fact)
recordMemoryMutation(
op: supersedesFactId == nil ? "ADD" : "UPDATE",
fact: fact,
@@ -245,6 +273,38 @@ public actor MemoryStore {
}
}
/// Audit 2026-05-12 / M-1 (Issue #12): populate `facts_vec` with the
/// embedding for a freshly-inserted fact. Best-effort — embedder errors
/// and SQLite errors are logged but never thrown; the facts row stays
/// (FTS5 search still works, vec cohort is empty for this fact).
///
/// No-op when the store was constructed without an embedder (the test
/// surface for that path is `MemoryStoreVecWriteTests`).
private func writeFactsVecRow(for fact: Fact) async {
guard let embedder else { return }
let text = "\(fact.subject) \(fact.predicate) \(fact.object)"
let embedding: [Float]
do {
embedding = try await embedder.embed(text)
} catch {
logger.warning("writeFactsVecRow: embedder failed for factId=\(fact.id): \(error). facts_vec row absent; FTS5 row remains.")
return
}
guard embedding.count == MemoryConstants.embeddingDim else {
logger.warning("writeFactsVecRow: embedder returned wrong dim (\(embedding.count) vs expected \(MemoryConstants.embeddingDim)) for factId=\(fact.id); skipping vec insert.")
return
}
let blob = embedding.withUnsafeBufferPointer { Data(buffer: $0) }
do {
try conn.exec(
"INSERT INTO facts_vec(fact_id, embedding) VALUES (?, ?);",
bindings: [.int(fact.id), .blob(blob)]
)
} catch {
logger.warning("writeFactsVecRow: facts_vec insert failed for factId=\(fact.id): \(error). FTS5 row remains.")
}
}
private func recordMemoryMutation(
op: String,
fact: Fact,
@@ -42,6 +42,91 @@ final class HybridSearchTests: XCTestCase {
)
}
// MARK: - Audit 2026-05-12 / M-5 — FTS5 query sanitization (Issue #16)
//
// `hybridSearchSQL` binds the query string directly to `facts_fts MATCH ?`.
// Real user queries like "what is my dog's name?" contain `?`, `'`, and
// other FTS5-reserved characters that throw `fts5: syntax error` at the
// SQLite layer. Sanitization in HybridSearch.searchFacts is the smallest
// safe rewrite: strip punctuation, split on whitespace, double-quote each
// term, join with spaces. Turns "what is my dog's name?" into
// `"what" "is" "my" "dog" "s" "name"`. FTS5-safe phrase-OR query.
func testSearchFactsSanitizesPunctuationBeforeFTS5() async throws {
let embedder = MockEmbedder()
let store = MockStore()
await store.setRows([])
let search = HybridSearch(store: store, embedder: embedder)
_ = try await search.searchFacts(
query: "what is my dog's name?",
k: 5,
triggerTurnId: 1
)
let captured = await store.capturedQuery
XCTAssertNotNil(captured, "store must receive a sanitized query")
let safeQuery = captured ?? ""
// Must NOT contain raw FTS5-reserved punctuation that breaks MATCH.
XCTAssertFalse(safeQuery.contains("?"),
"Issue #16: sanitized query must not contain raw `?` — FTS5 syntax error")
XCTAssertFalse(safeQuery.contains("'"),
"Issue #16: sanitized query must not contain raw `'` — FTS5 syntax error")
// Must preserve the meaningful tokens so search remains useful.
XCTAssertTrue(safeQuery.contains("dog"),
"Sanitization must preserve `dog` token")
XCTAssertTrue(safeQuery.contains("name"),
"Sanitization must preserve `name` token")
}
func testSearchFactsSanitizesQuotesAndOperators() async throws {
let embedder = MockEmbedder()
let store = MockStore()
await store.setRows([])
let search = HybridSearch(store: store, embedder: embedder)
// FTS5 operators (NEAR, AND, OR, NOT, *, :, parens) + embedded quote.
_ = try await search.searchFacts(
query: "remember when I said \"hi\" *or* something?",
k: 5,
triggerTurnId: 1
)
let captured = await store.capturedQuery
XCTAssertNotNil(captured)
let safeQuery = captured ?? ""
// The embedded raw quote must be escaped (FTS5 doubles internal "
// inside a phrase) so we don't ship an unterminated phrase to SQLite.
// The wrapper double-quotes EACH token so any token containing a raw
// " gets the inner " doubled per FTS5 grammar.
// Sanity: must still be non-empty (we didn't strip everything).
XCTAssertFalse(safeQuery.isEmpty, "sanitized query must not be empty")
// Star alone gets stripped (it's a prefix operator without a term);
// useful tokens like `remember`, `said`, `hi`, `something` survive.
XCTAssertTrue(safeQuery.contains("remember"))
XCTAssertTrue(safeQuery.contains("something"))
}
func testSearchFactsEmptyAfterSanitizeReturnsEmpty() async throws {
let embedder = MockEmbedder()
let store = MockStore()
await store.setRows([])
let search = HybridSearch(store: store, embedder: embedder)
// All-punctuation query → after stripping, no terms remain.
// Must short-circuit: never call store (would bind empty MATCH which
// is itself an FTS5 syntax error).
let refs = try await search.searchFacts(
query: "??!?...",
k: 5,
triggerTurnId: 1
)
XCTAssertEqual(refs.count, 0)
let captured = await store.capturedQuery
XCTAssertNil(captured,
"Issue #16: when sanitization yields no terms, search must short-circuit and never bind an empty MATCH to FTS5.")
}
func testSearchFactsCallsEmbedderOnce() async throws {
let embedder = MockEmbedder()
let store = MockStore()
@@ -301,6 +301,52 @@ final class MemoryExtractionOrchestratorTests: XCTestCase {
await orchestrator.shutdown()
}
/// Audit 2026-05-12 / M-4 (Issue #15) — `MemoryExtractionOrchestrator.process`
/// must NOT pass an FNV-1a hash of the TurnID UUID as `sourceTurnId`.
/// Pre-fix that value was a meaningless 64-bit hash that joined nothing
/// in the `turns` table. The sentinel 0 is the cleanest signal that
/// fact-to-turn correlation is "not yet wired" without storing nonsense.
/// (Audit option 1: drop the hash; future change may thread the real
/// `turns.id` rowid via `appendTurn` → `flushPair` → `ExtractionJob`.)
func testProcessUsesSentinelSourceTurnIdNotFNVHash() async throws {
let provider = TimedMockProvider()
provider.argsJSON = try JSONSerialization.data(withJSONObject: ["ops": [
["op": "ADD", "subject": "user", "predicate": "has_dog_named", "object": "Toby"]
]])
let extractor = MemoryExtractor(provider: provider)
let spy = SpyApplyOp()
let orchestrator = MemoryExtractionOrchestrator(
extractor: extractor,
applyOp: { op, turnId in spy.record(op, sourceTurnId: turnId) }
)
await orchestrator.start()
// A real-shape TurnID — the kind that produced
// `7747877241524896942` (FNV hash) in today's live DB pre-fix.
let realTurnId = TurnID(rawValue: "B0E2A6FC-6B0A-4F92-A3F1-D2D8AC8C0D52")
await orchestrator.enqueue(ExtractionJob(
turnId: realTurnId,
userText: "My dog's name is Toby.",
assistantText: "Got it — Toby."
))
try await Task.sleep(nanoseconds: 200_000_000)
XCTAssertEqual(spy.calls.count, 1)
let sourceTurnId = spy.calls.first?.sourceTurnId ?? -1
XCTAssertEqual(
sourceTurnId, 0,
"Issue #15: orchestrator must pass sentinel 0 — not an FNV hash — as sourceTurnId."
)
// Defense in depth: the FNV-1a hash of this UUID would be a huge
// positive Int64; assert we never see one.
XCTAssertLessThan(
sourceTurnId, 1_000_000,
"Issue #15: sourceTurnId must not look like an FNV hash."
)
await orchestrator.shutdown()
}
/// Process applies returned ops to the store via the callback.
func testProcessAppliesOpsToStore() async throws {
let provider = TimedMockProvider()
@@ -0,0 +1,134 @@
import XCTest
import Foundation
import AgentCore
import Replay
@testable import Memory
/// Audit 2026-05-12 / M-1 (Issue #12) — facts_vec write-side coverage.
///
/// The schema declares `facts_vec` (sqlite-vec virtual table) and the read
/// path's RRF SQL joins against it, but before this fix no producer ever
/// wrote into it. Result: the `vc` CTE in `hybridSearchSQL` returned zero
/// rows on every call and RRF degenerated to FTS5-only ranking. Semantic
/// recall ("dog" → "Toby" via embedding similarity) silently didn't work
/// despite vec0 being loaded and the embedder client being healthy.
///
/// Test plan:
/// - applyOp(.add) with a stub embedder injected → assert exactly one
/// facts_vec row exists for the new fact's id.
/// - applyOp(.add) with NO embedder injected → fact still persists with
/// FTS5 row; facts_vec row absent (degraded-but-searchable mode).
/// - applyOp(.add) with an embedder that throws → fact still persists;
/// embedder failure must not roll back the facts row.
final class MemoryStoreVecWriteTests: XCTestCase {
/// Stub embedder returning a constant 768-dim vector — no call recording,
/// no scripting. (Per project convention: Stub*, not Mock*.)
actor StubEmbedder: EmbeddingProviding {
let vector: [Float]
init(value: Float = 0.1) {
self.vector = Array(repeating: value, count: MemoryConstants.embeddingDim)
}
func embed(_ input: String) async throws -> [Float] {
return vector
}
}
/// Stub embedder that always throws — used to verify the writer's
/// degraded-mode discipline (FTS row stays, vec row absent, fact still
/// persisted).
actor ThrowingEmbedder: EmbeddingProviding {
struct Boom: Error {}
func embed(_ input: String) async throws -> [Float] {
throw Boom()
}
}
// MARK: - Tests
func testApplyOpADDWritesFactsVecRowWhenEmbedderInjected() async throws {
let tmp = try Self.tempDB()
let store = try MemoryStore(databaseURL: tmp, embedder: StubEmbedder())
let fact = try await store.applyOp(
.add(subject: "user", predicate: "has_dog_named", object: "Toby", embedding: nil),
sourceTurnId: 0
)
XCTAssertNotNil(fact)
guard let fact else { return }
// facts row count = 1, facts_vec row count = 1 (parity invariant).
let factsCount = try await store.queryRowCount("SELECT id FROM facts")
XCTAssertEqual(factsCount, 1)
let vecCount = try await store.queryRowCount(
"SELECT fact_id FROM facts_vec WHERE fact_id = \(fact.id)"
)
XCTAssertEqual(
vecCount, 1,
"Issue #12: facts_vec must contain exactly one row matching the new fact's id."
)
}
func testApplyOpADDPersistsFactWhenNoEmbedderInjected() async throws {
let tmp = try Self.tempDB()
// No embedder → facts row persists, facts_vec stays empty (degraded
// but searchable via FTS5 + recentActiveFacts).
let store = try MemoryStore(databaseURL: tmp, embedder: nil)
let fact = try await store.applyOp(
.add(subject: "user", predicate: "has_dog_named", object: "Toby", embedding: nil),
sourceTurnId: 0
)
XCTAssertNotNil(fact)
guard let fact else { return }
let factsCount = try await store.queryRowCount("SELECT id FROM facts")
XCTAssertEqual(factsCount, 1)
let vecCount = try await store.queryRowCount(
"SELECT fact_id FROM facts_vec WHERE fact_id = \(fact.id)"
)
XCTAssertEqual(
vecCount, 0,
"Without an embedder, facts_vec must NOT be populated — degraded but searchable mode."
)
}
func testApplyOpADDPersistsFactWhenEmbedderThrows() async throws {
let tmp = try Self.tempDB()
let store = try MemoryStore(databaseURL: tmp, embedder: ThrowingEmbedder())
// The embedder will throw — the fact INSERT must still succeed and
// be searchable via FTS5. Audit recommendation: "log + leave the FTS
// row (degraded but searchable) rather than rolling back the fact
// row."
let fact = try await store.applyOp(
.add(subject: "user", predicate: "has_dog_named", object: "Toby", embedding: nil),
sourceTurnId: 0
)
XCTAssertNotNil(fact)
guard let fact else { return }
let factsCount = try await store.queryRowCount("SELECT id FROM facts")
XCTAssertEqual(factsCount, 1,
"Embedder failure must not roll back the facts row.")
let vecCount = try await store.queryRowCount(
"SELECT fact_id FROM facts_vec WHERE fact_id = \(fact.id)"
)
XCTAssertEqual(
vecCount, 0,
"When the embedder throws, facts_vec row must be absent (FTS row remains)."
)
}
// MARK: - helpers
private static func tempDB() throws -> URL {
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("memoryvecwrite-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tmp, withIntermediateDirectories: true)
return tmp.appendingPathComponent("jarvis.db")
}
}
+90 -36
View File
@@ -218,45 +218,99 @@ public actor ReplayLog {
try conn.commit()
consecutiveFlushFailures = 0
} catch {
// ME-05: rollback the in-flight transaction, then re-buffer the
// batch at the FRONT of `pending` so the next flush attempt can
// retry. Pre-fix this silently dropped events on transaction
// failure, violating OBS-02's durability claim.
try? conn.rollback()
// Capture the turn_ids so a forensic reader can identify which
// turns lost which events (the reviewer's required minimum).
let turnIds = Set(batch.map { $0.turnId.rawValue })
.sorted().joined(separator: ",")
// Redact the error message in case sqlite3_errmsg ever echoes
// any caller-supplied bytes; defense-in-depth alongside HI-01.
let errStr = JarvisLogging.Redact.apply(String(describing: error))
consecutiveFlushFailures += 1
let escalated = consecutiveFlushFailures >= Self.flushFailureEscalationThreshold
// Re-buffer at the head; subsequent records append after.
pending.insert(contentsOf: batch, at: 0)
if escalated {
// Chronic failure — surface as critical so the dev overlay
// / structured-log scrapers can flag it. Don't `fatalError`:
// OBS-02 says replay is best-effort and must never propagate
// into orchestrator turn semantics.
logger.critical("replay flush failing chronically (data-loss risk)", metadata: [
"batch_size": "\(batch.count)",
"turn_ids": "\(turnIds)",
"consecutive_failures": "\(consecutiveFlushFailures)",
"error": "\(errStr)",
])
} else {
logger.error("replay flush failed; batch re-queued", metadata: [
"batch_size": "\(batch.count)",
"turn_ids": "\(turnIds)",
"consecutive_failures": "\(consecutiveFlushFailures)",
"error": "\(errStr)",
])
// Audit 2026-05-12 / M-2 (Issue #13) — batch-splitting defense.
//
// Pre-fix: a single FK-violation in the batch (poisoned
// turn_id) took every other event in the same transaction
// down on rollback. The log re-buffered the whole batch and
// retried forever, escalating to CRITICAL after 3 attempts
// and burying real turn events behind a single bad row.
//
// New strategy on batch failure: retry each event in its OWN
// transaction. Good events persist; only the actually-bad
// rows are dropped. We log each drop at error level with the
// turn_id so a forensic reader can identify which row broke.
// Best-effort per OBS-02: replay never propagates into
// orchestrator semantics.
let initialError = error
var droppedCount = 0
var droppedTurnIds: [String] = []
for e in batch {
do {
try conn.beginTransaction()
try writeRow(
sql: "INSERT INTO events(turn_id, ts, monotonic_ns, kind, payload_bytes) VALUES (?, ?, ?, ?, ?);",
bindings: [
.text(e.turnId.rawValue),
.int(e.ts),
.int(e.mono),
.text(e.kind),
.blob(e.payload),
]
)
try conn.commit()
} catch {
try? conn.rollback()
droppedCount += 1
droppedTurnIds.append(e.turnId.rawValue)
}
}
if droppedCount == 0 {
// The single-row retry pass persisted everything — the
// batch transaction must have failed for a reason that
// disappeared between attempts (e.g., transient SQLite
// BUSY). Treat as success.
consecutiveFlushFailures = 0
return
}
if droppedCount == batch.count {
// Every row failed individually too — this isn't a single
// poisoned event, it's a chronic disk/WAL fault. Restore
// the original pre-fix behavior: re-buffer at the head
// for retry, count toward escalation. This preserves
// ME-05 durability for genuine I/O faults.
let turnIds = Set(batch.map { $0.turnId.rawValue })
.sorted().joined(separator: ",")
let errStr = JarvisLogging.Redact.apply(String(describing: initialError))
consecutiveFlushFailures += 1
let escalated = consecutiveFlushFailures >= Self.flushFailureEscalationThreshold
pending.insert(contentsOf: batch, at: 0)
if escalated {
logger.critical("replay flush failing chronically (data-loss risk)", metadata: [
"batch_size": "\(batch.count)",
"turn_ids": "\(turnIds)",
"consecutive_failures": "\(consecutiveFlushFailures)",
"error": "\(errStr)",
])
} else {
logger.error("replay flush failed; batch re-queued", metadata: [
"batch_size": "\(batch.count)",
"turn_ids": "\(turnIds)",
"consecutive_failures": "\(consecutiveFlushFailures)",
"error": "\(errStr)",
])
}
return
}
// Partial — some events persisted, some were dropped (the
// bounded-blast-radius case the batch split is designed for).
// Reset the consecutive-failure counter because the log is
// not stuck; surface each dropped row so a forensic reader
// can see which events died and why.
consecutiveFlushFailures = 0
let droppedIds = Set(droppedTurnIds).sorted().joined(separator: ",")
let errStr = JarvisLogging.Redact.apply(String(describing: initialError))
logger.error("replay batch-split: dropped poisoned rows; siblings persisted", metadata: [
"batch_size": "\(batch.count)",
"dropped_count": "\(droppedCount)",
"dropped_turn_ids": "\(droppedIds)",
"error": "\(errStr)",
])
}
}
@@ -316,4 +316,63 @@ final class ReplayLogTests: XCTestCase {
let s = String(data: payload, encoding: .utf8)
XCTAssertEqual(s, secret, "replay log is the authoritative byte record; redaction belongs to the viewer (P8)")
}
// MARK: - Audit 2026-05-12 / M-2 (Issue #13) — batch-splitting defense
//
// Live failure mode pre-fix: a single poisoned event (FK violation from
// a synthetic memory-trigger-<hash> turn_id) took down every other event
// in the same flush transaction. The replay log re-buffered the whole
// batch, retried, failed again, and at 3 consecutive failures escalated
// to CRITICAL. By the end of the day batch_size had grown to 31+ and
// consecutive_failures was 23+. Real turn events queued behind the
// poison were silently lost.
//
// Defense layer 2 (audit recommendation): when a batch transaction
// fails, ReplayLog now retries events individually so one poisoned row
// can only take itself down, not 30 siblings.
func test_L10_batchSplitOnFKFailurePreservesOtherEvents() async throws {
let log = try ReplayLog(databaseURL: dbURL)
let session = try await log.beginSession(appVersion: "v", buildSHA: "s")
let goodTurn = TurnID.fresh()
try await log.startTurn(
turnId: goodTurn, sessionId: session, retryOf: nil,
turnNonce: "n", source: .text,
provider: "anthropic", modelId: "claude-opus-4-7"
)
// Two good events + one poison (synthetic turn_id not in `turns`).
// The whole batch will go through `flushPending` on endTurn.
await log.record(.textDelta("hello"), for: goodTurn)
await log.record(.textDelta("world"), for: goodTurn)
let poisonTurn = TurnID(rawValue: "memory-trigger-7747877241524896942")
await log.record(.memoryMutation(Data("{\"op\":\"ADD\"}".utf8)), for: poisonTurn)
await log.record(.textDelta("after"), for: goodTurn)
await log.endTurn(goodTurn, stopReason: "end_turn")
let conn = try SQLiteConnection.open(at: dbURL)
defer { try? conn.close() }
// The three good events + the turn_end event must persist even
// though the poison row could not be written.
let goodCount: Int64 = try conn.query(
"SELECT COUNT(*) FROM events WHERE turn_id=?;",
bindings: [.text(goodTurn.rawValue)],
map: { $0.columnInt(at: 0) }
).first ?? 0
XCTAssertEqual(
goodCount, 4,
"Issue #13: batch-splitting must preserve good events when one row in the batch FK-fails (3 textDelta + 1 turn_end)."
)
let poisonCount: Int64 = try conn.query(
"SELECT COUNT(*) FROM events WHERE turn_id=?;",
bindings: [.text(poisonTurn.rawValue)],
map: { $0.columnInt(at: 0) }
).first ?? 0
XCTAssertEqual(
poisonCount, 0,
"Poisoned row must NOT persist (FK violation) — but it also must not take its batch-siblings down."
)
}
}