Cover the hosted adapters' failure paths (#18 §E6) - #26
Conversation
`rust_out` and `tmp` are 3.6 MB Linux x86-64 ELF executables with debug info, committed into a repository developed on macOS. Nothing references either name in source, config, CI, or docs; both are the output of an ad-hoc build that was staged by accident, and every clone has carried 7.3 MB of them since. Ignored with anchored paths so a legitimate `tmp` directory inside a crate is unaffected.
The workspace declared three different answers: 1.96 at the root and for the module crate, 1.85 for `core` and both adapters, and nothing at all for `api`. The low ones were not true — the root facade requires 1.96 and depends on all of them, so nothing in this workspace builds on 1.85, and no CI job checked the claim. Aligning on 1.96 immediately surfaced three lints that the understated MSRV had been suppressing: clippy only suggests `is_multiple_of` when the declared minimum supports it. Those are fixed here rather than allowed, since they are a consequence of this change and not separable from it. `api/` gaining an explicit MSRV is worth noting for downstream: it is the dependency-light crate an embedding host binds directly, so it now states a floor rather than leaving consumers to infer one.
The layout section omitted `tinymemory-core` entirely — the largest crate in the repository, and the one a real host actually depends on. It also omitted `crates/tinymemory-module`. Adds both, and states plainly that `core` is not dependency-light the way `api` is, since that asymmetry is the thing a reader most needs to know before choosing which to depend on. Also documents `git submodule update --init --recursive`. Nothing builds without it, and because `core` names its engines by path through `vendor/`, an uninitialized checkout fails at manifest resolution with an error that does not mention submodules.
`audit_provider` checks that a driver's advertised capabilities match its reachable accessors — a structural check, proving the shape is honest, not the behaviour. Nothing checked that two drivers answer the same question the same way, which is the claim "swap the engine without the host learning anything new" actually rests on. `tinymemory-conformance` is that check. `assert_provider` takes any bound driver and drives the contract: the mandatory three families, upsert on `(namespace, key)`, namespace isolation, provenance preservation, recall limits and scoping, export pagination, import round-tripping, and unicode / empty / 64 KiB / control-character content. It depends on `tinymemory-api` and nothing else of substance. A suite that pulled in an engine could not prove interchangeability, having already chosen one; reaching `tinymemory-core` would drag in a bundled SQLite besides. Ships an in-memory reference driver, for two reasons. A suite that only ever ran against real engines cannot distinguish "the engine is wrong" from "the assertion is wrong", and a driver whose behaviour is obvious by inspection separates those. It also documents the contract by example — the upsert, the verbatim taint, the cursor that terminates on `None` rather than on an empty page. Writing it immediately found a distinction worth naming. The contract permits a driver that accepts writes and discards them; `NullMemoryProvider` is exactly that, and the storage assertions are vacuous for it. Rather than weakening each assertion to tolerate an empty read — which would let a driver that *intends* to retain and silently does not slip through — the suite probes retention once and reports which half it ran. The contract-shape assertions are never skipped. Provenance is the sharp assertion. A driver reading back `Internal` for content stored as `ExternalSync` has laundered external content into internal-trust content, and every downstream gate keyed on taint is then silently wrong. Refs tinyhumansai#18 (§E1)
Issue tinyhumansai#18 §E3. `AGENTS.md` mandates a `tests/` directory exercising only the public API; the repository had none at the root, and `core/tests/` holds fixtures with no test files. Four targets, matching the four §E3 names: `driver_selection.rs` pins admission: reserved ids resolve to fixed classes, an external driver with no entry is refused fail-closed, an untrusted external driver is refused even with one, a reserved id's class cannot be overridden by config, and a class typo is echoed back so the operator can find the line. `capability_negotiation.rs` covers both directions of the bind-time negotiation, including a deliberately lying provider that advertises a summary tree it has no accessor for — the failure `audit_provider` exists to catch, and which was previously asserted only in unit tests inside the contract crate. `taint_end_to_end.rs` drives provenance through store, get, list, recall, and the export/import round trip, at every driver this workspace ships. It also pins the fail-closed reading of an unknown persisted value, which is the one direction that cannot be undone. `null_provider.rs` asserts the compiled-out configuration is genuinely usable: every mandatory method answers rather than panicking, it reports Ready rather than a fault, and no optional family is either advertised or reachable. Two of the four are deliberately narrower than §E3 describes, and both say so in their module docs. `driver_selection.rs` cannot yet assert that a bound provider's `driver_id()` matches configuration, because nothing selects an engine from config — that is §A5. `taint_end_to_end.rs` cannot drive the sync path, because sync is welded to the engine until §B. Both are written against current behaviour, per the sequencing note in the issue, and each names where its missing leg joins. Refs tinyhumansai#18 (§E3)
Issue tinyhumansai#18 §C3. The adapter advertised Core, Recall and Portability and nothing else, which is the reason anything wanting a summary tree, entities, or a diff ledger reached past the contract to the engine directly — the families existed, but not through `MemoryProvider`. `crates/tinymemory-module` had grown all of them, because it needed them and nowhere else had them. They were never module-specific. Every one delegates to `tinymemory-core` on a blocking thread, and the two types they hold — `MemoryClient` and the host config — are core and contract types respectively. So the whole `ModuleMemoryProvider` moves down to `tinymemory-tinycortex` as `engine::TinycortexProvider`, and the module crate keeps only the thing that genuinely is its own: turning a `ModuleConfig` into the engine's runtime configuration. Its `provider.rs` goes from 2189 lines to 38. Nineteen family implementations move: documents, ingest, graph, goals, tool-memory, tree, entities, diff, sources, maintenance, people, chunks, retrieval, profile, and episodic, alongside the mandatory three. The diff family gets a `memory-git` feature rather than riding along unconditionally. It is what drags `git2` / `libgit2-sys` / `libz-sys` — a native build — into the graph, and this adapter had no such dependency before today; making it unconditional would hand every consumer a libgit2 build they never asked for. `cargo tree --no-default-features` confirms none is linked. The gate reaches `capabilities()`, not just the accessor. A build without `memory-git` neither advertises nor reaches `Diff`, so `audit_provider` still passes — which is the whole reason that audit exists. That rule is extracted as `engine::advertised_capabilities` so it can be tested directly: constructing a provider needs a `MemoryClient`, which needs the host's process-global seams installed, and a test that installs a process global is order-dependent. The new module is `engine`, not `provider`: the crate already has a `provider` function returning the mandatory-only driver, and both are worth keeping — a host with no workspace, config, or client still has the lighter one. Refs tinyhumansai#18 (§C3)
Issue tinyhumansai#18 §A5. The driver registry could answer "is this driver id real, and is it allowed to answer for memory", and nothing asked it: `DriverRegistry::admit` had no caller outside its own tests, `MemoryHostConfig::memory_provider()` had no reader, and the memory client factory constructed TinyCortex unconditionally. Configuration could not choose an engine. `DriverRegistry::select` closes that: it reads the engine from the host's configuration and puts it through `admit`, so both surfaces are now live. Two corrections to the issue, both load-bearing. §A5 names `memory_provider()` as the selector. That method is a `provider:model` routing string for the memory *workload* — which language model does summarisation and entity extraction — not the store the memory lives in. Reading it would have let a model change repoint a company's storage. Selection reads a new `memory_driver()` instead, defaulted to `None` so it breaks no existing implementation, and a test pins that the two fields stay independent. §A5 also asks that `create_memory_*` return a bound `Arc<dyn MemoryProvider>`. It cannot, and the reason is structural rather than unfinished: since §C3 `adapters/tinycortex` depends on `tinymemory-core`, so a core factory returning a constructed adapter provider is a dependency cycle. Selection therefore resolves the decision and the host constructs — which is what `src/registry`'s module docs have said all along: "It resolves the class, not the instance." A configuration naming no engine gets the reserved embedded default, so adding selection does not turn "I configured nothing" into a host that fails to start. Going through `select` does not loosen admission either: an external engine named in config is still refused without endpoint, credential and trust. Refs tinyhumansai#18 (§A5)
The `memory-git` feature added alongside the lifted diff family gates that family, and the test asserting it is *withheld* without the feature is `#[cfg(not(feature = "memory-git"))]`. Both existing jobs — `--all-features` and default — compile that test out, so it was checked by nothing: a feature-gated test whose default fate is to be built by one job and executed by none. Adds the configuration as its own step, and asserts the property the feature exists for: `cargo tree --no-default-features` must link no `git2` or `libgit2-sys`. A guard rather than a comment, because "this feature keeps the native build out of the graph" is a claim that silently stops being true the first time a dependency picks it up transitively. Verified: `diff_is_withheld_when_the_snapshot_store_is_compiled_out` appears in `--no-default-features -- --list` and is absent from the `--all-features` listing, which is what "running nowhere" looked like. Refs tinyhumansai#18 (§C3, §E2)
Issue tinyhumansai#18 §D4, with §D3 alongside it. `api/Cargo.toml` spells out a `cargo tree` command in a comment and asks that the contract crate never link a storage engine, a native library, an HTTP client, or an async runtime. It was left as a comment, so nothing ran it. A forbidden dependency does not arrive by someone typing it into the manifest; it arrives transitively, through a feature enabled two crates away, which is exactly the way nobody notices. The forward form is the one that works, and the manifest already explains why: `cargo tree -i <crate> -p tinymemory-api` discards the `-p` scope, prints the whole-workspace inverse tree, and exits 0 looking clean even when this crate is the one at fault. This runs what the comment says to run. Verified in both directions. The rule holds today — no match against `rusqlite|libsqlite|git2|reqwest|regex|tokio`. And injecting `regex = "1"` into the manifest makes the guard fire on `regex`, `regex-automata` and `regex-syntax`, then reverting makes it pass again. A guard nobody has watched fail is not yet a guard. §D3 asks that `--no-default-features` still compile and bind `NullMemoryProvider`. It does, so this pins it rather than changing anything: the minimal configuration builds, and tinyhumansai#21's `null_provider` integration test runs against it — so "usable" is asserted, not just "compiles". That test is the one that would catch a null driver which panics instead of answering, or reports a fault instead of `Ready`. Refs tinyhumansai#18 (§D3, §D4)
Issue tinyhumansai#18 §E6: "backend error, timeout, and partial-page responses on every remote adapter — currently zero coverage". The three existing per-adapter test files all drive a backend that answers correctly, which is the half that was never in doubt. Six tests, each running against all three adapters over a real TCP socket, using the axum-double harness the happy-path tests already use: - a 500 on write is reported rather than swallowed; - a 500 on read is not laundered into `Ok(None)`; - a 401 is not presented as an empty store, on either `list` or `recall`; - a `200 OK` carrying unparseable JSON is an error and not a panic; - an unreachable backend is reported rather than hanging; - a paginated export terminates instead of looping. The read case is the one that matters. `Ok(None)` after a 500 says "this memory does not exist" when the truth is "I could not ask", and a caller cannot tell those apart: it writes the memory again, or tells a user their memory is gone, or a sync job treats the empty read as authoritative and prunes. Nothing surfaces until much later. All three adapters already behave correctly — this pins behaviour rather than fixing it. That is worth stating plainly, because six tests passing on the first run is exactly what a test that never exercises its subject also looks like. So the read assertion additionally requires the backend's status to survive into the error message; without that it would pass just as happily if the adapter had failed on URL construction and never reached the network. Confirmed against the live errors: `memory API v3/container-tags/list returned HTTP 500`, `memory API memories?top_k=1000 returned HTTP 500`, and `memory API api/v1/datasets returned HTTP 500`. The assertions are deliberately about whether a failure comes back at all, not about which error it is. The adapters' HTTP layer `bail!`s into `anyhow`, so every one of these arrives as `MemoryError::Other` and "unsupported" is not yet distinguishable from "failed" — that is §A4, and it is not what this change is. The unreachable-backend test binds a port, reads its number, and drops the listener, so the address is reliably closed rather than merely unlikely to be in use. The export test is bounded by a timeout so a non-terminating implementation fails the test instead of hanging the suite. Refs tinyhumansai#18 (§E6)
|
Important Review available on request
Reviews should be triggered manually for repositories with fewer than 10 stars. Select Trigger review above or comment ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
How this change flows0 changed behaviours across 4 relationships. 6 surrounding behaviours are shown (60 graph nodes walked). 33 further behaviours left out to keep the diagram readable. flowchart LR
n0["MemoryProvider"]:::impacted
n1["pack_unpack_round_trip"]:::impacted
n2["MemoryError"]:::impacted
n3["unpack_embedding"]:::impacted
n4["other"]:::impacted
n5["assert_provider"]:::impacted
n1 -->|calls| n3
n1 -->|tests| n3
n4 -->|uses| n2
n5 -->|uses| n0
classDef changed fill:#0d4429,stroke:#238636,color:#e6edf3
classDef impacted fill:#161b22,stroke:#6e7681,color:#c9d1d9
classDef flagged fill:#5a1e02,stroke:#d93f0b,color:#ffffff
classDef blocking fill:#67060c,stroke:#f85149,color:#ffffff
Green: changed behaviour. Grey: surrounding behaviour. Arrows name the call, use, implementation, or test relationship. Orange: has findings. Red: has a finding that blocks the merge. |
M3gA-Mind
left a comment
There was a problem hiding this comment.
Reviewed this PR's own commit (21e0a5e). Clean — good tests.
Six failure paths, each iterating all three adapters (Supermemory, Mem0, Cognee) through a shared adapters() helper against an axum double. Hooked in as #[cfg(test)] mod failure_test;, so no feature gate and it runs under any cargo test — I checked, there is nothing here that compiles into a lane and executes in none.
The assertions target the right thing. I looked specifically for whether these would fail if the implementation were wrong, and they would:
a_backend_failure_on_read_is_not_reported_as_absencean_unauthorized_backend_is_not_reported_as_an_empty_store
Confusing an error for an empty store is precisely how a remote adapter silently loses data — a host that reads "no memories" instead of "backend rejected your credential" will happily proceed and overwrite. Naming the tests after the confusion rather than after the method is the right instinct.
Bounding the export test with a timeout so a non-terminating implementation fails rather than hangs the suite is also the correct choice; a hang in CI reads as flake and gets re-run.
This takes adapters/remote from 3 tests to 9, which is the crate §E8 named as the first coverage target — worth mentioning in that PR when the coverage number lands.
Summary
Issue #18 §E6 — the bullet reading "backend error, timeout, and partial-page responses on every remote adapter — currently zero coverage."
The three existing per-adapter test files all drive a backend that answers correctly. That is the half that was never in doubt.
Six tests, each running against all three adapters over a real TCP socket, reusing the axum-double harness the happy-path tests already use:
a_backend_failure_on_write_is_reported_rather_than_swalloweda_backend_failure_on_read_is_not_reported_as_absenceOk(None)an_unauthorized_backend_is_not_reported_as_an_empty_storelistorrecalla_malformed_backend_response_is_an_error_and_not_a_panic200 OK+ unparseable JSON errors rather than panickingan_unreachable_backend_is_reported_rather_than_hanginga_paginated_export_terminates_instead_of_loopingexport_pageterminatesThe read case is the one that matters
Ok(None)after a 500 says "this memory does not exist" when the truth is "I could not ask." A caller cannot tell those apart. So it writes the memory again, or tells a user their memory is gone, or — worst — a sync job treats the empty read as authoritative and prunes. Nothing surfaces until much later.All three adapters already behave correctly
This pins behaviour rather than fixing it, and that is worth saying plainly: six tests passing on the first run is also exactly what a test that never exercises its subject looks like.
So the read assertion additionally requires the backend's status to survive into the error. Without that it would pass just as happily if an adapter had failed on URL construction and never reached the network. Confirmed against the live errors:
Real vendor paths, real status — the tests reach the HTTP layer and discriminate.
What these deliberately do not assert
Which error comes back. The adapters' HTTP layer
bail!s intoanyhow, so all of these arrive asMemoryError::Other, and "unsupported" is not yet distinguishable from "failed". That is §A4, and conflating it with this change would hide the fact that the distinction does not exist yet.Two details worth a look in review
tokio::time::timeout, so a non-terminating implementation fails the test instead of hanging the whole suite. An error fromexport_pageis an acceptable answer there; a hang is not.Public API / behavior changes
None. Tests only.
Validation
cargo fmt --all -- --checkcargo clippy --all-targets --all-features -- -D warningscargo clippy -p tinymemory-tinycortex --all-targets --no-default-features -- -D warningscargo build --all-targets --all-featurescargo test --all-featuresRUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-featuresWhy this matters beyond #18
OPENCOMPANY_MEMORY=remoteis gated behindOPENCOMPANY_MEMORY_ALLOW_UNPROVEN_REMOTEin tinyhumansai/opencompany#936, and the reason given there is verbatim "no error mapping, no pagination, no taint preservation." This closes the first two of those three for all three adapters.It does not yet close acceptance criterion 5 (the conformance suite passes for TinyCortex and all three remote adapters) — #20's suite still runs only against the reference and null drivers. Running
assert_provideragainst these adapters over the same doubles is the natural next step, and needs each double filled out to serve the mandatory families end to end.Related
Part of #18 (§E6).