ADR-0017: Connection budget, read replicas and cells¶
| Status | Proposed. Design only: no code, chart or configuration changes accompany this record. |
| Date | 2026-10-13 |
| Deciders | Platform Engineering (maintainers) |
| Related | ADR-0014 horizontal scaling, ADR-0015 hybrid tenancy (PR #96, proposed; not yet on main, so not linked), ADR-0016 search_path round trips (PR #93, proposed; not yet on main), ADR-0002 schema per tenant (handbook), Hot-path performance, Deployment shapes |
Dependency on ADR-0015 (hybrid tenancy, PR #96, open). This record does not define a tenancy shape; it builds on ADR-0015's: tenants are
SCHEMA(dedicated schema) orSHARED(rows intenant_sharedunder row-level security), and the placement row already carries acellcolumn and expects an in-memory placement lookup on the hot path. Section 6 states exactly what is taken from it. If ADR-0015 changes before it merges, section 6 is the part to revisit. Numbering: 0015 is hybrid tenancy (PR #96), 0016 is thesearch_pathADR (PR #93), this record is 0017.
1. Context¶
ADR-0014 made the API scale by replica and put a render-time guard on the database connection budget. It deferred two things on purpose: read replicas were never designed, and cells were "deferred until one cluster is genuinely saturated". This record decides what to do about the database as the shared ceiling, in the order the ceiling is reached, and fixes the criteria that say when each step is worth its cost.
Constraints that do not move (AGENTS.md rules 6 and 8, ADR-0014 R1):
- The chat turn stays in one process. No step below may add a network hop between platform modules.
- Tenants are Postgres schemas selected by
SET search_path(viaConnection#setSchemainSchemaMultiTenantConnectionProvider). Every transaction runs theSET, the work, and a reset. PgBouncer must stay in session mode. - Chat-turn latency must not get worse. Every step is off by default and measured before it is turned on.
2. Decision summary¶
- Connection budget. Keep the existing render-time guard as the floor. Add measurement of the thing the
guard cannot see (how long connections are held per turn), and a decision order for what to do when the
budget is the limit: shrink hold time first, then raise
max_connections, then add cells. Do not fix it by moving PgBouncer to transaction mode. - Read replicas. Support them only for an explicit, small allowlist of read paths that tolerate staleness,
chosen per call site, routed in-process with a second
DataSource. Default off. Nothing on the authorization or write-then-read path ever goes to a replica. - Cells. The way to partition the turn path itself. A cell is a full stack (API pods, jobs, Postgres, Valkey) serving a subset of tenants. Routing is by tenant at the edge (DNS or ingress), using a cell directory. The API pod does not call a router. Build the pieces in an order where each is useful alone.
3. The connection budget¶
3.1 What the chart does today¶
Source: charts/vegaduta/templates/_guards.tpl, application.yml, Deployment shapes.
| Quantity | Value |
|---|---|
| Hikari pool, API | 12 (15 when core-jobs is not deployed); Spring default in application.yml is 15 |
| Hikari pool, jobs / ingest / worker / migrate Job | 10 / 6 / 8 / 10 |
Bundled cluster max_connections |
200 (postgres.parameters.max_connections) |
| Reserve the guard keeps | 20 (Keycloak, monitoring, superuser access) |
| Usable for core | 180 |
| API pods counted | max(replicaCount, hpa.maxReplicas, keda.maxReplicas); the guard assumes 4 if an autoscaler omits a maximum |
The guard sums API pods x API pool + jobs + ingest + worker + 10 (migrate) and fails the render above
max_connections - 20. It checks allocated connections, the Hikari maximum. It does not check how many
are in use.
3.2 Capacity model: how many API pods fit¶
Arithmetic from the formula above, not measured (recomputed by hand, e.g. 400 with worker: (400-20-34)/12 = 28.8, so 28; API only at 400: 370/15 = 24.7, so 24; at 800: 770/15 = 51.3, so 51). Pods = floor((max_connections - 20 - fixed) / API pool). "Fixed" is jobs (10) + migrate (10), plus ingest (6) and worker (8) where noted.
| Deployment | Fixed demand | API pool | Max API pods at max_connections 200 |
at 400 | at 800 |
|---|---|---|---|---|---|
| API + jobs (chart default shape) | 20 | 12 | 13 | 30 | 63 |
| API + jobs + ingest | 26 | 12 | 12 | 29 | 62 |
| API + jobs + ingest + Temporal worker | 34 | 12 | 12 | 28 | 62 |
API only, no core-jobs |
10 | 15 | 11 | 24 | 51 |
So the guard stops the default shape at 13 API pods on the bundled 200-connection cluster.
values-scale-out.yaml (3 replicas) is far below that.
3.3 What breaks first (ordering is a hypothesis to be measured)¶
The guard prevents the loud failure. The likely first failures are quieter and happen earlier:
| # | Failure | Why it is plausible | Signal that proves it |
|---|---|---|---|
| 1 | Hikari wait inside one pod: all 12 connections checked out, new transactions queue (connection-timeout 20 s) |
Virtual threads let far more than 12 turns run concurrently; a warm tool-resolve step alone is 8 checkouts (Hot-path performance section 3). Queueing shows as latency before it shows as errors. | hikaricp_connections_pending above 0, hikaricp_connections_acquire_seconds p99 |
| 2 | Pinned connections: each advisory-locked sweep that wins a tick holds one connection for the whole tick; sweeps walk every active tenant schema | 14 advisory-locked sweeps also tick in the API shape (ADR-0014 section 5). Cost grows with tenant count, not with traffic. | hikaricp_connections_usage_seconds by pod, sweep duration |
| 3 | Postgres CPU or memory before the connection count: each backend has real memory, and 180 busy backends on a small node is already contended | A connection limit is a count; the cost of a connection is not constant. | Node CPU, pg_stat_activity by state |
| 4 | max_connections reached (the guard's number) |
Only after autoscaling beyond the guard's assumption, or an external server whose limit was left at 0 (unchecked). |
FATAL: too many clients |
| 5 | PgBouncer client limit (Compose: MAX_CLIENT_CONN 200, DEFAULT_POOL_SIZE 25) |
In session mode a client holds a server slot for its whole lifetime, so the pooler multiplies nothing. Compose runs one core (pool 15, with background work), which fits 25; a second replica queues. | PgBouncer cl_waiting |
Consequence: in session mode PgBouncer is a cap and a connection-storm absorber, not a multiplexer. Capacity is bought by shorter hold times, a bigger server, or more servers (cells), never by the pooler.
3.4 Decision order when the budget is the limit¶
- Measure hold time first. Export per-pod Hikari usage and acquire histograms into the load harness
(
tests/load/kind), and record connection-seconds per turn next to the existing checkout count inHotPathQueryBudgetIT. Without this, pool sizing is guesswork. - Reduce hold time and checkouts per turn (the caching and snapshot work already under way; batching small transactions). This helps every later step.
- Right-size pools per workload class rather than raising all of them: the API pool is the only one proportional to chat traffic.
- Raise
max_connections/ the node, up to the point where criterion K1 below trips. - Cells (section 5) when K1 trips. Not a larger pooler, not transaction mode.
Transaction-mode pooling is closed, not merely discouraged. A multiplexing pooler needs search_path out of
the session: schema-qualified statements everywhere, or database-per-tenant. The
SchemaMultiTenantConnectionProvider javadoc records the measured failure (41 of 60 concurrent clients on the
wrong schema, 21 of them another tenant's). That is a rewrite of the data layer and out of scope here.
4. Read replicas¶
4.1 What exists¶
The bundled postgres chart is a CloudNativePG Cluster with an instances value (default 1). CloudNativePG
creates a read-only Service that routes to standbys when a cluster has more than one instance. Nothing in
this repository consumes it: there is no second DataSource, no routing and no chart value for a read
endpoint. That Service's behaviour is the operator's documented behaviour and was not run here.
There are 378 matches of readOnly *= *true in core Java sources (grep -rn 'readOnly *= *true' core --include='*.java'; 377 outside src/test). That attribute means "this code does not
write", not "this code tolerates stale data". It must not be the routing signal.
4.2 Safe and unsafe reads¶
A read may use a replica only if stale data is acceptable for its stated bound and it is not an input to an authorization or write decision. Streaming replication lag is normally small but unbounded in the worst case (a replay conflict with a long query, vacuum, a network problem).
| Read | Replica? | Tolerance | Reason |
|---|---|---|---|
Authorization, requireAccess, plan and permission checks |
No | 0 | Hot-path rule 3: authorization never reads a cache, and must not read a lagging copy |
| A read that follows a write by the same user in the same request or session | No | 0 | Read-your-writes would break (edit an agent, the list shows the old one) |
| Budget, quota and usage enforcement | No | 0 | Lag lets a tenant exceed a cap |
| Idempotency, effect journal, job claim, lease, advisory-lock work | No | 0 | Correctness of exactly-once handling |
| Per-turn agent config (already cached per pod with a short TTL) | Possible later | TTL plus lag | The cache already accepts TTL staleness; lag adds to the bound, so it must stay well under it |
| Console list views and history browse (agents, runs, audit viewer) | Yes | seconds | Read-only, human-facing; a refresh converges |
| Knowledge/RAG retrieval (vector and keyword search) | Yes, conditionally | seconds to a minute | Highest-volume read and the best offload candidate; a just-ingested document may be missing for the lag duration |
| Analytics, usage dashboards, exports, reporting | Yes | minutes | Heavy scans that should not compete with the primary |
| Sweeps that only scan (do not claim) | Possible | seconds | Must not decide on stale state |
Order of value: reporting and dashboards first (no turn impact, most primary load), retrieval second (turn-adjacent, so gated by measurement), per-turn config last.
4.3 Routing mechanism¶
Decision: explicit opt-in per call site, with a second pooled DataSource, inside the API process.
- A second Hikari pool pointed at the read endpoint, with its own small maximum and its own metrics. Same image, same process; no hop on the turn path beyond the database connection that already exists.
- Selection by an explicit marker, not by
readOnly: a@ReplicaRead(maxLagSeconds = n)annotation (or aReplicaReads.run(...)wrapper) on the specific service methods in the allowlist. Spring'sAbstractRoutingDataSourcewith the Hibernate multi-tenant provider is the usual composition; the routing key is "replica or primary" and the tenant key stays the schema. - The tenant schema must be applied on the replica connection exactly as on the primary. Because the
MultiTenantConnectionProviderobtains its connection from aDataSource, the replica path needs its own provider instance (or routing below it) sosetSchemaand the reset run on the same connection that serves the query. The tenant isolation tests must run against the replica path before it ships; the hot-path page already says not to change that provider without them. - Lag handling: the signal is the primary-side
pg_stat_replication.replay_lag(or an LSN comparison,pg_current_wal_lsn()against the standby'spg_last_wal_replay_lsn()), notnow() - pg_last_xact_replay_timestamp()on the standby, which keeps growing on an idle primary and would trip fallback with no real lag. A background check samples it everypseconds, so the worst-case staleness a call can see is roughlyp + lag, notlag. To honourmaxLagSecondsthe gate must therefore trip atmaxLagSeconds - p(withpkept to 1 to 2 s). When the sampled lag exceeds the gate, the sample is older than a few intervals, or the replica pool is unhealthy, the call falls back to the primary. Fallback, not failure: the feature must never make a read less available than today. - Ordering against transactions: a connection is acquired when the transaction begins, so the replica-or-primary
decision has to be made before it opens.
@ReplicaReadmust therefore sit on the outermost method, ahead of@Transactional(its advice ordered beforeTransactionInterceptor), and must set a thread-bound routing key that the routingDataSourceand the replicaMultiTenantConnectionProviderread at connection acquisition. A@ReplicaReadmethod called from inside an already-open primary transaction stays on the primary (a join cannot switch connection); a test must cover that. This is a design requirement, not something prototyped. - The replica pool counts in a connection budget of its own. The chart guard must add
replica pods x replica poolagainst the replica server'smax_connections, a separate limit. - A write in a replica-routed method is a bug; the standby rejects it, and a test must assert the allowlist contains no writing method.
4.4 Decision¶
Build it only behind app.datasource.read-replica.enabled=false and only after K2 below is met. First
consumer: one reporting endpoint, then retrieval, each measured against HotPathQueryBudgetIT and the kind
harness with and without it.
5. Cells¶
5.1 Definition¶
A cell is a full, independent deployment unit: API pods, jobs pods, a Postgres cluster, a Valkey, and optionally its own object storage, serving a fixed subset of tenants. A tenant lives in exactly one cell. All cells use the same chart and image; only values and the tenant list differ. It is the only mechanism in this record that partitions the turn path itself: a cell's database, pools and pods see only that cell's tenants, so more tenants means more cells rather than more load on one.
5.2 Routing without a hop on the chat path¶
The API pod must not ask "which cell?" at request time. Routing happens before the request reaches a pod:
| Layer | Mechanism | Hop cost |
|---|---|---|
| Per-tenant hostname | Each tenant gets a stable hostname (<tenant>.<domain>, or a custom domain). DNS (CNAME to the cell's ingress) resolves it to the owning cell. |
None added: DNS replaces the lookup and the browser connects straight to the cell. |
| Shared hostname, tenant in token | An edge router forwards to the cell by a JWT-claim match on tenant_id. Stock ingress-nginx cannot match on a JWT claim; this needs an Envoy-based gateway (a Gateway API implementation, or Envoy with its JWT filter) or an auth proxy in front. |
One proxy hop at the edge, which the ingress already is. It must be a data-plane proxy with a cached table, not a call into the application. Not available with the ingress controller the charts assume today. |
| Programmatic clients | The API base URL returned at sign-in or tenant discovery points at the cell hostname. | None on later calls. |
Per-tenant hostnames are not free of side effects. AGENTS.md rule 7 applies: every public issuer the browser
reaches Keycloak on must be listed in KEYCLOAK_ADDITIONAL_ISSUERS, so a hostname per tenant (or per cell) for
Keycloak itself would make that list change with every cell; the intended shape is one stable public Keycloak
hostname shared by all cells. Per-tenant application hostnames also multiply OAuth redirect URIs and allowed web
origins, CORS origins and cookie scope. None of this was designed or tried; it must be settled before the
hostname option is chosen over the claim-match option.
Recommendation: per-tenant hostname via DNS as the primary mechanism (subject to the issuer, redirect and CORS work above), edge match on the tenant claim (needs the gateway above) as the fallback for a shared hostname. Both keep the router out of the process and off the chat path. The control plane (cell directory, 5.3) is consulted at sign-up, login, migration and by the edge's config sync, never per chat request.
Tenant identity is a token claim in one Keycloak realm, so authentication may stay global while data is cell-local.
Control plane: "global in one home cell" does not work as the code stands. The control-plane tables (115
migrations under core/app/src/main/resources/db/migration/controlplane) live in the same database the core
boots against, and every tenant iterator (TenantSchemaIterator) reads the list of active tenants from them. A
cell's core therefore needs its own control-plane schema in its own database. The workable shape is: each cell
holds a replica of the registry rows for its own tenants (tenant, plan, status, placement) plus the
cell-local control-plane tables, and the home cell is the writer of record for sign-up, billing and platform
admin. That replicated registry is a cost the earlier draft left unstated: it needs a sync mechanism, a rule for
who wins a conflict (the home cell, for registry rows), and a statement of staleness (a cell keeps serving from
its copy if home is unreachable, but cannot learn of a suspension or plan change until sync resumes). It is the
load-bearing assumption of section 5, it was not validated, and it is a reason cells are criterion-gated (K1).
5.3 Cell directory¶
A small control-plane table: tenant_id -> cell_id, state, version, and cell_id -> ingress host, region,
capacity, status. Tenant states: ACTIVE, MIGRATING, READ_ONLY, DRAINING. Edge routers and DNS
automation sync from it; a pod never reads it on the turn path. A cell that cannot reach the directory keeps
serving its tenants from its own registry copy (5.2), so the directory is an availability dependency of
provisioning and migration only.
Relation to ADR-0015's placement row. ADR-0015 puts a cell column on the per-tenant placement row and
expects the hot path to consult placement from memory. That is the same fact as tenant_id -> cell_id
here, so there is one source of truth, not two: the cell column on the placement row in the control-plane
registry is authoritative for "which cell owns this tenant", and the directory is a projection of it for the
edge (DNS and gateway config), written by the home cell and never edited independently. A pod's in-memory
placement lookup (ADR-0015 section 3.2) answers "SCHEMA or SHARED" within its own cell and may check that
the tenant belongs to this cell; it does not route between cells. The earlier "pod never reads the directory"
is thus narrowed to: a pod never makes a call to find another cell, and consults only its own placement cache,
which ADR-0015 already requires.
Placement at sign-up: the least-loaded cell with headroom under K1 and K3, or a requested region for data residency (ADR-0002 already names residency as a reason for schema-per-tenant).
5.4 Migrating a tenant between cells¶
A tenant is a Postgres schema plus Valkey keys plus object-store paths plus Keycloak attributes. Migration is an operation, not a hot path, and may include a write freeze of minutes.
- Directory state
MIGRATING; the source cell sets the tenant read-only (the existing tenant emergency controls and kill switches are the natural lever). - Dump or logically replicate the tenant schema to the destination Postgres; copy object-store prefixes; verify row counts and checksums.
- Run Flyway for the tenant schema on the destination. Flyway versions are one namespace, so cells must run the same image, which the core-family image skew check already encourages inside a release.
- Flip the directory entry and the DNS record (TTL lowered beforehand). In-flight streams on the source finish within the drain window; resumable streams are cell-local and are not migrated.
- Keep the source schema read-only for a retention window, then drop it. Rollback is flipping back before the drop.
Open: durable agent jobs, schedules and workflow runs in flight at cut-over; per-tenant secrets
(APP_ENCRYPTION_KEY must be identical for every process sharing a database, so cells either share the key or
re-encrypt secrets on move).
5.5 Failure isolation¶
| Failure | Without cells | With cells |
|---|---|---|
| Postgres primary down or saturated | Every tenant | Only that cell's tenants |
| Bad deploy or migration | Every tenant | Roll out to a canary cell first; blast radius is the cell |
| One tenant's runaway load or noisy sweep | All pods and the database | Bounded by the cell; move the tenant to its own cell |
| Valkey outage | Every replica's shared stores | That cell |
| Control plane or directory down | Everything that provisions | Existing tenants keep serving; no sign-ups or moves |
Cost: N copies of the baseline footprint, N upgrades to coordinate, cross-cell reporting needs a fan-out job (off the chat path), and platform-admin features that assume one database must become cell-aware.
6. Composition with hybrid tenancy (ADR-0015)¶
This section relies on ADR-0015 (PR #96, proposed, design only) and does not change it. Taken from ADR-0015:
- Placement per tenant is
SCHEMA(a dedicated schema, for large or regulated tenants) orSHARED(rows in onetenant_sharedschema withFORCE ROW LEVEL SECURITY, for the long tail). Both live inside one Postgres database;DATABASEis mentioned there only as a possible later third placement for the top tier. - The placement row has a
cellcolumn, and there is a hard cap on dedicated schemas per database (initially 2,000), after which "a new cell is needed". The residency rule (a declared region only on a cell in that region) hangs offcell. - The hot path reads placement from an in-process cache (rule 6).
An earlier draft of this record assumed the opposite (small tenants as schemas in a shared database, large tenants on a dedicated database or cluster). That was wrong and is withdrawn. Consequences under the real shape:
- A cell is a database cluster plus its pods, and it holds both
SCHEMAtenants and thetenant_sharedtier. A cell is the level of scale above hybrid placement, as ADR-0015's alternative 2 says: hybrid placement reduces per-tenant catalog and release cost inside a database; cells bound the number of tenants and the blast radius per database. They are complementary, and neither replaces the other. - A large tenant is not a one-tenant cell by default; it is a
SCHEMAtenant in a cell. A one-tenant cell (or ADR-0015's possibleDATABASEplacement) is an option for the very top tier only. - The connection budget in section 3 applies per cell, shared by
SCHEMAandSHAREDtenants alike (ADR-0015 section 3.4: both draw from the same Hikari pools). ADR-0015's secondjobsrole and pool draws on the same budget and must be added to the "fixed demand" column of 3.2 if it is built; the table above does not include it. - Moving a tenant between cells (5.4) means, for a
SHAREDtenant, copying its rows bytenant_id(ADR-0015's export path), not dumping a schema; for aSCHEMAtenant it is the schema dump in 5.4. The procedure should reuse ADR-0015's promotion/demotion copy-and-fence machinery rather than build a second one. Neither exists; both are designs. - Read replicas are per cell and belong to the cell's Postgres. For a
SHAREDtenant the replica connection must setapp.tenant_idas well assearch_path, or RLS returns zero rows, so the replica provider (4.3) must reuse ADR-0015's provider switch rather than fork it. How it interacts with ADR-0016's transaction-scopedSET LOCAL(if adopted) is unresolved: the replica path must follow whichever tenant-binding scheme is in force. - Order of work: measurement (3.4), then ADR-0015's stage 0 and its gates, then the cell directory as a projection of the placement row, then per-tenant hostname or gateway routing, then the migration procedure. Cells should not be started before ADR-0015's early stages show whether the shared tier is built at all (its own kill criterion), because that changes how many tenants a cell needs to hold.
7. Alternatives considered¶
- Transaction-mode pooling to multiplex. Rejected: wrong-tenant data (3.4).
- Database per tenant. Needs a pool per tenant, the property ADR-0002 chose schema-per-tenant to avoid. A single-tenant cell remains possible for the very top tier (section 6).
- Sharding inside one deployment (a router in the API). Puts routing in the process and keeps one deployment as the failure domain. Cells move routing to the edge and isolate failure.
- Route every
readOnlytransaction to a replica. Rejected: the annotation does not mean stale-tolerant (4.1), and about 380 sites would change behaviour at once. - An application-level cell router the API calls per request. Rejected: a new hop on the turn path (AGENTS.md rule 6).
8. Kill criteria¶
Numbers are starting points to be set from measurement, not claims. The kind harness has run-to-run noise, so every latency threshold below (K1's 50 ms Hikari p99, K2's 3% turn p95, K4's "worsens") is judged on at least five alternating A/B runs per arm at equal load, comparing medians against the observed spread of the baseline arm; a difference inside that spread is "no change", neither pass nor fail. The run count and spread are recorded with the result. Until a baseline spread has been measured, these are not usable thresholds.
| ID | Do the step when | Abandon or roll back the step when |
|---|---|---|
| K1 (cells) | Primary Postgres at or above 70% CPU or 70% of usable connections at peak after 3.4 steps 1 to 4 are done, or Hikari acquire p99 above 50 ms with pool and server already enlarged, or one cluster's blast radius is a stated business risk | A rehearsal cannot move a test tenant with under 5 minutes of write freeze and zero data loss |
| K2 (replicas) | Reporting or retrieval reads are more than 25% of primary load (measured) and offloading them is projected to relieve K1 | Replica lag p99 exceeds the tightest enabled call-site bound for 5 minutes in a day, or turn p95 latency rises more than 3% in the kind harness with the replica path on, or any isolation test shows a wrong-schema read |
| K3 (move a tenant) | A single tenant uses more than 20% of a cell's connection-seconds or database CPU for a sustained week | Migration rehearsal fails twice |
| K4 (any step) | n/a | Chat-turn p50 or p95 worsens versus the previous published tests/load/kind run at equal load. Hard stop on every step: revert, do not tune forward |
9. Verification plan (before any code)¶
- Add Hikari usage and acquire histograms and connection-seconds per turn to the kind harness and
HotPathQueryBudgetIT, and record a baseline at 1, 2 and 4 API replicas. - A lag-injection test for the replica path (pause replay on the standby) that proves fallback to the primary.
- The existing tenant isolation tests run against a replica-routed provider.
- A migration rehearsal between two kind namespaces with a synthetic tenant and a running stream.
10. What is not proven¶
- No number in 3.3 is measured. The ordering of what breaks first is reasoning from pool sizes, the hot-path checkout counts and the known pinned-connection behaviour. Hold time per turn, Hikari wait and Postgres cost per connection were not measured.
- The capacity table in 3.2 is arithmetic from the chart guard's formula, not a load test. It counts allocated connections, not used ones, and covers Keycloak and monitoring only through the reserve of 20.
- The read-only Service is the CloudNativePG operator's behaviour. The repository has no consumer of it, and no cluster with more than one Postgres instance was run for this record.
- No replica routing was prototyped. That Hibernate's multi-tenant provider composes with a second
DataSourcewhile keeping thesetSchemaand reset guarantees is a design expectation, not a result. - Replica lag tolerances are proposals per read, not observed lag. Replay delay under real vacuum and long query load is unknown.
- No cell exists. The routing options, migration procedure and directory shape were not built or tested; the 5-minute write-freeze target is a goal. Whether Keycloak and the control plane can stay global was not verified, nor how many platform-admin features assume a single database.
- Hybrid tenancy (ADR-0015) and the
search_pathrecord (ADR-0016) are open PRs (#96, #93), not onmain. Section 6 was reconciled against the text of PR #96 as it stood when this was revised, not against code. - What was run for this PR: only the repository documentation gates (
make gates), which check links, file size and secrets and do not validate any technical content here. Every figure is hand arithmetic or agrepcount. - Multi-replica API itself remains unproven (ADR-0014 section 6), and everything here inherits that.