Contain the engine behind one module (#18 §C1) - #32
Merged
YellowSnnowmann merged 16 commits intoAug 18, 2026
Merged
Conversation
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)
Issue tinyhumansai#18 §E7. `AGENTS.md` documents an `examples/` directory and tells the reader to run `cargo run --example basic`. Neither existed: no `examples/` directory at all, so the documented command has been failing for as long as it has been documented. (The issue attributes the reference to `README.md`; it is `AGENTS.md` lines 30 and 74.) The example binds the null driver, which needs no engine, no workspace and no network, so it runs anywhere. It walks the order a host actually follows — admit an id, then construct, then check the negotiated capabilities, then use the mandatory families — because that order is the part worth demonstrating. Admission is engine-neutral and answers "is this id real and may it answer for memory"; construction needs everything an engine needs. Swapping `NullMemoryProvider` for an adapter's provider changes nothing else in the file. Writing it surfaced an API gap, which is the argument for having a compiled example at all: `FallbackReason` implemented `Display` but not `std::error::Error`, so the obvious `registry.admit(..)?` in a function returning `Box<dyn Error>` or `anyhow::Error` did not compile. Adding the impl is purely additive and changes nothing about the type; it makes the message usable where refusals actually travel. CI runs the example rather than only building it. `cargo build --all-targets` compiles examples, so a broken one still passes — and a compiled example can panic on its first line. Running it is what makes the documented command a promise rather than a comment. Not attempted here: §E7's "one example per engine". The three hosted engines need a live endpoint and a credential, which is why `adapters/remote/examples/conformance.rs` is a manual CLI rather than a CI target, and the embedded engine needs the host's process-global seams installed. Neither belongs in a `cargo run --example` a contributor is told to run. Refs tinyhumansai#18 (§E7)
Issue tinyhumansai#18 §D5, §E8, and the second half of §E2. Three CI additions that all answer the same kind of question — what is this build actually costing, and is it still true — so they land together. **Feature powerset (§E2).** `cargo hack --feature-powerset --depth 2`, check only. Cargo features are additive: enabling one for a crate enables it for every consumer in the graph, so a combination nobody builds deliberately can still be built by somebody else's dependency. `--depth 2` covers every pair without the blow-up of the full set. §E2 also lists per-feature lanes — `--features tinycortex`, `mem0`, `supermemory`, `cognee`, `sync-composio`. Those are not here because those features do not exist yet: they are §D1, and §D1 needs OpenHuman to opt into features it currently gets unconditionally. The powerset covers whatever features exist, so it starts useful and stays useful as §D1 adds them. Verified by hand across all eight combinations the workspace can express today (core: none, contacts, memory-git, test-support and their pairs; the tinycortex adapter with and without memory-git). Every one compiles, so this pins a property that currently holds rather than papering over a break. **Dependency budget (§D5).** `scripts/ci/dependency-budget.sh` prints the crate count for every configuration and fails when the minimal one grows past a ceiling. Today: minimal 40, api 39, the tinycortex adapter 168 — and 172 with `memory-git`, which is the native git stack the feature exists to keep out, so the +4 is the gate from §C3 working. The ceiling is 50 against a current 40, deliberately. A limit set at today's exact count fails on the first legitimate addition, gets raised without thought, and teaches everyone to ignore it. Only the minimal configuration is gated; the richer numbers are reported, because a number nobody chose is not a budget. **Coverage (§E8).** `AGENTS.md` asks for 80% of meaningful library behaviour and nothing measured it. The workspace is at **76.22% lines / 76.44% regions / 63.81% functions** — below the number it asks for, which is worth seeing rather than assuming. Some of the gap is stark: `core/src/tree/score/store.rs` and `core/src/tree/score/extract/mod.rs` are at 0.00%. Reported, not enforced, to begin with. A threshold picked before anyone has seen the number is a guess, and a gate that fails on day one gets disabled rather than fixed. The powerset and coverage run as their own job: both are slower than the main lane and independent of it, so a failure in one should not mask the other or delay the fast feedback the main job gives. Refs tinyhumansai#18 (§D5, §E2, §E8)
Issue tinyhumansai#18 §E5. The loader E2E drove three of the eighteen families the module advertises, and nothing checked that a write reached the workspace it was given. **Every declared method is routed.** The module advertises `Capabilities::all()` and declares 88 methods; the existing manifest test compares two lists, so it passes for a method that is declared, listed, and answers "unknown member" — the bus-level version of a capability set that overstates its accessors. This calls each declared method and distinguishes the kind of refusal: a wired method rejects the empty argument list with `ai.tinyhumans.tinybus.Error.BadArguments`, while an unwired one is `ai.tinyhumans.tinybus.Error.UnknownMethod`. The two names are what make the test discriminate rather than pass vacuously, and the unrouted shape is taken from the build under test rather than hard-coded. Eighteen bespoke round trips would assert more, and would also need eighteen sets of valid arguments and eighteen engine preconditions. This asserts the one thing that is true of all of them and is cheap to keep true. **A write lands in the host's workspace.** Every other test here stores and reads back inside one admission, so a module that kept its store in a temporary directory of its own, or in memory, passes all of them — and the difference shows on the user's next launch. §E5 asks for a shutdown/restart cycle, and it is not here because it cannot be: TinyBus never unloads a library, so a second admission in the same process is refused with `ModuleRefused { reason: "module initialization failed" }`. That is the same constraint that already forces every test in this file to be the only one in its process. A real restart needs a second process against a shared workspace, which is a change to the CI loop rather than a test, so the directory assertion covers what restart would have been checking and the limitation is written down rather than left as a gap someone rediscovers. Verified the way CI runs them — every ignored test in its own process, all ten green. Refs tinyhumansai#18 (§E5)
Issue tinyhumansai#18 §A1, the tinymemory half. Companion to tinyhumansai/tinycortex#149, which makes `tinycortex-api` re-export this workspace's contract rather than redefining it. `convert.rs` existed because the engine's contract crate and this one described the same values under two names — the same code, in fact, since `api/` was extracted from `tinycortex-api` and held byte-identical. Being nominally distinct meant every call across the seam translated, and a field added to either contract had to be added to the other and to the conversion. Three places, or the value was silently dropped. With one type set there is nothing to convert: `convert.rs` and its tests are deleted, and the adapter's eleven conversion call sites become plain delegation. `list` and `namespace_summaries` lose an `into_iter().collect()` that had become an identity map, and `store`/`store_with_taint` pass their arguments straight through. The gitlink moves to the commit carrying the re-export. That commit also stops publishing tinycortex, which is what makes the git dependency legal — and records the state that repository was already in, since `cargo package` there has failed since `api/` was split out. The workspace root gains a `[patch]` for the git contract dependency. Without it cargo resolves the git copy *and* the path copy as two distinct crates, and `MemoryCategory` from one is not the same type as the other — the exact duplication this change deletes, reintroduced by the fix for it. Found by the compiler at the seam rather than reasoned about, and the patch table is the same mechanism this workspace already uses for tinycortex itself. Acceptance for §A1, checked rather than asserted: - `adapters/tinycortex/src/convert.rs` is deleted - no conversion function remains anywhere in the workspace (grep: 0 hits) - the workspace builds and tests green with no conversion at the seam `cargo test --all-features` reports 1101 passing, down 9 from 1110. That difference is exactly the nine tests in `convert_test.rs`, which tested the layer this change removes. Refs tinyhumansai#18 (§A1)
Issue tinyhumansai#18 §A2. `tinymemory_core::traits` re-exported `Memory` and the memory value types from `tinycortex::memory` — from the *engine*. That is §1.1's finding: `tinymemory_core::MemoryEntry` was the engine's type rather than the contract's, so a second engine could not be bound without translating, and the crate was engine-neutral in name only. It now names `tinymemory-api` directly. Since §A1 the engine re-exports that same contract, so both spellings resolve to one type either way — but reaching an engine-neutral contract *through an engine* is precisely what would have to be undone before a second engine could be bound, and undoing it later is harder than not doing it. §A2's second clause comes along with the first: `UnifiedMemory` implements `crate::traits::Memory`, which is now the contract's trait, so it implements the TinyMemory trait directly with no further edit. This was not possible before §A1. The same repoint attempted then produced 10 compile errors, 9 of which wanted taint conversions added — the three-place edit §A1 exists to delete. With one type set behind both names it is a 20-line documentation change and a re-export, and the workspace compiles unchanged. Refs tinyhumansai#18 (§A2)
Issue tinyhumansai#18 §C1. Eighty-three files named the `tinycortex` crate, in two hundred and ninety-six places. That made the engine's shape an ambient fact of the whole crate rather than a dependency anyone had chosen, and made "what would a second engine have to provide?" a question nobody could answer without reading all of them. Every one now goes through `core/src/engine/backend.rs`, which re-exports the engine's memory surface and is the only file outside the seam that names it. Outside `core/src/engine/`, references to the crate are zero. Re-exporting rather than wrapping is deliberate. A wrapper over three hundred call sites would be a second surface to keep in step with the first, which is the failure §A1 had just finished deleting. What this buys is not insulation — the call sites use the engine's API verbatim — but a single enumerable place that names it. The seam is `engine`, not `tinycortex`. §C1's text says `core/src/tinycortex/`, but its own acceptance criterion asks that `grep -rl tinycortex core/src` match only files under that module, and those two cannot both hold: with the module named after the engine, every call site reads `crate::tinycortex::…` and matches. Naming the seam for its role rather than for one engine satisfies the criterion and is the better name regardless — a seam named after the thing it is meant to make replaceable is the coupling this section removes. The module was `pub` but had no consumer outside this crate, so the rename breaks nothing. The workspace root and the module crate each gain a `[patch]` for the contract's git dependency. Patch tables apply only from the root being built, and the module crate is its own root; without its own entry cargo resolves the git copy alongside the path copy and `MemoryTaint` from one is not the same type as from the other. Found by the compiler, not predicted. What is left matching `tinycortex` outside the seam is 82 log-message strings and a `tinycortex_kv` accessor name. Neither is a crate reference, and renaming the accessor is cosmetic churn §C1 does not ask for. Two stale doc comments describing a "thirteen-family `tinycortex_api` contract" are corrected — since §A1 the contract is `tinymemory-api`, and it has eighteen families. Acceptance, measured: 83 files naming the engine crate outside the seam, now 0. Refs tinyhumansai#18 (§C1)
|
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 |
| submodules: recursive | ||
| persist-credentials: false | ||
|
|
||
| - uses: dtolnay/rust-toolchain@stable |
| with: | ||
| components: llvm-tools-preview | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
|
|
||
| - uses: Swatinem/rust-cache@v2 | ||
|
|
||
| - uses: taiki-e/install-action@v2 |
The module crate is its own workspace root, and a patch table applies only from the root being built — so the parent workspace's entry does not reach it. Without its own, cargo resolves the git copy of `tinymemory-api` alongside the path copy and `MemoryTaint` from one is not the same type as from the other. Belongs in this commit rather than a later one: this is where the git dependency arrives, so this is where its consequence has to be handled. CI caught it — the module lane failed with eight type mismatches while every other lane was green, because it is the only job that builds from that root. Refs tinyhumansai#18 (§A1)
…-one-memory-trait
…e-engine # Conflicts: # crates/tinymemory-module/Cargo.toml
tinycortex#149 landed as a squash (8401346b), discarding the branch head this pin pointed at; 34cbb6c is diverged from tinycortex main rather than an ancestor of it. The merged commit is also the one that deletes the 33 duplicated files under api/src/, which is the state this stack depends on.
This was referenced Aug 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Issue #18 §C1. 83 files named the
tinycortexcrate, in 296 places. Outside the seam, that is now 0.That count is the point of the section: the engine's shape was an ambient fact of the whole crate rather than a dependency anyone had chosen, and "what would a second engine have to provide?" was a question nobody could answer without reading all 83.
Everything now goes through
core/src/engine/backend.rs— one file, which is the enumerable answer to that question.Re-export, not wrapper
Deliberate. A wrapper over 296 call sites would be a second surface to keep in step with the first — the failure §A1 had just finished deleting. This does not insulate core from the engine's API; the call sites use it verbatim. It makes the coupling enumerable rather than ambient.
The seam is
engine, nottinycortex— and it has to be§C1's text says
core/src/tinycortex/. Its acceptance criterion saysgrep -rl tinycortex core/srcshould match only files under that module. Those two cannot both hold: with the module named after the engine, every call site readscrate::tinycortex::…and matches the grep. I hit exactly that — 83 → 3 by the crate-path measure, but still 98 by the issue's literal grep.Naming the seam for its role rather than for one engine satisfies the criterion, and is the better name regardless: a seam named after the thing it exists to make replaceable is the coupling this section removes.
The module was
pubbut had no consumer outside this crate (checked against OpenHuman, the module crate, and the adapters), so the rename breaks nothing.The
[patch]had to be repeatedThe workspace root and the module crate each need one for the contract's git dependency. Patch tables apply only from the root being built, and
crates/tinymemory-moduleis its own root. Without its own entry, cargo resolves the git copy alongside the path copy:Found by the compiler, not predicted — the same two-copies hazard as #30, one workspace over.
What still matches, and why it stays
"[retrieval::fast] tinycortex query_len={}")tinycortex_kvaccessor nameZero code paths. Two stale doc comments describing a "thirteen-family
tinycortex_apicontract" are corrected — since §A1 the contract istinymemory-api, with eighteen families.Why this and not §A3
§A3 proposes routing these call sites through
&dyn MemoryProvider. That is not possible, andbackend.rs's module docs record why so the next reader finds it:rusqlite::TransactionorConnection. The entry points take them —upsert_buffer_tx(tx: &Transaction<'_>, …),shared_connection(config) -> Arc<PMutex<Connection>>. Serving those through the contract putsrusqliteintinymemory-api, which its manifest forbids and Enforce the contract crate's dependency rule, and the minimal build (#18 §D3/§D4) #25 made CI enforce.MemoryProviderzero times. Since §C3 (Lift the optional capability families into the TinyCortex adapter (#18 §C3) #22) the adapter wraps core's own functions as provider families, so core calling a provider is the implementation calling its own interface.Core and the engine co-implement one store. Separating them is a decomposition, not a routing change — and it needs the boundary decided before 73 files move.
Public API changes
tinymemory_core::tinycortex→tinymemory_core::engine. Public, but with no consumer outside this crate.tinymemory_core::engine::backend, the engine's re-exported surface.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-featurescargo run --example basic./scripts/ci/dependency-budget.shtest --lib/build --locked --release108 files changed, +500/−406.
Related
Part of #18 (§C1). §C2 (gating
people/behindcontacts) and §C4 remain blocked on a coordinated OpenHuman change.