DEV Community

Pranab Sarkar
Pranab Sarkar

Posted on Originally published at dev.to

A contributor found the live twin of a namespace bug I'd filed as low priority

The test store had two namespaces. Into "alice" went ALICE-SECRET der Server steht in Berlin. Into "bob" went BOB-SECRET der Server steht in Wien. The test then handed both memories the same hand-built embedding vector, which pins their cosine similarity at 1.0 whatever the words say, and let the engine run its trigger checks. The trigger that came back named both namespaces, and its payload read "text_a":"BOB-SECRET ...","text_b":"ALICE-SECRET ...","similarity":1.0.

Forcing the vectors takes the embedding model out of the experiment. What's left is the question the check should have been asking and wasn't: do these two memories belong to the same owner? It never looked. Any cross-namespace pair that cleared the threshold got written down with both texts in full, next to each other.

Hugo Gundlach (Huy3ko on GitHub) wrote that test against engine 0.23.3 and posted its output on an issue I had opened six weeks earlier about almost exactly this shape of bug. I had marked my issue low priority.

I maintain YantrikDB, a Rust memory engine that AI agents embed so facts and decisions survive between sessions. The core README describes the embedded engine as single-tenant, one file per user, and that is still its main shape. Every memory row also carries a namespace column, though, and integrations like the Hermes plugin use it to keep several agents or users apart inside one store. The engine treats that column as a hard boundary in several places. consolidate_cluster errors out with "consolidate_cluster: members span namespaces ('{}' vs '{}' at {rid}) — a cluster must be consolidated within one namespace", and the claim-conflict scans join on e1.namespace = e2.namespace.

The check

check_redundancy lives in crates/yantrikdb-core/src/cognition/triggers.rs. It runs by default as part of think(), the background pass that does consolidation plus redundancy and conflict detection. Before the fix, its query was:

SELECT rid, text, embedding FROM memories
WHERE consolidation_status = 'active' AND embedding IS NOT NULL
ORDER BY created_at DESC LIMIT 30
Enter fullscreen mode Exit fullscreen mode

That's the 30 most recent active memories in the whole store, each compared against every other. A pair above the threshold became a redundancy trigger, or a potential_conflict trigger through a sibling branch when the two shared an entity. Both kinds carry context.text_a and context.text_b holding the full source text with no length cap, and both get persisted to trigger_log. So a cross-namespace match is one person's memory sitting in a table row beside another person's.

The threshold has its own, older bug. It's meant to come from config.consolidation_sim_threshold, default 0.6, and the function ignored that and used a hardcoded let threshold = 0.85;. The fix PR left it alone and listed it as out of scope.

Issue #242, which I wrote

I'd come at this from a different direction in September. A user filed yantrikdb-hermes-plugin #87: on the embedded backend, think() silently never did any maintenance. The plugin always passes a namespace key in the think() config dict, think() whitelists its config keys, and namespace is only on recall()'s whitelist. Recall and remember worked; think did nothing, and the only evidence was a DEBUG-level think(): unknown config key "namespace".

While tracing that I read find_clusters() and noticed it never checks namespace at all. On 2026-09-17, on engine 0.23.0, I opened issue #242: "find_clusters() has no namespace check; cross-tenant merging is prevented only by the caller's grouping." Its caller, find_consolidation_candidates_page, groups memories by namespace before handing them over, and I confirmed that grouping held on 0.23.0. I proposed enforcing the rule in commit_consolidation, the chokepoint every consolidation path goes through, so it wouldn't depend on callers remembering. Then I wrote: "Low priority — no user is affected today. Filing it so the invariant is written down somewhere other than the caller's control flow."

The diagnosis was right as far as it went. Where I went wrong was treating find_clusters as the whole problem. I had just found a function that reads memories with no namespace predicate and is safe only by accident of its caller, and I didn't go looking for other functions that read the table the same way without any caller protecting them. check_redundancy was one.

Late on the 27th

