Accepted - 2026-07-02 (staged execution; slices tracked in § Rollout)
Resolves the choice deferred by ADR 0009 ("pick option 2 or 3 when multi-replica is actually required"). Multi-replica is now actually required: the owner directed the control plane out of the API pod, the cutover plan's Phase 3 (HPA) is blocked on it, and the planned multi-node demo topology needs a control plane that is not pinned to one process.
ADR 0009 recorded the honest gate: API horizontal scaling needs both an external serving engine (ADR 0006/0007 — executed) and control-plane state that is not embedded per-pod. This ADR decides where that state goes.
Six classes of per-pod state, all of which fork at replicaCount > 1:
| # | State | Where it lives today | Access path |
|---|---|---|---|
| 1 | Webhook delivery queue (webhook_delivery_queue) + attempt log (webhook_deliveries) |
embedded serving DuckDB | webhook_dispatcher.py:348,470,507,553 via query_engine._conn; read by routers/webhooks.py:112 |
| 2 | Alert history (alert_history) |
embedded serving DuckDB | alerts/escalation.py:251, alerts/history.py; read by routers/alerts.py:157 |
| 3 | Replay outbox (outbox) + dead_letter_events status transitions |
embedded serving DuckDB | processing/outbox.py (wired in main.py:214), routers/deadletter.py:67,79, processing/event_replayer.py |
| 4 | Usage accounting (api_usage) + sessions (api_sessions) |
separate per-pod DuckDB file (AGENTFLOW_USAGE_DB_PATH), a fresh file connection per request |
auth/usage_table.py, analytics.py |
| 5 | Webhook registrations | per-pod YAML file (config/webhooks.yaml) |
webhook_dispatcher.py:31,59-125 |
| 6 | Alert rules including mutable runtime state (state, fired_at, last_escalation_level, flap window, cooldown) |
per-pod YAML file (config/alerts.yaml), rewritten on every changed evaluation tick |
alerts/dispatcher.py:25,266-283 |
Items 5–6 were not in ADR 0009's list; they are the sharpest split-brain of all — a webhook registered on pod A does not exist on pod B, and N pods run N independent alert state machines that each page on-call and race the YAML writes.
Not blockers (already replica-safe, recorded for completeness):
- Rate limiting is Redis-backed with a per-process fail-closed fallback
(
rate_limiter.py) — shared across replicas whenever Redis is up. - Metric-cache invalidation targets the shared Redis cache and is
idempotent; per-pod
seen_event_idsscanning is safe once the durable enqueue below is an atomic claim (dedup moves to the store's primary key). - Serving reads and the freshness event scan go through the backend
abstraction since ADR 0006 Phase 1 (
QueryEngine.fetch_pipeline_events,clickhouse_sink.py) — the event→metric axis is already engine-external. (routers/lineage.py:74/routers/slo.py:64,106still read serving data over_conn; that is the serving-read seam from the 2026-07-02 audit §3.6, adjacent to but out of scope for this ADR.)
ADR 0009 says the decision "should start from the durable-delivery semantics pinned by the webhook/alert regression suites". Those pins, by suite:
- Enqueue is idempotent per
(webhook_id, event_id)— primary-key dedupe. - An event is marked seen only after every matching webhook is durably enqueued (audit_30 C2); marking seen drives cache invalidation and must work with zero webhooks registered.
- Inline delivery happens only for fresh enqueues; the re-drive path replays the stored canonical body verbatim.
- Outcome state machine:
pending → delivered, or backoff re-schedule, or park asdeadatmax_delivery_attempts; a removed/deactivated webhook parks rather than retrying forever. - Deliveries survive a process restart (a new instance re-drives
pending). - Alert firing/escalation/resolution advance only on successful delivery (audit_28 #4, audit_30 C1) — a failed page is re-attempted next tick.
- Escalation advances exactly one level per tick (lowest due level).
outboxmark-sent flipsdead_letter_events.status='replayed'in the same transaction; exhausted retries flip it to'failed'(outbox.py:_mark_sent/_schedule_retry).
Invariant 8 is a genuine cross-table transaction with rollback. Invariants 1/4 need atomic claim semantics as soon as more than one worker exists.
Rejected. The queue and the alert state machine are mutable row state with
claim semantics; ClickHouse mutations are asynchronous and there are no row
locks — N replicas polling due rows would each deliver the same row
(systematic N× duplicates), and nothing can express invariant 8's
transactional flip. CH remains right for what already lives there
(serving tables, pipeline_events journal).
2. Redis for queues/state + ClickHouse for history/usage (no new dependency — redis is already an optional import)
Rejected, on semantics rather than taste. Workable for the queue (streams +
consumer groups + XAUTOCLAIM), but: invariant 8 becomes a cross-store
consistency gap (queue transitions in Redis, dead_letter_events elsewhere —
atomicity only via Lua and hash-tag discipline, and not across stores at
all); the alert state machine needs WATCH/MULTI or Lua for compare-and-set;
history/usage reads are SQL-shaped (WHERE ts >= now() - INTERVAL 24h GROUP BY tenant) and would still need a second store. Two adapters, two failure
domains, and the hardest invariant lands on the weakest tool. Redis keeps
the jobs it already does well here: cache and rate limiting.
Rejected as the primary answer. Dispatch loops could be single-flighted, but control-plane state is written by API pods on the request path too: usage accounting on every request, webhook/alert CRUD, dead-letter redrive. Those writes need a shared store regardless, or the worker grows an internal RPC API (a bigger, stateful surface than a store port). It also leaves the pod with a writable volume, which is exactly what ADR 0007's "stateless pods" promise is about. A worker Deployment stays available later as a deployment-topology refinement on top of the shared store (move the loops out of API pods); it is not a substitute for one.
Every invariant maps natively: idempotent enqueue = INSERT .. ON CONFLICT DO NOTHING with insert-win detection; claims = FOR UPDATE SKIP LOCKED
with a lease column (work-stealing across replicas, no leader election
needed); invariant 8 = an ordinary transaction; alert single-flight = a
per-rule claimed tick; history/usage = plain SQL tables read exactly the way
the routers already query them. Operationally: the CI integration
environment already runs PostgreSQL (CDC path), the repo already carries
deep PG expertise and a proven no-Docker live-verify recipe (standalone
PG 17.5, docs/perf/vault-pii-governance-pg-verify-2026-07-02.md), and
psycopg slots into the same optional-import pattern redis already uses
(no hard dependency for the embedded profile). It also converges the
end-state triad this platform is heading toward anyway: ClickHouse =
analytics/serving, PostgreSQL = transactional state, Redis = cache/limits.
Cost, stated honestly: the scale profile gains a required PostgreSQL
service (DSN-configured; the chart will not ship one, mirroring how
ClickHouse is consumed), and psycopg joins the optional dependencies.
- A
ControlPlaneStoreport (src/serving/control_plane/) owns all six state classes. Two adapters:EmbeddedControlPlaneStore— extracts today's behavior (DuckDB tables via the serving connection, YAML files for registrations/rules), byte-compatible with existing on-disk formats. Default profile; the zero-dependency demo does not change.PostgresControlPlaneStore— all six classes as PG tables.psycopgis imported lazily/optionally (theredispattern). Selection:AGENTFLOW_CONTROLPLANE_STORE=embedded|postgres+AGENTFLOW_CONTROLPLANE_PG_DSN(secret-sourced in the chart).
- Claim semantics are part of the port contract, not adapter trivia:
enqueue_*returns whether this caller inserted the row (embedded: check-then-insert on one process; PG:ON CONFLICT DO NOTHING+ rowcount) — inline delivery belongs to the enqueue winner only.claim_due_*returns rows this worker now owns (embedded: plain select; PG:FOR UPDATE SKIP LOCKED+lease_expires_at; an expired lease makes the row due again — crash recovery without coordination).claim_alert_tick(rule_id, lease)single-flights each alert rule's evaluation across replicas; rule runtime state advances in the same transaction as the claim release.- outbox
mark_sent/mark_failedkeep thedead_letter_eventsflip in the same transaction (invariant 8).
- At
replicaCount = N, the documented semantics are: every pod scans events (idempotent; N× read amplification accepted and noted), enqueue dedupes on the store, exactly one pod inline-delivers a fresh enqueue, re-drive and outbox work is claimed, alert rules evaluate single-flight, cache invalidation stays idempotent on shared Redis. Delivery remains at-least-once end-to-end (unchanged; consumers already get signed, deduplicatable events). - The render gate lands now, ahead of the code (this change):
values.schema.jsonpinscontrolPlane.storeto the enum["embedded"]— the chart cannot advertise a profile the app cannot run;"postgres"joins the enum in the slice that ships the adapter.templates/deployment.yamlfails any multi-replica render (replicaCount > 1, or autoscaling enabled withmaxReplicas > 1) unlesscontrolPlane.store=postgresandserving.backend=clickhouse— ADR 0007's engine half and ADR 0009's control-plane half of the gate, enforced in one place at render time instead of a values comment.
- Port +
EmbeddedControlPlaneStore; webhook queue + delivery log move behind it (webhook_dispatcherstops touchingquery_engine._conn). - Alert history + alert rules/runtime-state repository behind the port (embedded impl keeps the YAML file format).
- Outbox + dead-letter behind the port (invariant 8 preserved verbatim).
- Usage/sessions behind the port —
usage_table.py,analytics.py,key_rotation.py's old-key-usage queries andadmin_ui.py's QPS tile all queriedapi_usage/api_sessionsdirectly; all four move behind the port (pure extraction, per-request file connections unchanged — pooling is not part of this ADR). PostgresControlPlaneStore+ live verification (standalone-PG probe suite: parallel claim exclusivity, lease expiry re-drive, restart re-drive, enqueue-win uniqueness, outbox↔dead-letter atomicity) + CI integration coverage (apostgres:17service added to the CI integration job). Executed 2026-07-03 with two scope additions the extraction slices had left open, both required for §1's "all six state classes" to hold: webhook registrations (class 5 — the sharpest split-brain — was still a per-pod YAML read outside the port; the registration CRUD now resolves the store fromapp, embedded keeps the byte-compatible YAML) and §2'sclaim_alert_tick/complete_alert_tickwired into the dispatcher (per-rule state persistence — a full-set save would let two replicas clobber each other's rule runtime state). Verified live: 31/31 probes,docs/perf/control-plane-pg-verify-2026-07-03.md.- Helm wiring (
controlPlane.store=postgresprofile: env + secret, schema enum extension — the render gate then admits multi-replica) and cutover plan Phase 3 execution: kind staging atreplicaCount=2, verifying exactly-one delivery per (webhook, event) with two pods, one alert page per incident, and a webhook registered through either pod visible to both. Chart wiring executed 2026-07-04: thestoreenum released to[embedded, postgres],controlPlane.postgres.{existingSecret,dsnKey}sources the DSN, andtemplates/deployment.yamlwiresAGENTFLOW_CONTROLPLANE_STORE+AGENTFLOW_CONTROLPLANE_PG_DSN; the scale profile is an overlay (k8s/staging/values-staging-scale.yaml.example), not a change to the zero-dependency default. Render side verified locally (helm template/lint+tests/unit/test_helm_values_contract.py), and re-verified 2026-07-06 on the Mac kind stand: the render gate correctly rejectsreplicaCount=2without both halves of the gate (the exact ADR error text) and correctly admits it once both are set (replicas: 2, correct env vars). The two-real-pods live run (kind atreplicaCount=2+ the replica-correctness checks above) needs Docker and was attempted 2026-07-06 on the Mac stand — genuinely incomplete: the shared host hit severe resource contention this session (co-tenant CPU spikes up to 80%, this cluster's own control plane crash-looping,kube-apiserver10+ restarts) that stalled theagentflow/apiimage build itself at near-zero CPU progress across two attempts. Seedocs/clickhouse-cutover-plan.mdPhase 3 for the full honest account.scripts/k8s_replica_correctness_verify.shremains ready to automate the pod-count and cross-pod registration-visibility checks whenever the live run can complete; the delivery/alert checks follow the recipe in the cutover plan (their store-level guarantee is already live-verified by slice 5's 31/31 PG probes).
- The scaling gate is enforced by the chart, not by a comment; it relaxes automatically and only when the store that makes scaling correct exists.
- One store, one adapter, one transaction model for all six state classes; the pinned regression suites transfer to the PG adapter unchanged.
- The demo/default profile keeps zero external dependencies and identical behavior through slices 1–4 (pure extraction).
- ADR 0007's "stateless pods" becomes literally true in the scale profile (no writable volume on the request path).
- The scale profile requires an operator-provided PostgreSQL (DSN), a third stateful system next to ClickHouse and Redis.
- Per-pod event scanning is N× read amplification on the serving backend at scale (accepted; the scan is bounded and cheap, and consolidating scanners is a later topology refinement, cf. option 3).
- Until slice 6 extends the chart, multi-replica is simply impossible to
render — a deliberate fail-closed period (the slice-5 adapter is app-side;
the schema enum still pins
embeddeduntil the helm profile ships).
- ADR 0009 remains accepted; its § Follow-up is resolved by this ADR.
- Cutover plan Phase 3 prerequisites updated to reference this rollout.
- The serving-read
_connseams noted out of scope here (routers/lineage.py,routers/slo.py) stay tracked via the 2026-07-02 architecture audit (§3.6).