Skip to content

Give the host every read it still runs raw SQL for - #99

Merged
YellowSnnowmann merged 6 commits into
tinyhumansai:mainfrom
YellowSnnowmann:feat/5560-close-every-gap
Aug 25, 2026
Merged

Give the host every read it still runs raw SQL for#99
YellowSnnowmann merged 6 commits into
tinyhumansai:mainfrom
YellowSnnowmann:feat/5560-close-every-gap

Conversation

@YellowSnnowmann

@YellowSnnowmann YellowSnnowmann commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Merge order — this PR is second. It vendors tinyhumansai/tinycortex#157 and does not compile without it. Merge #157 first, re-point vendor/tinycortex at the merge SHA, then merge this. tinymemory v1.5.0 is cut after that, and only then can openhuman#5560's host work begin — the host pins a released, digest-verified artifact, so a member that is merged but unreleased is a runtime UnknownMethod, not a compile error.

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 into append_filters and the count reuses it verbatim, appending no LIMIT. 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 MemoryEntities members 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 MemoryTree memberssummary_forest and recent_leaves — which walk the sealed forest the tree family could describe one node at a time but never enumerate.

Two verdicts, no member

  • The doctor's chunk count is already store_stats. Its blocker is that run is 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.
  • 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 (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 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 in version.rs says, 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

  • METHODS 99 → 103, registered in all four places, verified by sequence comparison rather than by eye
  • cargo check --workspace --all-targets clean; module workspace clean
  • cargo clippy --all-targets --all-features -- -D warnings — both workspaces
  • cargo test --all-features — no failures; conformance 20 passed, module lib 49 passed
  • Loader E2E against the real dlopen'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 pass

Known, not fixed here

count_chunks alone does not fully unblock OpenHuman's chunk-list handler: its filter also needs entity_ids (an index join), set-valued source_kinds/source_ids, and a content LIKE scan, none of which ChunkQuery expresses. And the paged row carries lifecycle_status / content_path / has_embedding, which Chunk does not — ChunkDetail has 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_embedding is dead. Every engine write path goes to the mem_tree_chunk_embeddings sidecar, so mem_tree_chunks.embedding is 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

ChunkQuery grows 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 to Unsupported so no existing driver stops compiling: MemoryChunks::{list_chunk_details, source_totals}, MemorySourceSink::forget_matching, MemoryMaintenance::purge_all.

ChunkListRow is deliberately ChunkDetail without its body. ChunkDetail::body's own docs promise that None means the vault read failed — a listing can only honour that 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 plus 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 — 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::start in 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 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. 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 SchedulerGate bus interface, which is separate work. claim_queue_pool exists because queue::start's Once is 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/contacts replaces tinycortex/people on the module's dependency, since upstream declares contacts = ["people", ...]. Without it SeedFromAddressBook compiles 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 SeedFromAddressBook assertion now 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. 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 warnings with --all-targets --all-features on the root and the module workspace; cargo fmt --check on both; cargo doc --no-deps --all-features under -D warnings; cargo deny check; engine-containment.sh and dependency-budget.sh; production-source coverage 81.89% and module coverage 86.81% lines, both over the 80% gate; the --locked release cdylib build; and the loader E2E, all 12 cases, one process per test, against that release artifact.

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>
@coderabbitai

coderabbitai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fd8ce38-9964-4b6f-b0cb-9c2957b68456

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@tinysweeper tinysweeper Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@tinysweeper

tinysweeper Bot commented Aug 24, 2026

Copy link
Copy Markdown

How this change flows

1 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
Loading

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.

tinysweeper 0.1.0

@tinysweeper tinysweeper Bot added the priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect. label Aug 24, 2026
…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>
@YellowSnnowmann
YellowSnnowmann marked this pull request as draft August 24, 2026 19:21
YellowSnnowmann and others added 4 commits August 25, 2026 00:57
`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.
@YellowSnnowmann
YellowSnnowmann marked this pull request as ready for review August 25, 2026 05:54
@YellowSnnowmann
YellowSnnowmann merged commit d85b8f9 into tinyhumansai:main Aug 25, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

priority: p3 Whenever. Cosmetic, a nicety, or a cleanup with no user visible effect.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant