feat: store-boundary write dedup (normalized dedup_hash, both add paths) - #402
feat: store-boundary write dedup (normalized dedup_hash, both add paths)#402gabaum10 wants to merge 7 commits into
Conversation
Self-review disclosureThis PR was reviewed by our own automated raccoon panel ( 🦝 Raccoon Review: store-boundary write dedup (normalized
|
Disposition receipt (Lens-verified)Every raccoon finding, its disposition, and where it landed. Verified against the pushed tree at Blockers & structural
Tail (11)
Suite delta: 1,166 → 1,255 green. |
coryzibell
left a comment
There was a problem hiding this comment.
VERDICT: REQUEST CHANGES
One blocking finding. Not because the code is broken — it is, in fact, unusually careful — but because the ticket named false-positive dedup "the worst failure mode here," and there is one such surface that is neither closed nor documented. Everything else is a comment.
One-line summary: Structurally sound, heavily-tested, honestly-disclosed dedup gate wired into all three write funnels — but the dedup identity is title+body only, so identical content re-filed under a different category or tags in the same session is silently dropped (exit 0), an undocumented false-positive data-loss class.
Findings, ranked
1. [BLOCKER] Dedup identity excludes category and tags — undocumented false-positive data loss.
src/knowledge.rs dedup_hash(title, body) hashes title+body only. src/surreal_db/knowledge.rs get_entries_for_session_async scopes candidates by session + owner + visibility — no category predicate. Net: within one session+owner, two entries with byte-identical title+body but a different category (or different tags) collide. The second write is skipped, exit 0, "Already saved as {other-category-id}." The caller believes it is saved; it is not — it points at a row in a different category.
This is exactly the false-positive class the ticket flagged. It is not a considered tradeoff: the W447 evidence (same-session regenerations) shares category, so adding category to the hash strictly reduces false positives at zero cost to the target use case. Its omission is an unforced gap. The PR uses a "documented-gap + pinned regression test" pattern for the public-owned/public-unowned case (public_owned_and_public_unowned_identical_content_do_not_dedup) — the category/tags dimension has neither a test nor a mention.
Fix: fold category (and decide on tags) into the dedup identity — either into the length-prefixed hash (add a third length prefix, exactly as the LANDMINE FOR THE NEXT EDITOR doc-comment already warns) or into the candidate query scope. If cross-category dedup is somehow intended, make it a decree with a pinning test like the owner case. Either way, the identity contract must be explicit about what it treats as "the same."
2. [COMMENT / acknowledged] "Store-boundary" is a misnomer — the gate is application-level, TOCTOU-open.
DedupIndex (src/handlers/memory.rs) is per-process read-then-write with no DB UNIQUE constraint. Two concurrent mx invocations both land. The PR discloses this honestly and defers the garbaggio DEFINE INDEX ... UNIQUE ON knowledge FIELDS session, owner, dedup_hash option. Acceptable given the disclosure and that the observed failure (regenerations "minutes apart" = sequential processes) is caught by the DB candidate fetch. But the title oversells; the real close is the deferred unique index. Note for merge: the unique-index option is where finding #1's category dimension must also land (FIELDS session, owner, category, dedup_hash).
3. [COMMENT / acknowledged] get_entries_for_session is unbounded.
No LIMIT; every write in a session rehashes the whole session group — O(n²) over a long-lived session. Disclosed as a follow-up. Bounded in practice today. Fine to defer, but it is on the record.
4. [COMMENT] Cross-invocation dedup depends on deterministic title.
add_one does not transform title/body (verified: entry.title = args.title.clone()), so the hash matches what's stored — good. But if title is auto-generated from content upstream for empty-title adds (docs reference this) and that generation is non-deterministic, a regenerated duplicate gets a different title → different hash → dedup misses. False-negative only (a dup slips through), not data loss. Worth a one-line confirmation that title resolution is deterministic before the hash is computed.
5. [MERGE-ORDER] PR #399 and #401 collide on the same functions.
This PR changes add_one's signature (adds dedup: &mut DedupIndex, allow_duplicate: bool) and return type (Result<KnowledgeEntry> → Result<WriteOutcome>), and rewrites the batch match arms. PR #399 (non-fatal post-write side effects) touches the same add-path functions — a textual conflict on add_one is near-certain, and semantically #399's side effects must sit after this PR's Skip early-return (a skip is a no-op → no side effects, which is correct). PR #401 (exclude-tags) also touches memory.rs + knowledge.rs. Whichever lands second re-threads. The PR already flags this; confirming it is a real, visible conflict, not a phantom. (Agreed landing plan below.)
What is right (the acceptable part)
src/knowledge.rsdedup_hash: length-prefixed title/body pair fed to blake3. The boundary-collapse blocker ("Ship it"/"now." vs "Ship it now"/"") is genuinely fixed, with a regression test (test_dedup_hash_does_not_collapse_title_body_boundary). TheLANDMINEdoc-comment is exactly the readability standard I ask for — it tells the next editor why, not just what.- Fail-open on lookup error, unified in one
dedup_gate()— with a fault-injecting unit test (lookup_failure_fails_open_and_the_write_still_lands) that asserts the row actually persists. Correct: a mechanism that tolerates a write race must tolerate a read blip. - All three funnels (standard
add_one, single-add--type, batch inline--type) route through the one gate; the previously-missed inline fact path is covered and tested (single_add_fact_type_path_*,batch_fact_type_path_*,standard_batch_path_also_dedups_via_add_one). - No migration risk:
dedup_hashis never persisted, recomputed from candidate rows on every check, so legacy unhashed entries participate in dedup on the next same-session write. Clean. record()fires only after a verified durable upsert, never on the skip or failed-write path — so a failed earlier batch line can't poison a distinct later one. Correct.- Skip is
Ok+ exit 0 +duplicate_ofpointer,idkey mirrors success payloads, plain-mode phrasing kept distinct from "Added entry:". Caller is told. - Test/logic ratio: ~2,100 lines test / ~700 lines logic / ~270 docs. The bulk is tests and fixtures, as it must be for a data-loss-risk feature.
Merge order (agreed cross-PR plan)
Recommended landing order across the three concurrent memory PRs: #401 → #402 → #399. This PR lands second — after #401, before #399.
- #401 first (exclude-tags, read-path only): independent, lands first to minimize rebase churn.
- #402 second (this PR): lands next carrying the category/tags dedup-identity fix (finding 1). Design the identity contract once, correctly, before anything rebases onto it.
- #399 third (non-fatal post-write side effects): rebases on top and threads its structured degraded-state field (
embed_deferred) into this PR's already-richer--jsonschema (skipped/duplicate_of/status), so the JSON contract is designed once, coherently. Note for #399's rebase: this PR'sSkipearly-return correctly means no post-write side effects run on a dedup skip — #399's degraded-state logic sits after the write, past the early-return, and never fires on a skip. - Deferred UNIQUE index (whenever it lands): it must include the category dimension too —
DEFINE INDEX ... UNIQUE ON knowledge FIELDS session, owner, category, dedup_hash— consistent with finding 1.
Fix finding #1. The rest is acceptable.
…#402 review F1) BLOCKER: dedup_hash keyed on title+body only, and get_entries_for_session scoped candidates by session+owner but not category. Identical title+body re-filed under a different category in the same session silently collided -- the second write was skipped as "already saved" pointing at a row in the wrong category (undocumented false-positive data loss, the exact class the ticket named as worst-case). Fix scopes the candidate query by category instead of folding a third field into dedup_hash (avoids the LANDMINE ambiguity the hash's doc-comment already warns about): get_entries_for_session/DedupIndex/dedup_gate now thread category through, and DedupIndex's group/seen keys include category so a same-hash entry from a different category's fetch can never shadow another category's check. All three write funnels (add_one, single-add --type, batch inline --type) now pass category through the shared gate. Tags are deliberately excluded from the identity (edge-modeled, not a scalar row field; folding them in costs a query per candidate) -- documented in dedup_hash's new IDENTITY CONTRACT note and pinned by identical_content_different_tags_still_dedups. New/updated tests: identical_content_different_category_does_not_dedup, identical_content_different_tags_still_dedups, test_get_entries_for_session_category_scope_excludes_other_categories, plus the existing get_entries_for_session/DedupIndex test suites updated for the new category parameter. docs/src/memory.typ coverage-boundary note updated to describe the category scope and the tags exclusion, and the deferred UNIQUE index note updated to key on (session, owner, category, dedup_hash). Deferred (not in this PR, per review triage lanes 2-5): TOCTOU-open in-process gate (garbaggio UNIQUE index option), unbounded get_entries_for_session candidate fetch, and the #399/#401 merge-order plan -- all acknowledged/comment-lane findings, not blockers.
|
Round 1 review addressed: 1 fixed, 0 deferred, 0 skipped.
Also in this push, two test-harness patches (pre-existing, environmental — unrelated to this PR's logic): |
Re-review (Verdictia Stern) — @ b9d6d0cVERDICT: APPROVE — conditioned on one mechanical rebase item (below), which the build forces anyway. Prior BLOCKER — dedup identity ignored category → RESOLVED ✅Fixed in two layers, correctly:
Proven, not asserted: Bonus: the Deferred UNIQUE index: still not landed (nothing persisted → no migration risk); the required shape Tags divergence — documented accepted false-negative (note, not blocker)Identical title+body with different tags still dedups; Prior positives — all hold, some improved ✅
|
| Sev | Location | Finding |
|---|---|---|
| W | memory.rs:4429, :4766 | Test doubles miss owned_private_matching → E0046 after rebase onto #405; add delegating stubs |
| N | memory.rs:5560 | Tag-divergence dedup: documented accepted false-negative; no action |
the smallest nod. Fix the two stubs on rebase. The rest is correct. Lands SECOND (#401 → #402 → #399).
mx stores content_hash but never enforces it, so regenerated/recased
duplicates (same meaning, different case/punctuation/whitespace) land as
separate rows. Add a normalized dedup_hash (title+body) checked at the write
boundary, against the writer's own same-session entries, via a shared
pre-upsert helper covering BOTH write funnels:
- add_one (single `memory add` + the AddBatch standard-path delegate)
- the AddBatch fact-type inline path ({type, content} lines), which builds
its entry and calls db.upsert_knowledge directly -- never touches add_one,
so it needed its own gate wired to the same DedupIndex.
On a same-owner, same-session hit the write is skipped and reported as
already persisted (exit 0, not a failure). `--allow-duplicate` (single add)
/ `allow_duplicate` (per JSONL line) forces the write through.
Candidate fetch is a new KnowledgeStore::get_entries_for_session, field-scoped
on the indexed `session` record-link (NOT the EXTRACTED_FROM edge, which is
frequently missing), hard owner-scoped in application SQL (`owner = $owner`
or `owner IS NONE` for unowned public entries -- never delegated to the
public-permissive visibility filter), and projecting only id/title/body
(never the 768-dim embedding). AgentContext is derived from the entry's own
owner, matching the existing write_verification_ctx pattern.
The in-process DedupIndex fetches lazily, one query per distinct
(session_id, owner) group actually seen in a run -- exactly one query for
the common single-session single-owner batch, and correct (not silently
mis-scoped) for batches spanning multiple sessions/owners, without querying
once per entry.
session_id=None bypasses dedup entirely (by design) and surfaces an
observable bypass signal (`"dedup": "bypassed_no_session"` in --json, a
stderr note in plain mode) rather than failing silently. TOCTOU: this is
in-process, best-effort duplicate prevention, not a DB-level uniqueness
guarantee -- documented as such, not over-claimed.
Output contract treats a skip as success, never failure: affirmative
"Already saved" language (never bare "skipped"/"duplicate"), a positional
per-entry batch line carrying the duplicate_of kn-id (so positional
mark-back stays aligned), and its own "K already saved" tally in the batch
summary, separate from added/failed.
Docs: memory.typ gains a "Write-boundary deduplication" section (behavior
contract, coverage boundary, --allow-duplicate), architecture.typ
distinguishes the legacy content_hash from the new dedup_hash, CLAUDE.md's
maintenance rule now points at PR bodies (no CHANGELOG file exists in this
repo).
Tests: real-store (SurrealDB in-memory) unit tests for get_entries_for_session
scoping (owner exclusion, None-owner, session-field scoping, no embedding
leak), a real-store counting-spy proving one query per distinct group not
per entry, and CLI-level integration tests (tests/dedup_write_boundary.rs)
covering both write funnels' idempotency, the evolved-re-add case,
--allow-duplicate, the session_id=None bypass, and the json skip contract.
No schema change (knowledge_session and knowledge_owner indexes already
exist). Legacy content_hash (title-only, import-time) is untouched.
Note for review: sibling PR #401 (feat/exclude-tags-search-list) is open
against this repo and also touches src/handlers/memory.rs and
src/surreal_db/knowledge.rs -- landing order is Cory's call. This PR does
not touch the areas #401 changes (search/list filtering) as far as this
diff shows, but a rebase may still be needed depending on landing order.
Also note: PR #399 (fix/non-fatal-post-write-side-effects) touches the same
add_one/AddBatch functions; its WriteOutcome-adjacent changes (embed/anchor
skip notices) are pre-existing and out of scope here, but whoever merges
second should expect to reconcile call sites.
┍╨┠──┓┉╩╗┍╿╍╭┆╡╋╷╦╍═┭┅┱╙┛┑┇╕╎┣┻┦╸┉┓┒╒┐┵╫┩┹┉╥╦┼╊┢╓╃┨╶╣╩┧╍┾┸┄┃╘┄╅└┓┡┣┲┘╍╍╮╡━┱┴┕┕╈╶╡╄┝┼┪┶┯┷┤┬╨╗┆╺╤┼╃┴┞┷┛┽╇╺╌┥┟┞╷╤┖╲┃═╿┳═┽╭┧╘┻┶╒┐╀====== [xxhash3-64:base64_radix|brotli:boxdraw]
…#402 review F1) BLOCKER: dedup_hash keyed on title+body only, and get_entries_for_session scoped candidates by session+owner but not category. Identical title+body re-filed under a different category in the same session silently collided -- the second write was skipped as "already saved" pointing at a row in the wrong category (undocumented false-positive data loss, the exact class the ticket named as worst-case). Fix scopes the candidate query by category instead of folding a third field into dedup_hash (avoids the LANDMINE ambiguity the hash's doc-comment already warns about): get_entries_for_session/DedupIndex/dedup_gate now thread category through, and DedupIndex's group/seen keys include category so a same-hash entry from a different category's fetch can never shadow another category's check. All three write funnels (add_one, single-add --type, batch inline --type) now pass category through the shared gate. Tags are deliberately excluded from the identity (edge-modeled, not a scalar row field; folding them in costs a query per candidate) -- documented in dedup_hash's new IDENTITY CONTRACT note and pinned by identical_content_different_tags_still_dedups. New/updated tests: identical_content_different_category_does_not_dedup, identical_content_different_tags_still_dedups, test_get_entries_for_session_category_scope_excludes_other_categories, plus the existing get_entries_for_session/DedupIndex test suites updated for the new category parameter. docs/src/memory.typ coverage-boundary note updated to describe the category scope and the tags exclusion, and the deferred UNIQUE index note updated to key on (session, owner, category, dedup_hash). Deferred (not in this PR, per review triage lanes 2-5): TOCTOU-open in-process gate (garbaggio UNIQUE index option), unbounded get_entries_for_session candidate fetch, and the #399/#401 merge-order plan -- all acknowledged/comment-lane findings, not blockers.
…e-existing; mirrors dedup_write_boundary pattern)
…e pre-existing leak as trigger_check)
♚♔♞♘♚♘♝♗♚♙♝♔♗♗♕♞♖♛♙♟♜♟♞♙♘♝♔♚♘♔♕♙♖♟♝♞♚♝♛♙♕♙♚♖♚♔♖♘♟♕♔♙♔♚♜♔♕♙♙♘♕♖♔♟♕♜♗♕♖♙♞♝♝♛♜♙♔♔♙♕♗♜♕♞♝♔♛♜♜♞♚♟♟♖♚♛♙♗♚♗♝♔♙♖♕♞♔♞♜♜♛♘♞♔♙♝♘♛♝♗♗♕♞♝♙♞♞♚♞♟♟♗♚♔♙♟♟♔♖♘♙♝♚♟♔♗♟♜♝♗♞♚♟♟♝♟♕♛♖♘♕♗♞♖♞♘♝♞♖♖♖♞♚♙♚♚♛♟♞♝♝♞♖♖♘♔♘♜♞♞♜♗♚♘♝♞♜♗♘♘♗♖♞♕♗♝♛♘♚♔♞♘♔♝♚♙♚♛♞♘♙♛♔♕♔♝♛♕♟♟♝♚♝♜♘♚♖♜♘♜♜♕♟♝♜♘♝♕♘♛♞♟♙♟♕♛♚♜♜♔♚♝♙♛♖♞♞♔♜♛♙♙♞♟♕♕♝♛♜♛♕♝♕♛♕♝♞♝♖♘♖♜♙♚♖♝♛♛♔♜♔♚♗♞♕♕♙♔♕♝♗♞♔♔♝♞♜♜♝♝♚♔♜♛♖♞♜♝♗♝♔♔♙♜♛♘♘♝♜♜♞♘♔♙♟♘♝♕♘♕♟♙♞♕♘♙♕♟♚♚♚♕♖♚♚♝♗♟♜♛♟♚♙♞♕♕♞♛♙♙♔♕♝♕♚♟♜♞♛♜♛♙♖ [crc32:base58ripple|lz4:chess]
b9d6d0c to
024b1ea
Compare
…(Cory B1) add_one's post-write embed/anchor steps are non-fatal by design (the entry is already durable and read-back verified before either runs), but a failure there was only ever eprintln'd -- a --json caller had zero signal that the write landed in a degraded state. Capture the failure in a new WriteSideEffects struct threaded through WriteOutcome::Written and fold embed_deferred/anchor_deferred into the Add JSON payload, following the same conditional-field pattern #402 established for dedup/dedup_lookup_warning (present only when the step actually failed; absent on success or on a deliberate --no-embed/--no-auto-anchor skip, which already has its own "(skipped)" notice). AddBatch always calls add_one with embed=false/no_auto_anchor=true (embed is hoisted, anchoring deferred to the nightly run), so its side_effects are always empty -- destructured and intentionally ignored there.
|
Rebased onto main (post-#405); delegating owned_private_matching stubs added to CountingStore + FailingStore per review. cargo check --all-targets + full suite green. |
Summary
Store-boundary write dedup for memory. Every
mx memory add— and every fact-type add — now hashes a normalizeddedup_hash(title+body, lowercased, punctuation-stripped) and checks it against same-owner rows before the upsert. On a hit, the write skips and exits 0 with aduplicate_ofpointer to the row that already holds the content. No new row, no edges, no embedding.The enforcement lives in one shared pre-upsert helper wired into both write paths: the plain
add_onepath and the fact-type add-batch inline path. There was no single choke point before, so the two paths could disagree about what counted as a duplicate. Now they don't.Candidate lookup is a same-owner fetch keyed on the
session_idfield — visibility-filtered, params bound. Hit -> skip and short-circuit. Miss -> the write proceeds exactly as it does today.Design notes
Nonematches null-owner rows, so a public unowned add dedups against its own kind rather than colliding with owned content.DedupIndexis keyed(owner, hash)so a mixed-owner batch dedups per-owner instead of smearing across owners inside a single add-batch.Okand short-circuit ahead of edge-building and embedding. A duplicate costs nothing downstream.session_id = Nonebypasses dedup and emits an observable json note rather than silently doing nothing. The bounded fallback for the no-session case is deferred, not forgotten.dedup_gate()helper. All three new-entry write funnels (standardadd_one, single-add--type, batch inline--type) now call one shareddedup_gate()— the check, the bypass-signal, and the fail-open lookup-error handling all live in one place instead of being hand-rolled per funnel.Honest disclosures
duplicate_oftells you). This is bounded to the pre-existing owner-claim authz — it exposes no more than a caller could already assert about their own owner scope, but it is a new confirmation surface and worth naming. Extended per fix-round review:add-batch's per-linesource_agent/ownermakes this sharper in practice (one batch call can probe many owner+content combinations at once) — same bound, not a new hole. Pinned bybatch_per_line_owner_claim_confirms_existence_of_matching_private_content.DedupIndexis per-process best-effort. Two concurrent writers can still both land the same content — the check-then-write window is real. This matches the pre-existing concurrency posture; we did not make it worse, and we did not close it. Fix round: a candidate-lookup failure (transient read blip) now fails the gate OPEN rather than aborting the write — a mechanism that already tolerates a check-then-write race should tolerate a read blip the same way.content_hashis untouched.dedup_hash(title+body, W447) is a distinct, additional mechanism. The old hash keeps doing its old job.Housekeeping
Motivated by ~20 double-write pairs traced across W342–W445 — regenerated duplicates that exact-match checks slid right past because a regenerated body differs by a comma. Normalization is what catches those.
Session-scope coverage (raccoon review, garbaggio — RESOLVED): the ~20 historical double-write pairs were traced (W447) to same-session dual generations (same
session_id, same resonance, minutes apart); session-scoped dedup covers the observed failure class; cross-session near-duplicates are deliberately out of scope.Sibling context for the merger: PR #401 (exclude-tags) is open on this repo. Both it and this PR touch
src/handlers/memory.rsandsrc/surreal_db/knowledge.rs, and PR #399 touches the same add-path functions. Landing/merge order across the three is Cory's call — flagging the overlap so it's a decision, not a surprise.Fix round (raccoon review response, 8 raccoons + 3 Opus deep-divers)
Both blockers fixed, the structural finding implemented, the test-authority gap closed, and every tail finding fixed or documented. Full per-finding disposition table went to Lens for verification alongside this PR. Summary:
dedup_hashtitle/body boundary collapse — fixed. Hashes the normalized title/body as a length-prefixed pair (not a joined string), so a duplicate that shifts the split point (title one turn, title+body the next) no longer collapses to the same hash.dedup_hashis recomputed on every call and never persisted, so there's no migration concern. Regression test added.--jsonalso carries adedup_lookup_warningfield; write proceeds) via the shareddedup_gate(). Unit test injects a lookup failure and asserts the write still lands.mx memory list --json.dedup_gate()helper; all three funnels call it.garbaggio's UNIQUE-index redesign was NOT implemented (see design option below) — this fix is the DRY consolidation only, not a store-level constraint.--allow-duplicatesilent-overwrite caveat documented oncli.rs --help,docs/src/memory.typ, andmx-docs.md(perturbing the id was considered and rejected — see disclosure above); json-mode double-signal nuance fixed (stderr note now gated on plain mode only); smart-quote/em-dash/ellipsis normalization extended (one-directional safe, false-negative-only); public+owned vs public+unowned dedup gap documented + pinned by a regression test (not fixed — would need an owner-predicate redesign); claimed-owner oracle disclosure extended to the batch per-line owner vector + regression test; unbounded candidate fetch documented as a follow-up (not fixed — see design option below); skip-payloadidkey added;duplicate_oflast-seen-not-canonical documented; batch-summary test assertions anchored to full lines instead of loose substrings.Design option for a future PR (not implemented here)
garbaggio's structural alternative, surfaced but deliberately not built this round: persistdedup_hashas a field onknowledgeand back it withDEFINE INDEX ... UNIQUE ON knowledge FIELDS session, owner, dedup_hash(the schema already has the pattern —tagged_with_unique,applies_to_unique,relates_to_unique). That would make "store-boundary" literally true and close the acknowledged TOCTOU gap at the database level, at the cost of mapping a constraint-violation error toWriteOutcome::Skippedand a migration to backfilldedup_hashon existing rows. Cory's call whether this is worth a follow-up PR — flagging so it's a decision, not a surprise. Also unaddressed this round:get_entries_for_sessionhas no result limit, so a very long-lived session pays a full rehash of its group on every write (bounded in practice; a follow-up if it stops being bounded).Test plan
cargo test— 1,255 tests green (1,203 baseline + 10 new: 2 inknowledge.rs, 2 inhandlers::memory::dedup_gate_tests, 6 intests/dedup_write_boundary.rs; 3 existing skip-tests strengthened with store-level row-count assertions), 0 failed, 3 pre-existing ignored.cargo fmt --check— clean.cargo clippy --all-targets -- -D warnings— clean.docs/build.shregenerated (docs/src/memory.typtouched — dedup section rewritten to cover all four funnels, the fail-open behavior, the allow-duplicate caveat, and the coverage-boundary open questions).No merge from me — Cory merges.