Give the host every read it still runs raw SQL for - #99
Conversation
openhuman#5560 wants zero direct engine references in the host. What was left was not routing — it was six reads the contract could not express, so five handlers kept a SQLite handle to answer them. These are those six. `MemoryChunks::count_chunks` answers the unpaged total a paging caller needs. The filter is the listing's own: tinycortex#157 factors the WHERE-clause construction out and the count reuses it verbatim, so a count and the page it labels cannot drift. Deliberately NOT defaulted to `list_chunks(..).len()` — that compiles, passes a naive test, and silently returns the driver's row cap as if it were the total. Three `MemoryEntities` members return what the occurrence index actually holds. `entities()` was not widened to fit: it is namespace-scoped and hotness-ranked, and the index is neither, so bending one member to serve both would have made its contract a lie. Two honest members beat one fudged one. Two `MemoryTree` members walk the sealed summary forest and its recent leaves, which the tree family could describe one node at a time but never enumerate. Two verdicts recorded rather than members added: the doctor's chunk count is already `store_stats` — its blocker is that `run` is sync while the member is async, which is a host restructure, not a contract gap. And the obsidian registry is host filesystem policy that happens to live in the engine crate, like redact and safety before it; the host can own it. CONTRACT_VERSION goes (2, 2) -> (3, 0). Every member here lands on a family a driver may already advertise, and negotiation is family-granular — a driver advertising `Chunks` at 2.2 would bind and then be asked for a method it has never heard of. The major half refuses that bind instead of discovering it at the call. Worth saying plainly: tinyhumansai#85/tinyhumansai#86/tinyhumansai#89/tinyhumansai#90 added methods to advertised families and stayed minor. That was wrong by this rule. Nothing was bound across the gap because those releases and their hosts moved in lockstep, but it is drift, not precedent. METHODS 99 -> 103, registered in all four places and verified by sequence, not by eye. Loader E2E passes against the built cdylib. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: Warning Your free Security trial is over. An organization admin can activate billing to continue. 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 |
There was a problem hiding this comment.
tinysweeper found nothing blocking. Approving.
$0.0858 · 522,922 in / 11,492 out · 53,330 cached (10%) · deepseek/deepseek-v4-flash, openrouter/openai/text-embedding-3-small, z-ai/glm-5.2 · 811 embedded
critique: $0.0157 · 222,960 in / 1,877 out · 17,815 cached (8%) · deepseek/deepseek-v4-flash, z-ai/glm-5.2
security: $0.0211 · 221,199 in / 2,088 out · 35,515 cached (16%) · deepseek/deepseek-v4-flash, z-ai/glm-5.2
tests: $0.0468 · 41,116 in / 6,658 out · 0 cached (0%) · z-ai/glm-5.2
description: $0.0019 · 34,021 in / 81 out · 0 cached (0%) · deepseek/deepseek-v4-flash
How this change flows1 changed behaviour across 8 relationships. 6 surrounding behaviours are shown (60 graph nodes walked). 40 further behaviours left out to keep the diagram readable. flowchart LR
n0["node_id_to_path<br/>changed"]:::changed
n1["call"]:::impacted
n2["Result"]:::impacted
n3["join"]:::impacted
n4["portability_and_lifecycle_round_trip"]:::impacted
n5["provider_config"]:::impacted
n6["iter"]:::impacted
n0 -->|calls| n3
n1 -->|uses| n2
n3 -->|calls| n6
n4 -->|calls| n1
n4 -->|tests| n1
n4 -->|uses| n2
n5 -->|calls| n3
n5 -->|tests| n3
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. |
…itself The gitlink moves onto tinycortex#157's branch, which now also carries the clippy drift `stable` picked up since that repo's last green run — five `chunks_exact` with a constant size plus three smaller lints, none of them in code either branch wrote. `chunks.rs`'s module doc spelled out a link target rustdoc already resolves from the path, which `-D warnings` treats as an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ingest_email` rendered every stored thread without a `To:` or `Cc:` line, repeated the thread's subject on every message, and dropped `List-Unsubscribe` entirely, because `IngestItem` had nowhere to put them. The driver said so in a comment and accepted the loss: those are display headers, and the bodies — what retrieval matches on — crossed intact. `List-Unsubscribe` is not a display header. It is the input an unsubscribe flow reads back out of stored mail, so dropping it does not make that flow less pretty, it makes it impossible. A host with mail ingest cannot move onto the contract while the contract cannot carry it. So widen `IngestItem` with `to`, `cc`, `subject` and `list_unsubscribe`. All four are `#[serde(default)]` and skipped when empty, which is the version rule's "new optional wire field" — minor-safe on its own, and here riding on the major bump this branch already carries. `subject` is optional rather than required because a reply keeps the thread's subject and only a renamed thread differs; absent means the thread subject stands, so the field being new leaves no mail unlabelled. The assertions go at both boundaries deliberately. The conformance test proves the driver's mapping; the loader E2E proves the same headers survive a real serialize/deserialize through the dlopen'd cdylib. Only the second would catch a shape that encoded them and then lost them in transit — and that failure is invisible from `written > 0`, which a dropped-header ingest still answers happily. The `vendor/tinycortex` pin also advances one commit, from `3679a92` to `2e7b57e`. Both are on the same in-flight tinycortex branch; this only catches the pin up to that branch's head so CI here builds what that PR actually proposes. The pin moves to tinycortex `main` once that merges. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HZ3Y9zHmLQL313GAq7Jm5J
…uman#5560
The host still links the memory engine directly because five of its own reads
and writes have no member on the bus, and because two engine-owned runtime
behaviours have no owner once the host's in-process engine is deleted. Both
halves are here.
## Contract
`ChunkQuery` grows six filters — `ids`, `source_kinds`, `source_ids`,
`entity_ids`, `entity_kinds` and `content_contains` — every one
`#[serde(default, skip_serializing_if = ...)]`, so an older peer on either side
of the boundary neither sends nor misses them. Four trait members join them,
all defaulted to `Unsupported` so no existing driver stops compiling:
`MemoryChunks::{list_chunk_details, source_totals}`,
`MemorySourceSink::forget_matching` and `MemoryMaintenance::purge_all`.
`ChunkListRow` is deliberately `ChunkDetail` without its body. `ChunkDetail`'s
docs promise that a `None` body means the vault read failed, and a listing can
only honour that promise by reading every file or by lying, so it does neither.
`MemoryEntities::chunk_entities` is amended rather than added: it now takes a
batch of chunk ids and an optional kind filter, and answers
`ChunkEntityOccurrence` rows that each name their own chunk. Widening it is
legitimate only because it has never shipped — it was added on this branch, so
no released host calls the single-id form — and its position in the member
sequence is unchanged, which is what the drift assertion pins.
The four new methods are registered in all four places that have to agree
(interface impl, module manifest, `tinymemory_bus::METHODS`, and the E2E's
expected set), in declaration order, because the drift test compares sequences
rather than sets.
## Runtime
The module now starts the engine's queue worker pool itself. Until now the only
`queue::start` in any tree was the host's, against the second in-process engine
the host also booted — so a host that deletes that engine, which is the entire
point of loading this module, silently turns `flush_pending`, `retry_failed`,
the re-embed backfill and the ingest path's `extract_chunk` into no-ops.
Ingestion keeps reporting success and the content is simply never indexed.
Two degradations come with it and are documented rather than hidden: the pool
runs unthrottled, because the scheduler gate is a host seam no bus interface
serves, and its shutdown hook is dropped, so locks held at exit are reclaimed by
lease expiry on the next start instead of released cleanly. `claim_queue_pool`
exists because `queue::start`'s `Once` is process-global while the pool is
bound to one workspace: a second workspace in one process is not a second pool,
it is a store with nothing draining its queue, and that now logs as an error
instead of being invisible.
`tinycortex/contacts` replaces `tinycortex/people` on the module's dependency,
since upstream declares `contacts = ["people", ...]`. Without it
`SeedFromAddressBook` compiles down to the stub that returns an empty contact
list, so a refresh reports success and imports nobody — the failure mode that
looks like an empty address book rather than a missing feature. The four objc2
crates behind the gate sit under a macOS target table upstream, so Linux and
Windows artifacts still compile none of them.
The E2E's `SeedFromAddressBook` assertion accepts a platform permission denial:
with `contacts` on, that call reaches the real address book, and on macOS that
is a per-application privacy grant a test runner may not hold. A denial is the
address book answering, so what is asserted is that the call routes and returns
under a contract error name — never that this particular host granted access.
`vendor/tinycortex` advances to the engine queries these members forward to.
Picks up the corrected nullability documentation on the lifecycle column and the test that pins it. No query changes, so nothing on this side moves; the gitlink is bumped to keep the two branches describing the same engine rather than leaving this one testing against a superseded commit. This still points at a pull-request head. When tinycortex#157 merges, re-point it at the merge SHA before this PR merges.
…o merged tinycortex The engine reaches its host through nine seam traits installed as process globals. This module installed seven of them; the host installs eight. The two nobody installed here were `ComposioHost` and `ConfigLoader`, and that gap is what stands between OpenHuman and deleting the second in-process engine it currently boots. It fails quietly, which is the reason to close it rather than document it: `require_composio_host()` returns `Err` and `is_available()` returns `false` when unset, so composio sync would simply stop, and the first symptom would be a user noticing their synced mail or chat had gone stale. `BusComposioHost` proxies the four members to an interface the host serves, following the shape `ChatHost` and `EmbeddingHost` already use. The engine's `&Config` never travels — the module supplies its own on this side of the proxy — and an unserved host is reported once per process through the already installed `ErrorReporter` rather than being swallowed. `is_available` answers `true` whenever it could not ask, because a wrong `false` is a silent "not signed in" skip while a wrong `true` is a named failure one call later. `ModuleConfigLoader` answers from the `ModuleConfig` this module is already handed rather than proxying to the host. Asking the host to re-read a config the module was given would let the two disagree about which workspace is being worked in, and `reload_snapshot` refuses a snapshot anchored at a different `config_path` for the same reason: answering it with this module's config would silently hand a caller another workspace's data. ## What is deliberately NOT here Ownership of the periodic composio sync loop does not move into this module, and that is a finding rather than an omission. Three things block it, each verified at source and each written into `lib.rs` at the line someone would try to add the call: - `sync/pipelines/host.rs`'s `composio_config` takes its direct branch only when `config.composio().mode == "direct"`, and `EngineRuntimeConfig` answers `ComposioMode::default()` with no session token, so backend mode fails and direct mode is never selected. `ComposioHost::api_key` cannot rescue this: it is consulted inside the branch that is not taken. - `run_composio_connection_with_caps` begins at `global::client_if_ready()`, which is `None` here — this module builds its store through `create_memory_client_with_local_ai` and never touches the global slot. - `EngineRuntimeConfig::memory_sync_interval_secs()` is `Some(0)`, which `effective_interval_secs` defines as manual-only, so both the composio and workspace loops would skip every source with no error at all. Until those are closed the loop stays where it is, host-side, against the engine the host still boots. `vendor/tinycortex` moves to tinycortex#157's merge commit on `main`, so this branch no longer points at a pull-request head.
Depends on tinyhumansai/tinycortex#157. The gitlink here points at that branch's head; it needs re-pinning to the merge SHA once #157 merges.
Why
openhuman#5560's goal is zero
tinymemory_core::references in the host. What was left after the routing passes was not routing — it was six reads the contract could not express, so five handlers kept a SQLite handle purely to answer them. These are those six.What was added
MemoryChunks::count_chunks(query, scope)— the unpaged total a paging caller needs. The filter is the listing's own: tinycortex#157 factors the WHERE-clause construction intoappend_filtersand the count reuses it verbatim, appending noLIMIT. A count and the page it labels cannot drift, because they are the same predicate.Deliberately not defaulted to
list_chunks(..).len(). That compiles, passes a naive test, and silently returns the driver's row cap as if it were the total.Three
MemoryEntitiesmembers returning what the occurrence index actually holds (entity_id, kind, surface, mention count), plus the entity→chunk-ids lookup that had no member at all.entities()was not widened to fit: it is namespace-scoped and hotness-ranked, and the index is neither. Bending one member to serve both would have made its documented contract a lie — two honestly-named members beat one fudged one.Two
MemoryTreemembers —summary_forestandrecent_leaves— which walk the sealed forest the tree family could describe one node at a time but never enumerate.Two verdicts, no member
store_stats. Its blocker is thatrunis sync-only while the member is async — a host restructure, not a contract gap. Adding a sync duplicate would have been a member nobody needed.redactandsafetybefore it. The host can own it.CONTRACT_VERSION(2, 2) → (3, 0)Every member here lands on a family a driver may already advertise, and negotiation is family-granular. A driver advertising
Chunksat 2.2 would be bound and then asked for a method it has never heard of; the major half refuses that bind instead of discovering it at the call. That is what the rule inversion.rssays, in those words.Worth stating plainly: #85/#86/#89/#90 also added methods to advertised families and stayed on the minor half. That was wrong by this rule. Nothing was bound across the gap because those releases and their hosts moved in lockstep, but it is drift, not precedent — and this PR is the one that stops compounding it.
Consequence: the host must re-pin to the release carrying this. It cannot stay on v1.4.0.
Validation
METHODS99 → 103, registered in all four places, verified by sequence comparison rather than by eyecargo check --workspace --all-targetsclean; module workspace cleancargo clippy --all-targets --all-features -- -D warnings— both workspacescargo test --all-features— no failures; conformance 20 passed, module lib 49 passeddlopen'ed release cdylib at 103 members:every_declared_method_is_actually_routed,the_manifest_declares_every_method_the_module_serves,the_module_advertises_the_complete_tinymemory_api,stateful_optional_families_round_trip_over_the_bus,query_and_maintenance_families_dispatch_typed_requests— all passKnown, not fixed here
count_chunksalone does not fully unblock OpenHuman's chunk-list handler: its filter also needsentity_ids(an index join), set-valuedsource_kinds/source_ids, and a contentLIKEscan, none of whichChunkQueryexpresses. And the paged row carrieslifecycle_status/content_path/has_embedding, whichChunkdoes not —ChunkDetailhas all three but costs 4 queries + a vault read per chunk, so calling it per row would be worse than the SQL it replaces. Closing that needs a batched detail listing, which is a second feature rather than a count.One finding worth flagging: the column the host reads for
has_embeddingis dead. Every engine write path goes to themem_tree_chunk_embeddingssidecar, somem_tree_chunks.embeddingis never written and that flag is effectively always false today. Whatever member ends up feeding it will flip it to true on every embedded chunk — a visible product change, not a routing swap.Update — the remaining gaps for openhuman#5560 are closed here
The PR started as six members. Finishing the survey turned up four more the host still runs raw SQL for, plus two engine-owned runtime behaviours that have no owner at all once the host deletes its in-process engine. Both halves are now in this branch, because splitting them would have deadlocked the release behind a second PR.
Contract
ChunkQuerygrows six filters —ids,source_kinds,source_ids,entity_ids,entity_kinds,content_contains— every one#[serde(default, skip_serializing_if = ...)], so an older peer on either side of the boundary neither sends nor misses them. Four trait members join them, all defaulted toUnsupportedso no existing driver stops compiling:MemoryChunks::{list_chunk_details, source_totals},MemorySourceSink::forget_matching,MemoryMaintenance::purge_all.ChunkListRowis deliberatelyChunkDetailwithout its body.ChunkDetail::body's own docs promise thatNonemeans the vault read failed — a listing can only honour that by reading every file or by lying, so it does neither.MemoryEntities::chunk_entitiesis amended rather than added: it now takes a batch of chunk ids plus an optional kind filter and answersChunkEntityOccurrencerows that each name their own chunk. Widening it is legitimate only because it has never shipped — it was added on this branch — and its position in the member sequence is unchanged, which is what the drift assertion pins. The four new methods are registered in all four places that must agree (interface impl, module manifest,tinymemory_bus::METHODS, the E2E's expected set), in declaration order, because that test compares sequences rather than sets.Runtime — the part that is not a member
The module now starts the engine's queue worker pool itself. Until now the only
queue::startin any tree was the host's, against the second in-process engine the host also boots. A host that deletes that engine — the entire point of loading this module — silently turnsflush_pending,retry_failed, the re-embed backfill and the ingest path'sextract_chunkinto no-ops. Ingestion keeps reporting success and the content is simply never indexed. That failure mode is why this is in the same PR as the members.Two degradations come with it, documented rather than hidden: the pool runs unthrottled, because the scheduler gate is a host seam no bus interface serves, and its shutdown hook is dropped, so locks held at exit are reclaimed by lease expiry on the next start instead of released cleanly. Closing either properly needs a
SchedulerGatebus interface, which is separate work.claim_queue_poolexists becausequeue::start'sOnceis process-global while a pool is bound to one workspace: a second workspace in one process is not a second pool, it is a store with nothing draining its queue, and that now logs an error instead of being invisible.tinycortex/contactsreplacestinycortex/peopleon the module's dependency, since upstream declarescontacts = ["people", ...]. Without itSeedFromAddressBookcompiles down to the stub returning an empty contact list, so a refresh reports success and imports nobody — the failure mode that looks like an empty address book rather than a missing feature. The four objc2 crates behind the gate sit under a macOS target table upstream, so Linux and Windows artifacts still compile none of them.The E2E's
SeedFromAddressBookassertion now accepts a platform permission denial: withcontactson, that call reaches the real address book, and on macOS that is a per-application privacy grant a test runner may not hold. What is asserted is that the call routes and returns under a contract error name — never that the host granted access.Verification
Both workspaces
cargo check --all-targets;cargo test --workspace(1919 pass) and--all-features(1781 pass); the full CI feature matrix, all 13 rows; clippy-D warningswith--all-targets --all-featureson the root and the module workspace;cargo fmt --checkon both;cargo doc --no-deps --all-featuresunder-D warnings;cargo deny check;engine-containment.shanddependency-budget.sh; production-source coverage 81.89% and module coverage 86.81% lines, both over the 80% gate; the--lockedrelease cdylib build; and the loader E2E, all 12 cases, one process per test, against that release artifact.