Deployment and scale-out¶
How to run several Keeper replicas and read replicas of Postgres without giving up correctness. Design references: 6.5 (refs, advisory locks), 6.6, 8 (read path), 15 (deployment), 13.
Status: Keeper, Scribe (scribe: job worker and scheduler) and Herald exist; the
compose stack runs Keeper and Scribe. The read endpoints do not call read_session yet,
so keeper.reads.prefer_replica takes effect endpoint by endpoint as they adopt it.
Topology¶
flowchart LR
C[Clients<br/>agents, web, CLI] --> P[Reverse proxy<br/>nginx, ingress or cloud LB]
P --> K1[Keeper 1]
P --> K2[Keeper 2]
P --> KN[Keeper N]
K1 & K2 & KN -->|writes, pinned reads,<br/>fallback| PG[(Postgres primary)]
K1 & K2 & KN -.->|reads, round-robin<br/>freshness guard| R1[(Replica 1)]
K1 & K2 & KN -.-> R2[(Replica 2)]
PG ==>|WAL streaming| R1
PG ==>|WAL streaming| R2
S1[Scribe worker 1] & S2[Scribe worker N] -->|claim jobs, commit| PG
Everything that matters is in Postgres: a Keeper holds no state it cannot rebuild, so replicas are added, removed and restarted freely behind any load balancer, without session affinity.
Keeper takes the client address from the socket peer and ignores forwarding headers unless the
peer is listed in keeper.trusted_proxies (the server's own proxy header handling is off).
Behind a proxy or load balancer, list its networks there so that API key allowlists see the real
client (docs/operations.md); list only addresses that clients cannot reach Keeper from directly.
What scales where¶
| Part | How it scales | Why it stays correct |
|---|---|---|
| Keeper | N identical stateless processes behind a load balancer | Coordination happens in Postgres: branch advisory locks, compare-and-swap on refs, the audit chain lock |
| Writes | One primary. Throughput comes from batching (one Revision holds thousands of changes) and from using several branches | Writers to one branch are serialized by a transaction-scoped advisory lock on hash(domain_id, branch); two Keepers writing the same branch queue on that lock, and a wait beyond store.commit.lock_timeout_ms answers 503 branch-busy with Retry-After. Locks die with the transaction, so a crashed Keeper never leaves one behind |
| Reads | Read replicas (database.read_dsn), round-robin |
Freshness guard, below |
| Caches | In-process, per Keeper | Tree nodes and compiled schemas are keyed by hash, so an entry can never be stale. The only time-based caches are authorization ones (keeper.auth.authz.grants_cache_ttl_s, principal cache): a grant change is seen by every Keeper after at most that TTL |
| Scribe workers | N workers pulling from the jobs table with SELECT ... FOR UPDATE SKIP LOCKED, no broker |
A job is claimed by exactly one worker; workers commit through the same pipeline as Keeper |
Read replicas and the freshness guard¶
A streaming replica trails the primary by a small, variable delay. A read that must not go back
in time (read your own write, or min_revision from a client) is checked before it is served:
- The caller names the branch head it must see:
expected_head(themin_revisionquery parameter inread_session), orread_your_writes, which reads the current head from the primary. - On the replica, in the transaction the read will use, one query returns the
refsrow of the branch and the replication lag. - If the head differs from the expected one, the replica has not replayed the commit: the next replica is tried, then the primary. A commit is one transaction (design 6.6), so a replica that shows the head shows everything the commit wrote.
- A replica above
database.read_max_lag_ms, or unreachable withindatabase.read_probe_timeout_ms, serves nothing. Withdatabase.read_fallback_to_primary: falsea read nobody can serve answers 503 instead of loading the primary.
The check is an equality, so when the branch has moved on since the caller learned its head the read goes to the primary: conservative, never wrong. Reads without a pin are eventually consistent by design (default: any replica within the lag limit). Lag is estimated as zero when the standby replayed all the WAL it received, otherwise as the age of the last replayed transaction, so it needs primary and standby clocks to agree; it is advisory, correctness of pinned reads rests on the head check.
database:
dsn: secret://env/LOREBASE_DB_DSN
read_dsn: [secret://env/LOREBASE_DB_READ_DSN_1, secret://env/LOREBASE_DB_READ_DSN_2]
read_max_lag_ms: 5000
read_fallback_to_primary: true
keeper:
reads:
prefer_replica: true # default false: behaviour is unchanged until enabled
Observability: /readyz adds a database_replicas check when read_dsn is set (ok,
degraded when a replica is unreachable or lagging while the primary can still serve, failed
only when prefer_replica is on, fallback is off and no replica is usable). Metrics:
lorebase.db.reads{source,reason} (where each read went and why),
lorebase.db.replica.lag (ms) and lorebase.db.replica.up per replica.
Database roles¶
Row-level security (design 13.3) is the second line of defence behind Keeper's authorization,
and Postgres skips it for superusers and roles with BYPASSRLS. The services therefore never
connect as the role that owns the tables.
| Role | Used by | Rights |
|---|---|---|
Owner (compose: POSTGRES_USER, a superuser of the stack's own Postgres; Helm: db-maintenance-dsn) |
migrate (alembic upgrade head), database.maintenance_dsn (index jobs run by Scribe, python -m lorebase_keeper bootstrap and principal create) |
Owns the schema, tables and the security definer read functions |
Application (compose: LOREBASE_APP_USER, default lorebase_app; Helm: db-dsn, database.appRole.name) |
Keeper and Scribe (database.dsn), read replicas (database.read_dsn) |
LOGIN NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOREPLICATION; SELECT, INSERT, UPDATE, DELETE on the tables except audit_log, which is append-only (SELECT, INSERT, revision 0015), USAGE, SELECT on the sequences, CONNECT, TEMPORARY on the database, EXECUTE on the read functions; no CREATE anywhere |
On compose, the one-shot db-roles (deploy/compose/postgres/app-role.sh) runs before the
migrations on every up: it creates or updates the application role with the password of
LOREBASE_APP_PASSWORD, revokes CREATE on public from PUBLIC, grants the rights above
on the existing tables and, through default privileges of the owner, on every table later
migrations create, takes UPDATE and DELETE on audit_log back, and grants the read functions when an earlier migration already created
them. It is idempotent, so a volume created before the role existed is converted on the next
up. migrate runs as the owner with database.application_role set to the application role,
so revisions 0005 and 0008 grant it EXECUTE on the read functions. Index DDL needs the owner, so
LOREBASE_DB_MAINTENANCE_DSN defaults to it.
Elsewhere, create the same two roles before the first migration: an owner (not necessarily a
superuser; it needs CREATE on the schema and the extensions vector and pg_trgm, see
store schema notes) and an application role with the grants above,
then set database.application_role for the migration. The Helm chart does the same with two
DSNs: the migration Job runs as the owner (db-maintenance-dsn) after an init container that
creates or checks the application role and applies the grants above, and Keeper and Scribe connect
as the application role (db-dsn); it refuses one role for both unless database.allowSharedRole
is set for development (kubernetes.md).
Check the role a service uses with
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user: both must be false.
Web sign-in¶
People sign in to the web app through Keeper, which acts as backend-for-frontend (design 13.2): Keeper runs the OpenID Connect authorization code flow with PKCE, keeps the provider's tokens server-side and gives the browser an opaque session cookie. The app never sees a token. Agents and scripts keep using API keys and bearer tokens, which work as before.
Requirements: keeper.auth.mode oidc or api_key+oidc (the oidc section names the issuer;
the same claims, group mapping and auto_provision apply to signed-in users), the database
section (sessions live in the web_sessions table of migration 0013), and a client registered
at the provider:
- redirect URI: the public URL of
<api_prefix>/auth/callback, for examplehttps://lorebase.example.com/api/v1/auth/callback; Keeper sends exactlykeeper.auth.sessions.redirect_uri, so register that string; - post-logout redirect URI:
post_logout_redirect_uri, orapp_urlwhen unset; - a confidential client (
client_secret_basicorclient_secret_post) or a public client withtoken_endpoint_auth_method: none; PKCE S256 is always used; - the groups claim (
keeper.auth.oidc.groups_claim) in the ID token, when grants name groups.
keeper:
auth:
mode: oidc
oidc:
issuer: https://idp.example.com/realms/acme
audience: [lorebase-api]
sessions:
enabled: true
client_id: lorebase-web
client_secret: secret://env/LOREBASE_OIDC_CLIENT_SECRET
redirect_uri: https://lorebase.example.com/api/v1/auth/callback
app_url: https://lorebase.example.com/
encryption_key: secret://env/LOREBASE_SESSION_KEY # openssl rand -base64 48
The web app runs with VITE_AUTH_MODE=session (authMode: 'session' in /config.js). Serve
it and Keeper on the same origin, with the reverse proxy sending /api/ to Keeper: the session
cookie is then first-party and no CORS is involved. A Keeper on another origin of the same site
also works with keeper.cors.allow_origins listing the app, allow_credentials: true, and the
app origin in sessions.allowed_origins if it is neither app_url's nor redirect_uri's
origin. A Keeper on another site does not: browsers do not send SameSite=Lax cookies there.
What the browser holds and what Keeper checks:
| Item | Detail |
|---|---|
| Session cookie | __Host-lorebase_session (cookie_name): 256 random bits, HttpOnly, Secure, SameSite=Lax (same_site: strict is possible), Path=/, no Domain, Max-Age = absolute_timeout_s |
| Sign-in cookie | __Host-lorebase_login, for login_timeout_s: state, nonce, PKCE verifier and return path, AES-256-GCM encrypted; cleared by the callback |
| Stored session | SHA-256 of the id, principal, subject, provider groups, timestamps, refresh and ID tokens encrypted with a key derived from encryption_key; never the id itself |
| Lifetime | Ends after idle_timeout_s without use or absolute_timeout_s after sign-in; a new id at every sign-in; sign-out deletes it |
| Refresh | When the access token expires, one request uses the refresh token (refresh_tokens); a refusal ends the session, new group claims apply; an unreachable provider postpones the refresh by refresh_lease_s |
| CSRF | POST, PUT, PATCH and DELETE with the cookie need X-CSRF-Token (csrf_header) with the token of GET /auth/session (an HMAC of the session id), an Origin among the trusted origins when present, and no Sec-Fetch-Site: cross-site; otherwise 403 with reason: csrf |
| Return path | return_to of /auth/login must be a path (one leading /, no scheme, host, backslash or control character); the callback redirects only to app_url's origin |
Operations: several Keeper processes share the sessions through the database, so no session
affinity is needed. Expired sessions are deleted when met and, purge_batch at a time, at each
sign-in. To end every session of a user at once, delete their rows:
DELETE FROM web_sessions WHERE principal_id = 'usr_...'. Changing encryption_key voids
sign-ins in progress and ends each session at its next refresh. Sign-ins and sign-outs are in
the audit log (auth.session.create, auth.session.delete); failed sign-ins redirect to
error_path with ?error=login_expired|login_failed|access_denied|no_account|provider_unavailable
and are logged by Keeper with the reason.
Trying it locally: run any OpenID Connect provider on loopback (Keycloak in development mode,
or a mock provider for tests) and register a client with the redirect URI
http://localhost:5173/api/v1/auth/callback. Start Keeper with the provider's issuer,
sessions.cookie_secure: false (plain http), redirect_uri as registered and app_url:
http://localhost:5173/; http is accepted for loopback hosts. Start the app with Vite's proxy so
that app and API share the origin: LOREBASE_KEEPER_PROXY=http://127.0.0.1:8080
VITE_API_MODE=live VITE_KEEPER_BASE_URL=/ VITE_AUTH_MODE=session pnpm dev in apps/web.
The compose web service is the same arrangement without Node.js: it serves the app on
http://127.0.0.1:8090 (LOREBASE_WEB_PORT) and forwards /api/ to Keeper, the sign-in routes
(/api/v1/auth/login, /callback, /logout, /session) included, so cookies stay on one origin.
Set WEB_AUTH_MODE=session in .env, and register http://127.0.0.1:8090/api/v1/auth/callback
with app_url: http://127.0.0.1:8090/. The overlay deploy/compose/oidc-mock.yaml does all of
this against a mock provider for a local try (quickstart).
Security headers¶
Keeper adds X-Content-Type-Options, X-Frame-Options, a frame-ancestors Content-Security-Policy and
Referrer-Policy to every response, and Strict-Transport-Security when the request came over https
(keeper.security_headers; a header a route sets itself is kept). Keeper runs without the server's proxy header
handling, so behind a TLS-terminating proxy hsts.enabled: auto needs the proxy in keeper.trusted_proxies and an
X-Forwarded-Proto: https header from it; otherwise set hsts.enabled: always, or never when the proxy sends
the header itself.
The web image (lorebase-web) writes its headers when the container starts, from the environment:
| Variable | Effect |
|---|---|
VITE_KEEPER_BASE_URL |
Its origin (scheme://host[:port]) is added to the connect-src of the policy; a relative URL adds nothing. Read at run time, not the build argument |
LOREBASE_WEB_CSP_CONNECT_SRC |
Further connect-src sources, space separated |
LOREBASE_WEB_CSP |
The whole policy, replacing the default |
LOREBASE_WEB_HSTS |
Strict-Transport-Security value, e.g. max-age=31536000; empty (default): not sent, as TLS is usually terminated before the container |
The default policy is default-src 'self'; script-src 'self'; worker-src 'self' blob:; connect-src 'self'
<Keeper origin>; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; object-src 'none';
base-uri 'self'; frame-ancestors 'none'; form-action 'self'. Values cannot contain a double quote, $, a backslash,
a brace or a newline; the container refuses to start otherwise. The Helm chart renders the same headers from
web.security (Kubernetes).
Webhook signing keys¶
Keeper stores the signing key of each webhook sealed under webhooks.encryption_key (32 characters or
more, the same for Keeper and Scribe) and refuses to create a webhook without it (409,
encryption_key_required). The compose stack passes it as secret://env/LOREBASE_WEBHOOK_KEY, which
make env generates; the Helm chart reads it from the Secret (secrets.keys.webhookEncryptionKey).
webhooks.allow_unsealed_keys: true stores new keys usable instead, for local development only.
Webhooks created before the key was set keep working: each delivery pass seals their keys. Rotation goes
through webhooks.previous_encryption_keys (agents).
Secret references¶
Every secret in the settings is a reference, never a value: secret://env/NAME reads an
environment variable and secret://file/PATH a file (a mounted Kubernetes or Docker secret).
Another secret store plugs in as a provider: lorebase_platform.secrets.register_secret_provider(name,
resolver), called before the settings load, makes secret://<name>/TARGET resolve through
resolver (TARGET in, the secret value out). env and file cannot be replaced, and a name
registered twice is refused unless replace=True (design 13.4).
Docker compose images and migrations¶
The compose file builds the backend, backend-ml and web images from the checkout on every up
(pull_policy: build, LOREBASE_PULL_POLICY in .env): unchanged layers come from the build
cache, so a rebuild after no change takes seconds, and an image left over from an older checkout
under the same tag (lorebase:dev) is never started against a newer database. Build explicitly
with
To run prebuilt images instead (a registry, a CI artifact), set LOREBASE_IMAGE,
LOREBASE_ML_IMAGE, LOREBASE_WEB_IMAGE and LOREBASE_TAG to them and
LOREBASE_PULL_POLICY=missing (or always); up --no-build also skips the build for one run.
The published ghcr.io/aantenore/lorebase* images of 0.3.0 are linux/amd64 only, so on an
arm64 machine (Apple silicon, Graviton) keep the default build; 0.3.1 and later are published for
linux/amd64 and linux/arm64.
migrate refuses a database that a newer Lorebase already migrated, before changing anything:
it exits 1 with FAILED: the database is at revision <db revision>, which this code does not
know (its newest revision is <code head>) ... and names the fix: run the version that migrated
it (rebuild the images from that checkout), or restore a backup taken before that upgrade.
Keeper, which waits for migrate, does not start. alembic stamp --purge stays available for a
manual repair of the version table.
Keeper settings from the environment file¶
Keeper reads its settings from the mounted YAML file (LOREBASE_CONFIG_FILE) and from
LOREBASE__KEEPER__... variables. The compose file forwards a documented set of them from .env
(or the shell) to keeper and keeper-2 under their own names, and only when they are set, so
the YAML file decides otherwise:
| Variable | Setting |
|---|---|
LOREBASE__KEEPER__AUTH__MODE |
keeper.auth.mode |
LOREBASE__KEEPER__PROPOSALS__ALLOW_SELF_APPROVAL |
keeper.proposals.allow_self_approval |
LOREBASE__KEEPER__PROPOSALS__REQUIRED_APPROVALS |
keeper.proposals.required_approvals |
LOREBASE__KEEPER__OPERATIONS__DIRECT_COMMIT_ON_PROTECTED |
keeper.operations.direct_commit_on_protected |
LOREBASE__KEEPER__CORS__ALLOW_ORIGINS |
keeper.cors.allow_origins (JSON list) |
LOREBASE__KEEPER__CORS__ALLOW_HEADERS |
keeper.cors.allow_headers (JSON list) |
LOREBASE__KEEPER__CORS__EXPOSE_HEADERS |
keeper.cors.expose_headers (JSON list) |
LOREBASE__KEEPER__CORS__ALLOW_CREDENTIALS |
keeper.cors.allow_credentials |
For a single-operator demo, for example:
# in .env
LOREBASE__KEEPER__PROPOSALS__ALLOW_SELF_APPROVAL=true
LOREBASE__KEEPER__OPERATIONS__DIRECT_COMMIT_ON_PROTECTED=true
Any other setting goes into a copy of config/lorebase.example.yaml named by
LOREBASE_CONFIG_FILE, or into an overlay compose file (COMPOSE_OVERLAYS).
Docker compose profile scale-out¶
Adds a second Keeper (keeper-2), a Postgres streaming replica (postgres-replica), a one-shot
replica-prep and an unprivileged nginx (proxy) in front of the Keepers.
cp .env.example .env # set POSTGRES_PASSWORD, LOREBASE_APP_PASSWORD and REPLICATION_PASSWORD
# share the rate limits of both Keepers (keeper.limits.rate.backend): set in .env
# KEEPER_RATE_LIMIT_BACKEND=postgres
# route reads to the replica as the application role: uncomment/set in .env
# LOREBASE_DB_READ_DSN=postgresql+asyncpg://lorebase_app:<password>@postgres-replica:5432/lorebase
# KEEPER_PREFER_REPLICA=true
docker compose --env-file .env -f deploy/compose/docker-compose.yaml --profile scale-out up -d
curl http://127.0.0.1:8088/readyz # through the proxy; PROXY_HOST_PORT
Choices:
- Replica image: the official
pgvector/pgvectorimage plus two small scripts (deploy/compose/postgres/). The replica needs the same pgvector build as the primary (the image everyone already pulls),pg_basebackupships with Postgres, and there is no second image or init framework to track. A Bitnami image was rejected because it does not ship pgvector. replica-prepappends onepg_hba.confrule to the primary's data directory (the official image accepts replication connections from localhost only), creates aREPLICATIONrole that can do nothing else, and reloads. It is idempotent and works on a volume created earlier, so the base stack is untouched.- The replica takes a base backup through its own replication slot on first start (empty volume)
and resumes streaming afterwards. A slot keeps WAL for a replica that is down: drop the
replica for good (
DROPthe slot) or the primary's disk fills. - Rate limits. With the default
localbackend each Keeper keeps its own buckets, so two Keepers allow twice the limits;KEEPER_RATE_LIMIT_BACKEND=postgresmakes them share the buckets through the primary (operations). - nginx spreads requests round-robin, skips a Keeper that fails and retries idempotent requests on the other one after an error or timeout (POST and PATCH are not replayed once sent). Terminate TLS here or in front of it; production uses the cluster's ingress instead.
Verified on this change (docker compose v5, Postgres 16): docker compose config with and
without the profile; stack up in about 15 seconds after the image build; /readyz through the
proxy reported database_replicas: ok, replica-0 ... lag 0 ms; requests kept answering 200 with
keeper-2 stopped; with the replica stopped /readyz stayed 200 and reported
degraded ... unreachable, and returned to ok after restart (streaming resumed). Against the
real replica, 40 commits each followed by a read-your-writes read always saw the new head; with
WAL replay paused on the replica for 10 of them, 9 of those reads fell back to the primary
(primary/stale) and every other read was served by the replica.
BM25 search overlay¶
deploy/compose/search-bm25.yaml runs Postgres on the ParadeDB image, whose pg_search
extension ranks lexical search by BM25 (search). It is optional:
pg_search is licensed under AGPL-3.0, unlike Lorebase (Apache-2.0); read the
licence note before you use it. The default stack never pulls the image.
docker compose --env-file .env -f deploy/compose/docker-compose.yaml \
-f deploy/compose/search-bm25.yaml up -d
docker compose --env-file .env -f deploy/compose/docker-compose.yaml \
-f deploy/compose/search-bm25.yaml --profile maintenance run --rm storage apply
curl -s http://127.0.0.1:8080/readyz # "search_backend": {"status": "ok", "detail": "bm25"}
(make up COMPOSE_OVERLAYS=deploy/compose/search-bm25.yaml for the first command.) The overlay:
- runs
postgresonparadedb/paradedb:0.26.3-pg16, pinned by digest (POSTGRES_BM25_IMAGEreplaces it), withpg_searchfirst inshared_preload_libraries(POSTGRES_PRELOAD_LIBRARIESadds others,pg_prewarmfor example), and hides the image's own initialization scripts, which create extensions Lorebase does not use; - sets
search.backend: bm25on the Keepers andstore.storage.bm25_index: trueon thestorageservice, whoseapplycreates the extension and builds the index; - with profile
scale-out, runs the standby on the same image withpg_searchpreloaded, so that reads routed to it are ranked by BM25 too.
Until storage apply has built the index, and after the overlay is removed, search ranks the
default way and /readyz reports search_backend as degraded (the service stays ready). The
ParadeDB image is built on a newer Debian release than pgvector/pgvector:pg16: start it on a
new volume, or move an existing database with pg_dump and pg_restore, rather than pointing
it at a volume the default image initialized (search).
Kubernetes¶
The Helm chart in deploy/helm/lorebase runs this topology on a cluster: stateless Keeper
Deployment with HPA and PDB, Scribe workers, read replica DSNs, the migration as a hook Job and an
Ingress in front. Install steps and the minimal, production and scale-out profiles are in
kubernetes.md.
Sizing hints¶
Starting points to measure against, not benchmarks.
- Keepers: two for availability, then add on CPU or p95 latency. Memory is mostly the node
cache (
store.nodes.cache_bytes, 64 MiB by default, per process). - Connections: each Keeper may open
pool_size + max_overflowconnections (20 by default) to the primary, and the same to each replica. KeepKeepers x 20below the server'smax_connectionswith headroom for Scribe, migrations and admin. A pooler in front of Postgres is untested here; verify prepared statement and advisory lock behaviour before using one. - Primary: size memory for the hot part of the indexes (
mat_recordsand its indexes), then IOPS for commits. Commit throughput per branch is bounded by the lock: split independent work across branches and batch changes into fewer, larger Revisions. - Replicas: one per 2 to 3 Keepers of read-heavy traffic is a reasonable start. Long queries
on a standby can conflict with replay; keep
database.statement_timeout_msat or belowmax_standby_streaming_delay, or accepthot_standby_feedbackand its bloat on the primary. - Scribe: workers are CPU and model bound, not database bound; scale them on queue depth.
- Backups and failover: standard PITR on the primary (see design 15.3). Promoting a replica
needs only a DSN change; pools re-validate connections (
pool_pre_ping).
Deliberately not done¶
- Distributed Postgres (Citus, CockroachDB, Yugabyte): the engine relies on advisory locks, a transactional commit pipeline and one consistent view for merges and the audit chain. Revisit if a single primary with replicas stops being enough for the data volume, measured after the 10M-Record performance work.
- Sharding inside a Domain and an external broker or cache: see below and design AC-18.
Sharding: decided not now, prepared by Domain¶
Why not inside a Domain. Three-way merge, per-branch advisory locks, compare-and-swap on refs and the hash-chained audit log all need one transaction and one global view of the Domain. Split a Domain over several databases and each becomes a distributed protocol (two-phase commit or sagas) for little gain: a single primary with read replicas is expected to carry hundreds of millions of rows (to be confirmed by the performance work).
Search-engine ideas already applied (no engine needed):
- inverted index:
tsvectorwith GIN for lexical search, trigram for typos; - vector search: HNSW indexes in pgvector, per configured model;
- immutable, content-addressed tree nodes behave like index segments: cacheable forever, never invalidated, shared across branches and Revisions;
- the materialized head is a read model that can be rebuilt from the tree at any time;
- replica fan-out for reads, with the freshness guard above;
- hash partitioning by
domain_idfor objects, tree nodes and materialized rows.
The path if one primary stops being enough: the Domain is the shard unit. No Relation crosses
Domains (design AC-04), so a routing table domain -> database lets each Domain live on its own
Postgres with no cross-shard joins, merges or locks. What stays installation-wide, in a control
database: principals, groups, grants and API keys; the Domain registry itself (the routing
table); the installation-level audit chain (per-Domain chains already travel with their Domain);
the job queue and outbox. Routing would be configuration, not code paths:
database:
dsn: secret://env/LOREBASE_DB_DSN # control database, and default for every Domain
domains: # not implemented
supply: {dsn: secret://env/SUPPLY_DSN, read_dsn: [secret://env/SUPPLY_RO_DSN]}
A Domain absent from the map uses the default DSN, so adoption is incremental. Moving a Domain is an export of its Revision graph (content-addressed, so portable) plus a routing switch with a short write freeze. Revisit for per-tenant data residency or isolation, or when several large Domains outgrow one primary; one very large Domain is not helped by this and needs a bigger primary.
Optional external full-text engine. If lexical search outgrows Postgres, OpenSearch can be added later as an adapter behind the search port, fed from the outbox events, with Postgres remaining the source of truth. It is an optimization behind an interface, not a prerequisite.