Skip to content

Add rolling-safe HA for versiond routing - #1587

Open
snevolin wants to merge 118 commits into
gonka-ai:upgrade-v0.2.15from
snevolin:sn/versiond-router-ha
Open

Add rolling-safe HA for versiond routing#1587
snevolin wants to merge 118 commits into
gonka-ai:upgrade-v0.2.15from
snevolin:sn/versiond-router-ha

Conversation

@snevolin

@snevolin snevolin commented Aug 13, 2026

Copy link
Copy Markdown

Important

GitHub requires the base branch to belong to the target repository, so this
PR is opened against gonka-ai/gonka:upgrade-v0.2.15. Functionally it is
stacked on snevolin/gonka:sn/versiond-host-evacuation-0.2.15 and should be
reviewed relative to that branch.

  • Conceptual base: b1f28c12c5157bc49775b5bfa689d0d67a27b28d
  • Net diff: b1f28c12c5157bc49775b5bfa689d0d67a27b28d..c0342b4837f5e79af1e08908fa11f308b1796bde
  • Feature commits: 740ad0902a9ef7b76557bc1179a2a22e590129b1^..c0342b4837f5e79af1e08908fa11f308b1796bde (41 commits)
  • Fork comparison

GitHub's Files changed view also includes the prerequisite host-evacuation
series between upgrade-v0.2.15 and b1f28c12c; that series is outside this
PR's conceptual scope.

Summary

This change turns versiond-router from one process into a replicated,
stateless routing tier. Three independent router slots derive the same
version-aware pools and escrow-based consistent-hash placement from Docker DNS,
active checks, and DAPI's existing /versions catalog. They require no shared
routing database, leader, or replica-to-replica coordination.

The PR also supplies the operational machinery needed to use that redundancy
safely: rolling fleet reconciliation, end-to-end admission checks, automatic
route discovery, crash-recoverable host upgrade, exact rollback, release
preflight, and production acceptance coverage.

Architecture

clients
   |
   v
