Promised in #68 ("keyed CRUD for Mem0/Cognee (#18 U2) needs vendor-API research first; filing separately") — the research is done. Three-angle investigation: a code audit of the adapters' current enumeration cost, and vendor-API research for Mem0 (cloud + OSS server) and Cognee (cloud + OSS), synthesized into the design below. Headline: Cognee's 1+D+N-request keyed get shrinks to 3 requests using listing data the adapter already receives but ignores; Mem0 cloud has true server-side metadata filtering the adapter already writes the keys for. Every claim carries its file:line or URL; unverified vendor claims are flagged with mitigations.
U2 design — keyed CRUD / write-path indexing for the Mem0 and Cognee remote adapters (issue #18)
Scope: adapters/remote/src/{common,mem0,cognee,supermemory}.rs on branch feat/a4-typed-errors-retry-health. All code claims below are from the code audit (Report 1, paths verified in the working tree at /Users/shreyanshsharma/Desktop/Openhuman/tinymemory); vendor claims carry their doc/source URLs (Reports 2–3). Conflicts between the reports are flagged inline and collected in §6.
Notation: N = records the adapter owns, D = Cognee dataset count (one per namespace), n_ns = records in one namespace, T = Supermemory container-tag count, L = export page limit.
1. The problem, quantified
Root cause is structural, not per-adapter. The Dialect trait (common.rs:529-548) has exactly five ops — upsert, entries() ("Enumerates every record owned by this adapter", common.rs:535), search, delete, health — i.e. no keyed read exists in the dialect vocabulary. RemoteMemory<D> therefore resolves every keyed read by full enumeration + client-side filter: get (common.rs:640-648), list (:651-667), count (:700-702), namespace_summaries (:675-697). Each dialect's upsert and (for Mem0) delete additionally do their own enumeration to discover remote_id, which is never cached anywhere — RemoteMemory holds only the dialect (common.rs:552-554), dialects hold only the HTTP client, and remote_id has zero references outside the four adapter files.
Per-op HTTP request cost today:
| Op | Mem0 cloud (page=200) | Mem0 self-hosted | Cognee | Supermemory |
|---|
get(ns,key) | ⌈N/200⌉ | 1 while N<1000; hard error at N≥1000 (mem0.rs:272-281) | 1 + D + N (serial raw fetch per record, cognee.rs:231-249) | 1 + Σ_t ⌈n_t/200⌉ (whole account) |
list / count / namespace_summaries | ⌈N/200⌉ each | same | 1 + D + N each | same |
store (new or re-store) | ⌈N/200⌉ + 1 | 1 + 1 / bail | (1 + D + N) + 1 | ⌈n_ns/200⌉ + 1 (already namespace-scoped, supermemory.rs:313-319) |
forget(ns,key) | ⌈N/200⌉ + 1 | 1 + 1 / bail | 2 + n_ns + 1 (already dataset-scoped, cognee.rs:420-434) | ⌈n_ns/200⌉ + 1 |
recall | 1 | 1 | 1 | 1 |
Concrete: on a 10k-record store, one Mem0-cloud get is 50 requests downloading the entire account body; one Cognee get is ~10,002 serial requests. Reads retry up to 3× on transient failure (common.rs:307-342), so worst cases triple.
Amplification in the Portability path (mandatory layer, not adapter-fixable alone): export_page calls namespace_summaries()andlist(ns,..) per page (api/src/mandatory/mod.rs:287, :305-308) — 2 full enumerations per exported page. Mem0 cloud, 10k records, L=100 ≈ 10,100 requests to export what 50 can download; Cognee ≈ >2,000,000. Import calls store_with_taint per record → one existence-probe enumeration of the growing destination per record: ≈N²/400 requests into Mem0 cloud, O(N²/2) into Cognee.
The cost is acknowledged in-tree (mem0.rs:234-236, :241-255; supermemory.rs:253-257 — the latter records that this exact regression was already fixed once, for Supermemory's writes only). No TODO/FIXME, no cache, no index exists.
2. What each vendor actually offers
2.1 Mem0
Cloud (api.mem0.ai) — server-side keyed lookup exists:
POST /v3/memories/ ("Get Memories") takes a required filters object (must include ≥1 entity ID) with AND/OR/NOT and comparison operators; paginated {count, next, previous, results[]}, page_size 1–200 (default 100). https://docs.mem0.ai/api-reference/memory/get-memories- Metadata filtering:
{"metadata": {...}} clauses — but top-level metadata keys only, operators eq/contains/ne only; in/gt/lt on metadata raise FilterValidationError; nested keys unsupported. https://docs.mem0.ai/platform/features/v2-memory-filters. Our metadata keys tinymemory_namespace/tinymemory_key (mem0.rs:399-410) are top-level and need only equality — inside the documented envelope. - By-ID CRUD:
GET/PUT/DELETE /v1/memories/{id}/ (update rewrites text and metadata). https://docs.mem0.ai/api-reference/memory/get-memory, .../update-memory, .../delete-memory - Batch:
PUT|DELETE /v1/batch/ (≤1000/request); DELETE /v1/memories/ accepts a metadata filter param (server-side delete-by-metadata, async); memory_ids is filterable in list filters (batch get by id). https://docs.mem0.ai/api-reference/memory/batch-update, .../batch-delete, .../delete-memories - v1/v2 endpoints the adapter already uses "continue to work… no requirement to migrate" — https://docs.mem0.ai/migration/platform-v2-to-v3.
- Caveat:
AND-ing user_id + agent_id returns only records with both set (v2-memory-filters page). Safe for us: creates send user_id = namespace (mem0.rs:441) and the existing cloud enumeration/search already filter on agent_id/AND[agent_id,user_id] successfully (mem0.rs:304, :500-506), so both fields are populated. - Rate limits exist but are numerically undocumented (changelog only: https://docs.mem0.ai/changelog/platform).
OSS server (mem0ai/mem0 server/main.py — https://github.com/mem0ai/mem0/blob/main/server/main.py):
GET /memories filters only by user_id/run_id/agent_id (top_k ≤ ALL_MEMORIES_LIMIT = 1000). No metadata parameter on the list route. But user_id filtering means namespace-scoped listing is available server-side today, since the adapter already writes user_id = namespace.POST /search passes filters straight through, and custom metadata keys are first-class, written bare (e.g. {"user_id": "alice", "AND": [{"priority": {"gte": 7}}]}) — note the grammar differs from cloud (bare keys vs {"metadata": {...}} wrapper). Reliability is vector-store dependent: Qdrant/pgvector full; Chroma basic; Pinecone partial; Weaviate filters only entity IDs; "if an operator is unsupported, most stores silently ignore it or fall back to equality." https://docs.mem0.ai/open-source/features/metadata-filtering- By-ID CRUD exists (
GET/PUT/DELETE /memories/{id}); no batch routes. - Cloud rejects empty search queries (
minLength: 1); OSS behavior for empty query undocumented — use a short dummy query and treat filters, not scores, as truth.
Ambiguities preserved from Report 2: v3 endpoint docs give no metadata example (grammar comes from the v2-named filters page; whether v3 relaxes the operator limits is unstated); the cloud search endpoint's separate metadata body param vs metadata-inside-filters interaction is unstated. The design below avoids both by using the list route with a single-key metadata clause.
2.2 Cognee
No server-side keyed or filtered lookup exists, Cloud or OSS. What exists:
GET /api/v1/datasets/{id}/data takes only the path param — no filter, no pagination — but each row already carries name (uploaded filename stem, extension stripped) plus id, timestamps, extension, mimeType; on OSS ≥ v1.5.0 also label and externalMetadata. https://docs.cognee.ai/api-reference/datasets/get-dataset-data; router source https://raw.githubusercontent.com/topoteretes/cognee/main/cognee/api/v1/datasets/routers/get_datasets_router.py; changelog https://docs.cognee.ai/changelog- Name→id resolution therefore costs one listing call, no raw fetches — the adapter's current per-record raw-fetch loop (cognee.rs:243-249) exists only because it reads namespace/key out of the envelope body instead of matching the deterministic filename (
stable_id("key", key) + ".tinymemory.json", cognee.rs:196-198) against the listing name. - By-id:
GET /datasets/{id}/data/{data_id}/raw (read), DELETE /datasets/{id}/data/{data_id} (delete). https://docs.cognee.ai/api-reference/datasets/get-raw-data, .../delete-data - Replace:
PATCH /api/v1/update?data_id=&dataset_id= — delete-then-re-ingest but data_id is pinned across the replace (landed 2026-08-11/13; https://raw.githubusercontent.com/topoteretes/cognee/main/cognee/api/v1/update/update.py). Not in the hosted API-reference nav — verify per Cloud tenant via its OpenAPI /docs before relying on it. - Blind re-
add is unsafe as upsert: dedup is by content hash scoped to (dataset, owner, tenant); same filename with different content mints a new row under the same name (https://raw.githubusercontent.com/topoteretes/cognee/main/cognee/tasks/ingestion/ingest_data.py, .../modules/ingestion/identify.py). - Namespace→dataset:
POST /api/v1/datasets is get-or-create by name (https://docs.cognee.ai/api-reference/datasets/create-new-dataset), and the dataset name is computable client-side ("tinymemory__" + stable_id("dataset", ns), cognee.rs:192-194) — zero-enumeration resolution. - ≥ v1.5.0:
POST /add//remember accept per-file labels + external_metadata, echoed back by the listing (add router + changelog) — a key/namespace channel independent of filename normalization. Cloud docs lag these fields; feature-detect per tenant. - Search/recall filter by dataset but are semantic — not a dependable name→id resolver (https://docs.cognee.ai/guides/search-basics).
- Filename normalization caveats: name is percent-decoded and only the final extension stripped (
_derive_basename, get_file_metadata.py) — so the listing name for our upload is stable_id(...) + ".tinymemory" (only .json stripped). Report 1 confirms the adapter's listing decode already handles a “.json-stripping quirk” (cognee.rs:235-241).
3. Design options per adapter
3.0 Shared precondition (both options depend on it)
Add keyed reads to the Dialect trait, with default impls that fall back to today's entries()-filtering so no dialect breaks:
// common.rs — additions, defaults preserve current behaviorasyncfnentry(&self,namespace:&str,key:&str) -> Result<Option<StoredEntry>>;asyncfnnamespace_entries(&self,namespace:&str) -> Result<Vec<StoredEntry>>;
Rewire RemoteMemory::get → dialect.entry(), RemoteMemory::list(Some(ns),..) → dialect.namespace_entries() (common.rs:640-667). This alone upgrades Supermemory for free: its find_entry(namespace,key) (supermemory.rs:313-319) is exactly the keyed-get seam RemoteMemory::get currently cannot reach, and memories_in_tag (supermemory.rs:258-309) is namespace_entries.
Universal correctness rule for every option below — verify-after-resolve: whatever record a filtered/name-matched/cached path returns, decode it and check tinymemory_namespace/tinymemory_key equality client-side before surfacing it. This makes silent server-side filter degradation (Mem0 OSS stores) and name collisions (Cognee duplicates) fail safe into the enumeration fallback instead of returning the wrong record.
3.1 Mem0
(a) Server-side filter route
- Cloud keyed get = 1 ×
POST /v3/memories/ with
filters: {"AND": [{"agent_id": "tinymemory"}, {"user_id": <ns>}, {"metadata": {"tinymemory_key": <key>}}]}, page_size small. Single metadata key + equality keeps us inside the documented limits (top-level, eq only) and sidesteps the undocumented multi-key-metadata semantics; user_id carries the namespace as an indexed entity filter. Result yields the mem0 id; then PUT|DELETE /v1/memories/{id}/ for upsert-write/forget. - Cloud namespace list = paged
POST /v3/memories/ with AND[agent_id, user_id=ns] → ⌈n_ns/200⌉ requests. Cloud count = 1 request reading the envelope's count with page_size=1 (envelope shape per https://docs.mem0.ai/api-reference/memory/get-memories; that count = total matches is inferred from the standard paginated envelope — confirm once against the live API). - OSS keyed get, two tiers:
- Fast path: 1 ×
POST /search with dummy query + bare-key filters {"AND":[{"user_id": <ns>}, {"tinymemory_key": <key>}]}, scores ignored, verify-after-resolve mandatory (silent-degradation risk). - Portable floor: 1 ×
GET /memories?user_id=<ns>&top_k=1000 + client-side key match. This is strictly better than today: the enumeration and the hard 1000-record bail become per-namespace instead of per-store (the bail must stay — a full page is still indistinguishable from truncation — but it now triggers at 1000 records in one namespace).
Then by-id PUT/DELETE /memories/{id}.
- Costs after (a): cloud get = 1; store = 2 (resolve + write); forget = 2; list(ns) = ⌈n_ns/200⌉; count = 1. OSS get = 1–2; store/forget = 2–3; ceiling per-namespace. Not improved:
namespace_summaries (and list(None)) — Mem0 has no group-by; full enumeration ⌈N/200⌉ remains (optionally slimmed via the fields param on cloud). - Correctness risks: (i) cloud metadata-operator limits are documented on a v2-named page and v3 behavior is not restated — pin with a live integration test; (ii) OSS silent filter degradation → wrong-record risk, closed by verify-after-resolve + fallback to tier 2; (iii) grammar divergence (cloud
{"metadata":{...}} wrapper vs OSS bare keys) must be encoded per-flavour — the adapter already branches on flavour. - Conformance needs: mock-server request-count ceilings (
get ≤ 2 regardless of N); wrong-record guard (mock returns a non-matching record under a filter → adapter must not surface it, must fall back); per-namespace-bail test (1000 in ns A must not break ops on ns B); external-delete staleness (get after out-of-band delete returns None, not a ghost); flavour-grammar tests for both filter shapes.
(b) Client-side write-path index
A (namespace,key) → remote_id map maintained on store/forget/import, consulted before any enumeration.
- Persisted where? Three sub-options: (1) process-memory only — zero new config, lost on restart, first miss per key rebuilds via one enumeration; (2) local sidecar file (JSON/SQLite keyed by endpoint+account) — survives restarts but introduces a local-state surface the remote adapters deliberately don't have today (they hold only the HTTP client, mem0.rs:226-229), plus multi-process coherence and cleanup problems; (3) server-side — no vendor surface for it; not available.
- Hard limitation: a cache cannot make a cold or negative lookup cheap — a miss is indistinguishable from "not stored" without an authoritative resolver, so
get of an unknown/absent key still costs a full enumeration, and negative caching is unsafe under concurrent writers. So (b) alone fixes repeated-op and import cost but not first-touch get. - Correctness risks: staleness under concurrent writers (external delete → cached id 404s → must invalidate + re-resolve, never error through; external delete+re-create → cached id points at a dead record — 404-invalidate covers it since mem0 ids are stable per Report 2); index loss = one rebuild enumeration (today's cost, once).
- Cost after (b): warm keyed ops O(1)+write; import into a store warmed by one initial enumeration drops from ≈N²/400 to N+⌈N/200⌉; cold
get unchanged (O(N)). - Conformance needs: everything in (a)'s staleness list plus 404-invalidate-and-retry, cache-poisoning (two adapter instances interleaving writes must converge), and rebuild-after-loss.
(c) Hybrid
(a) as the authoritative resolver + a per-process (ns,key) → id memo (no persistence) to skip re-resolution on repeated ops, with 404/mismatch → invalidate → re-resolve via (a). All of (a)'s costs become the worst case instead of the every-time case; none of (b)'s persistence problems.
3.2 Cognee
(a′) "Narrow resolution" (there is no server-side filter route — this is the closest achievable analogue)
- namespace → dataset_id: 1 ×
POST /api/v1/datasets{name: computed} (get-or-create; name computable offline, cognee.rs:192-194). Replaces the GET /datasets enumeration. - key → data_id: 1 ×
GET /datasets/{id}/data, match client-side against the computed post-normalization name (stable_id("key", key) + ".tinymemory"). Eliminates the per-record raw-fetch loop entirely. On ≥ v1.5.0, additionally stamp labels/external_metadata = {tinymemory_namespace, tinymemory_key} at add-time and prefer matching those listing fields — immune to filename normalization; feature-detect via the listing DTO (presence of label) or the tenant's OpenAPI. - Keyed ops by id: get = +1
GET .../raw (envelope remains authoritative — verify ns/key inside it); forget = DELETE .../data/{data_id}; upsert-update = PATCH /api/v1/update (data_id pinned) with delete-by-id + re-add fallback where PATCH is absent (Cloud unverified) — accepting that the fallback changes data_id. Never blind re-add (duplicate-row hazard, §2.2). - Costs after (a′): get = 3 (dataset resolve + listing + raw); store = 3 (resolve + listing probe + write); forget = 3 (down from 2+n_ns+1). The listing is unpaginated — one request but O(n_ns) payload of metadata rows; that is the floor.
- What stays expensive, version-gated:
stable_id is one-way, so key and namespace values are not recoverable from names. Pre-1.5.0: list(ns) needs 1 raw per returned record (content + key live only in the envelope) and namespace_summaries needs ≥1 raw per dataset to learn the namespace name (all envelopes in a dataset share it → 1 + D + D total, already a huge cut from 1+D+N). On ≥1.5.0 with stamped metadata: namespace_summaries and key-only listings become raw-free (1 + D requests); list/export with content still pay 1 raw per record — the envelope is the content store; that is not solvable. - Correctness risks: (i) duplicate names in one dataset are possible if a blind re-add ever happened historically — on multi-match the adapter must not pick arbitrarily: define deterministic newest-
createdAt-wins + warning (or hard error), pinned by conformance; (ii) filename normalization (percent-decode, final-extension strip) — non-issue for stable_id output today but must be pinned by test so a future stable_id/extension change can't silently break matching; (iii) PATCH absence on a Cloud tenant → fallback changes ids (only matters if a data_id cache exists); (iv) docs/version skew — label/externalMetadata must be probed, never assumed.
(b) Client-side write-path index
(ns,key) → "{dataset_id}:{data_id}" (the composite remote_id format already exists, cognee.rs:252). The namespace → dataset_id half is uniquely safe to cache forever (deterministic name + idempotent get-or-create). The data_id half is cache-friendly because PATCH pins ids, but external delete+re-add mints a new id → same 404-invalidate rule. Marginal value: it only saves the one listing call that (a′) already reduced things to; cold miss = one listing (cheap). Persistence is not worth introducing.
(c) Hybrid
(a′) + a per-process namespace → dataset_id memo (permanent) + optionally a (ns,key) → data_id memo (404-invalidate). Get drops to 2 requests warm (listing skipped only with the data_id memo: 1 raw + occasional re-resolve).
Conformance needs (Cognee): request-count ceilings (get ≤ 3 regardless of N and D; forget ≤ 3); a no-raw-fan-out assertion (keyed ops perform ≤1 raw fetch); name-normalization pinning (keys containing dots, %xx sequences, unicode roundtrip through the naming scheme); duplicate-name determinism test; PATCH-absent fallback path test; both sides of the ≥1.5.0 label gate (stamped and unstamped stores must both resolve correctly, and a pre-1.5.0-written store read by a ≥1.5.0 adapter must still match by name).
4. Recommendation
Ship the shared Dialect seam first (§3.0) — it is a precondition for everything, is behavior-preserving via default impls, and immediately fixes Supermemory's get/list (whole-account → per-tag) using code that already exists.
Mem0: hybrid (c), with (a) as the substance. Cloud gets true server-side keyed lookup (1-request get, 2-request writes) using the metadata filter the adapter already writes for exactly this purpose (mem0.rs:399-410 — the seam Report 1 identified as "one body change away"). OSS gets the user_id-scoped list as the portable floor (works on every vector store, since entity-ID filtering is the one universally supported dimension — https://docs.mem0.ai/open-source/features/metadata-filtering) with the /search metadata fast path where the store supports it, guarded by verify-after-resolve. Skip the persistent index (b) — its persistence surface buys nothing the memo doesn't, and it can't fix cold gets anyway. Honestly not solvable on Mem0: namespace_summaries/list(None) remain full enumerations (no server-side group-by on either flavour); the OSS 1000-row truncation ambiguity remains — it just becomes a per-namespace ceiling instead of a whole-store death sentence; Weaviate-backed OSS never gets sub-namespace server-side filtering.
Cognee: (c) = narrow resolution (a′) + dataset_id memo + ≥1.5.0 metadata stamping. There is no server-side keyed lookup to adopt — Report 3 is unambiguous — so the win comes from exploiting the two deterministic names the adapter already computes (cognee.rs:192-198) against listing data it already receives but ignores. That converts 1+D+N into 2–3 requests per keyed op and O(N²) imports into O(N). Honestly not solvable on Cognee: one unpaginated per-namespace listing per keyed op is the floor (no filter, no pagination on the data route); content retrieval is 1 raw per record forever (the envelope is the content store), so content-bearing list/export remain O(n_ns) raws; pre-1.5.0 servers additionally pay raws for key/namespace recovery as scoped in §3.2.
Follow-up, separate issue (mandatory layer, not U2):export_page's per-page namespace_summaries() + list() double-call (mod.rs:287, :305-308) should compute summaries once per export, not per page. The adapter fixes above shrink each call it makes; the call-count shape is upstream of the adapters.
5. Sizing and shipping order
| # | Work item | Scope | LOC (rough) | Ships independently? |
|---|
| 1 | Dialect entry/namespace_entries + defaults + RemoteMemory rewiring + Supermemory plug-in of find_entry/memories_in_tag | common.rs, supermemory.rs | 100–150 | Yes — first. Behavior-preserving for Mem0/Cognee (defaults), immediate Supermemory win |
| 2 | Mem0 cloud filtered resolver + by-id read/write path + verify-after-resolve | mem0.rs | 150–250 | Yes (after 1) |
| 3 | Mem0 OSS user_id-scoped list + /search fast path + per-flavour filter grammar + per-namespace bail | mem0.rs | 150–250 | Yes (after 1); independent of 2 |
| 4 | Cognee narrow resolution (get-or-create dataset, name-match listing, by-id ops, PATCH-with-fallback upsert); deletes the raw-fetch loop | cognee.rs | 200–300 (net smaller) | Yes (after 1) |
| 5 | Cognee ≥1.5.0 labels/external_metadata stamping + feature detection + label-preferred matching | cognee.rs | 100–150 | Yes (after 4); pure enhancement |
| 6 | Per-process memos (Mem0 id memo; Cognee dataset_id/data_id memo) with 404-invalidate | common.rs or per-dialect | 80–120 each | Optional, last; pure perf |
| 7 | Counting-mock-server conformance harness + the request-ceiling / wrong-record / staleness / normalization tests in §3 | test infra | 200–300 shared | With 1; each adapter adds its cases with its item |
| 8 | Export-page double-enumeration restructure | api/src/mandatory/mod.rs | — | Separate issue (out of U2 scope) |
Items 2, 3, 4 are mutually independent; a partial landing (e.g. 1+4 only) still removes the worst offender (Cognee's 1+D+N).
6. Report conflicts & discrepancies (flagged, not papered over)
- Metadata key names: Report 2's closing recipe filters on
metadata: {"namespace":…, "key":…}, but the adapter actually stores tinymemory_namespace/tinymemory_key (Report 1, mem0.rs:399-410). Design uses the real names; both are top-level, so the cloud limits still hold. - Namespace→entity mapping: Report 2 recommends encoding namespace into
run_id/agent_id "keeping user_id for the tenant"; Report 1 shows the shipped scheme is already user_id = namespace, agent_id = "tinymemory" (mem0.rs:441, :304). Resolved in favor of the existing scheme — it already delivers entity-scoped namespace filtering on both flavours and every OSS store, and switching would require re-tagging all existing data for no capability gain. - Cognee listing-name shape: Report 3's worked example (
mykey.tinymemory → name mykey) uses a different filename than the adapter uploads (stable_id + ".tinymemory.json", Report 1 cognee.rs:196-198); with only the final extension stripped, our listing name is stable_id + ".tinymemory". Report 1 independently confirms the adapter's decode already handles a ".json-stripping quirk" (cognee.rs:235-241). Consistent once reconciled, but the exact post-normalization name must be pinned by a conformance test, not assumed. - "Raw fetches are necessary" vs "unnecessary": Report 1 (current code) raw-fetches every record because ns/key live in the envelope; Report 3 says one listing suffices. Not a contradiction — Report 1 itself flags the filename seam as present-but-unused — but note the limit: the envelope stays authoritative for content and for pre-1.5.0 key/namespace values (hashes are one-way), so raws don't disappear from content-bearing paths (§3.2).
- Unverified vendor claims the design depends on (each carried with a mitigation): Mem0 v3 metadata-operator limits are documented only on a v2-named page (integration-test pin); the cloud list envelope's
count semantics for a 1-request count() (verify once live); Cognee PATCH /api/v1/update on Cloud tenants (OpenAPI probe + delete+add fallback); Cognee label/externalMetadata fields on any given server (feature-detect).
Promised in #68 ("keyed CRUD for Mem0/Cognee (#18 U2) needs vendor-API research first; filing separately") — the research is done. Three-angle investigation: a code audit of the adapters' current enumeration cost, and vendor-API research for Mem0 (cloud + OSS server) and Cognee (cloud + OSS), synthesized into the design below. Headline: Cognee's 1+D+N-request keyed get shrinks to 3 requests using listing data the adapter already receives but ignores; Mem0 cloud has true server-side metadata filtering the adapter already writes the keys for. Every claim carries its file:line or URL; unverified vendor claims are flagged with mitigations.
U2 design — keyed CRUD / write-path indexing for the Mem0 and Cognee remote adapters (issue #18)
Scope:
adapters/remote/src/{common,mem0,cognee,supermemory}.rson branchfeat/a4-typed-errors-retry-health. All code claims below are from the code audit (Report 1, paths verified in the working tree at/Users/shreyanshsharma/Desktop/Openhuman/tinymemory); vendor claims carry their doc/source URLs (Reports 2–3). Conflicts between the reports are flagged inline and collected in §6.Notation: N = records the adapter owns, D = Cognee dataset count (one per namespace), n_ns = records in one namespace, T = Supermemory container-tag count, L = export page limit.
1. The problem, quantified
Root cause is structural, not per-adapter. The
Dialecttrait (common.rs:529-548) has exactly five ops —upsert,entries()("Enumerates every record owned by this adapter", common.rs:535),search,delete,health— i.e. no keyed read exists in the dialect vocabulary.RemoteMemory<D>therefore resolves every keyed read by full enumeration + client-side filter:get(common.rs:640-648),list(:651-667),count(:700-702),namespace_summaries(:675-697). Each dialect'supsertand (for Mem0)deleteadditionally do their own enumeration to discoverremote_id, which is never cached anywhere —RemoteMemoryholds only the dialect (common.rs:552-554), dialects hold only the HTTP client, andremote_idhas zero references outside the four adapter files.Per-op HTTP request cost today:
get(ns,key)list/count/namespace_summariesstore(new or re-store)forget(ns,key)recallConcrete: on a 10k-record store, one Mem0-cloud
getis 50 requests downloading the entire account body; one Cogneegetis ~10,002 serial requests. Reads retry up to 3× on transient failure (common.rs:307-342), so worst cases triple.Amplification in the Portability path (mandatory layer, not adapter-fixable alone):
export_pagecallsnamespace_summaries()andlist(ns,..)per page (api/src/mandatory/mod.rs:287, :305-308) — 2 full enumerations per exported page. Mem0 cloud, 10k records, L=100 ≈ 10,100 requests to export what 50 can download; Cognee ≈ >2,000,000. Import callsstore_with_taintper record → one existence-probe enumeration of the growing destination per record: ≈N²/400 requests into Mem0 cloud, O(N²/2) into Cognee.The cost is acknowledged in-tree (mem0.rs:234-236, :241-255; supermemory.rs:253-257 — the latter records that this exact regression was already fixed once, for Supermemory's writes only). No TODO/FIXME, no cache, no index exists.
2. What each vendor actually offers
2.1 Mem0
Cloud (api.mem0.ai) — server-side keyed lookup exists:
POST /v3/memories/("Get Memories") takes a requiredfiltersobject (must include ≥1 entity ID) withAND/OR/NOTand comparison operators; paginated{count, next, previous, results[]},page_size1–200 (default 100). https://docs.mem0.ai/api-reference/memory/get-memories{"metadata": {...}}clauses — but top-level metadata keys only, operatorseq/contains/neonly;in/gt/lton metadata raiseFilterValidationError; nested keys unsupported. https://docs.mem0.ai/platform/features/v2-memory-filters. Our metadata keystinymemory_namespace/tinymemory_key(mem0.rs:399-410) are top-level and need only equality — inside the documented envelope.GET/PUT/DELETE /v1/memories/{id}/(update rewritestextandmetadata). https://docs.mem0.ai/api-reference/memory/get-memory, .../update-memory, .../delete-memoryPUT|DELETE /v1/batch/(≤1000/request);DELETE /v1/memories/accepts ametadatafilter param (server-side delete-by-metadata, async);memory_idsis filterable in list filters (batch get by id). https://docs.mem0.ai/api-reference/memory/batch-update, .../batch-delete, .../delete-memoriesAND-inguser_id+agent_idreturns only records with both set (v2-memory-filters page). Safe for us: creates senduser_id = namespace(mem0.rs:441) and the existing cloud enumeration/search already filter onagent_id/AND[agent_id,user_id]successfully (mem0.rs:304, :500-506), so both fields are populated.OSS server (
mem0ai/mem0 server/main.py— https://github.com/mem0ai/mem0/blob/main/server/main.py):GET /memoriesfilters only byuser_id/run_id/agent_id(top_k≤ALL_MEMORIES_LIMIT = 1000). No metadata parameter on the list route. Butuser_idfiltering means namespace-scoped listing is available server-side today, since the adapter already writesuser_id = namespace.POST /searchpassesfiltersstraight through, and custom metadata keys are first-class, written bare (e.g.{"user_id": "alice", "AND": [{"priority": {"gte": 7}}]}) — note the grammar differs from cloud (bare keys vs{"metadata": {...}}wrapper). Reliability is vector-store dependent: Qdrant/pgvector full; Chroma basic; Pinecone partial; Weaviate filters only entity IDs; "if an operator is unsupported, most stores silently ignore it or fall back to equality." https://docs.mem0.ai/open-source/features/metadata-filteringGET/PUT/DELETE /memories/{id}); no batch routes.minLength: 1); OSS behavior for empty query undocumented — use a short dummy query and treat filters, not scores, as truth.Ambiguities preserved from Report 2: v3 endpoint docs give no metadata example (grammar comes from the v2-named filters page; whether v3 relaxes the operator limits is unstated); the cloud search endpoint's separate
metadatabody param vsmetadata-inside-filtersinteraction is unstated. The design below avoids both by using the list route with a single-key metadata clause.2.2 Cognee
No server-side keyed or filtered lookup exists, Cloud or OSS. What exists:
GET /api/v1/datasets/{id}/datatakes only the path param — no filter, no pagination — but each row already carriesname(uploaded filename stem, extension stripped) plusid, timestamps,extension,mimeType; on OSS ≥ v1.5.0 alsolabelandexternalMetadata. https://docs.cognee.ai/api-reference/datasets/get-dataset-data; router source https://raw.githubusercontent.com/topoteretes/cognee/main/cognee/api/v1/datasets/routers/get_datasets_router.py; changelog https://docs.cognee.ai/changelogstable_id("key", key) + ".tinymemory.json", cognee.rs:196-198) against the listingname.GET /datasets/{id}/data/{data_id}/raw(read),DELETE /datasets/{id}/data/{data_id}(delete). https://docs.cognee.ai/api-reference/datasets/get-raw-data, .../delete-dataPATCH /api/v1/update?data_id=&dataset_id=— delete-then-re-ingest butdata_idis pinned across the replace (landed 2026-08-11/13; https://raw.githubusercontent.com/topoteretes/cognee/main/cognee/api/v1/update/update.py). Not in the hosted API-reference nav — verify per Cloud tenant via its OpenAPI/docsbefore relying on it.addis unsafe as upsert: dedup is by content hash scoped to (dataset, owner, tenant); same filename with different content mints a new row under the same name (https://raw.githubusercontent.com/topoteretes/cognee/main/cognee/tasks/ingestion/ingest_data.py, .../modules/ingestion/identify.py).POST /api/v1/datasetsis get-or-create by name (https://docs.cognee.ai/api-reference/datasets/create-new-dataset), and the dataset name is computable client-side ("tinymemory__" + stable_id("dataset", ns), cognee.rs:192-194) — zero-enumeration resolution.POST /add//rememberaccept per-filelabels+external_metadata, echoed back by the listing (add router + changelog) — a key/namespace channel independent of filename normalization. Cloud docs lag these fields; feature-detect per tenant._derive_basename, get_file_metadata.py) — so the listing name for our upload isstable_id(...) + ".tinymemory"(only.jsonstripped). Report 1 confirms the adapter's listing decode already handles a “.json-stripping quirk” (cognee.rs:235-241).3. Design options per adapter
3.0 Shared precondition (both options depend on it)
Add keyed reads to the
Dialecttrait, with default impls that fall back to today'sentries()-filtering so no dialect breaks:Rewire
RemoteMemory::get→dialect.entry(),RemoteMemory::list(Some(ns),..)→dialect.namespace_entries()(common.rs:640-667). This alone upgrades Supermemory for free: itsfind_entry(namespace,key)(supermemory.rs:313-319) is exactly the keyed-get seamRemoteMemory::getcurrently cannot reach, andmemories_in_tag(supermemory.rs:258-309) isnamespace_entries.Universal correctness rule for every option below — verify-after-resolve: whatever record a filtered/name-matched/cached path returns, decode it and check
tinymemory_namespace/tinymemory_keyequality client-side before surfacing it. This makes silent server-side filter degradation (Mem0 OSS stores) and name collisions (Cognee duplicates) fail safe into the enumeration fallback instead of returning the wrong record.3.1 Mem0
(a) Server-side filter route
POST /v3/memories/withfilters: {"AND": [{"agent_id": "tinymemory"}, {"user_id": <ns>}, {"metadata": {"tinymemory_key": <key>}}]},page_sizesmall. Single metadata key + equality keeps us inside the documented limits (top-level,eqonly) and sidesteps the undocumented multi-key-metadata semantics;user_idcarries the namespace as an indexed entity filter. Result yields the mem0id; thenPUT|DELETE /v1/memories/{id}/for upsert-write/forget.POST /v3/memories/withAND[agent_id, user_id=ns]→ ⌈n_ns/200⌉ requests. Cloud count = 1 request reading the envelope'scountwithpage_size=1(envelope shape per https://docs.mem0.ai/api-reference/memory/get-memories; thatcount= total matches is inferred from the standard paginated envelope — confirm once against the live API).POST /searchwith dummy query + bare-key filters{"AND":[{"user_id": <ns>}, {"tinymemory_key": <key>}]}, scores ignored, verify-after-resolve mandatory (silent-degradation risk).GET /memories?user_id=<ns>&top_k=1000+ client-side key match. This is strictly better than today: the enumeration and the hard 1000-record bail become per-namespace instead of per-store (the bail must stay — a full page is still indistinguishable from truncation — but it now triggers at 1000 records in one namespace).Then by-id
PUT/DELETE /memories/{id}.namespace_summaries(andlist(None)) — Mem0 has no group-by; full enumeration ⌈N/200⌉ remains (optionally slimmed via thefieldsparam on cloud).{"metadata":{...}}wrapper vs OSS bare keys) must be encoded per-flavour — the adapter already branches onflavour.get ≤ 2regardless of N); wrong-record guard (mock returns a non-matching record under a filter → adapter must not surface it, must fall back); per-namespace-bail test (1000 in ns A must not break ops on ns B); external-delete staleness (getafter out-of-band delete returnsNone, not a ghost); flavour-grammar tests for both filter shapes.(b) Client-side write-path index
A
(namespace,key) → remote_idmap maintained onstore/forget/import, consulted before any enumeration.getof an unknown/absent key still costs a full enumeration, and negative caching is unsafe under concurrent writers. So (b) alone fixes repeated-op and import cost but not first-touchget.getunchanged (O(N)).(c) Hybrid
(a) as the authoritative resolver + a per-process
(ns,key) → idmemo (no persistence) to skip re-resolution on repeated ops, with 404/mismatch → invalidate → re-resolve via (a). All of (a)'s costs become the worst case instead of the every-time case; none of (b)'s persistence problems.3.2 Cognee
(a′) "Narrow resolution" (there is no server-side filter route — this is the closest achievable analogue)
POST /api/v1/datasets{name: computed}(get-or-create; name computable offline, cognee.rs:192-194). Replaces theGET /datasetsenumeration.GET /datasets/{id}/data, match client-side against the computed post-normalization name (stable_id("key", key) + ".tinymemory"). Eliminates the per-record raw-fetch loop entirely. On ≥ v1.5.0, additionally stamplabels/external_metadata={tinymemory_namespace, tinymemory_key}at add-time and prefer matching those listing fields — immune to filename normalization; feature-detect via the listing DTO (presence oflabel) or the tenant's OpenAPI.GET .../raw(envelope remains authoritative — verify ns/key inside it); forget =DELETE .../data/{data_id}; upsert-update =PATCH /api/v1/update(data_id pinned) with delete-by-id + re-add fallback where PATCH is absent (Cloud unverified) — accepting that the fallback changesdata_id. Never blind re-add(duplicate-row hazard, §2.2).stable_idis one-way, so key and namespace values are not recoverable from names. Pre-1.5.0:list(ns)needs 1 raw per returned record (content + key live only in the envelope) andnamespace_summariesneeds ≥1 raw per dataset to learn the namespace name (all envelopes in a dataset share it → 1 + D + D total, already a huge cut from 1+D+N). On ≥1.5.0 with stamped metadata:namespace_summariesand key-only listings become raw-free (1 + D requests);list/export with content still pay 1 raw per record — the envelope is the content store; that is not solvable.createdAt-wins + warning (or hard error), pinned by conformance; (ii) filename normalization (percent-decode, final-extension strip) — non-issue forstable_idoutput today but must be pinned by test so a futurestable_id/extension change can't silently break matching; (iii) PATCH absence on a Cloud tenant → fallback changes ids (only matters if a data_id cache exists); (iv) docs/version skew —label/externalMetadatamust be probed, never assumed.(b) Client-side write-path index
(ns,key) → "{dataset_id}:{data_id}"(the compositeremote_idformat already exists, cognee.rs:252). The namespace → dataset_id half is uniquely safe to cache forever (deterministic name + idempotent get-or-create). The data_id half is cache-friendly because PATCH pins ids, but external delete+re-add mints a new id → same 404-invalidate rule. Marginal value: it only saves the one listing call that (a′) already reduced things to; cold miss = one listing (cheap). Persistence is not worth introducing.(c) Hybrid
(a′) + a per-process
namespace → dataset_idmemo (permanent) + optionally a(ns,key) → data_idmemo (404-invalidate). Get drops to 2 requests warm (listing skipped only with the data_id memo: 1 raw + occasional re-resolve).Conformance needs (Cognee): request-count ceilings (
get ≤ 3regardless of N and D; forget ≤ 3); a no-raw-fan-out assertion (keyed ops perform ≤1 raw fetch); name-normalization pinning (keys containing dots,%xxsequences, unicode roundtrip through the naming scheme); duplicate-name determinism test; PATCH-absent fallback path test; both sides of the ≥1.5.0 label gate (stamped and unstamped stores must both resolve correctly, and a pre-1.5.0-written store read by a ≥1.5.0 adapter must still match by name).4. Recommendation
Ship the shared Dialect seam first (§3.0) — it is a precondition for everything, is behavior-preserving via default impls, and immediately fixes Supermemory's
get/list(whole-account → per-tag) using code that already exists.Mem0: hybrid (c), with (a) as the substance. Cloud gets true server-side keyed lookup (1-request get, 2-request writes) using the metadata filter the adapter already writes for exactly this purpose (mem0.rs:399-410 — the seam Report 1 identified as "one body change away"). OSS gets the
user_id-scoped list as the portable floor (works on every vector store, since entity-ID filtering is the one universally supported dimension — https://docs.mem0.ai/open-source/features/metadata-filtering) with the/searchmetadata fast path where the store supports it, guarded by verify-after-resolve. Skip the persistent index (b) — its persistence surface buys nothing the memo doesn't, and it can't fix cold gets anyway. Honestly not solvable on Mem0:namespace_summaries/list(None)remain full enumerations (no server-side group-by on either flavour); the OSS 1000-row truncation ambiguity remains — it just becomes a per-namespace ceiling instead of a whole-store death sentence; Weaviate-backed OSS never gets sub-namespace server-side filtering.Cognee: (c) = narrow resolution (a′) + dataset_id memo + ≥1.5.0 metadata stamping. There is no server-side keyed lookup to adopt — Report 3 is unambiguous — so the win comes from exploiting the two deterministic names the adapter already computes (cognee.rs:192-198) against listing data it already receives but ignores. That converts 1+D+N into 2–3 requests per keyed op and O(N²) imports into O(N). Honestly not solvable on Cognee: one unpaginated per-namespace listing per keyed op is the floor (no filter, no pagination on the data route); content retrieval is 1 raw per record forever (the envelope is the content store), so content-bearing
list/export remain O(n_ns) raws; pre-1.5.0 servers additionally pay raws for key/namespace recovery as scoped in §3.2.Follow-up, separate issue (mandatory layer, not U2):
export_page's per-pagenamespace_summaries()+list()double-call (mod.rs:287, :305-308) should compute summaries once per export, not per page. The adapter fixes above shrink each call it makes; the call-count shape is upstream of the adapters.5. Sizing and shipping order
entry/namespace_entries+ defaults +RemoteMemoryrewiring + Supermemory plug-in offind_entry/memories_in_taguser_id-scoped list +/searchfast path + per-flavour filter grammar + per-namespace baillabels/external_metadatastamping + feature detection + label-preferred matchingItems 2, 3, 4 are mutually independent; a partial landing (e.g. 1+4 only) still removes the worst offender (Cognee's 1+D+N).
6. Report conflicts & discrepancies (flagged, not papered over)
metadata: {"namespace":…, "key":…}, but the adapter actually storestinymemory_namespace/tinymemory_key(Report 1, mem0.rs:399-410). Design uses the real names; both are top-level, so the cloud limits still hold.run_id/agent_id"keepinguser_idfor the tenant"; Report 1 shows the shipped scheme is alreadyuser_id = namespace,agent_id = "tinymemory"(mem0.rs:441, :304). Resolved in favor of the existing scheme — it already delivers entity-scoped namespace filtering on both flavours and every OSS store, and switching would require re-tagging all existing data for no capability gain.mykey.tinymemory→ namemykey) uses a different filename than the adapter uploads (stable_id + ".tinymemory.json", Report 1 cognee.rs:196-198); with only the final extension stripped, our listing name isstable_id + ".tinymemory". Report 1 independently confirms the adapter's decode already handles a ".json-stripping quirk" (cognee.rs:235-241). Consistent once reconciled, but the exact post-normalization name must be pinned by a conformance test, not assumed.countsemantics for a 1-requestcount()(verify once live); CogneePATCH /api/v1/updateon Cloud tenants (OpenAPI probe + delete+add fallback); Cogneelabel/externalMetadatafields on any given server (feature-detect).