Run Omi fully on-prem: storage behind a neutral, swappable port (Firestore | Mongo) - #10887
Open
abunet wants to merge 52 commits into
Open
Run Omi fully on-prem: storage behind a neutral, swappable port (Firestore | Mongo)#10887abunet wants to merge 52 commits into
abunet wants to merge 52 commits into
Conversation
Docker Compose baseline that boots the Omi backend fully offline, no cloud
dependencies, as the first brick of the full-on-prem initiative (ADR-0001).
Services (network internal: true -> zero egress by construction):
- backend: built from backend/Dockerfile with PYTHON_BASE_IMAGE overridden to
public python:3.11-slim; uvicorn main:app on :8080
- firestore-emulator: Firestore + Auth emulators (node:22-trixie + JDK 21;
emulator jar pre-fetched at build time so runtime needs no egress)
- valkey: Redis-wire-compatible cache (Valkey, BSD) -> no backend code change
Verified on Linux/docker:
- GET /v1/health -> 200 {"status":"ok"}
- GET /v3/memories (Bearer dev-token) -> 200 []; no auth -> 401
- egress to api.openai.com blocked (Could not resolve host)
Out of WP0 scope (added later to the same compose): Pusher, Typesense, Mongo/
ArcadeDB, Qdrant, RustFS/SeaweedFS, Keycloak, GPU inference.
backend.env is gitignored (*.env); backend.env.example committed. Runbook and
deviations in deploy/onprem/SELFHOST_NOTES.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
The backend/migrations/ scripts are one-shot, standalone data migrations with no runtime importers (verified: no static or dynamic import outside migrations/). In a greenfield self-host deployment there is no historical Firestore data to migrate, so they are dead weight. This is step 1 of sealing the persistence boundary (WP1): the directory held 22 `.collection(` and 9 `firestore.` raw ops that no longer count. Note: diverges from upstream; a future `git merge upstream/main` may conflict on backend/migrations/ (ADR-0013). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
Introduce database/sentinels.py, the single blessed place that re-exports Firestore sentinels and field transforms (DELETE_FIELD, SERVER_TIMESTAMP, ArrayUnion, ArrayRemove, Increment, Query). Code outside database/ imports sentinels from here instead of importing the Firestore SDK, so the upcoming persistence-boundary guard can forbid google.cloud.firestore / firebase_admin.firestore imports everywhere but database/. First consumer: utils/retrieval/tools/google_utils.py now uses `from database.sentinels import DELETE_FIELD` and drops `from google.cloud import firestore`. Verified in the WP0 test container: database.sentinels + google_utils import cleanly; tests/unit/test_firestore_di_seam.py and test_gmail_scope_gating.py pass (14 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
Relocate raw Firestore access that lived outside database/ into the persistence
boundary, so those modules no longer import the client or the SDK:
- utils/x_connector.py: X sync registry ops (set/delete/stream on the top-level
x_connector_users collection) -> new database/x_sync_registry.py
(register_sync_user / unregister_sync_user / list_sync_user_ids). Also drop two
behavior-free db_client=db pass-throughs (resolve_memory_system / MemoryService
default to the same client).
- routers/fair_use_admin.py: collection_group('fair_use_events') case_ref lookup ->
database/fair_use.lookup_fair_use_event_by_case_ref().
- routers/agent_tools.py: users/{uid} agentVm update + delete ->
database/users.update_agent_vm() / clear_agent_vm() (uses firestore.DELETE_FIELD
inside the boundary).
- utils/retrieval/tools/screen_activity_tools.py: screen_activity OCR read ->
database/screen_activity.get_screen_activity_ocr_text().
All four callers now import no Firestore client/SDK. Verified in the test container
(file-isolated): test_firestore_di_seam, test_fair_use_engine, test_fair_use_async,
test_screen_activity_tool_result_bound, test_screen_activity_search_utc,
test_x_memory_extraction_retry all pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
Two modules outside database/ imported pure helpers from the client module database._client; repoint them to public boundary modules so the upcoming guard can forbid database._client imports outside database/: - models/memories.py: document_id_from_seed -> database.document_ids (its real home). - services/conversation_finalization.py: is_document_size_limit_error -> new database/firestore_errors.py (re-exports the Firestore error classifiers). These are pure functions, not the client; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
… (WP1) Introduce database/firestore_paths.py, the path-based Firestore verb layer for the memory subsystem. Callers thread a db_client (real client, test fake, or None) and pass path strings; the verb resolves None to the injected boundary client `db` and runs the raw op INSIDE database/. This lets memory modules stop importing database._client while keeping the db_client dependency-injection seam intact. First sealed leaf: utils/memory/atom_keyword_index.py now calls firestore_paths.get_document(db_client, path) instead of resolving `default_db_client` and doing a raw `.document(path).get()`; its database._client import is gone. Test injection moves with it: tests patch `database.firestore_paths.db` (the single verb-layer resolution point) instead of the per-module `default_db_client`. test_ws_m_atom_keyword_index passes (22). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
- non_active_route_audit.py: route-doc collection read -> firestore_paths.stream_collection / stream_collection_where; default arg db_client=db -> None. - kg_graph_traversal.py: no raw ops of its own; drop the default_db_client resolution and thread db_client straight to the callees (which resolve None themselves). - canonical_short_term_maintenance_cron.py: same — drop the resolution, thread db_client. All three no longer import database._client. Verified: test_non_active_route_audit/_report/ _admin_endpoint/_routes, test_ws_n_graph_traversal, test_canonical_short_term_maintenance_cron pass (37). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…client (WP1) Move the raw Firestore ops in these two memory leaves behind firestore_paths verbs (get_document / set_document / update_document / document_exists / stream_collection) and drop the local default_db_client resolution, threading db_client straight through. Neither module imports database._client anymore. Verified: test_canonical_kg_promotion, test_canonical_extraction_subject_wiring, test_memories_stale_updates, test_canonical_maintenance_ordering, test_ws_b_short_term_lifecycle, test_ws_c_backfill, test_short_term_lifecycle pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
Route its control-state read/write, operation-journal writes and item reads through firestore_paths verbs (get_document / set_document / document_exists) and thread db_client straight to callees instead of resolving default_db_client locally. No database._client import. Verified: test_canonical_consolidation, test_canonical_consolidation_apply, test_canonical_maintenance_ordering, test_canonical_short_term_maintenance_cron pass (39). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
Route its long-term-dup query, control-state read/write (merge-preserving), operation-journal writes and item reads through firestore_paths verbs; thread db_client to callees instead of resolving default_db_client locally. No database._client import. Verified: test_short_term_promotion_control_state, test_ws_b_short_term_lifecycle, test_canonical_consolidation_apply, test_canonical_memory_vectors, test_canonical_short_term_maintenance_cron, test_canonical_maintenance_ordering, test_workstream_association pass (98). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
The verb layer is the backend-neutral document-store *port* for the memory subsystem, not a Firestore-only helper — name and docstring now say so. Its contract (path strings in, a document handle with .exists/.to_dict() out, db_client=None -> injected boundary client) is deliberately backend-agnostic so WP2 (ADR-0002) can put Firestore | Mongo | ArcadeDB adapters underneath and/or domain repositories on top. The current Firestore implementation becomes the port's firestore adapter — this seam evolves, it is not thrown away. Pure rename across the already-sealed callers + the test injection point (database.document_store.db). Verified: test_ws_m_atom_keyword_index, test_ws_c_backfill, test_canonical_memory_vectors, test_ws_b_short_term_lifecycle pass (97).
Route its control-state read/write, evidence + operation-journal writes and item reads through document_store verbs; thread db_client to callees instead of resolving default_db_client. No database._client import. Verified via test_ws_c_backfill and the short-term-lifecycle suites.
The last utils/memory leaf. Its 16 raw ops — including the delete-batch transaction — now go through document_store verbs: - reads/writes -> get_document / set_document / document_exists; - the transaction body uses new_transaction + tx_get / tx_set (the @transactional decorator itself already comes from the in-boundary database.memory_apply_store, so it stays); and db_client is threaded to callees instead of resolving default_db_client locally. document_store gains the transaction helpers (new_transaction / tx_get / tx_set / tx_update / tx_delete). No database._client import remains in utils/memory. Verified (incl. transaction paths): test_memories_batch_delete (23), test_canonical_consolidation_apply (17), test_memory_service_parity (27), test_delete_conversation_cascade, test_delete_account_purge_storage, test_canonical_memory_vectors, test_canonical_extraction_subject_wiring, test_ws_c_backfill.
… document_store (WP1) These files never imported database._client (so the import-ban never flagged them) but ran raw Firestore ops on the injected db_client — invisible raw ops outside database/. Route them through the document_store port so there are truly zero raw ops in the memory subsystem: - v3/production_runtime.py, v3/account_generation_source.py, vector_search_service.py, product_memory_read_service.py: get_document / stream_collection. - default_read_rollout.py: its gate reads (with the anti-hang timeout) now go through document_store.get_document(..., timeout=...); the port gained an optional timeout with a fake-safe fallback. Verified: test_v3_production_runtime_wiring (13), test_product_memory_read_service, test_product_memory_items, test_mcp_memory_filters, test_memory_service_parity (56 total).
…e (WP1) The last raw-op site outside database/ (besides the routers): the lifecycle transition store's transaction now uses document_store.new_transaction + tx_get / tx_set, and the short-term item fetch uses document_store.stream_collection_where. The custom transaction driver (_run_short_term_lifecycle_transaction) is unchanged — it drives the transaction handle, not the client. With this, a full census of raw .document()/.collection()/.transaction() calls outside database/ (excluding the routers, still to seal) is ZERO. Verified: test_short_term_lifecycle_worker, test_short_term_lifecycle_firestore_store, test_ws_b_short_term_lifecycle (36).
…sh document_store rename (WP1)
Sealing the last _client importers — the 17 router/util "pass-through" files that inject
db_client but run no ops themselves. They now import the client from a new database.persistence
handle instead of database._client:
- routers: developer, mcp, mcp_sse, memory_admin, memory_product, integration, knowledge_graph,
conversations, memories
- utils: apps, conversations/{memories,merge_conversations,process_conversation}, llms/memory,
retrieval/{tool_services/memories, tools/memory_tools, tools/preference_tools}
database/persistence.py re-exports `db` as the sanctioned dependency-injection handle. It is a
pass-through only: callers forward it as db_client=; the boundary guard forbids raw
.document()/.collection()/.transaction() outside database/, so it cannot be used to run an op.
This preserves canonical_activation's deliberate fail-closed-on-missing-client safety gate
(callers still inject a real client) — the "enterprise" choice over silently defaulting.
Also completes the firestore_paths -> document_store rename that a partial `git add` had left
uncommitted (atom_keyword_index, canonical_consolidation/kg_promotion/required_processing,
non_active_route_audit, short_term_promotion), and updates two tests' injection points
(mem_mod.db, memories_router.db).
With this, zero _client/SDK imports remain outside database/ (tests/testing/scripts/agent-proxy
excepted). Verified: 13 router+memory suites pass (220 tests) incl. test_memories_batch_delete,
test_v3_production_runtime_wiring, test_mcp_data_endpoints, test_memory_service_parity.
Static AST ratchet (.github/scripts/check_firestore_persistence_boundary.py, stdlib-only) that
keeps database/ the only door to Firestore. Outside backend/database/ (and the documented
exceptions tests/ testing/ scripts/ agent-proxy/) it forbids BOTH:
- importing the client/SDK: database._client, google.cloud.firestore*, firebase_admin.firestore;
- running a raw op: .document()/.collection()/.collection_group()/.transaction() calls.
Baseline is empty ({}) — WP1 achieved a true zero: no such imports or raw ops remain outside the
boundary. Registered in checks-manifest.yaml (local+ci); checker test in
backend/tests/unit/test_check_firestore_persistence_boundary.py (rides the backend unit suite).
This is the guard that makes the storage layer swappable in WP2 (ADR-0002/0004): the entire
Firestore surface is now confined to database/, reachable only via its ports
(document_store / sentinels / firestore_errors / persistence).
Introduce database/store/, the backend-neutral storage abstraction (ADR-0002/0004). This is the
"store" the domain will depend on; Firestore/Mongo/ArcadeDB become interchangeable implementations
behind it (none privileged in the domain), selected by STORAGE_BACKEND.
- records.StoredDocument: neutral read result ({exists,id,path,data}) with .to_dict()/.exists/.id
— never a Firestore snapshot, so a Mongo/ArcadeDB adapter returns the identical record.
- sentinels: neutral field transforms (DELETE / ArrayUnion / ArrayRemove / Increment /
SERVER_TIMESTAMP), each adapter maps to its own primitive.
- ports.DocumentStore (Protocol): path-string addressing + plain dict payloads + neutral
(field, op, value) filters + neutral transaction handle. No Firestore path/filter/snapshot
crosses this boundary.
Foundation only (pure stdlib, import+smoke verified); adapters + users.py migration follow.
…the storage port (WP2) Implement the neutral DocumentStore port with two interchangeable backends selected by configuration (ADR-0002/0004): - adapters/firestore.py: the reference adapter, wrapping database._client (db, delete_collection_recursive, run_transactional) and the SDK primitives (FieldFilter, @transactional). Neutral sentinels/filters are translated to Firestore here and nowhere else. - adapters/mongo.py: pymongo adapter. Path model maps each logical document to the Mongo collection named after its leaf collection, keyed by a full-path _id (globally unique, no cross-user subcollection id clashes), scoped by _parent, payload isolated under `d`. Neutral sentinels map to $set/$unset/$addToSet/$pull/$inc/$currentDate; transactions use a replica-set session. Firestore set(merge=True) deep-merge is documented as a contract boundary. - factory.get_document_store(): STORAGE_BACKEND seam, default firestore (ADR-0003, first-class). Adapters are imported lazily so a Firestore deployment never needs pymongo, and vice versa. - requirements.txt: add pymongo + mongomock (pylock regen via uv is deferred, see BACKLOG D5/D6). Verified by the dual-backend contract test (added in a following commit): 38/38 green, the same 19 behaviors identical on the Firestore emulator and a real Mongo replica set. WP1 boundary guard stays at 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…aseline (WP2) Add a `mongo` service gated behind the `mongo` compose profile so the default on-prem baseline stays Firestore-only (ADR-0003). Single-node replica set (--replSet rs0) because the deletion/speech-sample transaction paths require one; the healthcheck initiates the set on first probe and reports healthy once primary. backend.env.example documents STORAGE_BACKEND=mongo + MONGO_URI/MONGO_DB. The internal:true network seal is unchanged (mongo has no egress). docker compose --profile mongo up -d --build Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
The same 19 behaviors run against both adapters: the Firestore reference adapter (emulator) and the Mongo adapter (real single-node replica set). Covers point ops, set-merge vs full replace, projections, dotted-key nested merge, neutral sentinels (DELETE/Increment/ArrayUnion/ArrayRemove/ SERVER_TIMESTAMP), scoped subcollection queries with order+limit, get_many id-ordering, recursive delete of a document subtree, and transactions (commit + abort-on-exception). Parity is the proof that the port abstracts the backend rather than leaking Firestore. Not a hermetic unit test (needs live services) — lives in tests/contract/, skips a backend whose env is absent, and is not run by backend/test.sh. Offline run recipe in docs/BACKLOG.md §Handoff. Verified: 38 passed (19 x 2 backends) via mongo:latest replica set + omi-onprem-firestore-emulator, both sharing one network namespace so the loopback-only hermetic guard accepts them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…ge (WP2, D5/D6) Regenerate pylock.runtime.toml from requirements.txt with the pinned uv 0.11.13. Pure addition (+pymongo, +mongomock, +sentinels; zero unrelated version changes) so the production backend image — built via `uv pip sync pylock.runtime.toml` — ships pymongo and can run STORAGE_BACKEND=mongo. The dev pylocks (pylock.toml / macos / macos-x86_64 / windows) are intentionally NOT regenerated here: pylock.toml is the lock CI runs on Linux and it is stale relative to the versions already pinned in the macos/windows locks (fastapi 0.121 vs 0.134, cryptography 46 vs 48, ...). Refreshing it would bundle that unrelated harmonization into a WP2 change and shift CI's tested versions, so it is deferred to a separate reviewed lock refresh (tracked as WP2 debt D5-dev). Verified: freshly built omi-onprem-backend image imports pymongo 4.10.1 / mongomock 4.3.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…ymongo/mongomock (WP2 D5-dev) pylock.toml is the lock CI syncs on Linux (`uv pip sync pylock.toml`), and it was stale relative to requirements.txt: fastapi 0.121 vs 0.134, cryptography 46 vs 48, starlette 0.49 vs 1.3.1. Because the runtime/prod image is built from pylock.runtime.toml (already at the requirements pins), CI has been testing a different dependency set than production ships. Regenerate all four dev locks with the pinned uv 0.11.13: - pylock.toml: adds pymongo/mongomock/sentinels AND aligns to the requirements.txt pins (also pulls rich-toolkit, a transitive already present in the macos/windows locks). This closes the CI-vs-prod drift. - pylock.macos.toml / pylock.macos-x86_64.toml / pylock.windows.toml: were already aligned; they only gain pymongo/mongomock/sentinels. Validated on an env synced from the new pylock.toml (fastapi 0.134 / cryptography 48 / starlette 1.3.1): a representative sensitive suite passes — routers/users (19), byok cryptography (81), location-context consent (3), platform normalization (6), paywall/ws gate (32), ws surface (7), get_people_by_ids (2) = 150 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…ut pilot) First domain slice on the neutral port (ADR-0002). The seven people functions (create_person, get_person, get_people, get_person_by_name, get_people_by_ids, update_person, delete_person) now call the port via a module-level `_store()` seam instead of the raw Firestore client. Return shapes are unchanged (plain dicts), so the ~10 router/util callers are untouched. `db` stays imported for the not-yet-migrated functions in this module. - update_person is now transactional: the existence check and the rename run in one port transaction, which is atomic on both backends. This removes the read-then-write race the Firestore-only path guarded against by catching NotFound, so the google NotFound import is dropped. Test pattern established for the rollout: - `_store()` is the injection seam (the WP2 analogue of stubbing `users.db`). - tests/store_fakes.py: a reusable in-memory FakeDocumentStore (full port incl. transactions) for hermetic domain unit tests where mongomock can't help (it has no sessions). - Hermetic units: get_people_by_ids skip-malformed + people CRUD over a mongomock-backed real Mongo adapter (non-transactional, exercises the adapter in CI); update_person over FakeDocumentStore. - Live dual-backend parity: tests/contract/test_users_people_contract.py runs the migrated functions on the Firestore emulator AND a real Mongo replica set. Verified: 10 hermetic unit tests + 8 live people-contract tests (4 x 2 backends) green; persistence boundary guard at 0. The 3 pre-existing `database.persistence` import-isolation failures in the offline test image are unchanged by this diff (confirmed against baseline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…DK imports (WP2 rollout) Complete the users.py rollout begun with the people slice: every remaining function now talks to the neutral port via the _store() seam instead of the raw Firestore client. The module no longer imports `db`, `firestore`, `FieldFilter`, `transactional`, `get_firestore_client`, or `delete_collection_recursive` — only the pure `document_id_from_seed` helper. Return shapes are unchanged (plain dicts / models), so the many router/util callers are untouched. What moved: - user-doc accessors, projections (get(fields=...)), and cache closures (language, transcription prefs, ai profile); neutral sentinels replace firestore.ArrayUnion / DELETE_FIELD. - subcollections: client_devices, people (earlier), task_integrations, integrations. - the account-deletion state machine: the 16 @transactional helpers become closures / neutral-tx logic helpers over store.run_transaction; the wipe queries become store.query; delete_user_data becomes store.delete_recursive(users/{uid}). - speech samples + speaker embeddings; get_user_by_stripe_customer_id -> store.query. - location-context consent drops its firestore_client DI param for the _store seam. Tests reshaped off the old `db` stub onto the port (mongomock real-adapter, FakeDocumentStore, or a live backend): claim/reconcile + add-sample transaction bodies now take a neutral tx; missing-doc guards, subscription read-boundary, client-device provenance, user speaker embedding, and location consent inject `_store`. Verified in the offline test image: the directly-affected unit suites (claim/deletion, add-sample, people, missing-doc guards, subscription boundary, client-device, speaker embedding, location consent, byok, platform, transcription prefs, agent-vm, routers/test_users, services/test_data_export) are green; the 23 database.users importers pass; the dual-backend contract (port + users people) is 38+8 green on the Firestore emulator and a real Mongo replica set; the persistence-boundary guard stays at 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…+ neutral errors (WP2, ADR-0016) The domain rollout past users.py needs operations the neutral port didn't cover (conversations.py: create-if-not-exists markers, batched subcollection writes, .select() projections). Add them to the contract, both adapters, and the FakeDocumentStore: - create(path, data): insert-if-absent; on conflict raises the neutral database.store.errors. AlreadyExists (Firestore AlreadyExists/Conflict, Mongo DuplicateKeyError map to it) so the domain never catches an SDK exception. - batch() -> WriteBatch: path-based set/update/delete + commit, for throughput. Firestore commits atomically; Mongo groups by collection and bulk_writes. Documented as NOT a cross-backend atomicity guarantee — use run_transaction for read-modify-write. - query(..., fields=[...]): projection (Firestore select, Mongo projection). - errors.py: minimal neutral store errors (StoreError, AlreadyExists). - FakeDocumentStore now resolves neutral sentinels in set() (fixes ArrayUnion-in-set-merge) and gains create/batch/query-projection. Verified: dual-backend contract test at 52 (added create+conflict, batch set/update/delete, query projection — each on the Firestore emulator and a real Mongo replica set). Boundary guard at 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…WP2, ADR-0017) conversations.py stamps updated_at from the Firestore snapshot update_time; the neutral record had no equivalent and Mongo exposes no server update_time the same way. Add StoredDocument.updated_at, filled by the adapters (not the domain): - Firestore: from snapshot.update_time (DB commit time), normalized tz-aware. - Mongo: stamp an _updated_at metadata field (app-server write time) on every set/update/create/batch and return it. - FakeDocumentStore: monotonic stamp (base + counter) so a later write always reports a not-earlier revision — deterministic for units. Documented boundary: the time source differs by backend (Firestore DB-commit vs Mongo app-server); both are "last write", not a microsecond-comparable cross-backend order (not needed — per-document). Verified: dual-backend contract test at 54 (updated_at populated and non-decreasing across two writes, on the Firestore emulator and a real Mongo replica set). Guard at 0; users.py unit suites green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
conversations.py paginates (offset) and aggregates (count); add both to the port surface and both adapters + FakeDocumentStore: - query(..., offset=N): Firestore .offset(), Mongo .skip(), fake slice. - count(collection, filters=...): Firestore .count().get(), Mongo count_documents, fake len. Verified: dual-backend contract test at 58 (added offset+count on the Firestore emulator and a real Mongo replica set). Boundary guard at 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…op SDK imports (WP2 rollout) Second domain module fully on the neutral port (ADR-0002). Every function talks to the port via the _store() seam; the module no longer imports the Firestore client/SDK (db, firestore, FieldFilter, run_transactional, delete_collection_recursive, get_firestore_client) — only google.api_core's generic NotFound (guard-allowed) for the claim-status not-found signal. Return shapes unchanged. Mappings used: _document_data_with_revision now reads StoredDocument.updated_at (ADR-0017); .select([]) -> list_ids; .select([...]) -> query(fields=...); FieldFilter chains -> query(filters); order_by DESCENDING -> direction='desc'; limit/offset -> query params; .count() -> store.count; .create() -> store.create (neutral AlreadyExists); db.batch() -> store.batch() (5 batch loops: photo delete, protection-level migration, unlock, model segments/emotions); db.get_all -> get_many; @firestore.transactional (7 bodies: upsert/persist/segment-text/claim-status/write-segments/ store-photos) -> store.run_transaction closures; delete_conversation -> delete_recursive; firestore_client DI params dropped for the _store seam; the removed _firestore_revision_datetime helper is gone. Tests reshaped off the raw-db fakes onto the port: transcripts-missing-start, delete-cascade, discard-revival (FakeDocumentStore); the expired-transaction regression now targets FirestoreDocumentStore.run_transaction directly (the layer that owns run_transactional). New live dual-backend contract test covers create/conflict, get+revision, query/count/delete, enum-filter, and create-once marker. Verified: 16 conversations importers + 4 router/service suites green; dual-backend contract at 64 (port + users people + conversations) on the Firestore emulator and a real Mongo replica set; boundary guard at 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…torage port (WP2, ADR-0018)
chat.py's reconcile journal paginates by keyset and must not skip/duplicate rows that share the
order_by value. Add query(start_after={'value','id'}): returns rows strictly after the (order_by
value, id) position, with a document-id tiebreak.
- Firestore: order_by(field)+order_by(__name__)+start_after([value, id]).
- Mongo: $or keyset with a full-path _id tiebreak, and sort ALWAYS [(field,dir),(_id,dir)] so tie
order is stable across pages (mirrors Firestore's implicit __name__ ordering).
- FakeDocumentStore: sort by (value, path), filter after the cursor.
Verified: dual-backend contract at 68 — walking 5 tie-valued docs in pages of 2, asc and desc, visits
each exactly once with no duplicates, on the Firestore emulator and a real Mongo replica set. Guard 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
Move database/chat.py off the raw Firestore client onto the neutral
document-store port (STORAGE_BACKEND: Firestore | Mongo). No SDK symbols
remain in the module; all persistence goes through get_document_store()
via the module-level _store() seam, neutral Filters, and neutral
sentinels (ArrayUnion/ArrayRemove/Increment). Caller contracts (dict
returns) are unchanged.
Behavioral changes worth calling out:
* get_messages_reconcile_page now uses the port keyset capability
start_after={'value','id'} (ADR-0018) instead of a created_at '<'
filter. The filter shortcut was tie-UNSAFE: messages sharing a
created_at were silently skipped across page boundaries. The keyset
cursor is deterministic on (order_by value, id) and tie-safe; the
reconcile contract walks equal-timestamp docs in pages without gaps or
duplicates on both backends.
* delete_messages converts the Firestore last_update_time OCC
preconditions to a port run_transaction: the session docs are read and
the message deletes + inverse counter/id/preview updates commit
together, and the transaction's contention retry replaces the raw
precondition loop (ADR-0002). The decrement stays bounded by the stored
counter (never negative), and the requery loop drains backlogs larger
than one batch.
Tests reshaped from raw-db mocks to behavioral FakeDocumentStore tests
through the _store() seam:
* test_desktop_migration.py (107 tests) fully reshaped. This also
resolves debt D10: the 7 assistant/notification wire-compat tests left
red by the users.py migration (f930655) now assert on stored-doc
state instead of raw db call patterns.
* test_chat_session_normalize.py, test_chat_message_count.py reshaped to
the port (scripted-count store for the count aggregation paths).
* Two delete_messages tests that pinned the now-removed Firestore
precondition mechanism (FailedPrecondition retry bound, losing-clear
requery) were replaced with the surviving invariants they protected:
decrement bounded by stored count, and paginate-across-batches
termination.
Verification (offline test image omi-onprem-backend-test):
* tests/unit/test_desktop_migration.py: 107 passed
* every chat-importing unit test file, file-isolated: 31/32 green; the
one failure (test_conversation_events_bounds) is the pre-existing
database.persistence import-isolation break (backlog D9), unrelated to
this change (fails before any chat import).
* dual-backend contract (real Firestore emulator + Mongo replica set):
68 passed (document_store + conversations + users).
* persistence-boundary guard: exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…pter (WP2) The WP2 conversations migration moved the neutral revision (ADR-0017) into FirestoreDocumentStore._revision, but that only accepted a datetime — it silently dropped the protobuf-Timestamp handling the pre-port database boundary had (_firestore_revision_datetime): a snapshot update_time exposed as a ToDatetime()-capable object, or raw seconds/nanos (str or int), would degrade to updated_at=None instead of a datetime. The production client returns a DatetimeWithNanoseconds so prod was unaffected, but emulators/fakes can expose the protobuf shape, so the neutral revision was not backend-neutral. _revision now normalizes datetime (made tz-aware), ToDatetime(), and seconds/nanos to an aware datetime — the same SDK-variation handling as before, now at the adapter boundary where ADR-0017 puts it. test_conversation_revision_contract.py is reshaped from the old raw-Firestore fake (it patched the removed conversations.db) to behavioral FakeDocumentStore tests through the _store() seam, and the two protobuf-normalization cases now target the adapter's _revision directly (where the logic lives). Verification (offline test image): * tests/unit/test_conversation_revision_contract.py: 16 passed * dual-backend contract (real Firestore emulator + Mongo replica set): 68 passed * persistence-boundary guard: exit 0 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
A full offline-suite sweep (768 unit files, file-isolated) compared against a
baseline worktree at the branch point isolated 16 test files that fail on
fullonprem but pass at baseline — all broken by our WP1 boundary sealing and
WP2 port migration, none a product defect. Fixed by class:
WP1 boundary modules missing from import-isolation stub lists (tests that stub
the `database` package and load a module which now imports a WP1-introduced
submodule):
* database.persistence (the DI handle): test_batch_upload_storage,
test_conversation_events_bounds, test_kg_user_type_mismatch, test_tools_router,
test_conversation_search_date_validation, test_merge_validation (debt D9)
* database.sentinels: test_async_resource_correctness
* database.document_ids: test_memories_validation
* google.api_core.exceptions.InvalidArgument (reached via the WP1 document_store
handle → database._client) and database.users.{update,clear}_agent_vm (WP1
moved the agent-VM Firestore ops into database/users.py): test_tools_agent_
route_response_shape, test_agent_vm_reaped_record_recovery
WP2 migration: tests that patched the removed raw `conversations_db.db` /
`users_db.db`, reshaped to behavioral FakeDocumentStore tests via the _store()
seam: test_conversation_lifecycle_contract, test_lazy_conversation_processing,
test_people_conversations_500s.
WP2 API/shape changes: test_conversations_count dropped its static source-scrape
tripwire (FieldFilter strings) for a behavioral test of the real function on the
port; test_public_shared_conversation_chat drops the removed firestore_client
kwarg and asserts the field mask via a capturing store; test_google_calendar_
auth_recovery asserts google_utils.DELETE_FIELD (now from database.sentinels).
Offline-harness only (not a code regression): test_check_firestore_persistence_
boundary skips when its repo-root guard script is absent (the backend-only test
image does not mount .github/); CI checks out the full repo so the guard still runs.
Verification (offline test image omi-onprem-backend-test):
* all 16 files green (or skipped) file-isolated
* full 768-file re-sweep vs baseline: HEAD failures == baseline failures exactly,
0 remaining regressions (the residual 79 failures are pre-existing/environmental,
identical on the pre-WP baseline)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…-0019)
The apps.py rollout (personas, capabilities) needs Firestore's array_contains
predicate, which the neutral port did not expose. Added array_contains to the
(field, op, value) filter set: it selects documents whose array-valued field
contains value.
* Firestore adapter: native — FieldFilter(field, 'array_contains', value) is
passed through unchanged; no adapter change needed.
* Mongo adapter: {"d.field": value} in the shared _filter (query + count) —
Mongo matches an array field against a scalar by membership.
* FakeDocumentStore: membership test in _OPS.
* ports.py docstring updated to list the operator.
Verification: dual-backend contract adds test_query_array_contains (query +
count) — 70 passed against the real Firestore emulator + Mongo replica set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
Move database/apps.py off the raw Firestore client onto the neutral
document-store port (STORAGE_BACKEND: Firestore | Mongo). No SDK symbols remain;
all persistence goes through get_document_store() via the _store() seam, neutral
(field, op, value) filters, and neutral ArrayUnion/ArrayRemove sentinels. Caller
contracts (dict returns) are unchanged.
Mappings applied:
* Top-level collections (plugins_data / plugins / testers) and their
subcollections (reviews / api_keys, usage_history) addressed as neutral paths.
* BaseCompositeFilter('AND', [FieldFilter...]) -> filters list (every composite
was AND; no OR queries). Positional .where('username','==',x) -> filter tuple.
* capabilities/persona predicates -> array_contains operator (ADR-0019).
* .add(data, id) -> store.create (Firestore .add() uses .create(): fail-if-exists).
* .set/.update/.delete -> store.set/update/delete; .count().get() -> store.count;
.stream()/.get() query -> store.query; .order_by(DESCENDING) -> direction='desc';
.limit(1) -> limit; ArrayUnion/ArrayRemove -> neutral sentinels.
* migrate_app_owner_id_db uses the neutral record .id.
test_app_visibility_missing_doc_guard reshaped from a fresh-load + raw-db mock to
a behavioral FakeDocumentStore test via the _store() seam.
Verification (offline test image):
* every apps-importing unit test file, file-isolated: 26/26 green
* dual-backend contract: 70 passed; persistence-boundary guard: exit 0
* full 768-file re-sweep vs baseline: 0 new regressions (HEAD failures ==
baseline failures exactly)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
The memories.py rollout reads with a two-field ordering (scoring DESC, then created_at DESC) plus limit/offset; the port only exposed a single-field order_by, and degrading to the primary field alone would change pagination (ties on scoring are non-deterministic across pages). query(order_by=...) now accepts either a single field name (str, unchanged) or a sequence of (field, direction) pairs applied most-significant first. Keyset (start_after) stays single-field. * Firestore adapter: one chained .order_by(field, direction) per pair (native). * Mongo adapter: cursor.sort([(d.f1,d1),(d.f2,d2),..., (_id, last_dir)]) — the _id tiebreak (mirrors __name__) stays for single-field/keyset consistency. * FakeDocumentStore: successive stable sorts (most-significant last) with an ascending path tiebreak. The single-field str branch is left byte-identical so the keyset start_after ordering it relies on is unchanged. Verification: dual-backend contract adds test_query_multi_field_order_by — 72 passed against the real Firestore emulator + Mongo replica set; guard exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…t (WP2, ADR-0021)
Move the memory subsystem — memories.py, memory_ledger.py, projection_repair.py,
short_term_memories.py — off the raw Firestore client onto the neutral document
store (STORAGE_BACKEND: Firestore | Mongo). No SDK symbols remain; all persistence
goes through get_document_store() via the _store() seam.
Contract changes (ADR-0021):
* projection_writer is now neutral: the callback receives the port's transaction
(get/set/update/delete by path) and addresses memory docs by logical path
(users/{uid}/memories/{id}), not by Firestore DocumentReference. Same for the
mutation_builder(tx) in append_commit_with_builder.
* memory_ledger.append_commit* run in _store().run_transaction(fn). Contention
retry is now owned by the port's transaction runner (Firestore run_transactional,
Mongo with_transaction), so run_with_transaction_contention_retry /
_typed_transactional are no longer used by the ledger. Read-before-write ordering
(the BasedHardware#9780 apply_control fallback read) is preserved.
* The firestore_client DI parameter is retired from all four modules → _store()
selected by config. In-tree callers are unaffected: the util call sites that pass
firestore_client target task-intelligence/workstreams functions (not migrated);
legacy_backfill already falls back when the reader rejects the kwarg.
* tx.update on a missing document is adapter-defined; callers that need idempotent
"update-if-exists" (invalidate_memory, short_term.mark_consolidated) now guard
with tx.get(path).exists rather than catching a NotFound raise.
Mappings: .stream()/.get() query -> store.query; get_all -> get_many;
.select([]) -> list_ids; .select([...]).stream() -> query(fields=); 10 batch loops
-> store.batch(); multi order_by (scoring, created_at) -> query(order_by=[(f,dir)])
(ADR-0020); memory_id/app_id/is_locked filters -> neutral filters.
Tests reshaped from raw-db/firestore_client mocks to behavioral FakeDocumentStore
tests via the _store() seam (mongomock has no transactions): test_memory_ledger
(strict-tx store preserves the read-before-write guard; 4 ledger-level contention/
typed_transactional tests removed as that mechanism moved to the port),
test_firestore_di_seam, test_mark_consolidated_missing_doc,
test_memories_delete_batch_chunk, test_memories_stale_updates,
test_migrate_memories_rekey.
Verification (offline test image):
* every memory-module test file, file-isolated: 60/61 green; the one failure
(test_shared_ns2_legacy_isolation_readiness) is pre-existing/environmental — a
repo-root docs file absent in the backend-only image, identical on the baseline
(backlog D12), not a regression.
* dual-backend contract (real Firestore emulator + Mongo replica set): 72 passed.
* persistence-boundary guard: exit 0.
* full 768-file re-sweep vs baseline: 0 new regressions (HEAD failures == baseline).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…2 D1, ADR-0022) database/document_store.py — the WP1 path-based seal used by the memory service layer — becomes a thin shim over the neutral port (get_document_store()). Its functions no longer take a db_client and no longer return Firestore snapshots; reads return a neutral StoredDocument (.exists / .to_dict() / .id): * get_document(path), document_exists(path), set/update/delete_document, stream_collection(path), stream_collection_where(path, field, op, value, limit=) * transactions: run_transaction(fn) with fn(tx) using tx.get/set/update/delete by path; the port's runner owns contention retry. * the get_document timeout kwarg is retired (no port backend exposes it). The 14 callers (utils/memory/* + jobs/short_term_lifecycle_worker.py) stop passing db_client to document_store; the two transaction sites (canonical_memory_adapter, short_term_lifecycle_worker) move to document_store.run_transaction(fn). The db_client parameter STAYS on those service functions — it is the pervasive DI convention still threaded to other, not-yet-migrated modules (resolve_memory_system, kg_db, read_canonical_memories, atomic_bump_source_generation, the vector-repair outbox writer, ...); purging it everywhere is a separate wave (D2). This is not a compatibility shim: document_store simply leaves the DI, the rest keeps the current convention. Tests: FakeDocumentStore gains a `backing=` ctor param so a test can share its Firestore-shaped fake's path->data `.docs` dict — document_store._store() and the injected db_client fake then see one store. 30 memory-service/router test files were reshaped to patch document_store._store over that shared dict and to rework assertions that checked the fake's now-unused Firestore call mechanics (.collection_paths / .document_get_paths / .set_calls) into behavioral assertions. The bulk (29 files) was done by a fan-out of one subagent per file, each self-verifying its file green in the offline image; the router test was reshaped separately. Verification (offline test image): * every reshaped file green, file-isolated. * dual-backend contract: 72 passed; persistence-boundary guard: exit 0. * full 768-file sweep vs baseline: 0 net regressions (the residual 79 failures are the pre-existing/environmental baseline set — repo-root files absent in the backend-only image, backlog D12; incl. test_rollout_schema_readiness and test_workflow_contracts, both failing identically on the baseline). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…nal (D4, ADR-0023) The persistence-boundary guard already excludes backend/scripts/ (one-off operational tooling and emulator tests that must speak raw Firestore) and backend/agent-proxy/ (a separately deployed service with its own firebase_admin app). ADR-0023 records these as intentional, permanent exclusions rather than latent debt — closing backlog D4. Comment now points at the ADR instead of a dangling "ADR follow-up". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
… + add query_group (WP2 D2, ADR-0024/0025) First batch of the WP2 rollout-completion program (ADR-0024): 8 self-contained DI=0/tx=0 database modules moved off the raw Firestore client onto the neutral port via the _store() seam, migrated by a fan-out of one subagent per module (each self-verifying its module import + tests green in the offline image): auth, tasks, import_jobs, trends, entities, focus_sessions, daily_summaries, fair_use Each: db.collection/document chains -> port path ops; FieldFilter -> neutral (field,op,value) filters; order_by(DESCENDING) -> direction='desc'; no SDK symbols remain; public return shapes unchanged (no caller changes — these modules take no injected client). Port extension — query_group (ADR-0025): fair_use needs a Firestore collection-group query (fair_use_state / fair_use_events across all users). Added query_group(group, *, filters, order_by, direction, limit, offset) to the port: returns records carrying the full logical path so the caller recovers the parent (uid). Firestore adapter uses collection_group(); the Mongo adapter reads the whole leaf-named collection with no _parent scope (docs from every parent already live there per the path model); the fake matches by containing-collection leaf name. Dual-backend contract adds test_query_group_spans_parents. Tests reshaped to the _store() seam (FakeDocumentStore); a new test_fair_use_store.py was added for the collection-group paths. Verification (offline test image): * every migrated module SDK-clean; module imports + reshaped tests green. * dual-backend contract: 74 passed (incl. query_group x2 backends). * persistence-boundary guard: exit 0. * full 768-file sweep vs baseline: 0 net regressions (residual 79 == baseline, D12). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
… (WP2 D2, ADR-0024) Second batch of the rollout-completion program: 10 self-contained DI=0 database modules moved onto the neutral port via the _store() seam (fan-out of one subagent per module, each self-verifying): folders, user_usage, phone_calls, notifications, announcements, llm_usage, wrapped, screen_activity, x_posts, advice Uses established port surface only (no port extension this batch): path ops, neutral filters, order_by/limit/offset, run_transaction (llm_usage/user_usage transactional paths), and query_group for collection-group reads (notifications 'fcm_tokens', llm_usage cross-user 'llm_usage'). Public return shapes unchanged; no caller changes (DI=0). Tests reshaped to FakeDocumentStore via _store(); new test_screen_activity_store and test_wrapped_store_migration added. Verification (offline test image): all 10 modules SDK-clean; module imports + tests green; dual-backend contract 74 passed; persistence-boundary guard exit 0; full 768-file sweep vs baseline = 0 net regressions (residual 79 == baseline, D12). Note: the two modules' subagents (llm_usage, user_usage) hit the session limit during their final self-report; their source+tests had already landed and are verified green here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
… (WP2 D2, ADR-0024) Third batch of the rollout-completion program: the last 8 self-contained DI=0 database modules onto the neutral port via _store() (fan-out, one subagent per module, self-verified): app_review_config, calendar_meetings, mcp_oauth, phone_call_config, staged_tasks, task_intelligence_control, webhook_health, x_sync_registry Established port surface only (no port extension): path ops, neutral filters, run_transaction (mcp_oauth consume/exchange/rotate; calendar_meetings upsert), store.batch() (staged_tasks, calendar_meetings). Return shapes unchanged; DI=0 so no caller changes. Tests reshaped to FakeDocumentStore via _store(); new test_x_sync_registry_store_seam added. Also completes a D9-class stub fix: test_desktop_migration stubs database._client for import isolation; now that task_intelligence_control reads via _store() -> get_document_store() -> the Firestore adapter (which imports run_transactional from _client at module load), the stub must expose run_transactional. Added it. Verification (offline test image): 8 modules SDK-clean; module imports + reshaped tests green (test_desktop_migration 107 passed); dual-backend contract 74 passed; guard exit 0; full 768-file sweep vs baseline = 0 net regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…port + drop injected client (WP2 D2, ADR-0024)
Migrate six database modules that took an injected Firestore client onto the
neutral storage port and remove the db_client/firestore_client parameter, updating
every in-tree caller in the same change (no compatibility shim, upstream rule):
memory_vector_repair_outbox_worker, memory_imports, memory_compatibility_projection,
memory_app_key_grants, recording_sessions, recurrence_inbox
Chosen as a disjoint-caller set so the per-module caller edits do not overlap.
Callers updated to stop threading the client: routers/memories.py,
utils/memory/{product_authorization,v3/production_runtime}.py,
utils/conversations/lifecycle.py, utils/task_intelligence/workstream_association.py,
database/dev_api_key.py (call site only; dev_api_key stays raw-SDK, still DI>0),
and the two scripts/vector_repair_outbox_* entrypoints + scripts/listen_lifecycle_emulator_test.py.
store/sentinels.py: give the DELETE / SERVER_TIMESTAMP singletons __copy__/__deepcopy__
so identity survives a (deep)copy of a write payload (adapters/fakes match by identity).
Verification: guard check_firestore_persistence_boundary.py = 0; all six modules
SDK-clean and parameter-free; no call site still passes the client to them; the 13
touched test files pass file-isolated; full 768-file file-isolated sweep vs the
branch-point baseline = 79/79, 0 net regressions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
Add a "Testing the backend offline" section: build the test image, run suites FILE-ISOLATED (one process per file — the suite's module-level state cross-contaminates otherwise), the parallel full sweep (xargs -P over one-process-per-file, ~7.5x; not pytest-xdist loadfile, which reuses processes and reintroduces contamination), the regression guard, the dual-backend contract test, and the pre-existing environmental failures note. This is the durable runbook, kept in the repo so it travels with the code; the planning backlog keeps only living state and points here. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
… known residuals
Commit deploy/onprem/Dockerfile.test (was an ad-hoc snippet): WP0 backend image +
git + helm + `git config --system safe.directory '*'` + the test-only Python deps.
Triage of the offline image's failing set (was 79 files) found none were self-host
regressions (HEAD-vs-branch-point failure signatures identical) — they were harness
artifacts:
- 71/79 failed only because the container mounted backend/ at /app, flattening the
tree so repo-root path walks overshot to / -> FileNotFoundError. Fix: mount the repo
root (-v <repo>:/repo -w /repo/backend).
- git/helm contract tests errored for lack of the binaries + git dubious-ownership on
the host-owned mount. Fix: install git+helm, set safe.directory.
- two files asserted the wrong environment because the harness forced
OMI_ENV_STAGE=offline (a runtime-only var). Fix: drop it from the unit sweep.
Result: full unit sweep green except two pre-existing, non-self-host residuals
(test_auto_dev_backend_scope executes the real CI workflow bash; test_ws_auth_handshake
needs LOCAL_DEVELOPMENT unset, which conflicts with the dev-bypass ~46 files require).
SELFHOST_NOTES documents the corrected harness and both residuals.
Verified: full 773-file file-isolated sweep under the new harness = 2 failures (the
documented residuals), 0 regressions vs the previous harness (77 files fixed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…to the neutral port (WP2 D2, ADR-0024) Disjoint-caller batch, one unit per agent: - Desktop cluster (desktop_beta_breakglass, desktop_previews, desktop_update_channels, desktop_update_policy): migrated to the port and the firestore_client parameter dropped from the three that had it. These are intra-coupled (beta_breakglass calls update_channels) and share routers/updates.py, so they move as one unit. routers/updates.py and utils/desktop_update_resolver.py already called them with the default client, so no call-site edits were needed there; testing/desktop_beta_admission/firestore_contention_test.py updated. - dev_api_key, mcp_api_key, sync_ledger: source-only migrations (no injected parameter). - memory_non_active_routes: its persist_non_active_route_outcome took a db_client parameter; dropped it and cascaded the drop up through utils/llm/working_observations.py and utils/memory/patch_adapter.py (removed their now-dead db_client params and branches). Verification (new harness, ADR-0026: repo-root mount, no OMI_ENV_STAGE): guard = 0; all eight modules SDK-clean and parameter-free; no call site still passes a client to them or to the cascaded helpers; touched test files reshaped to FakeDocumentStore; full file-isolated sweep = the two known residuals only, 0 net regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…sation_finalization_jobs) (WP2 D2, ADR-0024/0027)
memory-canonical cluster (one unit — shared canonical_* callers):
- review_queue (source-only), knowledge_graph and memory_apply_store (drop db_client; 4 transactions
in memory_apply_store rewritten to _store().run_transaction). Callers updated to stop threading the
client: routers/{memories,knowledge_graph}.py, utils/llm/knowledge_graph.py, utils/memory/{
canonical_memory_adapter,canonical_consolidation,canonical_required_processing,canonical_kg_promotion,
kg_graph_traversal,short_term_promotion,legacy_backfill}.py, scripts/firestore_python_apply_emulator_test.py.
(database/entities.py imports knowledge_graph but called it without a client — left as is.)
conversation_finalization_jobs (1393 lines, 15 transactions, 2 collection-groups): migrated; the
cross-user resumable sweep pages a query_group by document-name keyset. Callers updated:
routers/{conversation_finalization,pusher}.py, services/conversation_finalization.py,
utils/conversations/lifecycle.py, testing/e2e/fakes/listen_pusher_wire.py.
Port: add start_after (document-name keyset) to query_group (ADR-0027) across ports.py + both
adapters + FakeDocumentStore, with a dual-backend contract case. The contract test caught a real
Firestore-adapter bug — the cursor was passed as a bare DocumentReference (works on the fake, breaks
the real SDK's cursor normalization); fixed to pass it as a single-element list matching the __name__
order field.
Verification (new harness, ADR-0026): guard = 0; the four modules SDK-clean and parameter-free; no
call site still threads a client to them; full file-isolated sweep = the two known residuals only,
0 net regressions; dual-backend contract = 76 passed (Firestore emulator + Mongo replica set).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…rt (WP2 D2, ADR-0024) Final DI>0 unit: the five tightly-coupled task-intelligence modules — action_items, goals, task_recommendations, candidates, workstreams (~5700 lines, 34 transactions total) — migrated to the storage port with firestore_client dropped. Migrated sequentially (not in parallel) because they share ~30 caller files (routers/*, utils/task_intelligence/*, utils/llm/*); each step is self-consistent since a caller may still thread the client into a not-yet-migrated sibling. The sequential order left a transient _FirestoreTxRelationshipReader shim in candidates.py (so its still-raw transaction could call action_items' already-neutral helper); the candidates step removed it and passes a neutral transaction handle directly. No compatibility layer is committed. config/task_intelligence_sources_v1.json: retire the now-stale workstreams.py "canonical_firestore.action_items" writer anchor. That source-scrape anchor is only discovered for raw-Firestore transactional writers; workstreams now runs through _store().run_transaction, so it is legitimately no longer a canonical-firestore writer. Domain ownership stays recorded via the source's owner_paths. Verification (new harness, ADR-0026): guard = 0; all five modules SDK-clean and parameter-free; no call site still threads a client to them; full file-isolated sweep = the two known residuals only, 0 net regressions (test_sync_v2 is flaky under parallel load — 168/168 in isolation). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…rt (WP2 D2, ADR-0024) Complete the database/*.py rollout with the two modules missed by the earlier enumeration: - memory_vector_repair_outbox.py: write_vector_repair_purge_outbox_records dropped its db_client parameter and now persists via _store().set; callers updated (routers/memory_product.py and the scripts/ emulator test, whose failure-propagation check now monkeypatches the module's _store). - helpers.py: _get_user_profile_for_data_protection dropped its firestore_client parameter. Post migration no decorated database function threads firestore_client, so the set_data_protection_level decorator's client-bypass branches were dead; simplified to always use the cached/users_db path, removing the last raw Firestore access (and the now-unused _typed_doc helper). Tests reshaped to the _store() seam (test_vector_search_service, test_product_memory_router). Every database/*.py module is now on the storage port. The only remaining consumer of an injected client is the canonical-memory subsystem's fail-closed gate (utils/memory, tracked as D3), which gates removal of the database.persistence.db handle. Verification (new harness): guard = 0; both modules SDK-clean and parameter-free; full file-isolated sweep = the two known residuals only, 0 net regressions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…— WP2 D2 CLOSED (ADR-0028)
The final step of WP2: with every backend/database/*.py module on the neutral storage
port, the injected db_client/firestore_client was vestigial across the canonical-memory
subsystem — it performed no raw Firestore op (read-helpers ignored it and used the port),
serving only as the fail-closed gate's "was a client wired" signal. The port makes
persistence always available per-request, so that failure mode cannot occur.
- utils/memory/* (25 files): dropped db_client from ~61 function signatures, MemoryService,
and the v3 production-runtime config. Retired the `if db_client is None: ... fail_closed`
branch in canonical_activation.canonical_write_decision (reason "missing_db_client" is gone);
the substantive gate — canonical cohort membership + read_v3_control readiness — is unchanged.
- 17 router/util entry points: stopped importing database.persistence and stopped threading
the client into the memory subsystem.
- Deleted backend/database/persistence.py (the raw-client re-export handle).
- Swept the long tail: jobs/short_term_lifecycle_worker.py (ignored param dropped + callers),
utils/task_intelligence/workstream_{index,association}.py (inert firestore_client dropped),
and 7 scripts that still passed the kwarg to migrated functions (kept each script's own raw
emulator-verification client).
- Reshaped the affected tests; deleted two that asserted the retired behavior
(missing_db_client fail-closed; explicit firestore_client threading to the legacy reader).
WP2 D2 is closed: the datastore is fully behind the neutral port with interchangeable
Firestore/Mongo implementations, and no raw persistence client is injected anywhere in-tree.
Verification (new harness, ADR-0026): guard = 0; no db_client/firestore_client threaded to a
migrated function anywhere; full file-isolated sweep = the two known residuals only, 0 net
regressions; dual-backend contract = 76 passed (Firestore emulator + Mongo replica set).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…ssed ports (D2 close follow-up) database/persistence.py was deleted in 90bce17 (WP2 D2). This removes the now-dangling reference to it from the guard's docstring and FAIL hint. Left uncommitted there because the D2-close commits were scoped to backend/; the guard lives under .github/scripts/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
…rt invariant across 205 upstream commits Periodic upstream alignment (branch-point b2afb37): 50 ours / 205 theirs. Resolved per ADR-0029 ("the clean merge lies") — a merge is not done at conflict resolution; a post-merge audit re-establishes the WP2 invariant (all persistence behind the neutral port, no raw client / db_client outside database/store) across the WHOLE merged tree, not just conflicts. Resolution: - 43 content conflicts (18 source + 25 test): kept upstream's new behavior, re-expressed through the port (_store(), neutral sentinels/transactions); tests reshaped to the FakeDocumentStore seam. - 4 modify/delete: accepted upstream's removal of utils/memory/{patch_adapter,promotion_bundle_builder}.py and their tests (no surviving callers). Post-merge audit (the part a naive merge silently breaks): - Guard caught 4 files auto-merged/new that reintroduced raw Firestore outside database/: three NEW upstream routers (desktop_agent_vm, desktop_realtime, desktop_tts_updates) and the new utils/memory/v3/compatibility_projection_sync.py — all migrated to the port. - memory_service.py: dropped a db_client=self._db_client arg auto-merged onto a field removed in ADR-0028. - 17 test regressions fixed (all test-side: db_client= kwargs, module.db references, import-isolation stubs missing run_transactional, and one hermeticity-meta cascade over the known ws_auth residual). Verification (new harness, ADR-0026): guard = 0; no db_client/firestore_client/database.persistence reintroduced anywhere; full file-isolated sweep = the two known residuals only, 0 net regressions; dual-backend contract = 76 passed (Firestore emulator + Mongo replica set). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ULLL5zPwMU6rqatBtePGHU
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.
First, thank you for building Omi in the open — betting on personal context as the moat, and on open source as the trust wedge, is exactly right, and it's what made us want to contribute rather than just watch.
This PR delivers the first milestone from #10884 : the datastore. We took the manifesto at its word. If Omi's edge is all of a person's context, and open source is the trust wedge, then the strongest possible proof of the privacy commitment — the one an enterprise security team will actually accept — is simple: you can run the whole thing yourself, and no byte of context has to leave your walls.
So we made the backend run entirely on-prem, with storage sitting behind a neutral abstraction that has real, interchangeable implementations. The cloud backends stay first-class for everyone who wants them. Nothing about the hosted product changes; we just made "host it yourself, keep your data" a supported, tested reality.
STORAGE_BACKEND).database/may touch a raw persistence client.What landed
Offline baseline (WP0) — a hermetic
deploy/onprem/compose (backend + Firestore emulator + Valkey) on aninternal: truenetwork; proven no-egress. Runbook indeploy/onprem/SELFHOST_NOTES.md.Sealed the persistence boundary (WP1) —
database/is now the only door to the datastore, enforced by an AST guard (.github/scripts/check_firestore_persistence_boundary.py, exit 0 = clean). No moredb.collection(...)scattered across routers/utils.Neutral storage port + two implementations (WP2) — a small, backend-agnostic
DocumentStoreport (backend/database/store/) that speaks logical paths + plain dicts + neutral sentinels, never Firestore snapshots. Surface:get / set / update / delete / exists / query / query_group / count / get_many / list_ids / delete_recursive / run_transaction / batch, with keyset pagination andarray_contains/ multi-field ordering. Two adapters behind it — a Firestore reference adapter and a MongoDB adapter (single-node replica set for transactions) — plus an in-memory fake for tests.Migrated the entire data layer — all ~50
backend/database/*.pymodules now run on the port (users, conversations, chat, apps, the whole memory subsystem, task-intelligence, desktop, sync, and the long tail), including their transactions and collection-group queries. The injected raw-client threading was removed end-to-end; no compatibility shims were left behind (per the repo's no-compat rule — every in-tree caller migrated in the same change).Kept everyone honest with tests — a dual-backend contract suite proves the two adapters behave identically against real services; a reproducible offline test image and a parallelized, file-isolated test sweep gate every change against a branch-point baseline (net regressions: zero).
Wrote the decisions down — the initiative is captured as a set of ADRs (on-prem goal, uniform abstraction pattern, the port's shape and its incremental extensions, the deliberate deviations from upstream conventions, and the offline test harness), so the why travels with the code.
Principles we stuck to
Not in scope (on purpose)
Historical data migration between backends, a pure-on-prem push transport, and adopting a graph/vector engine as the implementation are explicitly deferred (see ADRs). This PR is about making the datastore swappable and the backend self-hostable — not about moving anyone's existing data.
What's next — the rest of the road to zero cloud
Storage is the biggest cloud dependency, but not the only one. The same "neutral port + interchangeable implementations, cloud stays first-class" pattern applies to the remaining touchpoints, and each is a natural follow-up (tracked in #):
Land those three and the loop is closed: identity, structured data, vectors, and blobs all run on-prem, with the hosted cloud stack still the default for anyone who prefers it. Each ships the same way this one did — abstraction first, a real second implementation to prove it, contract tests for parity, no compatibility shims.
How to try it
Flip
STORAGE_BACKENDto point the same code at Firestore or Mongo. Offline test recipe and the dual-backend contract run are documented inSELFHOST_NOTES.md.Open source as a trust wedge only cuts if people can actually pick it up and run it. Now they can — on their own metal, with their own data, for real. 🔓