proxy-router (public HAProxy, :80/:443)
   |
   +--> proxy-policy A/B (private nginx: TLS, HTTP/2, CORS, limits, rewrites)
           |
           +--> ordinary API and node routes
           +--> /devshard/* --> proxy-router :18081
           |                       |
           |                       +--> versiond-router slot 0
           |                       +--> versiond-router slot 1
           |                       +--> versiond-router slot 2
           |                                  |
           |                                  v
           |                         versiond --> devshardd
           |                                  |
           |                           shared PostgreSQL
           |
           +--> Tier A /v1/* --> proxy-router :18082 --> edge-api pool

HAProxy owns membership, health, and replica selection. nginx keeps the
existing HTTP policy. Established streams remain on the process that accepted
them during a planned drain; new requests avoid starting, draining, and
unavailable members.

What this delivers

  • Router redundancy: one inner router can fail or be replaced while the
    remaining admitted slots continue receiving new traffic.
  • Deterministic stickiness: both routing levels normalize the same escrow
    key and use address-keyed consistent hashing. Placement is stable across DNS
    answer order and router restarts.
  • Per-version readiness: a host missing v5 leaves only the v5 pool and
    can continue serving healthy versions such as v4.
  • Automatic version routes: VERSIOND_VERSIONS is a cold-start floor, not a
    day-2 allowlist. Newly approved names are learned from /versions, prepared
    in bounded backend slots, and published only after the configured ready
    reserve exists. No hoster edit or router restart is required.
  • Crash-safe catalog projection: the complete admitted map is persisted
    atomically and restored after restart. Malformed, stale, removal, or
    capacity-exhausting snapshots cannot erase working routes.
  • Safe request semantics: proxies retry only failures that are safe at the
    routing layer. A non-idempotent request is not replayed after it may have
    reached an application.
  • End-to-end status: route readiness is verified locally and at the parent
    distributor. Fleet status and Prometheus metrics cover both router levels.
  • Narrow authority: router slots receive a read-only /versions bridge
    instead of access to DAPI's callback surface; Runtime API mutation remains on
    container-local Unix sockets.

Fleet and upgrades

deploy/join/versiond-router-fleet.sh provides one guarded lifecycle for
network preparation, bootstrap, status, rolling replacement, rollback,
maintenance rollout, shutdown, and cleanup. It replaces one slot at a time,
keeps the configured ready reserve, verifies actual parent admission for every
required route, and restores the exact previous image and environment on
failure. Placement-changing configuration requires an explicit maintenance
operation rather than silently mixing hash contracts.

The v5 updater uses the same fleet lifecycle and treats the host update as one
recoverable deployment transaction:

  • preserve the host's ordered Compose model, project identity, official and
    operator overlays, observability settings, and external PostgreSQL topology;
  • checkpoint application, ingress, and fleet phases under one deployment lock;
  • resume after a crash or signal without guessing which resources changed;
  • keep ingress rollback armed until the real fleet is admitted;
  • bind commit to immutable release images, rendered Compose, fleet spec, and
    the actual shared PostgreSQL identity observed through both supervisors;
  • reject ambiguous PostgreSQL settings before the first mutation;
  • provide a machine-checked release contract, host preflight, upgrade guide,
    recovery procedure, and network-update source.

This makes future router fixes part of the normal host update path instead of
an undocumented side command.

Scope

  • This is process-level redundancy on one Docker host. The public
    proxy-router, Docker daemon, host networking, and bundled PostgreSQL remain
    host-level failure domains.
  • Multi-host ingress and Kubernetes deployment are outside this series. A
    future Kubernetes topology must preserve version-aware routing.
  • The logical diff does not change inference-chain governance, DAPI catalog
    semantics, or ML execution algorithms. Catalog monotonicity and recovery are
    local properties of the HA routing projection.
  • Route additions are automatic; removal of an admitted route remains an
    explicit maintenance operation.
  • Cross-replica exactly-once behavior for an explicit client retry is unchanged
    and is not claimed by this routing layer.

Validation

Coverage added or extended by this series includes:

  • rendered and live HAProxy checks for route selection, readiness, hash-ring
    stability, retries, catalogs, isolation, and PROXY protocol;
  • Docker fleet acceptance for reserve-preserving rollout, exact rollback,
    degraded-slot repair, network ownership, shutdown, and cleanup;
  • Compose contracts for supported standard, HA, edge-api, observability, and
    external-PostgreSQL topologies;
  • failure-injection tests for interrupted cutover, transaction resume, topology
    drift, PostgreSQL identity mismatch, and rollback;
  • release contract/gate, published-image smoke, catalog cache, ShellCheck, and
    existing host-evacuation acceptance against the replicated path.

snevolin added 30 commits July 29, 2026 06:47
Track B: graceful evacuation, replacement, addition and permanent
decommission of a whole versiond host.

- table-driven host lifecycle and full-response admission leases in versiond
- one absolute, configurable shutdown budget for proxy drain, child drain,
  HTTP shutdown, escalation and child reap
- table-driven child-generation, supervised-process and devshardd lifecycle
  machines
- persistent router membership with immutable membership IDs and an explicit
  one-host-at-a-time transfer
- forward-reconciling router control plane with revisioned nginx config
  projections, durable completion receipts and an audit outbox
- local gonka-routerctl and resumable SSH-based gonka-hostctl workflows
- full-stack coverage for a sticky long-running SSE request, evacuation,
  replacement, decommission and re-addition

Replay of sn/versiond-host-evacuation (3f68fde..2064415, 40 commits)
onto upgrade-v0.2.15. The original history could not be rebased: two upstream
backmerges carried manual conflict resolutions that a flattening rebase drops.
The resulting tree is byte-identical to the source branch.
0.2.14 is released, so its release guide is restored to the base state.
Add devshard/docs/release-0.2.15-v5.md following the v4 guide structure and
carry the graceful-shutdown section and rollout checklist item over verbatim.
Move the Docker host evacuation acceptance job into a dedicated
workflow with path filters for Track B code, devshardd lifecycle code,
the test harness, and its build inputs.

Cancel superseded runs for the same pull request so unrelated testenv
changes and stale pushes do not consume the 40-minute runner budget.
Stop triggering the 40-minute acceptance test for every root
Makefile change. The shared Testermint workflow already exercises the
devshardd-build target, while the dedicated workflow retains triggers
for Track B code, its test harness, and direct build metadata.
versiond-router had no Go code and no persisted state before this work, so
router state schema 1, WAL schemas 1-4 and hostctl journal schemas 1-2 only
ever existed inside this branch. No deployment wrote them.

Remove the migration paths and pin every persisted format at schema 1:

- delete router state and operation-journal migrations; decode the current
  schema directly and reject any other version
- delete the legacy rollback recovery policy, the pre-image journal fields it
  needed, and the config restore it performed
- delete the audit-to-receipt-index import and the ActionAgnostic/Conflict
  receipt fields that only that import could set
- delete the hostctl journal migration and the host_idle resume alias

Torn-tail audit repair stays: it protects the append path independently of the
removed import.
Host evacuation was built around nginx OSS not being able to discover that a
versiond is draining: since it cannot health-check upstreams, something had to
tell it. That something was a 12.9k-line control plane — a durable router FSM,
membership IDs, transfer ownership, a WAL, a receipt index, an audit outbox, and
two SSH-driven CLIs — whose entire job was to move an upstream to `down` before
anyone sent SIGTERM.

Move both routers to HAProxy and let them observe instead:

  * membership comes from DNS. VERSIOND_POOL_HOST / EDGE_API_POOL_HOST is one
    name with an A record per running instance (a Compose network alias), read
    through `server-template` + `resolvers`. Starting or stopping a container is
    the whole operation; no config change, no reload.
  * health comes from `GET /readyz` once a second. A host that is draining,
    still converging, or cut off from the chain takes no traffic and rejoins on
    its own.

versiond makes that safe with a new `announcing` state: on SIGTERM it fails
/readyz while still accepting for VERSIOND_DRAIN_ANNOUNCE (5s), so the router
removes it before admission closes. Readiness moved to the traffic listener,
which drops the loopback admin listener entirely and is what a Kubernetes
readinessProbe will consume unchanged.

Readiness also had to be relaxed: `Converged` now latches once every desired
version has run. Without that, a routine same-name SHA bump — published to every
host at once — would un-converge the whole pool simultaneously and evict it.

HA storage safety gains a startup half: with GONKA_HA set, devshardd refuses to
boot on storage a sibling cannot see, instead of booting and failing every
HA-marked request. The per-request Devshard-Ha guard stays, because a partial
rollout can leave a process that started before the deployment became HA.

edge-api gets the same treatment: a cached /readyz that reports chain
reachability, so an instance that loses the chain leaves the round-robin
rotation rather than answering.

What remains of the control plane is `gonka-drain`, an 80-line shell guard over
the HAProxy Runtime API for quiescing a host without stopping it. It addresses
hosts by container name or IP and refuses to drain the last one still serving.

Failover discipline is preserved from the nginx config: retry on connect
failure, empty response, and upstream 502; never on 503, which is what a
draining host and the storage guard answer; and `disable-l7-retry` for
non-idempotent methods so an inference is never executed twice.

Net effect on the PR: +6.0k/-0.8k across 75 files, down from +18k.

Verified end-to-end against real HAProxy 3.0 containers: sticky hashing by
escrow, legacy version pinning, /devshard prefix handling, Devshard-Ha
stamping, health-based eviction and re-entry, DNS-driven join and removal,
the last-host drain guard, and edge-api round-robin with health eviction.
`make test-render` renders every supported shape and validates each result with
the HAProxy the routers ship.
Adding /readyz to edge-api put it behind an active health check without giving
it anything to announce: SIGTERM went straight into Echo.Shutdown with a
hardcoded 10s, /readyz kept answering 200 while the chain was reachable, and no
Compose file set stop_grace_period. Replacing an instance therefore cut every
query still running at ten seconds, and the router only found out afterwards.

Mirror the versiond sequence:

  * BeginDrain latches /readyz to 503 with reason "draining", checked before the
    chain probe so it does not wait out the readiness cache;
  * keep serving for EDGE_API_DRAIN_ANNOUNCE (5s) so the router's 1s check
    removes the instance before it stops accepting, with a second signal cutting
    the wait short;
  * then Shutdown under EDGE_API_SHUTDOWN_BUDGET (2m, matching the router's
    default read timeout — the process should wait exactly as long as the hop in
    front is still willing to wait), and Close with a diagnostic if it expires.

Malformed durations now fail at boot rather than silently falling back to a
default that is only wrong during an outage.

Every Compose file that runs edge-api gets stop_grace_period: 3m, including the
single-instance base. Without it Docker's 10s default would SIGKILL the process
in the middle of the drain it just learned to do.

Verified against the built binary with a chain endpoint that accepts and never
answers: /readyz reports "draining" at t+1s while /healthz still serves; a query
in flight survives past the old 10s cutoff and keeps running to t+28s (announce
plus budget); at budget expiry the process closes it and logs
"graceful shutdown did not finish" instead of waiting for SIGKILL.
Three gaps in the previous commit, from review:

The single-instance Compose never passed the settings. Compose forwards only
what a service lists, so EDGE_API_DRAIN_ANNOUNCE and EDGE_API_SHUTDOWN_BUDGET in
config.env reached nothing and the built-in defaults were always used. Both are
now declared on the base edge-api service, and the announce window is in
config.env alongside the budget it pairs with.

A second signal could only cut the announce window short. Once Shutdown started,
nothing read the signal channel — and signal.Notify had already taken SIGTERM
away from the runtime, so an operator staring at a stuck two-minute drain had
nothing left but SIGKILL. Shutdown now runs in a goroutine while the caller
selects on {finished, budget expired, another signal}, and either failure mode
closes the remaining connections.

Escalation goes to http.Server.Close rather than echo.Close, which turned out to
matter: echo.Shutdown holds startupMutex for its entire run, so escalating
through Echo deadlocks against the very drain it is meant to interrupt. The
end-to-end test below is what found that — the fix was written first and hung.

The sequence itself had no test. It is now extracted as drainAndShutdown and
covered against a real server on a real port with a blocking handler:

  * an in-flight request survives the drain and completes, while /readyz already
    reports "draining" — both halves of the guarantee in one test, so either one
    regressing fails CI;
  * budget expiry ends the wait and says why;
  * a signal during Shutdown forces the close (this is the deadlock guard);
  * BeginDrain is called before Shutdown, not after.
Two follow-ups from review.

The budget was only enforced through the error Shutdown chose to return, so it
depended on Shutdown reaching the point where it looks at its context. A Shutdown
blocked before that — on a lock, say, which is exactly how the echo.Close
deadlock behaved — would have run past the budget unbounded. Watch ctx.Done
directly alongside the result and the signal, so the ceiling holds whether or not
the thing it bounds cooperates.

Shutdown also reports a failed listener close the same way it reports a deadline,
and wrapping both as "shutdown budget expired" would send the next operator to
tune a setting that has nothing to do with it. Only context.DeadlineExceeded is
now called a budget expiry; anything else keeps its own cause.

Both guards were checked against a reverted implementation: without the ctx.Done
branch the blocking-Shutdown test hangs to the test timeout, and without the
cause check the listener-failure test fails.
A server's position on a consistent-hash ring defaults to its numeric slot id.
With server-template the slots are filled from a DNS answer whose order nothing
guarantees, so a plain router restart could hand the same hosts different slots —
and move every session at once. That is the one failure the sticky pool exists to
prevent, and it would have happened silently on a restart nobody thought of as
risky.

Measured with four backends and the same addresses in different slot orders,
default keying:

  forward order:  .5 .2 .2 .2 .4 .2 .5 .3
  reversed order: .2 .5 .5 .5 .3 .5 .2 .4

Not one of the eight escrows stayed put. With hash-key addr the mapping is
identical under forward, reversed and shuffled slot order. hash-key is a server
keyword and is already available in the HAProxy 3.0 the routers ship, so no image
bump is needed.

Two guards, at the two costs they are worth:

  * make test-render asserts the pool's servers carry hash-key addr, so removing
    it fails on every PR;
  * the evacuation acceptance test now restarts the router and requires the
    escrow to come back to the same host, which is the behaviour rather than the
    spelling.

Moving onto this router re-homes sessions once, since the ring is not the one
nginx computed. HA sessions recover from shared Postgres, so that costs a lookup.
Two problems with the previous commit's guard, from review.

The acceptance test restarted the router and then kept talking to the port it
had before. The harness publishes random host ports, and Docker hands out a new
one when the container comes back, so the suite failed with connection refused —
reproducibly, in CI. Endpoints are re-resolved after the restart now.

That test also could not prove anything: a restart does not force DNS to answer
in a different order, so the very regression it was written for would have
sailed through it. It stays as a smoke test, with a comment saying so, and the
proof moves somewhere it can be made deterministic.

`make test-hash-ring` takes the hashing directives out of the rendered config,
puts the same four addresses into the server slots in forward, reversed and
shuffled order, and requires every escrow to reach the same address in all
three. It needs no upstreams: HAProxy logs which server it selected even when
the connection is refused, and that selection is the entire question.

Checked in both directions. With `hash-key addr` all three orders agree; with it
removed from the template the test fails and names the sessions that moved. The
first attempt at that negative check passed by accident — the extraction matched
`hash-key addr` inside a comment — so it now reads only the server-template line.

test-render depends on it, so CI and `make -C devshard ci-testenv-unit` pick it
up with no workflow change.
The rejoin check required observing the target DOWN right after start, which is
a race the test loses whenever the child comes up quickly — reproducibly, per
review. Waiting for a transient is no way to check that an unready host gets no
traffic; the host has to be held unready.

It now comes back pointed at an oracle that refuses connections. versiond boots,
appears in DNS and stays there, but never learns what to run, so /readyz keeps
failing for exactly as long as the test holds the barrier — no transient to
catch. Lifting the barrier restores the real oracle and the host rejoins.

Verified the barrier directly against a versiond container: alive with /healthz
200 and /readyz 503 at 6s and still at 16s, reconcile failing on every poll. It
is a state, not a window.

The barrier is deliberately scoped to one host, which is why PatchComposeEnvKey
would not do — it rewrites the key in every service, and repointing the survivor
too would just empty the pool. PatchComposeServiceEnv edits one service block and
returns the value it replaced, so the restore is the file's own value rather than
one reconstructed from config. It has its own unit test, including that a sibling
service and an unrelated block with the same key are left alone.

This also pins something worth pinning: one host that cannot converge does not
make the pool unready. The survivor keeps its oracle, keeps serving, and the test
asserts the session stays reachable on it throughout.
/readyz gated on Conditions.Degraded, which is set by any reconcile error —
including "oracle fetch failed". Every versiond reads the same oracle, so that
error does not arrive on one host: it arrives on all of them, within one poll
interval of each other. An unreachable oracle or one bad archive would therefore
have emptied the pool, and the router would have answered 503 for every version,
while every child was still running and serving normally.

That is the same correlated-failure shape the Converged latch already fixes, and
it contradicts what the endpoint is for: readiness answers "can this host serve
now", not "did the last control-plane poll succeed". Reconcile failures keep
being reported through Degraded, /healthz and the logs, which is where a
deployment problem belongs.

The cost is named rather than hidden, in code and in the docs: a host that has
served before and then fails to install a newly approved version stays in the
pool, so requests for that one version fail on it instead of moving to a host
that installed it. Fixing that properly needs per-version readiness, which one
balancer health check cannot express. Should a condition turn up under which
accepting traffic is genuinely unsafe, it gets its own typed condition — not a
ride on the generic reconcile error.

The acceptance-test barrier is unaffected and was re-checked against a real
container: a host pointed at a dead oracle has never converged and has no child,
so it stays 503 on /readyz — alive, /healthz 200, still 503 at 18s — with
Available and Converged now carrying that on their own.
Two docs said a failed reconcile is reported "through the Degraded condition, in
/healthz and in the logs". Only the last of those is true: /healthz serves
health.StatusEntry — name, port, status, sha256, binary_version — and nothing
else. Promising a field that is not there is worse than saying nothing, because
an operator can build an alert on it and only find out during the outage it was
meant to catch.

Corrected to what actually happens: the failure is kept in versiond's internal
Degraded condition and logged at ERROR, and /healthz is deliberately unchanged
because that array is a contract existing clients parse.

The gap is recorded as a follow-up rather than papered over. Reconcile failures
have no machine-readable exposure today; that belongs in a metric, not bolted
onto the legacy JSON, and versiond has no metrics endpoint of its own yet.
A new SHA that will not download reaches Degraded through the reconcile result,
not through ReportReconcileError, so the existing regression test for an
unreachable oracle did not cover it. Both triggers are fleet-wide — every host
reads the same oracle and the same archive — and both must leave a serving host
in the pool.

The test asserts what the balancer consumes after a failed install: the old child
is still running so Available holds, Converged is not retracted, and the failure
is still reported as Degraded. That downloadAndSwap keeps the old child is already
covered by TestDownloadAndSwap_NewChildNotReadyKeepsOldServing; this pins the
conditions that follow from it.
The docs said a reconcile failure is logged at ERROR. An unreachable oracle is,
but an empty version list sets the same Degraded condition and logs at WARN, so
anyone grepping for the promised level would miss half the cases. Say only that
it is logged.
A single readiness flag per host cannot say "I am missing one of several
versions", so the choice was between letting a host serve a version it does not
have, and evicting it entirely. Both are wrong, and the second is worse: the
cause of a missing version is a shared archive, so it is missing on every host at
once and the whole pool would leave over a version most traffic does not use.

So stop asking the host-level question. versiond now answers
/readyz?version=<v> from the same route table the proxy uses, and the router
keeps one backend per version in VERSIOND_VERSIONS, health-checked with that
version's own question. Same hosts in every backend; what differs is which of
them pass. A host that cannot run v5 leaves v5's ring and keeps serving v4.

Approving a new version becomes safe for the same reason: until a host has v6
running it is simply not in v6's pool, so v6 traffic goes only where it can be
served and v4 carries on untouched.

The per-version answer needs no convergence latch and no view of the desired set,
which is what makes it precise — either a running child serves that version here
or it does not. The host-level /readyz stays as the fallback for versions the
router was not told about, so listing a version buys precision and is never a
prerequisite for serving it.

All three pool backends are rendered from one pool-backend.cfg.template, so the
routing policy — hashing, hash-key addr, retry discipline, header handling —
cannot drift between them. test-render asserts each rendered backend carries all
five policy directives, which is the check that would catch a fragment edited for
one pool and not the others.

Verified end to end against the shipped image with two upstreams that disagree
about which versions they serve: v4 spreads over both, v5 reaches only the host
that has it, an undeclared version falls back to the host-level pool, and
gonka-drain can inspect any of them. make test-version-routing pins exactly that,
and fails when the version dispatch is removed.
Three holes in the previous commit, from review.

gonka-drain drained one backend. HAProxy server state belongs to a backend/server
pair, and a host now sits in every pool, so draining it from versiond_ha_pool
left it serving every declared version — the command reported success and did
almost nothing. It now plans across all pools, refuses if any of them would be
emptied, applies, and rolls back if one apply fails, so a host is never left half
out. Verified: draining one of two hosts removes it from v4 and v5 at once, and
putting it back restores both.

An undeclared version fell back to the host-level pool, which is the coarse check
per-version pools exist to replace: the request reached whichever host the hash
picked and 404'd there if that host lacked the version. So the claim that
approving a new version was automatically safe was wrong, and the docs said so.
While any version is declared, an undeclared one is now refused at the router
with a 503 that names the setting to fix. Non-version paths like /healthz are
exempt, and an empty VERSIOND_VERSIONS disables the mechanism entirely and keeps
the previous behaviour. The two-phase rollout — declare, then approve — is
documented as the procedure it is.

Version names had two contracts. The router accepted only [A-Za-z0-9._-] and
refused to *boot* on anything else, while the chain accepts any non-empty name
and versiond only bars path separators. A legal approved version could therefore
take the router down. The name is now taken as governance wrote it and only the
HAProxy identifier is derived from it, with a loud failure if two names derive
the same one — a silent collision would merge two versions' pools.

test-version-routing covers the new refusal and that /healthz survives it; the
render test covers name derivation and collisions.
Six problems, from review.

The acceptance test failed on the drain refusal wording. The message and the
assertion now say the same thing, and the message names the backend it is
protecting.

The strict guard called the first path segment a version, so it refused
/metrics, /stats, /devshard/healthz and the session observability routes — all
of which versiond serves without a version. It now classifies against that
grammar (proxy.go: isVersionlessObsPath) on the canonical path, so the prefix
form is covered too. Checked against each of those paths.

gonka-drain skipped versiond_legacy, so draining the legacy owner reported
success while legacy traffic kept arriving. Every backend the host appears in is
now included, which makes the legacy owner undrainable — its backend has one
server, and emptying it would fail every pinned version. That is the honest
answer, and it is now the one the command gives. A legacy backend with nothing
pinned to it is skipped, since no request can reach it.

The last-server check counted the backend rather than the target: a host that
serves v4 but not v5 could not be drained, because v5's pool had one server —
which was not the host being drained. The guard now applies only where the target
is itself taking traffic.

Version names went through a lossy tr, so v5+cuda and v5-cuda derived the same
backend, and '+' in the health-check query decodes to a space on the versiond
side, leaving the host down forever. One grammar now applies —
[A-Za-z0-9][A-Za-z0-9._-]* — used verbatim as backend name, query value and map
key, with anything else refused at startup. Narrowing the chain's own validation
to match is recorded as a follow-up.

The rollback was not one: it applied the opposite action instead of restoring
what was there, and two concurrent drains could each see a live peer and then
leave none. Admin states are snapshotted per server and restored exactly, and the
whole plan-and-apply is under a lock. Verified: concurrent drains leave one host
serving, and the loser is told why.

The documented rollout said "restart the router", which does not pick up an
environment change, and recreating it cut live streams. The procedure is now
`up -d --force-recreate`, and the router stops on SIGUSR1 — HAProxy's soft stop —
so the outgoing container finishes its streams.
…prove

Seven problems, from review.

The acceptance test asked the host-level /readyz while waiting on a per-version
pool; those do not turn 200 at the same moment. The harness was also flattening
every backend into one list, so a wait settled on whichever backend printed last.
RouterSlot now carries its backend, the waits name the pool they mean, and
TryVersiondReady asks the same question the router asks.

Replacing the router to declare a version refused every new connection from the
moment the old container was told to stop until it finished its longest stream —
minutes, up to stop_grace_period, not the "short gap" the docs claimed. Added
gonka-reload: render, check, then SIGUSR2, which is HAProxy's master-worker
reload. The listener never closes and established streams stay on the old worker.
Measured over 1200 requests at 92/s, one reload cost a single 503, a window under
about 10ms. Server states are handed to the new worker first, or it would re-probe
from scratch — and would also forget which hosts an operator had drained.

Version names are no longer restricted to what happens to be a valid HAProxy
identifier, which the chain does not promise. Each of the three uses now gets a
form that suits it: the map key is the name as written, because it is matched
against the path segment; the health-check query is percent-encoded, because '+'
there decodes to a space; the backend identifier gets a hash appended when the
name is not already one. v5+cuda routes end to end. A name that cannot appear
literally in a path segment is still refused, because the path would not match
it — that residue is documented rather than papered over.

gonka-drain matched slot names per backend, but a slot is local to its backend:
versiond1 is a different host in each. Draining by slot could leave the legacy
backend serving. The target is resolved to one address first and every backend is
matched on that address alone.

The versionless ACL was unanchored, so /metrics-v9 and /healthz-v9 slipped past
the undeclared-version guard. Each alternative is anchored now, and both are
refused while /metrics and /healthz still work.

Duplicate detection matched with a regex, so 'v1.2' collided with 'v1x2'. It
compares the map's first field exactly.

gonka-drain read the legacy map from disk, which misses a pin added through the
Runtime API. It reads the live map from HAProxy.
The last review's closing point is the one that matters: gonka-reload put a
second source of truth back into the router. Four of its findings are the same
finding — a reload re-rendered the config from the environment and so discarded
runtime map pins, left drains applied in some pools and not others, resurrected
stale drains from a state file on disk, and reported success before the new
worker was serving. That is a control plane growing back, one convenience at a
time, in the PR that deleted one.

So the reload is gone, along with the server-state file it needed. Declaring a
version changes the deployment and replaces the container, as any other router
config change does. To keep a governance approval from needing that at all, the
join overlay now declares a window — v4 through v8 — because a pool for a version
nobody runs has no healthy members and costs nothing but its checks. The README
says this plainly instead of offering an in-place path that cannot be made
consistent.

Separately, a real routing bug: a host was selectable the moment DNS mentioned
it, before its first health check had run — a window straight onto a versiond
that is still starting, which is what the acceptance test kept catching. HAProxy
3.1 added `init-state fully-down` for exactly this; the routers move to 3.2,
still LTS, and every pool server now starts down and has to earn its place.
Measured with a host whose listener starts six seconds late: the router answers
503 until the checks pass at t+9s, and never routes to it before.

Also from review: gonka-drain treated "this host is in no backend" as success,
which is worst for a host that has just entered DNS and is about to join — it now
fails and says so. Duplicate version detection compared with awk's ==, which is
numeric when both sides look like numbers, so 1, 01 and 1.0 were one version.

The rolling-update suite drained the legacy owner, which is correctly refused. It
does not need legacy pinning, so it clears it, and the legacy backend then has
nothing routed to it and stays out of the drain.
Readiness for a version was answered from the route table alone, which only says
a child process is running. devshardd reports itself unready when its chain
subscription drops, and versiond went on answering 200 for that version — the
router kept the host in the pool and kept sending it work. ServesVersion now
re-asks the child, cached for under a second so a per-second balancer check does
not become a per-second probe of the child, and bounded so a child that has
stopped answering is not a serving one.

gonka-drain loses `out` and `in`. HAProxy identifies a server by its slot in a
server-template and slots are reused: a drained host that leaves DNS frees its
slot, and the next host to arrive inherits the drain — kept out of rotation with
nothing to show why. Admin state belongs to the identity of a process, and the
router has no such identity, only an address DNS lent it. Taking a host out of
rotation is stopping its versiond, which is graceful by construction and cannot
be inherited. `status` stays, read-only.

The rolling-update suite pinned traffic by draining the other host; it stops it
instead, and the old-generation check now looks only at the host that flipped,
since a stopped host has no health to report.

Docs caught up with the code. rolling-update.md still specified a transactional
router FSM, hostctl checkpoints, membership IDs and schema migrations for tests
that no longer exist, and claimed them as the implemented status of Track B. The
release guide said the join overlay declares v4 when it declares v4 through v8,
and said in one place that a host failing to install a version leaves that
version's pool and in another that it stays. The README still demanded the old
name grammar that the entrypoint no longer enforces.
The cached probe had the shape the review described. It read the child under the
lock, released it, spent up to two seconds on HTTP, then wrote the answer keyed
by version name — so a swap in that window let a departed generation decide for
its replacement, in either direction, and concurrent callers could land their
answers out of order. Nothing invalidated the cache on a route rebuild either,
and the test that came with it deleted the entry by hand, so it never exercised
any of that.

Both that and the fan-out are the same design mistake: answering a question by
doing I/O inside the answer. A monitor now runs per generation, refreshing that
generation's own flag once a second, and ServesVersion is a pure read. The flag
lives on the child, so it is bound to the generation by construction rather than
by a check — a late answer can only be written to the child that was asked, and a
swap simply ends one monitor and starts the next. A balancer asking every second
cannot become a probe every second, and no number of concurrent callers fans out.

The flag also carries when it was last refreshed, and an answer older than five
seconds is not an answer: a monitor that has stopped must not leave the version
frozen at "ready". Tests cover losing and regaining readiness through the real
monitor, staleness, and that the current generation alone decides.

Docs: the release guide still offered gonka-drain for quiescing and still said a
host failing to install a version stays in that version's pool; rolling-update.md
still credited gonka-drain with refusing to empty the pool; and the evacuation
invariants skipped from 6 to 8 after the earlier renumbering.
Five findings from review, four of them seams where the monitor met the rest of
the manager.

The route could be seen before the flag. close(c.ready) wakes the swap path,
which publishes the new generation itself, and c.serving was stored only after
the unlock — so a per-version check landing in that window got 503, and with
fall 1 one such answer evicts the host. A fleet-wide rolling update could blink
the whole pool. The flag is now seeded before the lock is even taken, so nothing
can observe the route without the flag.

A crash-restart could leave two monitors on one child. The monitor's context was
the generation's, and a monitor caught inside its two-second probe while the
process died and came back would see running again and keep writing over the new
attempt's answers. The context is now scoped to the process attempt, cancelled
and awaited right after proc.Wait() — the probe is context-aware, so the await is
prompt. The contract is pinned by a test: after cancel+await, a flipped child
provokes no further writes.

The empty-VERSIOND_VERSIONS mode never saw live readiness: the coarse /readyz
gated on Available, which only says a child process exists. Conditions gains
Serving — at least one running child whose vouch is current, computed by the same
predicate ServesVersion uses so the two answers cannot drift — and versiondReady
requires it. Deliberately "at least one", not "every": requiring every child
would let one version's fleet-correlated unreadiness empty the pool and take the
healthy versions with it, which is the same trap as gating on Degraded. The
reviewer's single-child scenario is covered identically either way; per-version
pools remain the precise tool.

The legacy /healthz fallback logged a warning per probe, and the monitor probes
every second. It logs once per generation now, from the callers that know the
generation — which also quiets the startup poller, the other repeat offender.

Docs: invariant 9 claimed an unconverged host is not routed to at all; it is not
routed to through the host-level pool, while per-version pools serve what it
already has, on purpose. The three descriptions of the coarse /readyz now
mention the live-readiness requirement.
Serving means "at least one live-ready child", so with VERSIOND_VERSIONS empty a
host whose v5 child went unready keeps answering 200 as long as v4 is healthy,
and v5 requests keep landing on it hash-dependently — the exact failure the
per-version pools exist to prevent, reachable through a supported configuration.

Making the coarse answer stricter is not the fix; "every child ready" is the
correlated-eviction trap again. The fix is not letting an HA deployment route
blind: GONKA_HA with an empty VERSIOND_VERSIONS now fails at startup, with the
message naming both remedies. The escape hatch is deliberate and says what it
accepts — VERSIOND_ROUTER_ALLOW_COARSE_READINESS=1 — because one legitimate user
exists: local-test-net mints version names dynamically inside test scenarios and
cannot declare them up front. The join overlay already declares v4 through v8
and never hits the check. test-render pins refusal, override and the non-HA
pass-through.

Two documentation corrections from the same review. The evacuation doc promised
the vouch is withdrawn "within a second"; it normally takes one probe interval,
a probe may run up to its 2s timeout, and an unrefreshed answer expires after
5s — it now says that. The README still claimed a host downloading an archive or
restarting a child reports 503; after first convergence the latch holds, and a
restarting child leaves only its own version's pool.
The review found the override fail-open: any non-empty value, 0 and false
included, unlocked coarse readiness. That is an instance of a class, and the
class was worse than the instance. Every boolean env here was parsed by each
consumer separately, and the two consumers of GONKA_HA disagreed: the router
read non-emptiness, so false meant on; devshardd read a known-values switch
whose default was off, so a typo silently disabled the storage boot guard —
the one value the variable exists to enable. The same deployment setting could
be simultaneously on for routing and off for safety.

One grammar now, on both sides: 1/true/yes are on, empty/0/false/no are off,
anything else refuses to start and names the variable. Each entrypoint parses
its booleans once, at the top, into plain variables — GONKA_HA, the coarse
override, RENDER_ONLY — so no use site can reinvent the parse; devshardd's
HADeployment returns an error for values outside the grammar and the boot
guard propagates it instead of guessing "off".

Pinned from both directions. Go: every off spelling passes on sqlite, every on
spelling refuses it, garbage refuses to boot naming the grammar. test-render:
0/false/NO do not unlock the override, garbage overrides and garbage GONKA_HA
are refused with the grammar named, and GONKA_HA=false renders without the
Devshard-Ha header — the spelling that used to mean on. Reverting the parser to
non-emptiness fails the suite on the first check.
GONKA_HA=' true ' was on for devshardd and a startup failure for the router —
the one grammar still had two readings at its edges. Both bool_env helpers now
trim leading and trailing whitespace only, matching Go's TrimSpace: deleting
all whitespace would accept 't rue', which Go rejects, and recreate the
divergence in the opposite direction.

The render tests pin ' true ' as on, header stamped, and ' no ' as off, header
stripped; both were added before the fix and failed against the untrimmed
parser.
Confirmed from review, and worse than stated. edge-api gives its chain probe two
seconds, but neither router set `timeout check`, and without it `inter 1s` is
HAProxy's entire check budget — connect and read. A chain answering in one to
two seconds is slow, not down, yet every check against it fails, and with
fall 1 every edge-api leaves the pool at once, since they all share the chain
node. The part the review could not see from outside: the probe ran on the
request's context, so the checker aborting at 1s cancelled the probe, and the
resulting context.Canceled was cached as "chain unreachable" for the next three
seconds of checks. The pool did not just blink on a slow moment; the abort kept
it down.

Both routers now set `timeout check 3s`, above edge-api's 2s probe budget, with
the relationship written on both sides of the boundary — the constant in
readiness.go names the router setting and the template comment names the
constant, since no compiler spans the two. versiond-router gets the same 3s: its
/readyz is a memory read, so the tolerance costs nothing, and a host that cannot
answer a memory read in three seconds is genuinely wedged.

The probe itself now runs on its own context with the readiness budget — the
answer is about the chain, not about the caller's patience — and is
singleflighted, so an expired cache under concurrent checks costs one chain
query instead of one per caller. Tests pin both: eight concurrent checks share
one probe, and a hanging probe is cut at its own budget, verdict cached, not at
whatever deadline the checker happened to have. Render suites pin the presence
of `timeout check` in both routers.
The name promised draining, which the script deliberately no longer does, and
its place in /usr/local/bin presented it as an operator CLI, which it is not:
it is a read-only formatter over the HAProxy Runtime API whose output the
citest harness parses. It is now /usr/local/lib/versiond-router/pool-status —
named for what it does, off PATH because its primary consumer is a test
harness, and stripped of the out/in refusal stub: the control plane it
apologised for never shipped, so nobody has muscle memory for it. Any argument
is refused with a pointer to docker compose stop.

The harness invokes it by full path and names it as its contract; the docs show
the full path, which doubles as a statement that this is a diagnostic you look
at, not a tool you operate.
The assert greps for 'timeout check' and the comment I put beside the directive
names it verbatim, so sed carries the words into the rendered config and the
check passed with the directive deleted — the same comment-satisfies-the-grep
mistake the hash-key assert had, made again one commit after documenting the
first. The pattern now requires the directive itself, at line start with its
value; both suites were shown to fail against a template with the comment kept
and the directive removed, then pass restored.

Audited the remaining render asserts for the class: the rest either anchor
already or assert phrases no comment spells out.
The updater treated an existing router-HA marker as a reason to exit, even when
images, services, or the actual Compose topology no longer matched the release.
A crash after cutover but before the marker write could also leave a healthy v5
deployment that the next run refused to adopt.

Serialize upgrades with a deployment lock and store a fingerprint of the
release, topology, ordered Compose files, project identity, rendered config,
and image set. On every rerun, recover that committed topology when no explicit
one was supplied, idempotently converge each application service and the router
fleet, verify application and ingress state, and only then rewrite the marker.

The updater now repairs drift instead of trusting the existence of a file, and
child commands cannot accidentally retain the lock after signal handling.
Tests cover missing services, changed fingerprints, committed custom topology,
and recovery after the cutover/marker crash window.
Repeated router-HA runs replaced the public proxy without preserving the exact
running generation. A bad image or routing environment could pass container
startup, fail admission, and leave no automatic route back to the previously
working ingress.

Before either the initial v4 cutover or a later v5 update, tag the running image
by digest and capture every routing environment key. Keep rollback armed while
the candidate starts, while fleet routes are checked, and while edge-api
admission converges. On failure or signal, recreate the previous proxy with its
captured mode and settings and verify that its production routes are usable.

Only a fully admitted candidate clears the traps and removes the temporary
image. Shell tests cover v4 and v5 compensation, changed environments, failed
admission, and interrupts so a public-router update has an explicit commit
point rather than a best-effort restart.
The catalog reconciler could expose a newly approved version as soon as one
backend became healthy. That made a governance addition routable without the
configured replica reserve and turned the first host failure into an immediate
outage during rollout.

Introduce a shared activation minimum for the parent and inner catalogs. Slot
assignment and health checks may prepare a version locally, but its map entry
is not published until enough servers pass the per-version readiness contract.
Existing version routes remain untouched while the new revision waits.

Carry the reserve through Compose, fleet placement compatibility, release
cutover, and documentation. Live routing tests hold one candidate below the
threshold and prove that no partial route leaks before the required HA capacity
is present.
Matching PGHOST, database, and user strings does not prove that two versiond
replicas reached the same database. DNS, proxies, or operator overrides could
send them to different clusters while every static configuration check passed,
breaking session and execution consistency.

Create one durable UUID in the devshard PostgreSQL schema and expose it through
a loopback-only versiond diagnostic. The upgrade driver reads that identity
through both supervisors and refuses to commit an HA deployment unless the
values match. The endpoint returns no useful surface to remote clients and
fails closed when PostgreSQL lookup is unavailable.

Migration, handler, lookup, and upgrade tests cover stable identity creation,
loopback access, unavailable storage, and divergent replicas. Shared storage is
now verified by data-plane identity rather than inferred from connection text.
Named container tags can be moved after the release metadata is reviewed. A
host could therefore run the documented upgrade command at two different times
and receive different binaries under the same apparent release contract.

Require every v5 release image reference to include an immutable digest and
validate that form before the updater or release gate proceeds. Keep the digest
pins in the central release environment so documentation and automation consume
the same artifact identities.

Contract tests reject mutable tags, malformed digests, and missing pins. The
release source commit and every deployed image are now reproducible inputs
rather than registry conventions.
A version name accepted by governance could still be impossible to represent
as a URL path segment, HAProxy Runtime API key, or local binary basename.
Different components had overlapping but non-identical validation, so one name
could be stored on chain and then disappear or be interpreted differently in
the serving path.

Define the safe-basename rules once in the consensus parameter validation and
align versiond's oracle client and the routing reconciler with them. Reject path
separators, query and fragment delimiters, percent escapes, whitespace,
backslashes, quotes, and dot path components before approval or installation.

Cross-component tests cover accepted punctuation and every unsafe class. A
governance-approved name can now travel unchanged through dapi, routing maps,
health URLs, and binary storage.
The cutover verified that both live versiond replicas shared one database, but
the completed marker did not remember which database it was. A later rerun
could adopt a different PostgreSQL cluster, see two matching replicas, and
silently declare the changed storage topology converged.

Persist the verified database UUID in the atomic upgrade marker. Subsequent
runs compare the live identity with both the peer replica and the committed
value before application or ingress state may be accepted. The marker is
written only after the identity has been read successfully.

Upgrade tests cover marker creation, stable reruns, and replacement with a
different database identity. Recovery can therefore repair containers without
changing the durable state domain it previously committed.
Router slots retain version-to-backend assignments monotonically so a transient
catalog contraction cannot reshuffle established routes. Governance could
still remove an approved name, however, leaving routers and hosts with
different ideas of whether that name was permanently retired or merely stale.

Reject parameter updates that remove any previously approved devshard version.
New names may be appended up to the routing capacity guaranteed by the shipped
software, and changing metadata for an existing name remains possible without
reusing its identity. Raise that capacity only together with a software upgrade
that provides more router slots.

Keeper and parameter tests cover removals, additions, corrupt legacy entries,
and the capacity ceiling. The catalog revision can now grow monotonically from
a consensus-enforced source rather than relying only on router convention.
The reconciler previously added version and readiness map entries one by one.
A process crash or Runtime API error in the middle of a governance revision
could expose only part of that revision, making requests depend on which key
had been written before the failure.

Build the complete projection first, reserve and enable every required backend,
and verify the activation minimum before publishing any data route. Use
HAProxy's map transactions to seed a full replacement and commit readiness
first, then commit the request-routing map as the visibility point. Persist the
accepted catalog before publication so restart can replay the whole revision.

Live tests inject multi-version revisions and failed publication paths and
assert that traffic never observes a partial request map. A catalog update is
now one ordered commit rather than a sequence of independently visible edits.
The initial execution claim prevented two replicas from starting together but
did not distinguish a crash before the ML POST from a crash after it. Reclaiming
every stale claim risks duplicate side effects; never reclaiming one that was
not dispatched leaves work stuck forever.

Persist explicit claimed, dispatched, completed, and abandoned phases. A short
lease may transfer only a pre-dispatch claim, and every transfer receives a new
fence. Immediately before sending the request, the engine atomically marks the
target as dispatched; dispatched work is never retried automatically, while an
undispatched failure may be abandoned and safely claimed by another replica.
Only the matching owner and fence may commit the result.

Host, engine, memory, hybrid, and PostgreSQL tests cover lease expiry, stale
owners, crash boundaries, replayed results, and forbidden post-dispatch
fallback. The durable FSM makes the exact side-effect boundary explicit instead
of inferring it from request errors.
A process could finish the router cutover and lose the public proxy container
before writing the final marker. On rerun, topology discovery then had neither
the old runtime labels nor enough explicit input and rejected the already
committed deployment instead of recreating it.

Treat a valid release marker as an authoritative recovery source for topology
mode, ordered Compose files, project name, and project directory. Respect an
explicit operator selection, but otherwise restore those values before runtime
discovery and allow the committed model to recreate a missing public proxy.
Still reject a project override that contradicts surviving runtime ownership.

Tests cover absent proxy recovery, auto-mode restoration, custom projects, and
conflicting overrides. The marker now contains enough deployment identity to
rebuild the committed topology rather than merely report its release number.
The public HAProxy balanced an anonymous scaled nginx service. Compose could
replace all replicas together, and the release driver had no stable identity
with which to update one worker, verify admission, or restore its exact image.
That made a routine policy or certificate-image update a tier-wide restart.

Represent the two policy workers as fixed Compose services with the same
policy contract. Capture each running image and replica count, verify that the
candidate policy and public proxy advertise a compatible contract, replace one
worker at a time, and wait until HAProxy admits its concrete address before
moving to the next. Keep both policy and public-proxy compensation armed until
the complete ingress path is healthy.

Update observability and compatibility projections for the fixed slots and add
tests for mixed generations, admission delay, interrupted rollout, exact-image
rollback, and the absent-public-proxy bootstrap. At least one known-good policy
worker remains available through every supported update order.
The release updater, router cutover, and fleet helper each protected only its
own operation. A crash between application convergence, ingress convergence,
and the final marker left no durable phase from which to resume, while another
entry point could mutate the same deployment concurrently.

Introduce one re-entrant deployment lock shared through a verified file
descriptor and a phase journal containing the exact release and Compose state.
Checkpoint prepared, applications-verified, and ingress-verified phases with
atomic renames; restore the saved topology from an interrupted journal; and
remove it only when the final committed marker is durable. Child helpers inherit
the lock intentionally, so no competing mutation can enter while compensation
or signal handling is still reaping them.

Include the pinned PostgreSQL image in convergence and require router images to
advertise a compatible persistent-catalog protocol before rolling the fleet.
Failure-injection tests exercise every journal boundary, lock handoff, signal,
rollback, and resume path. A host can rerun the same command after interruption
and converge forward without guessing which half of the release was applied.
Prevent net/http from transparently replaying an inference POST after dispatch and cover the failure window with a raw TCP regression test.

Enforce one ASCII protocol-version grammar at consensus, versiond, and router boundaries, and prohibit removing the devshard catalog through nil params.

Publish data routes and per-version admission keys through one HAProxy map transaction so readiness can never lead routability.

Persist versiond's full last-known-good catalog before reconciliation and reject stale revisions, in-place mutations, and version removals after restart.
Treat the public ingress cutover and the enclosing v5 release as one crash-recoverable operation instead of a sequence whose rollback state lived in shell variables. The ingress journal now records the exact previous Compose generations, rollback image references, config hashes, and every resource before it is touched. Recovery replays only those resources in reverse mutation order, survives SIGKILL and reboot, and redacts the potentially secret-bearing rollback model before the transaction is committed.

The release journal now outranks an older committed marker, fences resume with transaction, base, desired-state, and rendered-Compose fingerprints, and is atomically renamed into the final marker. PostgreSQL identity is carried from the committed state and persisted as soon as the first upgraded versiond can prove it, so later supervisors and resumed runs cannot silently switch execution ledgers.

Render and verify an exact v4 nginx rollback projection, including operator-owned observability environment, and require Compose 2.24.4 for reset/override semantics. Extend cutover, crash recovery, topology, and fleet tests; the maintenance rollback test now uses an explicitly route-dead candidate rather than treating a valid legacy routing declaration as invalid. Document the local PostgreSQL failure boundary and the private transaction-journal contract.
Route every consensus catalog mutation through one validation and progression contract, including the v0.2.15 upgrade handler. A fresh versiond now authenticates DAPI's first catalog against a caught-up local consensus node, while an existing last-known-good catalog continues serving through temporary API failures.

Bump the router cache protocol and migrate the legacy cache atomically into a generation-specific file. Keep the old cache intact for rollback, allow one protocol generation per fleet rollout, and require both router release images to publish the new contract.

Make deployment recovery safer without adding hoster steps: recover interrupted ingress before slow convergence, recheck the effective Compose model before each mutation, update the non-serving policy replica first, infer and apply the external-PostgreSQL overlay automatically, and reject ambiguous HA database configuration.

Cover invalid consensus upgrades, first-catalog trust, cache durability and migration, degraded policy rollout, Compose drift, external PostgreSQL, transaction recovery, and the complete Docker fleet lifecycle.
The ML backend has no Idempotency-Key deduplication contract, so forwarding a generated key advertised protection that did not exist and made the HTTP replay behavior harder to reason about.\n\nRemove the header and key generation while retaining the real at-most-once boundary: persist dispatched before sending, never steal dispatched executions, and never replay a failed ML POST. Update the architecture and storage docs to describe only that implemented contract.
Stop exposing internal router and edge-api addresses in public response headers, and keep the graceful fleet test deterministic without a production test hook.

Reduce the durable execution FSM to the state and lease data used for correctness. Keep the PostgreSQL upgrade append-only while creating a compact schema for hosts upgrading from the release base.

Remove dead deployment variables and unsupported overrides for fixed router internals, with render assertions preventing diagnostic headers from returning.
Gate versiond startup traffic on a fresh consensus-verified artifact catalog while preserving same-name rolling replacements after admission. Require HA consensus endpoints, reject local overrides in HA, and constrain catalog revisions to the local consensus height.

Bind ingress rollback to actual container generations and the outer Compose fingerprint so interrupted or drifting upgrades cannot recreate unchanged services or commit a different topology. Normalize the HA PostgreSQL contract by rejecting service-file overrides.

Bound ambiguous ML execution recovery, disable transport-level request replay, validate legacy chain catalogs during migration, and align release smoke tests and operator documentation with the resulting contracts.
Track the SHA from each freshly accepted catalog before reconciliation and give an already-admitted old generation one non-renewable rollout lease. Failed same-name replacements now leave only the affected version pool after the deadline, while mirror URL changes with identical bytes remain admissible.

Parse GONKA_HA once with the full closed boolean grammar and pass the typed value through versiond startup and session lookup. Prefer fresh consensus state before reconciling persisted LKG children, and reject DATABASE_URL already present in running HA supervisors.

Require matching rollback container IDs to remain running and healthy before treating recovery as complete. Journals without generation identity now restore conservatively, with crash tests for stopped generations and absent legacy resources.
The bounded same-name rollout lease could not protect a host that missed the catalog update, did not cover direct versiond routes, and expired before the supported download budget. Remove that second eligibility model instead of extending another timeout.

Consensus now permanently binds an approved version name to its SHA while allowing the download mirror to change. New artifact bytes require a new version name, so name-only routing remains correct even when hosts observe different catalog revisions. Versiond keeps its route-based serving predicate and no longer exposes or configures an artifact rollout lease.

Verify DAPI catalogs against inference params queried at the catalog's exact Cosmos block height, including the returned-height proof. Governance and v0.2.15 upgrade tests pin immutable SHA progression and URL-only mirror updates.

Make ingress --recover-only restore from journaled project and rollback context before sourcing forward config, fix the exact CI shellcheck failure, cover config-independent recovery, and include the external PostgreSQL overlay in release smoke triggers with a complete cold-start example.
Remove the inference execution fence, append-only governance catalog, consensus verification, and versiond artifact lease introduced while hardening the router HA rollout. These mechanisms change Gonka core behavior and require separate architectural approval.

Restore inference-chain, DAPI, chainoracle, devshard inference/storage interfaces, and versiond artifact reconciliation to the parent branch contracts. Keep the HA-specific PostgreSQL liveness signal and durable storage identity used to prove that versiond replicas share one database.

Make both router tiers consume the existing DAPI {versions:[...]} response. Monotonicity and generation tracking now belong only to the local crash-safe HAProxy projection cache, without adding a governance revision API.

Update deployment configuration, documentation, and router acceptance tests for the restored boundary.
Include the canonical router fleet specification in the release and ingress transaction fingerprints, and reject configuration drift before fleet mutation or ingress commit.

Gate standalone HA activation on the live PostgreSQL identity and the router-compatible version-name catalog. Make recovery fail closed for corrupt or unknown journals while retaining them for diagnosis.

Preserve complete operator Compose topologies in day-2 documentation and document the remaining additions-only routing and client-retry semantics. Add regression coverage for fleet drift, database mismatch, invalid catalog names, and recovery failures.
Reject PGOPTIONS in both rendered HA Compose models and running versiond supervisors before any replacement or fleet mutation. Treat even matching non-empty values as unsafe because libpq session parameters can select a different logical schema.

Use an absolute end anchor for version catalog names so trailing line feeds cannot pass the deployment grammar gate. Compare standalone router cutovers with the PostgreSQL identity stored in an existing upgrade marker.

Cover matching and divergent PGOPTIONS, terminal-LF names, and committed identity drift. Run the full router fleet Docker acceptance as a separate pull-request CI job.
@snevolin
snevolin marked this pull request as ready for review August 13, 2026 01:32

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ab4542bd76

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread deploy/join/enable-router-ha_test.sh Outdated
Comment on lines +469 to +470
[[ $signal_status -eq 143 ]] || fail \
"TERM during cutover returned $signal_status instead of 143"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restore the TERM contract before requiring the test

On GNU Bash 5.2.21, running deploy/join/enable-router-ha_test.sh directly—the same command required by the compose-contract job in .github/workflows/release-image-smoke.yml—deterministically reaches this assertion with signal_status=1 after the script reports that automatic ingress rollback failed, rather than returning 143. Consequently, every PR matching that workflow's paths gets a failing required job; fix the signal/rollback scenario so it preserves the expected exit status before making this test mandatory.

Useful? React with 👍 / 👎.

The live version-routing recipe used Bash's [[ =~ ]] operator even though Make executes it with /bin/sh. Ubuntu runners therefore stopped at parse time before exercising the dynamic governance route. Express the host assertion as a POSIX case statement so the same test runs under dash and Bash.

Treat a TERM during ingress cutover according to the durable transaction contract rather than one exact shell status. Both signal exit 143 and rollback-failure exit 1 are valid non-success outcomes; require a recoverable active or rolled-back journal and prove that the following run commits it. This keeps the test strict about recovery while removing a timing-dependent exit-code failure.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ac710847d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

&& chown -R haproxy:haproxy /etc/haproxy /var/run/haproxy \
/var/lib/gonka-router

COPY versiond-router/haproxy.cfg.template /etc/haproxy/haproxy.cfg.template

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align the Dockerfile with the local Compose build context

When the local versiond stack is built from local-test-net/docker-compose.versiond.yml, Compose uses context: ./versiond-router, so this source path resolves outside the build context and Docker fails before creating the router image (the later router-runtime/... copy is likewise unavailable). Use repository-root context for that Compose service, as the Makefile and generated testenv already do, or make the Dockerfile compatible with its directory-local context.

Useful? React with 👍 / 👎.

Model policy replacement with the nginx image's graceful SIGQUIT shutdown instead of force-removing an upstream after it may have accepted a POST.

Make the connect-level redispatch assertion deterministic by selecting a hash key pinned to a fixture whose data listener is then disabled while its admin readiness remains available.

Give policy rollback explicit success and failure returns so interrupted ingress recovery behaves consistently on Bash 5.2 and 5.3, with resource-specific diagnostics when restoration fails.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c0342b4837

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 142 to +146
versiond2:
<<: *versiond
container_name: versiond2
deploy:
replicas: ${VERSIOND2_REPLICAS:-1}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent standby replicas from serving stale sessions

In the new two-versiond HA overlay, an active session that exists when both devshardd children start is eagerly reconstructed and cached by both replicas (session/manager.go:424-446); after the selected replica advances that session, the standby is never refreshed because SessionServerExisting returns its cached server directly (session/manager.go:159-170). If the router later evacuates the selected host or remaps the hash, versiond2 therefore serves its old in-memory state despite sharing the current PostgreSQL rows, which can produce invalid nonces or conflicting state transitions. Recovery must be made ownership-aware or fenced/refreshed before enabling the second replica.

Useful? React with 👍 / 👎.

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