fix(sources): route connector items into the memory tree - #134
Conversation
`MemorySourceSink::accept_source_items` wrote namespace documents and vector
chunks but never `mem_tree_chunks`, so a Composio sync landed thousands of
documents and embeddings while every tree-backed surface — tree recall, the
Memory Tree graph, the source row's ingest status — still reported zero. None
of those read `memory_docs`.
This is the #5473 reconnect restored on the connector path. The migration to
`accept_source_items` bypassed the `SkillDocSink` path that carried it rather
than extending it, so the scope rules are now one shared function both paths
call instead of a method on the old path's adapter:
- `ingest_connector_item_into_tree` owns the `{toolkit}:{connection_id}` tree
scope, the `{...}:{item_id}` per-item key and the blank-half guard. That key
pair is what OpenHuman's `source_id_prefix` counts a Composio source by, so a
drift empties the source status and the graph together, silently.
- `ingest_connector_item_tolerated` owns the failure policy, so
`corruption::escalate_or_count` stays `pub(crate)` and no call site can
implement a weaker one. A tree failure is counted and warned, never fatal:
propagating lets one poisonous item stall a connection and re-bill the
Composio page on every retry. Corruption still aborts the run.
Gated on `source_kind == "composio"` *and* a non-empty `{toolkit}:{connection}`
split. Both halves: OpenHuman derives a toolkit-shaped ingest prefix only for
Composio and keys every other kind `mem_src:{id}:`, so treeing a non-Composio
source would write rows nothing can count — the same invisible-write bug in a
different costume.
`forget_source` also had to learn the per-item key. It deleted chunks by exact
source id, which never matches `{source_id}:{item_id}`, so once items are treed
a user who disconnected Gmail would keep every synced message retrievable. The
prefix sweep is unconditional: it matches nothing for a source that never had
per-item rows, and a shape gate here could drift out of step with the write
gate, which is the direction that leaks.
Tests: the non-connector source asserts *zero* tree rows (that is the gate); a
connector source asserts the exact per-item id and `path_scope`, then asserts
`forget_source` takes those rows with it. The pre-existing #5473 test is
unchanged and still green, which is what makes the refactor behavior-neutral.
Refs openhuman#6007
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: Warning Your free Security trial is over. An organization admin can upgrade to Advanced for continuous pull request security review or dismiss this notice. Comment |
There was a problem hiding this comment.
Requesting changes: 1 lane(s) blocking, worst finding is high.
Fix or reply to the findings below and push. The next review clears this automatically once they are gone — you should not need to dismiss anything by hand.
$0.0195 · 173,062 in / 3,962 out · 18,996 cached (11%) · openrouter/openai/text-embedding-3-small, deepseek/deepseek-v4-flash, z-ai/glm-5.2 · 703 embedded
critique: $0.0096 · 73,860 in / 2,579 out · 11,044 cached (15%) · deepseek/deepseek-v4-flash, z-ai/glm-5.2
security: $0.0069 · 66,174 in / 522 out · 7,952 cached (12%) · deepseek/deepseek-v4-flash, z-ai/glm-5.2
tests: $0.0018 · 20,398 in / 376 out · 0 cached (0%) · deepseek/deepseek-v4-flash
description: $0.0012 · 12,630 in / 485 out · 0 cached (0%) · deepseek/deepseek-v4-flash
| // because the tree ingest below has to receive the same body the | ||
| // document store does. Only a tree-scoped source pays the clones — | ||
| // `tree_scope` is `None` for every other kind. | ||
| let tree_item = |
There was a problem hiding this comment.
Move tree_item capture after the move of item fields into input
tree_scope.map(|_| …) captures title and item.content by value at this point, even when tree_scope is None and the closure is never called — because clone() is called eagerly on item.content (not inside the closure body) to produce the tuple, and title on line 2210 was already bound from item.title into a local (also a move). The effect is that item.content and item.title are consumed (moved) before the NamespaceDocumentInput construction on lines 2219–2233 that also references item.title (title is swallowed by the earlier binding) and item.content. This will fail to compile because the fields are used after partial move. The fix is to compute the clone inside the closure, so that fields are only borrowed (not moved) when tree_scope is None.
[RULE] use-after-move ·
There was a problem hiding this comment.
Not a use-after-move — this compiles, and the premise of the finding is inverted.
The finding states the clones are "called eagerly on item.content (not inside the closure body) to produce the tuple". They are inside the closure body. The whole tuple expression is the closure body:
tree_scope.map(|_| (item.item_id.clone(), title.clone(), item.content.clone()))|_| (…) has the tuple as its body, so nothing in it evaluates unless map calls it. Because the body only calls .clone(), the closure captures item and title by shared reference, not by value; Option::map takes the closure by value and either invokes it or drops it, so the borrow ends when map returns. item.item_id, item.content and title are then free to move into NamespaceDocumentInput on the next statement. That is also exactly the property the comment above the line claims ("only a tree-scoped source pays the clones") — for None, map never runs the body and no clone happens.
Verified on this SHA rather than argued: cargo check --all-targets is clean, and cargo test -p tinymemory-tinycortex --test full_provider_conformance passes 33/33 — including the two new cases that exercise both the Some and None sides of this exact map.
The suggested fix ("compute the clone inside the closure") is what the code already does, so there is no change to make here.
How this change flows4 changed behaviours across 24 relationships. 6 surrounding behaviours are shown (60 graph nodes walked). 38 further behaviours left out to keep the diagram readable. flowchart LR
n0["HostSyncAdapter<br/>changed"]:::changed
n1["...pe_item_is_skipped_for_memory_tree_ingest<br/>changed"]:::changed
n2["..._is_tolerated_and_skill_store_is_retained<br/>changed"]:::changed
n3["...nd_tool_rule_state_transitions_round_trip<br/>changed"]:::changed
n4["from_workspace_dir"]:::impacted
n5["join"]:::impacted
n6["with_config"]:::impacted
n7["memory_fixture"]:::impacted
n8["...nal_reader_lists_and_reads_a_local_folder"]:::impacted
n9["assert"]:::impacted
n1 -->|uses| n0
n1 -->|calls| n4
n1 -->|tests| n4
n1 -->|calls| n5
n1 -->|tests| n5
n1 -->|calls| n6
n1 -->|tests| n6
n2 -->|uses| n0
n2 -->|calls| n4
n2 -->|tests| n4
n2 -->|calls| n5
n2 -->|tests| n5
n2 -->|calls| n6
n2 -->|tests| n6
n2 -->|calls| n9
n3 -->|calls| n9
n7 -->|calls| n4
n7 -->|calls| n5
n8 -->|uses| n0
n8 -->|calls| n4
n8 -->|tests| n4
n8 -->|calls| n5
n8 -->|tests| n5
n8 -->|calls| n6
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. |
…Apply-all
The per-row Sync button special-cased Composio; the Apply-all sweep did not.
It sent every enabled row through `MemorySourceSync::run_source_sync`, which
the driver refuses for this kind ("... is synced through the connector module,
not this engine"), so "sync everything" failed for exactly the rows a user
reaches for it to fix.
Two call sites open-coding one rule is how they drifted, so the decision is now
one pure function — `sync_dispatch` over the registry entry, returning
`SyncDispatch::{Connector, Driver}` — and both sites match on it. The sweep's
trigger closure takes the whole entry rather than the id, because routing needs
the kind, the connection and the per-source cap.
The sweep also hard-errored as a whole when `as_source_sync()` answered `None`,
which for a Composio-only profile turned Apply-all into one flat refusal over a
capability none of its rows use — the same finding the row-level path already
carries (#5932). The family is now resolved as an `Option` and refused per row,
which is what this sweep already aggregates (#5820).
The reason stays `manual`: `parse_sync_reason` accepts only `manual`,
`periodic` and `connection_created`, so a sweep-specific reason would fail
every Composio row with "unrecognized sync reason".
This is the secondary half of #6007. The primary half — connector items never
reaching `mem_tree_chunks` — is fixed in tinymemory (tinyhumansai/tinymemory#134)
and reaches users through a module release and registry re-pin, not this change.
Refs #6007
v1.13.8 carries tinyhumansai/tinymemory#134, which routes connector items into the memory-tree ingest funnel. Before it, a Composio sync wrote namespace documents and vector chunks but never `mem_tree_chunks`, so Gmail synced thousands of records while tree recall, the Memory Tree graph and the source row's ingest status all reported zero. This repo only dev-depends on `tinymemory-tinycortex` — the production engine ships inside the prebuilt TinyBus module — so that fix reaches nobody until the pin moves. Four pins move together, because any subset is a red intermediate state: - `vendor/tinymemory` -> the v1.13.8 commit (ded26ae -> c253e70) - the registry descriptor: version, release_url, and all 11 platform assets with the checksums published on the release - `ARTIFACT_CAPABILITIES_PIN`, which `the_capability_list_matches_the_pinned_release` compares against the registry - `memory_version` / `memory_sha256` in ci-full.yml, ci-lite.yml and e2e-reusable.yml, which pin the ubuntu-22.04-x86_64 archive independently (4 sites). `the_ci_workflows_pin_the_same_module_digest_as_the_registry` caught these; unfixed, CI downloads the 1.13.8 archive, checks it against the 1.13.7 digest and refuses it. Verified rather than assumed, since a pin aimed at the wrong build fails silently: v1.13.8 has the #134 merge (f143a150) as an ancestor and carries `ingest_connector_item_tolerated` at the tag; all 11 checksums were generated from the release's own checksum.toml and cross-checked back against it, with each archive name and its digest rewritten as one unit so a name cannot end up beside the previous release's hash; and `git diff v1.13.7..v1.13.8 -- crates/tinymemory-api/src/capabilities.rs` is empty, so no family was added and only the pin string moves. Recovers nothing already on disk — the per-item sync gate treats ingested items as done, so existing records need the backfill tracked in #6012. Refs #6007
…y tree #134 fixed the routing: connector items reach `mem_tree_chunks` as they sync. It could not fix the records already on disk, and neither can a re-sync — the per-item sync gate treats an ingested document as done, so it fetches nothing and creates no tree rows. Those memories stay fully embedded in the namespace store and invisible to tree recall, the memory graph and the source row's ingest status, and recovery today means removing and re-adding the account. `MemoryMaintenance::backfill_connector_trees` walks the connector namespaces and feeds each stored document through the funnel the sync path already uses, so a backfilled row and a freshly-synced row are the same row. The walk lives in `tinymemory-core::backfill` next to that funnel and the provider is a thin conversion: reconstructing `{toolkit}:{connection_id}:{item_id}` at a second call site is exactly what caused openhuman#6007. Namespaces are rebuilt from the source registry rather than parsed out of `list_namespaces`, because the registry is what the writers used and cannot drift, while parsing a namespace back into halves has to guess where the toolkit ends. Legacy `skill-<toolkit>` documents record no connection at all — `store_skill_sync` takes an `_integration_id` it never persists — so they are attached only where the registry holds exactly one connection for the toolkit, and skipped by name where it holds several. A wrong attribution in a memory system is worse than a missing one. `ingest_connector_item_into_tree` now answers `Option<IngestResult>` instead of `()`. Without it the backfill cannot tell "just treed this" from "the tree already had it", and that distinction is the difference between reporting progress and reporting nothing. Both existing callers drop the payload, and the funnel's own test now asserts `None` on a blank scope rather than discarding the value. The member is appended at the tail of `METHODS`, not filed beside `FlushPending` where its family sits. Member order is wire order, so filing it with its family would renumber every member after it and make a host built against v1.13.8 invoke the wrong method — silently. The append-only guard caught that; the slot is now pinned at 142. Idempotent by construction: the ingest pipeline answers `already_ingested` when its transaction persists nothing, so a second pass writes nothing and an interrupted pass loses nothing. `limit` therefore bounds cost rather than carrying a cursor. `dry_run` reports what a pass would examine while writing nothing, because a full pass is one read and one set of chunk embeddings per document and that cost should be visible before it is paid. Nothing calls this automatically. The null driver refuses, as every other mutating Maintenance member does: "backfilled nothing" must not read as work done by a driver that stores nothing. Refs tinyhumansai/openhuman#6012, tinyhumansai/openhuman#6007
Summary
MemorySourceSink::accept_source_itemswrote namespace documents and vector chunks but nevermem_tree_chunks. A Composio sync therefore landed thousands of documents and embeddings while every tree-backed surface — tree recall, the Memory Tree graph, and the source row's ingest status — still reported zero. None of those readmemory_docs.Reported downstream as tinyhumansai/openhuman#6007, from a profile showing 2120
source_gmail*docs and 7986 vector chunks against 0 Gmail rows inmem_tree_chunks, next to a pre-migration profile with 908.Root cause
This is the #5473 reconnect, regressed. #5473 added tree ingest to
HostSyncAdapter::ingest_document_into_memory_tree, on theSkillDocSinkpath. The connector migration then built a second ingest path —accept_source_itemsintinymemory-tinycortex— which never learned the tree half.LocalDocumentSink(folder sync) still went throughingest_document_with_scope, which is why local sources kept working and only connector sources looked empty.So the fix is not a second call to the same rules — it is one copy of them:
ingest_connector_item_into_treeowns the{toolkit}:{connection_id}tree scope, the{…}:{item_id}per-item key, and the blank-half guard. That key pair is what OpenHuman'ssource_id_prefixcounts a Composio source's ingest by ("{toolkit}:{connection_id}:"), so a drift there empties the source status and the memory graph together — silently, because the documents and vectors are still written.ingest_connector_item_toleratedowns the failure policy, which is whycorruption::escalate_or_countstayspub(crate): callers get the policy, not the primitive, so a new call site cannot quietly implement a weaker one. A tree failure is counted and warned, never fatal — propagating lets one deterministically-poisonous item stall a whole connection and re-fetch the page (real Composio spend) on every retry. Corruption still aborts the run (openhuman#5820).HostSyncAdapternow delegates, so both paths share the rules. The pre-existing #5473 test is unchanged and still green, which is what makes that refactor behavior-neutral.The gate
Tree ingest is gated on
source_kind == "composio"and a non-empty{toolkit}:{connection_id}split. Both halves, deliberately:SourceKind::Composioand keys every other kindmem_src:{id}:. Treeing a non-Composio source would write rows no status or graph surface can count — the same invisible-write bug, wearing a different source kind.":conn"guard: a blank half yields a scope with no platform prefix, which no retrieval kind resolves.forget_sourcehad to learn the same keyNot in the original report, but it ships here because this change is what makes it reachable.
forget_sourcedeleted chunks withdelete_chunks_by_source, which matches a source id exactly — it never matches{source_id}:{item_id}. Once items are treed per-item, a user who disconnected Gmail would keep their documents cleared and every synced message still retrievable in the tree.The prefix sweep is unconditional on purpose: it matches nothing for a source that never had per-item rows, and a shape gate here could drift out of step with the write gate, which is the direction that leaks. The existing bare-id
delete_orphaned_source_treestill lands, because the items' sharedpath_scopeis the bare source id.Test plan
cargo test -p tinymemory-core --lib engine::sync— 15/15, including the untouched #5473 guard and a new test for the funnel's blank-connection_idhalf (a blank toolkit was already covered through the sink path; no sink path can produce a blank connection, butaccept_source_itemscan).cargo test -p tinymemory-tinycortex --test full_provider_conformance— 33/33."drive-1"/"drive") now asserts zero tree rows. That assertion is the gate test.gmail:conn-1:msg-1andpath_scopegmail:conn-1, then assertsforget_sourcetakes those rows with it.cargo fmt --all --checkNotes for the reviewer
tinymemory-tinycortex; the engine ships inside the prebuilt TinyBus module, pinned at 1.13.7. The host-side re-pin is a separate openhuman PR gated on a patch release cut from this.vector_chunks, once intomem_tree_chunks. On the reported profile that is ~8000 additional chunk embeddings for one account, which touches the embedding budget openhuman#5324 added warnings for. Pre-existing on the old sink path, but the blast radius grows; worth measuring on a real Gmail sync.memory_docsnamespaces and creates the tree rows is deliberately not in scope here and will be filed separately.Refs tinyhumansai/openhuman#6007