Skip to content

Run Omi fully on-prem: storage behind a neutral, swappable port (Firestore | Mongo) - #10887

Open
abunet wants to merge 52 commits into
BasedHardware:mainfrom
abunet:fullonprem
Open

Run Omi fully on-prem: storage behind a neutral, swappable port (Firestore | Mongo)#10887
abunet wants to merge 52 commits into
BasedHardware:mainfrom
abunet:fullonprem

Conversation

@abunet

@abunet abunet commented Jul 30, 2026

Copy link
Copy Markdown

"The next AI moat isn't intelligence. It's personal context."
"By collecting ALL data about the user… we commit to privacy and open-source."
"We use open source as a trust wedge." — the manifesto

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.

  • The backend boots and serves with zero cloud egress (hermetic compose).
  • Every persistence call now goes through a neutral document-store port; Firestore (reference) and MongoDB (proof the abstraction is real) are drop-in, config-selected implementations (STORAGE_BACKEND).
  • Parity between backends is enforced by a dual-backend contract test running against a real Firestore emulator and a real Mongo replica set.
  • A CI guard keeps the boundary from eroding: only database/ may touch a raw persistence client.

What landed

Offline baseline (WP0) — a hermetic deploy/onprem/ compose (backend + Firestore emulator + Valkey) on an internal: true network; proven no-egress. Runbook in deploy/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 more db.collection(...) scattered across routers/utils.

Neutral storage port + two implementations (WP2) — a small, backend-agnostic DocumentStore port (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 and array_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/*.py modules 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

  • Cloud stays first-class. Firestore/GCP remain the default and are fully supported; on-prem is an option, not a replacement. (A second implementation existed mostly to prove the abstraction wasn't secretly Firestore-shaped.)
  • One honest exception. Push notifications are the single genuinely cloud-bound feature; they sit behind a feature flag rather than pretending to be on-prem.
  • No in-repo compatibility layers. Migrations updated every caller in the same change — no deprecated aliases, no dual code paths.

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 #):

  • Auth (OIDC). Abstract identity behind a provider port so it's Firebase Auth or a self-hosted OIDC provider (e.g. Keycloak). An enterprise brings its own IdP; tokens are verified locally, no Google dependency in the request path.
  • Vector store. Put semantic memory behind a vector port so it's Pinecone or a self-hostable engine (e.g. Qdrant) — embeddings and recall stay on your infrastructure, which for "all of a person's context" is exactly the data you least want to ship out.
  • Object storage (S3). Route audio/artifacts through an object-storage port so it's GCS or any S3-compatible on-prem store (e.g. SeaweedFS / RustFS). Raw recordings never have to touch a public bucket.

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

cd deploy/onprem
cp backend.env.example backend.env && sed -i "s/^ENCRYPTION_SECRET=.*/ENCRYPTION_SECRET=$(openssl rand -hex 32)/" backend.env
docker compose up -d --build          # 3 services become healthy, no internet needed at runtime
docker compose exec -T backend curl -fsS http://localhost:8080/v1/health   # {"status":"ok"}

Flip STORAGE_BACKEND to point the same code at Firestore or Mongo. Offline test recipe and the dual-backend contract run are documented in SELFHOST_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. 🔓

Review in cubic

Marco and others added 30 commits July 28, 2026 14:47
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
Marco and others added 22 commits July 28, 2026 21:10
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant