fix(memory): canonicalize memory identifiers symmetrically (#5164) - #5275
Conversation
…sai#5164) `document namespace/key cannot contain personal identifiers` reached Sentry 3,055 times from a single user in one day (TAURI-RUST-QWW). The rejection is deterministic in the caller's own input, so every retry re-reported it. tinyhumansai#5171 stopped rejecting and started rewriting the namespace/key, but used `redact_pii` — the *content* scrubber — on identifiers, and only on the write side. Two defects follow: * `redact_pii` rewrites bare digit-run shapes that the crate's own boundary predicate deliberately tolerates because the scanners build identifiers out of them (WhatsApp JIDs, iMessage `+1…` chat ids, ms timestamps, padded counters). Two contacts then share one `(namespace, key)` and the upsert's `ON CONFLICT … DO UPDATE` has one contact's document overwrite the other's. * rewriting an identifier changes the row's address, so `Memory::get` / `Memory::forget` (raw key), `query.rs` / `graph.rs` (namespace without the rewrite) and the KV `get_*` / `delete_*` addressed rows the write never created. The caller reads the row as absent and writes again — the same unthrottled loop, now silent instead of erroring. Canonicalization is now single-sourced and strict-gated: `safety::canonical_identifier` (+ `canonical_document_key` for the trim) rewrite only formatted / keyword-gated national IDs, and are idempotent so read paths can apply them unconditionally. The namespace step moves into `sanitize_namespace`, the one funnel every namespace path already shares, and the by-key paths (both upserts, `Memory::get`, `Memory::forget`, the KV shim) go through the document-key helper. The rejections that remain deliberate — secret-shaped identifiers (tinyhumansai#4947), keys that trim to empty — now classify as `ExpectedErrorKind::MemoryIdentifierRejected`, so their retry volume stays out of the error stream while real failures on the same write path (SQLite, embeddings, sidecar IO) still page. Regression coverage: PII-bearing keys/namespaces round-trip through get/list/forget and the KV shim; scanner-built identifiers keep their identity and stay distinct documents; the classifier demotes every rejection wording and no real write failure.
There was a problem hiding this comment.
M3gA-Mind has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 59 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
Comment |
document namespace/key cannot contain personal identifiersreached Sentry 3,055 times from a single user in one day (TAURI-RUST-QWW,openhuman@0.63.0). The literal rejection was already removed by #5171, but that fix left the underlying defect in place — and added a second one. This closes both.Summary
+1…chat ids, millisecond timestamps, padded counters — keep their identity, so two contacts can no longer collapse onto one(namespace, key)and silently overwrite each other's documents.ExpectedErrorKind::MemoryIdentifierRejected, so their retry volume can't flood Sentry again while real failures on the same write path still page.Problem
The boundary check used the strict predicate
has_likely_piito reject a write. Rejection is deterministic in the caller's own input, so the caller retried, failed identically, and re-reported — 3,055 events, one user, one day. Not 3,055 defects; one defect at retry rate.#5171 stopped rejecting and started rewriting the namespace/key with
redact_pii. Two defects follow.1. Wrong scrubber.
redact_piiis the content scrubber. The crate's own tests spell out why it must not be used on identifiers:It rewrites bare digit-run shapes that
has_likely_piideliberately tolerates because the scanners build identifiers out of them. So:Both land on one
(namespace, key), whereON CONFLICT(namespace, key) DO UPDATEhas one contact's document overwrite the other's. Same for iMessage+1…chat ids,screen_intelligence_…-1747729035001-…, and padded counters.2. Write-only. Rewriting an identifier changes the row's address, and only the write side was rewriting:
upsert_document*Memory::get/Memory::forgetsanitize_namespace)query.rs(recall/search),graph.rsget_*/delete_*/list_*The write lands, the read misses, the caller treats the row as absent and writes again — the same unthrottled loop, now silent instead of erroring. A pre-existing test asserted exactly that miss:
Solution
One helper, strict-gated, idempotent (
memory_store/safety/mod.rs):canonical_identifierrewrites only whathas_likely_piiflags — formatted / keyword-gated national IDs (ssn-123-45-6789,cliente-RFC-VECJ880326XK4,cuit-20-11111111-2).[REDACTED_PII_*]placeholders carry no PII pattern, so the transform is a fixed point on its own output, which is what lets read paths apply it unconditionally.canonical_document_key= trim +canonical_identifier, single-sourcing the exact transform the upserts apply tomemory_docs.key.Applied symmetrically:
UnifiedMemory::sanitize_namespace— the one funnel writes, reads,query.rs,graph.rs, deletes and the on-disknamespaces/<ns>/directory already share. The four hand-rolledredact_pii(namespace)wrappers indocuments.rsare removed as redundant.Memory::get,Memory::forget, and thekv.rsshim — go throughcanonical_document_key/canonical_identifier. The KV compensation lives in the host shim (the crate'sset_*canonicalizes, itsget_*/delete_*do not), so no submodule bump is needed; canonicalizing there is a no-op on the write path and makes the read path symmetric.Sentry, defence in depth:
ExpectedErrorKind::MemoryIdentifierRejecteddemotes the remaining rejection wordings (document namespace/key cannot contain secretsper #4947,document key cannot be empty, thekv/episodicvariants, and the retired PII wording still sent by pre-#5164 cores) towarn. Anchors require the memory-store subject, soupsert memory_docs: database is locked, embedding failures and sidecar IO errors still reach Sentry as errors.Impact
has_likely_piinow runs persanitize_namespacecall (short strings, candidate-scan fast path that returns early when no class is flagged) — negligible against the SQLite work it precedes.Submission Checklist
memory_store::safety,namespace_store::init,namespace_store::documents_tests,core::observability)N/A: behaviour-only change— coverage matrix unchanged (no feature added/removed/renamed)N/A— no matrix feature IDs affectedN/A— does not touch release-cut surfacesCloses #5164Tests, failing before / passing after:
pii_like_document_key_round_trips_through_get_and_forget,pii_like_namespace_round_trips_through_get_and_list,metadata_only_write_round_trips_through_pii_like_key— write/read symmetry.scanner_built_phone_shaped_keys_stay_distinct_documents,scanner_built_identifiers_are_preserved_verbatim— the collision/overwrite regression.canonical_identifier_rewrites_only_strict_pii,canonical_identifier_is_idempotent,canonical_document_key_trims_before_canonicalizing,sanitize_namespace_canonicalizes_pii_and_preserves_scanner_namespaces— the helper contract.classifies_memory_identifier_rejections_as_expectedand its over-suppression guarddoes_not_classify_real_memory_write_failures_as_identifier_rejections.Related
get_*/delete_*key asymmetry is compensated at the host shim and could be pushed upstream into the crate.