I shipped v0.23.3 at 20:38 UTC on 2026-10-27, closing out a partial fix for the consolidation-seed bug (#253) and a long-pending bundled-embedder cache-dir fix (#252). About three hours later, at 23:49 UTC, Huy3ko committed his fix. He opened PR #256, "fix(triggers): keep redundancy pairs inside one namespace," eight minutes after that, and two minutes later commented on #242: "Related, but on a different path — and this one was not latent." The PR body says "Refs #242, does not close it." He wasn't new to the project; he'd had a PR open against the Hermes plugin since 2026-10-17, adding correction chains and historical recall, and it's still open.

The fix has two layers. check_redundancy now first runs SELECT DISTINCT namespace FROM memories WHERE consolidation_status = 'active' AND embedding IS NOT NULL, then calls a new private check_redundancy_in_namespace once per namespace, each with AND namespace = ?1 bound through rusqlite::params![namespace].

Behind that sits drop_cross_namespace_triggers, called inside filter_and_persist_triggers just before anything reaches trigger_log. It resolves the namespace of every source_rid on each trigger and drops, with a tracing::warn!, any trigger whose rids land in more than one namespace. If a rid can't be resolved because its memory has since been deleted, the trigger passes through untouched; the code comment says "guessing ownership there would drop legitimate single-rid triggers for forgotten memories." The commit message gives the reason for the second layer: "the boundary does not depend on every future check remembering to scope itself."

My opinion, which not everyone will share: that second gate matters more than the query fix. The query fix closes this function. The gate at the write path covers whichever trigger check someone adds next year without thinking about namespaces, which is the kind of mistake I'd just demonstrated I make.

The diff was 2 files, 288 insertions, 2 deletions. A new test file, crates/yantrikdb-core/tests/trigger_namespace_isolation.rs, has three tests. no_trigger_payload_carries_two_peoples_text is the Berlin/Wien one above. identical_text_in_two_namespaces_yields_no_cross_namespace_trigger is the plainer case: the same sentence, "Der Mailserver laeuft auf Debian 12 mit Postfix.", with the same vector, in both namespaces. Both fail without the fix. The third, near_identical_text_in_one_namespace_still_raises_redundancy, has to pass before and after, so the fix can't get the first two green by switching redundancy detection off. 2207 tests passed, cargo fmt --check was clean, and clippy reported no new warnings.

The commit message does get one detail wrong. It says think() reaches this check twice per run, once in Phase 1 and again through check_all_triggers. think() calls check_redundancy once, in Phase 1. check_all_triggers is a separate public function, also exposed to Python, that think() never calls. It doesn't affect the fix. Nobody caught it in review, and I didn't catch it when I merged.

Review and merge

The review on the PR came from the delegated reviewer that posts under my spranab account. My local checkout was behind main, so it checked the change through gh pr diff and gh api against the fork branch without running the suite. It confirmed the old query had no namespace predicate and that text_a/text_b carry full source text, calling a cross-namespace row in trigger_log "a genuine disclosure." It checked both out-of-scope claims in the PR: the aggregate trigger checks (check_valence_trend, check_relationship_insight, check_entity_anomaly, check_consolidation_triggers) still compute store-wide but carry no text, and the 0.85 threshold predates the PR. Then it approved and stopped: "Since it's a logic change to the core engine (not docs/data), merge is Pranab's call rather than something I'll action directly — flagging it to him now."

That's the rule I set for it, and this PR is the case the rule exists for. It can check a diff line by line against the code; a change to what the core engine writes to disk is still mine to merge.

I merged at 02:19 UTC on 2026-10-28, 2 hours 22 minutes after the PR opened, and published v0.23.4 at 02:44, about six hours after v0.23.3. From Huy3ko's comment on #242 to a shipped release took under three hours. My release notes:

Behavior change: the redundancy scan now covers up to 30 records per namespace instead of 30 per store. On stores with many namespaces, think() does proportionally more work there and can raise more redundancy triggers.

Existing stores: this release prevents new cross-namespace triggers but does not remove ones already written. If you run multiple namespaces in one store and want to clear old ones, inspect trigger_log for redundancy / potential_conflict rows whose source_rids resolve to different namespaces.

Some people will think a privacy fix should clean up after itself on upgrade. I'd rather a patch release didn't delete rows from someone's database without asking, and tell them exactly which rows to look at instead.

I thanked him on the PR: "the write-up made it quick to review: the control test, and the out-of-scope items measured and named rather than left out." The fix commit's author field reads "Vela vela@localhost", an address not linked to any GitHub account, so GitHub doesn't credit him. A same-night commit to CONTRIBUTORS.md records the credit by hand, along with wolf-crazy and antonius888. I don't know where the name Vela comes from.

Still open

242 is still open. find_clusters still relies on its caller's grouping, and the four aggregate checks still compute across the whole store. They carry no text, so they can't leak the way check_redundancy did, but the boundary isn't enforced everywhere yet. PR #256 fixed one function and its payload, and added a gate in front of the log.

I'd like to say a quick grep would have found check_redundancy in September. It wouldn't have. Searching the core engine's source for FROM memories lines with no namespace on the same line returns roughly 277 of 319, most of them fine, scoped by a bound parameter a line further down or correctly store-wide by design. Finding the bad ones means reading each query and asking who owns the rows it returns. That reading is what I skipped when I verified one caller and wrote "no user is affected today."


Pranab Sarkar, Independent Researcher

Top comments (0)