Skip to content

feat(cluster): planned-failover control plane -- POST /cluster/stepdown (BACKLOG #1494) - #1004

Merged
wshallwshall merged 21 commits into
mainfrom
claude/manager-424d8b
Sep 10, 2026
Merged

feat(cluster): planned-failover control plane -- POST /cluster/stepdown (BACKLOG #1494)#1004
wshallwshall merged 21 commits into
mainfrom
claude/manager-424d8b

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Builds slice 1 of ADR 0056 (BACKLOG #1494): the planned-failover control plane. The VIP mechanism itself is not built and stays proposed.

What this ships

1. The coordinator seam. ClusterCoordinator.step_down_leadership() -> tuple[bool, float | None], returning (was_leader, released_at). Both DB coordinators reuse _release_leadership() verbatim, so the ordering that makes the release safe — demote the cached gate before touching the DB, so a concurrent is_leader() reader never sees a stale true — is the same one stop() runs. NullCoordinator returns (False, None).

2. RBAC. CLUSTER_CONTROL = cluster:control. A dedicated capability, ADMINISTRATOR-only, and in CUSTOM_ROLE_FORBIDDEN_PERMISSIONS — so "Administrator only" is enforced on every minting path rather than merely observed of the built-in roles, which is the treatment its closest analog dr:operate already gets. Also added to _GRANT_AUDIT_PERMISSIONS.

3. POST /cluster/stepdown. 200 / 400 single-node / 403 permission-step-up-MFA / 409 not-leader / 503 no-engine-or-no-auth, plus 422 on the deferred force flag.

4. Audit. cluster_stepdown with {node_id, was_leader, released_at} as the coordinator returned them. There is no is_leader() pre-read in the handler at all, so there is no reading for a fence or a lost-lease tick to invalidate. The audit detail is the response body itself, so the two cannot drift apart field by field.

5. Docs. CLUSTERING.md gains a planned-failover section; the ADR 0056 note there and the "proposed only, with no code" sentence in AOAG-DEPLOYMENT.md now separate the control plane from the VIP. SECURITY.md's catalogue, role matrix, route map, gate-wrapper table and counting basis all move with the new permission and route.

The gate composition I chose, and why

require_step_up(Permission.CLUSTER_CONTROL), alone. ADR 0056's decision table asks for require_step_up() plus TOTP MFA, and in this codebase that is one wrapper, not two: require_step_up charges the per-actor admin-write pacing floor (BACKLOG #193), then the MFA gate, then the new-client-IP signal, then the credential-recency window. The neighbouring DR endpoints use require_paced, which is the same pacing floor without MFA or step-up — right for dr:operate, wrong here, because the ADR explicitly asks for the second factor. require_step_up_action was the other candidate and is deliberately not used: its single-use action binding is reserved for durable-takeover ops (disable-MFA, session terminate), and it would make an operator re-auth per stepdown during a maintenance window with no attack it closes that the session window does not.

Deferred, per the ADR's own terms

  • force — the ADR defers it; 409 is the normative non-leader answer. ClusterStepdownRequest is an empty RequestModel, so {"force": true} gets a 422 rather than being silently ignored.
  • new_leader_eligible — dropped. The ADR lists it unresolved ("derive the successor cheaply, or drop it and have the console re-poll"). At the instant of release no standby has acquired yet, so any successor named here would be a guess; the caller re-polls GET /cluster/nodes and watches lease_owner move.

One thing the ADR did not consider — read this part

ADR 0056 says a stepdown differs from a clean stop only in that the node "keeps running and heartbeating afterward", and that the standby then "acquires the expired lease". The second half does not follow from the first.

_release_leadership() sets lease_expires_at = 0 but leaves owner naming the releasing node, and the claim statement's renew branch is WHERE leader_lease.owner = $2 OR <expired> — the renew half carries no expiry test. On a clean stop that is safe, because stop() has already cancelled the maintenance loop. On a stepdown the loop is still running, so the drained node's very next tick matches its own renew branch and takes leadership straight back. Which node wins comes down to whose heartbeat phase lands first, and the endpoint would have answered 200 either way.

The fix is a bounded post-stepdown claim pause of two heartbeats, checked in exactly the position ADR 0096's promotable = false short-circuit already occupies. It is a strictly stricter claim predicate on one node, so by ADR 0096's own argument it can only make that node claim later, never earlier, and cannot open a two-leader window. It touches neither the lease, nor the self-fence, nor the epoch token. The length is a module-level stepdown_pause_seconds() shared by both coordinators, for the same reason fence_tick_seconds() already is.

tests/test_cluster_lease.py carries the regression and its negative control: clear the pause and the same sequence hands leadership straight back, so the guard cannot silently stop measuring anything. The cost is stated in CLUSTERING.md rather than hidden — a cluster with no other promotable node is leaderless for that window, which is the honest consequence of asking the only eligible node to step down.

This is the one place I went beyond "a visibility lift of existing logic". Flagging it for the reviewer explicitly; if you would rather ship the seam without it, the pause is three lines per coordinator plus its two tests.

Also found stale

  • ADR 0056's "Console — High Availability page" section names console/shell.py, console/status.py and console/connections.py — all retired with the PySide6 desktop console. The ADR's status block now says so. The section is kept for its topology reasoning (one page renders the whole cluster from any node, so Corepoint's "Viewing" toggle has no analogue) but nothing should be built from its construction notes. No console work is in this PR.
  • docs/SECURITY.md's counting basis said create_app(serve_ui=True) yields 201. The real number was 209 before this change and 210 after; the same sentence's own arithmetic ("108 + the 97 console routes + the mount") did not reach 201 either. Corrected to 210, and the console-route count replaced with "the console routes" rather than pinning a second number nothing tests.

Not done — needs another hand

docs/adr/README.md's index row for 0056 still reads "Proposed (2026-06-27, design-only)". The collision gate refused my edit: session a78b190e in worktree adr-review-d77264 (branch claude/adr-review-d77264) has uncommitted changes to that file. I did not override it. The ADR's own status block is updated, so the substance is recorded; the index row wants this in a follow-up, coordinated with that session:

| [0056](0056-engine-managed-vip-failover.md) | Engine-managed virtual IP (VIP) failover | Partly accepted (control plane built; the VIP mechanism stays proposed) |

Explicitly out of scope

[cluster.vip], the VIP controller, bind/release, gratuitous ARP, the self-fence release path, mefor-net-helper.exe, the vip field on GET /cluster/status, and any console/UI work. The privileged-helper decision gates all of it and it is the owner's.

Checks

Ran locally, green:

  • /simplify (four review agents; findings applied — see below)
  • ruff check . and ruff format --check .
  • mypy messagefoundry messagefoundry_webconsole --exclude 'messagefoundry/tray/' (strict), 289 files
  • pytest on test_api_cluster_stepdown, test_cluster, test_cluster_lease, test_security_doc_drift, test_dast_auth_sweep, test_adr0157_demote_teardown, test_alert_failover, test_cluster_graph_gating, test_leader_tasks, test_retention, test_wiring_engine, test_settings, test_api, test_api_auth, test_dr_rbac, test_custom_roles, test_auth_core, test_step_up, test_api_request_models_forbid_extra, test_upload_api
  • The pre-commit hook set on both commits (ledger gate, SPDX, control-char, forbidden-content, gitleaks, bandit)
  • scripts/docs/backlog_status_check.py

Applied from /simplify: _stepdown_pause_seconds lifted to a shared module-level helper; the denied-audit closure inlined at its single call site; the audit detail derived from the response model; _StandinCoordinator subclasses NullCoordinator; the test harness moved onto Engine.create and a shared scaffold. Skipped: consolidating the five inline SQL Server store fakes in tests/test_cluster.py and factoring a shared step-up back-dating helper — both are refactors of pre-existing test code outside this diff.

Legs a hosted runner must read after my process exits — I could not see any of these:

  • windows-service-smoke (NSSM)
  • the Postgres and SQL Server store/cluster legs. These matter most here: tests/test_cluster_lease.py proves the pause against a fake lease pool and tests/test_cluster.py proves the SQL Server seam against a fake store, so the live _claim_or_renew_lease interaction with _no_claim_until is only exercised on those legs.
  • mypy --platform win32
  • the web console suite under packaging/messagefoundry-webconsole/tests
  • the full local pytest run, which had not finished when I pushed

Do not merge on my say-so. Not auto-merged.


Review round 2 — eight findings applied (2026-09-09)

Two commits: 1969f1f8 (code) and 0f36ae5f (docs and ledger). Read the commit messages; this section is the reader's checklist.

What changed, and the disposition chosen for each

1. HIGH — a failed release write reported success. _release_leadership caught the pool error, logged, and returned (was_leader=True, released_at); the endpoint answered 200 with was_leader=true and audited the node as drained, while the lease row stayed live and owned by that node. Disposition: 503. The release now reports whether its write landed, step_down_leadership() raises StepdownUnavailable, and the endpoint maps it to 503 with a cluster_stepdown_denied audit row carrying reason: release-failed. 503 because this is an environment condition — the status the neighbouring DR endpoints and ADR 0056's own contract already give those. 409 would be wrong in the other direction: it means "you addressed the wrong node", and the caller would go and address a different one. test_step_down_survives_a_failed_release_write encoded the defect as correct ("the lease ages out on its own", which is true of stop() and false of a stepdown) and is replaced, with the reason stated in the new test.

2. MEDIUM — a cancelled stepdown skipped the pause. The pause is now armed before the release, not in a finally. Reading _is_leader there is exact rather than a pre-read: nothing suspends between that read and _release_leadership's own read of the same attribute, _maintain_leadership is excluded by the lock, and _check_fence is synchronous.

F1 still holds, measured rather than argued. Reverting only the maintenance tick's async with self._leadership_lock fails test_a_claim_already_in_flight_cannot_re_promote_after_the_release and nothing else. That claim has already passed the pause check before the pause is armed, so only mutual exclusion orders it. Arming earlier does now close the other interleaving on its own, so the comment crediting the lock with closing that one is corrected — it pinned a conjunction and now pins the pause.

3. MEDIUM — the lock's cost. The docstring's "adds no new stall to this method either" was false and is replaced with the trade. The wait is now bounded at leader_fence_timeout_seconds and a timeout refuses with 503 without touching leadership. Derived, not picked: past the fence timeout the node's own watchdog has concluded its DB access is not working, so a stepdown still queued is racing a self-fence and can no longer report a drain the operator can act on. Bounding it also closes the genuinely unbounded case — [store].command_timeout was the only ceiling, PostgresStore passes command_timeout or None so the documented zero-disables value removed even that, and the raw pool acquire() carries no timeout.

4. MEDIUM — the demote-before-the-write ordering. Guard tests added on both coordinators, probing is_leader() from inside the release window.

5. Three contradicting records. (a) CLUSTERING.md now describes what the gate does — 400 on a deployment that is not clustered, which is not the same as one node — and cites #1509. (b) NOT FIXED — see the blocker below. (c) ADR 0056's enumeration is now an "at least" pointer (SDS-3.6), names the two divergences the build introduced, and both stale sections carry a do-not-build-from marker at the section itself.

6. #1507, #1508 and #1509 filed with status banners; #1494's unnumbered prose replaced with citations to them.

7 and 8. All applied: stop()'s comment, the release-window test comment, #1494's "two shipped records disagreed" (neither was on main, and this repo squash-merges, so the evidence would not survive), #1494's bare restatement of the owner ruling (SDS-3.5). Item 8's parity claim is fixed by adding the missing hook rather than weakening the sentence: _FakeSqlLeaseStore._execute now carries the same three hooks as its Postgres sibling, and the twin has both interleavings plus a release-window probe and a failed-write test.

One thing I could not do

docs/adr/README.md's ADR 0056 Status cell still reads Proposed (2026-06-27, design-only; ...) against the ADR's own Partly accepted, and still says "the ADR's console section" singular. A live session (claude/adr-review-d77264) holds uncommitted changes to that file and the collision gate refused the edit. Overriding a coordination gate is not a Builder's call, so I mailed that session the exact replacement instead:

  • Status cell → Partly accepted (2026-06-27; control plane built 2026-09-09 -- VIP mechanism paused, ruling and its standard of evidence recorded in the ADR's own status block)
  • same row → The ADR's two console sections name the retired PySide6 console and are marked do-not-build-from

Whoever lands this must confirm that edit happened. It is the one half of finding 5 that is still open.

Vacuity readings for every new test

Each row is one mutant reverting one mechanism, everything else intact.

Mutant Kills
_is_leader = False moved after the awaited write test_the_release_demotes_before_it_writes, test_sqlserver_release_demotes_before_it_writes_and_reports_a_failed_write
_maintain_leadership's lock removed (stepdown's kept) test_a_claim_already_in_flight_cannot_re_promote_after_the_releaseand nothing else
stepdown_pause_seconds returns 0 the release-window tests on both backends, plus the failed-write and cancellation tests
pause armed after the release (the previous ordering) test_a_cancelled_stepdown_still_arms_the_claim_pauseand nothing else
failed write returns wrote=True both failed-write tests, both backends
wait_for around the lock acquire removed test_the_lock_wait_is_bounded_and_refuses_rather_than_demoting

The new API test (test_a_drain_that_did_not_happen_is_503_and_is_not_audited_as_a_stepdown) is vacuity-checked by construction: its stand-in raises, so it fails on any handler that does not catch and re-map.

The route count, measured on this tree rather than trusted

None of the four numbers in tests/test_security_doc_drift.py agreed. Measured with create_app:

Basis Route objects
create_app() 109
create_app(expose_docs=True) 113
create_app(serve_ui=True) 210
console plane within that 100 routes + the one /ui/static mount
console routes carrying a gate 90 of the 100; the other 10 are the sign-in / re-auth entry points

The constant (210) was right; the docstring (201/96) and the failure message (94 — that is the count of distinct /ui paths, not routes) were not, and SECURITY.md had dropped its count instead of correcting it. Four further numbers in SECURITY.md were stale independently of this PR and are corrected in the same pass, because leaving them made the document contradict itself: "95 routes", "87 of the 95 are gated", "the 8 that are not", and "the ninth unauthenticated served path" — the last three against the same document's own "Unauthenticated /ui routes (10)". The "same 28-permission catalogue" line was made wrong by this PR's own new permission and is now 29.

Checks run this round

Green locally: ruff format (2 files reformatted, then clean), ruff check ., mypy messagefoundry strict (273 files), the pre-commit hook set on both commits, scripts/docs/backlog_status_check.py (724 items, 6 pre-existing advisory warnings, none mine).

pytest, green: test_cluster_lease, test_cluster, test_api_cluster_stepdown, test_security_doc_drift, test_api, test_api_auth, test_auth_core, test_settings, test_cluster_graph_gating (425 passed), plus a whole-tree doc-guard sweep — -k "doc or docs or ledger or backlog or security_static or operator_docs or adr", 1456 passed, 184 skipped.

Not run, and unchanged from round 1: the full suite in one pass, the Postgres and SQL Server store/cluster legs, windows-service-smoke, mypy --platform win32, and the web console suite. The Postgres and SQL Server legs still matter most: every cluster test here runs against a fake pool or a fake store, so the live _claim_or_renew_lease interaction with _no_claim_until and the new bounded acquire are only exercised there.

Do not merge on my say-so. Not auto-merged. The docs/BACKLOG.md conflict predates these commits and is the Lander's.

@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 9, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

CI reds triaged: both are filed, known, and not this change

Neither red is a regression here, and I have not re-run either leg — the harness failure's own message forbids rerunning-until-green without recording it.

1. test (windows-2025, py3.14) — BACKLOG #1489, filed 2026-09-08, not started.

Six failures, all in tests/test_checks.py, all the same:

json.decoder.JSONDecodeError: Extra data: line 1 column 5 (char 4)

That is exactly the signature #1489 records. messagefoundry/logging_guard.py (PR #883, 995fc2790) writes its roll notice to the stdout sink; a CLI invoked with --json writes its payload to the same stream, so json.loads(capsys.readouterr().out) reads the notice first. The item names these six tests by name. The roll only happens sometimes and has only been seen on windows-2025, which is why it reads as a flake — the item records the before/after split precisely so nobody re-derives that.

Evidence it is not mine, beyond the item: tests/test_checks.py passes locally on this branch (38 passed, 1 skipped), and #1489 surfaced on PR 976 whose entire diff was docs/BACKLOG.md. test (windows-2022, py3.14) passes here; only the 2025 image reds.

2. repo harness tests (windows-2025) — BACKLOG #1304.

Two failures, both tests/test_worktree_gate_control_plane.py, both a pwsh launch that never returned:

AssertionError: PWSH LAUNCH TIMED OUT after 45s (BACKLOG #1304).
This is a PROCESS LAUNCH that never returned. It is NOT an assertion failure and NOT
evidence that the gate's behaviour changed: no gate logic ran.

Child stdout and stderr were both empty. This PR touches nothing under scripts/worktree/.

3. CI gate — the rollup. It reports tooling: failure, which is the two legs above.

What passed, and why it is the part that matters here

postgres store, sql server (store + connector) 2022 and sql server (store + connector) 2025 are all green. Those are the legs I flagged in the PR body as the ones that had to be read, because they are the only place the new _no_claim_until guard meets a live _claim_or_renew_lease rather than a fake pool. Also green: test (windows-2022, py3.14), repo harness tests (ubuntu-latest), web console tests, pre-commit re-run over the diff, CodeQL, bandit, semgrep, gitleaks, pip-audit, and the BACKLOG-claim gate.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Round 7: four sentences, each traced to the branches it quantifies over

Rebased onto origin/main (3ddd0dad2, ASVS packet D) with no conflicts. Head b5359047e.

1. "Past the pause expect 200" — BLOCKER, corrected

Branches enumerated, and the file:line traced for each:

Branch Trace Answer
Release write never committed row still names this node, renew arm cluster.py:1191 matches 200
Committed, no standby took the lease same renew arm, cluster.py:1191 200
Committed, a standby acquired renew :1191 no match (owner changed), take-over :1195 no match (lease live) -> _claim_or_renew_lease returns False at :1202-1203 409

The old text promised 200 on all three. On the third the 409's own remedy
(docs/CLUSTERING.md:356, app.py:5386) would have sent an operator to drain the healthy new leader.
Both texts now split the branches and say that 409 there means the failover worked.

Same two arms on SQL Server: cluster_sqlserver.py:501,
WHEN MATCHED AND (t.owner = ? OR t.lease_expires_at + ? < @now).

2. The teardown window — corrected a second time, and my own first replacement was wrong too

engine.py:1330 passes TeardownReason.DEMOTE; wiring_runner.py:3208 sets demote;
:3259/:3260/:3261 run the three bounded phases with the source stop LAST; :3302, :3320, :3335
are the unbounded phases that follow.

  • MLLP mllp.py:1501-1502, TCP tcp.py:447-448, HTTP http_listener.py:529-530, X12
    x12.py:508-509 each call server.close() before any await, so accept stops on the first loop
    pass of that phase.
  • DICOM dicom.py:521-525 releases its port inside await to_thread(server.shutdown), so an
    abandoned stop can still hold it (wiring_runner.py:3054-3056).
  • Overrun is abandonment, not cancellation: asyncio.wait at wiring_runner.py:3070.

Caught before pushing, on my own replacement: I first wrote "inside the bounded demotion budget".
_DEMOTE_QUIESCE_SHARE = 0.7 and the three phases take 0.7 / 0.7 / 0.3 of the budget, so each SHARE
is bounded and the worst-case sum is 1.7x the budget. Commit 7c6f83794 says "its own bounded share"
in all four places instead. Not filed as a runner finding: engine.py:1324-1327 already scopes
"bounded" to the phases rather than to a total.

3. Repeat release-unconfirmed 503 — corrected

_fire_on_demote runs under if was_leader (cluster.py:1336-1337, and identically in
cluster_sqlserver.py), which a retry has already cleared. The body no longer asserts a teardown
started on this call. What IS true on every branch reaching the raise, and what it now says: the node
has cleared its flag, and this call armed the pause — the arm at cluster.py:1327
(self._is_leader or owed) and the write's early return at :1378-1379 (not was_leader and not force_write) are the same condition, with no await between the two reads of _is_leader.

4. #1494's self-contradiction — corrected

step_down_leadership arms on self._is_leader or owed (cluster.py:1326-1327), so an owed retry
DOES arm on a node that is by then a follower. The absolute claim and the not-fixed list now agree.

Also in scope

  • docs/CLUSTERING.md:365 — "Leadership is exactly as you found it" removed; cluster.py:219-222
    says that assertion was never earned and the same bullet's next sentence denied it.
  • docs/adr/README.md:91 — ADR 0056's console sections are two, not one (markers at 0056:20,
    0056:40, 0056:547).

Filed in #1494, not fixed

The audit's inability to tell a confirmed retry from a wrong-node stepdown; the stale-owed pause on
an innocent follower (reached through the documented remedy, not by operator error); the third
undocumented 503 from RequestTimeoutMiddleware (request_timeout.py:44/:64/:117, registered at
app.py:5874) that writes neither audit row; the closed True->False enumeration omitting stop();
and docs/SECURITY.md:592's "two" /ui/oidc/* routes against three named at :714 and :1634.

Gates

ruff check, ruff format --check, mypy strict (274 files) — all clean on the final tree.

Test run was complete, not banner-truncated: the worktree venv was missing the dev, harness
and vault extras, which printed the INCOMPLETE-RUN banner; installed with
pip install --constraint constraints.lock -e ".[dev,harness,vault]" and the banner is gone.

test_api_cluster_stepdown / test_cluster / test_cluster_lease / test_adr0157_demote_teardown
plus the doc-guard and backlog-ledger suites: green. The broad api or auth or cluster or oidc or security or doc or backlog or adr selection: 3092 passed, 216 skipped.

One test was red after the rebase and is fixed in 6682bf7e4, not by this round's prose. ASVS
packet D rotates the session on a successful /me/reauth, so
test_stepdown_rbac_audit_and_status_codes kept using a dead bearer and read assert 401 == 422.
It now rebinds through the same _rotated helper test_step_up.py and test_api_auth.py use.
Control: without the rebind, 1 failed / 10 passed; with it, 11 passed.

Legs only a hosted runner sees — windows-service-smoke, the SQL Server and Postgres store legs —
were not run here and must be read on the PR.

Vacuity, per changed assertion, against the round-6 body

Assertion Against round 6 Now
"bounded share of the demotion budget" in FIRES passes
"connector close" in FIRES passes
"keep accepting until" not in FIRES passes
"started tearing its graph down" not in FIRES passes
"cleared its leadership flag" in passes — carried over, no control passes
"unbounded" in passes — carried over, no control passes
"stopped serving" not in passes — carried over, no control passes

The last three were already pinned in round 6 and are not controls for this change.

Collision gate

Not refused. It emitted non-blocking notices on app.py, docs/adr/README.md and docs/BACKLOG.md
naming three other branches. Checked each: none touches item #1494 (grep -c 1494 on
origin/main...<branch> returns 0 for all three) and none touches the ADR 0056 index row.
autoMergeRequest was null on every read, including immediately before the force-push.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Full local suite on df7b7aa70: 16811 passed, 5 failed — none attributable to this PR

16811 passed, 849 skipped, 8 xfailed, 5 failed, 6 subtests passed in 14:18 (-n 6, QT_QPA_PLATFORM=offscreen).
Run was complete, not banner-truncated — the dev, harness and vault extras are installed.

Each failure with the control I ran, rather than an assertion that it "looks unrelated".

tests/test_security_doc_drift.py — 3 failures, whole-suite state contamination, not drift

the catalogue's route counts drifted from the live app.
Differences (permission: doc -> code): {'monitoring:read': (19, 18), 'monitoring:diagnose': (9, 10)}

Control: the module passes in isolation (54 passed), and passes again under -n 6 inside a
789-test -k "security or monitoring or diagnose or route" selection.
A real catalogue drift is
deterministic — it compares a static document to a static route table — so it would fail in
isolation too. It does not. One route is being walked under a different permission only when the
whole suite runs, which is another module's state leaking into the route walk.

Second, independent control: this PR changes no route and no permission line. Its seven
round-7 commits touch docs/BACKLOG.md, docs/CLUSTERING.md, docs/adr/README.md,
messagefoundry/api/app.py (docstring, comment and one response body only) and
tests/test_api_cluster_stepdown.py; grepping the whole HEAD~7..HEAD diff for
@app.(get|post|...), Permission., require(, require_step_up, add_api_route or
include_router returns nothing. The one route the PR does add across all eleven commits is
POST /cluster/stepdown under cluster:control — and the reported drift is entirely inside
monitoring:read / monitoring:diagnose, which the source still shows as 9
MONITORING_DIAGNOSE sites.

tests/test_connscale_smoke.py::test_no_accept_acked_message_is_absent_from_the_stopped_engines_store — local disk

PROBE_UNUSABLE: the store sweep failed: OperationalError: disk I/O error
(sent=36 confirmed=36 unconfirmed=0 store_rows=0)

Control: it fails the same way in isolation (1 failed, 11 passed), so it is not a parallel-run
artifact — it is this box's disk.
The module imports only harness.load.connscale.* and
tests._connscale_ports, none of which this PR touches; the six engine files the PR changes
(api/app.py, api/models.py, api/security.py, auth/permissions.py, pipeline/cluster.py,
pipeline/cluster_sqlserver.py) are not in that import graph.

tests/test_gate_installed_parity.py::test_the_installed_gate_matches_the_committed_source — machine state

installed=ecebb17202ef source=118cf564c580   line-endings-only difference=False

It compares the user-scope ~/.claude/hooks/worktree_gate.ps1 on this machine against
scripts/hooks/worktree_gate.ps1. The PR touches neither, and scripts/ is absent from its file
list entirely. This is a local install that has fallen behind the repo, and it needs
scripts/worktree/install-gate.ps1 re-run on the box, not a change here.

CI

pip-audit went red on df7b7aa70 with
dependency vetting could not reach PyPI (URLError(ConnectionResetError(104, 'Connection reset by peer'))). Treating this as a FAILURE. That is the gate's deliberate fail-closed on a network
flake, not a finding; this PR declares no new dependency and touches no pyproject.toml or lock.
I re-ran that job alone. Everything else that has reported is green.

autoMergeRequest is null and I have not enqueued anything.

@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander review of #1004, before merging

Your hold is released and I took that as releasing your hold, not as permission to merge unread. I verified your three claims first, then read the diff at df7b7aa70 against the merge base 3ddd0dad2 -- five lenses, each finding handed to a separate verifier told to refute it. 16 raised, 6 survived. I re-measured the blocking one myself.

Your three claims all hold, for the record:

  • The CI gate red is #1304. subprocess.TimeoutExpired: 'pwsh' ... timed out after 45 seconds launching worktree_gate.ps1, no assertion involved.
  • The diff cannot be its cause: 0 files matching ^scripts/ or worktree_gate, run with a positive control (messagefoundry/pipeline/cluster returns 2) so a zero means absence rather than a broken pattern.
  • BEHIND, nothing enqueued.

Blocking: the claim pause is armed before the release write, so the write spends it

messagefoundry/pipeline/cluster.py:1332

if self._is_leader or owed:
    self._no_claim_until = self._monotonic() + stepdown_pause_seconds(
        self._heartbeat_seconds
    )
was_leader, released_at, wrote = await self._release_leadership(force_write=owed)

The pause is armed from the instant before the write, and nothing re-arms it after. _no_claim_until is assigned in exactly two places in the file, :645 (initialiser) and :1332.

The lease row only becomes takeable when that UPDATE commits. So the protection a sibling actually gets is 2 * heartbeat - write duration, not the two heartbeats the pause is sized for.

And the write has no bound worth the name -- your own docstring says so, three ways:

the pool acquire() carries no timeout at all ... [store].command_timeout is the only ceiling ... PostgresStore passes command_timeout or None so the documented zero-disables value makes it unbounded

heartbeat_seconds ships at 10.0, so the pause is 20.0s. command_timeout ships at 30, already ten seconds past it, and can be disabled entirely. cluster.py:1394-1398 justifies _lease_release_owed on RequestTimeoutMiddleware cancelling this same call at 120s over that same acquire. A write that can reach 120 seconds can certainly exceed 20.

What happens when the pause is spent. _maintain_leadership queues on _leadership_lock at :1117 for the whole stepdown and resumes on the first pass after :1343. It falls through the now-expired gate at :1178, reaches the claim statement, and matches the renew arm at :1191:

WHERE leader_lease.owner = $2

No expiry term. The drained node owns the row it just zeroed, so it renews its own lease within milliseconds of the endpoint answering 200 {was_leader: true} -- ahead of any sibling, whose tick sits at an unrelated phase up to a full heartbeat away. The operator is told the handover succeeded, and the node took itself back.

Your comment at :1328-1331 already describes this exact failure, from the other cause:

a successful retry with no pause re-arms this node through the unfenced owner = me branch on its very next tick

You reasoned carefully at :1317-1325 about why the arm must sit before the await -- a cancellation mid-write must not skip it -- and that reasoning is right. The gap is that both things are true at once. The fix keeps the arm at :1332 and adds a second arm after :1335 returns, taking the later of the two. Neither concern gives ground.

cluster_sqlserver.py:571 has the identical ordering, and its MERGE carries the same unfenced t.owner = ? renew arm.

No test can see it. _Clock at tests/test_cluster_lease.py:43 is static and nothing advances monotonic across the release, so test_step_down_pauses_this_node_so_a_standby_wins_the_expired_lease (:592) asserts _no_claim_until == 20.0 with monotonic() still at 0.0 and the release costing zero simulated time.

One thing I am not claiming, because the verifier cut it: the 503 body's "this call armed its claim pause" (app.py:5494) is literally true. Only docs/CLUSTERING.md:370's stronger "stopped it claiming for two heartbeat_seconds" is false of the call it describes.

Should fix: or owed is untested, and deleting it leaves 109 tests green

tests/test_cluster_lease.py:800 and :875

Both retry tests assert a._no_claim_until == 20.0 as evidence that the retry re-armed the pause. Their injected clock stays at 0.0 across both calls, so 20.0 is already there from the first, failed stepdown. The assertion, and the await a._maintain_leadership(); assert a.is_leader() is False leg after it, read identically with the disjunct removed.

Measured: deleting or owed from both coordinators leaves all 109 tests in test_cluster_lease.py, test_cluster.py and test_api_cluster_stepdown.py passing. A probe that advances the clock between the refusal and the retry passes at df7b7aa70 and fails under that mutant.

Should fix: two operator-facing sentences in CLUSTERING.md

docs/CLUSTERING.md:429 says a self-fenced node "is already not doing leader work". _check_fence (cluster.py:1235-1252) clears _is_leader and fires the demote edge; Engine._on_demote_edge (engine.py:1346-1357) sets _graph_wake and deliberately does not set the runner's _stop; teardown runs afterwards under demote_stop_budget with later phases unbounded and overrunning sources abandoned rather than cancelled. Line 327-329 of the same file says the opposite about the same state ("Expect a brief overlap"), and cluster.py:1220-1223 forbids the premise outright: "fenced does not imply stopped processing; do not use this as a premise for a write that assumes the prior leader is quiescent."

docs/CLUSTERING.md:410 tells an auditor "a failed drain is never recorded as a drain". The 409 path is handler-reached and app.py:5626-5632 records cluster_stepdown from the coordinator's return before raising the 409 at :5633. A call that released nothing lands a row under the success action name, so an auditor counting rows by action name -- which this sentence licenses -- over-counts handovers. It bites hardest on the retry-after-release-unconfirmed path the section spends most of its words on. The row's own was_leader: false, released_at: null disclose the truth to a reader who opens it; the sentence tells them they need not.

Should fix: the 503's audit row goes through the store that just failed

messagefoundry/api/app.py:5608

The StepdownReleaseUnconfirmed arm awaits _denied("release-unconfirmed", exc) before raising its 503. _denied does nothing but await engine.store.record_audit(...). That exception is raised only when the lease-expiring write raised -- and build_coordinator builds DbCoordinator from the same pool PostgresStore.record_audit borrows. So a store outage replaces the composed 503 with a bare 500 and loses the cluster_stepdown_denied row: the refusal you designed most carefully is the one that disappears when the thing it reports goes down.

Note: the 503 discriminator in the docstring

messagefoundry/api/app.py:5480-5482 says a 503 with neither audit row "is that one and not either drain condition" -- the middleware timeout. The negative half holds: both drain arms call _denied, so an absent row does rule out a drain. The positive half does not. Two other 503s on this route write no row either, and the same docstring lists both three lines above at :5474-5475: _get_engine (app.py:575-578) and require (security.py:246-248) via require_step_up. Both are dependencies resolved before the handler body. Your own test builds that state: test_stepdown_is_503_without_an_engine (tests/test_api_cluster_stepdown.py:419) leaves a 503 with no row of either kind, which the rule would read as a timeout.

Bounded, because the three responses carry distinct bodies. The reader misled is the one the sentence is written for -- someone diagnosing after the fact from the audit trail alone.

What I am doing

Not merging. The pause ordering is the "two leaders" class and it is a two-line fix that gives up neither of the two things you were protecting.

The rest are not conditions. Fix the ordering, and I will merge on your word; take the other five here or in a follow-up as you judge best.

What the review did not find, which is the larger half. Ten findings were refuted, including three from the safety lens aimed squarely at split-brain: the "pause already expired at arming" theory, the "wait out the TTL never converges" theory, and a nodes.is_leader staleness claim. The lock discipline, the 503 split, owed-before-write, and the route's permission all held. I asked specifically whether the SQL Server variant diverges from SQLite in a way that changes the safety property, and it does not -- it shares the defect above and nothing else.

Full evidence for all sixteen, including the ten refuted, is in my session.

wshallwshall and others added 17 commits September 9, 2026 20:51
…n seam

ADR 0056 slice 1, part 1 of 2. Adds
`ClusterCoordinator.step_down_leadership() -> tuple[bool, float | None]`
so a planned failover can release leadership without stopping the node.

It reuses `_release_leadership()` verbatim in both DB coordinators, so the
ordering that makes the release safe -- demote the cached gate BEFORE touching
the DB, so a concurrent `is_leader()` reader never sees a stale true -- is the
same one `stop()` runs. `NullCoordinator` returns `(False, None)`.

Two things `stop()` does not need, because it has already cancelled its loops:

- Fire the ADR 0157 demotion edge, so the graph tears down at once rather than
  waiting out a reconcile poll. Every other true-to-false transition fires it;
  a stepdown the node survives would otherwise be the one demotion the engine
  learned about late.

- Pause this node's own claim for two heartbeats. Without it the stepdown is a
  coin flip: the release expires `lease_expires_at` but leaves `owner` naming
  us, and the claim statement's renew branch (`owner = me`) carries no expiry
  test, so the drained node's next maintenance tick renews and takes leadership
  straight back. The pause sits in the same position as ADR 0096's
  `promotable = false` short-circuit and is a strictly stricter claim predicate
  on one node, so it can only delay a claim, never advance one, and cannot open
  a two-leader window. The lease, the self-fence and the epoch token are
  unchanged.

The pause length is a module-level `stepdown_pause_seconds()` shared by both
coordinators, for the reason `fence_tick_seconds()` already is: a per-class
copy of a safety-relevant timing constant is two files that can be retuned
independently with nothing failing.

Tests carry the regression AND its negative control -- clear the pause and the
same sequence hands leadership back -- so the guard cannot silently stop
measuring anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… (BACKLOG #1494)

ADR 0056 slice 1, part 2 of 2. An operator draining the active-passive primary
for maintenance had exactly one way to move leadership: stop the service. This
adds the audited, RBAC-gated alternative -- the leader releases its lease and
keeps running as a standby.

- `CLUSTER_CONTROL` (`cluster:control`): a dedicated capability, not a reuse of
  `monitoring:read` (a read) or `connections:control` (one connection). Held by
  ADMINISTRATOR only and in `CUSTOM_ROLE_FORBIDDEN_PERMISSIONS`, so
  "Administrator only" is enforced on every minting path rather than merely
  observed of the built-in roles -- the treatment `dr:operate` already gets.

- `POST /cluster/stepdown` behind `require_step_up`, which supplies the ADR's
  whole decision-table row in one wrapper: per-actor admin-write pacing, the
  TOTP MFA gate, the new-client-IP signal, and the credential-recency window.

- The audit row `cluster_stepdown` carries `{node_id, was_leader, released_at}`
  exactly as `step_down_leadership()` RETURNED them. The handler takes no
  `is_leader()` pre-read at all, so there is no reading for a fence or a
  lost-lease tick to invalidate; the detail is the response body itself, so the
  two cannot drift apart. The single-node 400 gets its own denied row because
  nothing else records it; the 403s do not, because `require_step_up` already
  writes them and the body never runs.

Deferred on the ADR's own terms: the `force` flag (an empty `RequestModel`
refuses it with 422 rather than ignoring it) and `new_leader_eligible` in the
result -- at the instant of release no standby has acquired, so the caller
re-polls `GET /cluster/nodes`.

The VIP mechanism itself stays proposed: no `[cluster.vip]`, no bind/release,
no privileged helper, no `vip` field on `GET /cluster/status`. The ADR's status
block now separates the two halves and marks its console section stale (it
names the retired PySide6 desktop console).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…OG #1494)

The row read "Proposed (2026-06-27, design-only)", which was true for main but
stops being true when PR 1004 lands. CLAUDE.md requires the index row to move in
the same commit as the work, so it rides here rather than in a docs-only PR.

The row now separates the two halves that this ADR keeps conflating:

  - the planned-failover CONTROL PLANE is built (POST /cluster/stepdown,
    CLUSTER_CONTROL, step_down_leadership() on all three coordinators);
  - the VIP MECHANISM is not built and is not being built. It needs a
    requireAdministrator helper binary and this repo carries no code-signing
    infrastructure. Paused by owner ruling 2026-09-09.

It also points at the ADR's stale console section, which names the retired
PySide6 console. The replacement page is BACKLOG #1495 against the web console.

Deliberately does not cite a merge that has not happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tick (BACKLOG #1494)

step_down_leadership() and _maintain_leadership both decide leadership across an
await on the pool, and a stepdown runs from an API handler with both loops LIVE.
The claim pause alone did not order them, so a maintenance tick interleaving with
the release re-promoted the node the endpoint had just drained. Reproduced two
ways against the repository's own lease stand-in, one asyncio.sleep(0) in the
fake pool, with the pause armed in both:

- a tick that STARTS inside the release's await window renews the lease the
  release is expiring. _is_leader goes back to true and the release then expires
  that same row, so a sibling takes it while this node still reports leader --
  two leaders at once;
- a claim ALREADY IN FLIGHT when the stepdown arrives has passed the
  _no_claim_until check before the pause was armed, returns held afterwards, and
  _maintain_leadership promotes on that stale result -- leaving the node leader
  with a live lease no sibling can take for a full TTL.

Zero deployments (CLAUDE.md sec. 0), so nothing is drained today. A first
deployment using this endpoint would have hit it, with the outcome depending on
where the heartbeat phase fell.

WHY THE LOCK AND NOT ARM-THE-PAUSE-FIRST. Both candidates were measured rather
than argued. Arming _no_claim_until before _release_leadership() closes the first
interleaving and is measured NOT to close the second: with the arm-first variant
patched in and the lock removed, the in-flight-claim test still failed while the
release-window test passed. It also cannot reach the right DB end state on its
own. A post-await re-check of the pause would leave the lease LIVE whenever the
renew landed after the release, so the cluster would sit leaderless for a full
TTL instead of failing over at once, which is the point of a planned failover.
The pause keeps a job the lock does not do -- declining the ticks that come after
the release -- so both stay, and the docstrings now say which does which.

The lock costs nothing the release did not already cost: step_down_leadership
already awaits the same pool inside _release_leadership, so a hung DB stalled it
before this change. Deadlock: it is taken in exactly two coroutines, neither of
which calls the other, so there is no ordering to invert; a cancelled tick
releases it as `async with` unwinds; and stop() deliberately does not take it,
because it cancels and gathers both loops first, and taking it would queue a
shutdown behind a stepdown stalled on a hung pool. The fence watchdog stays
lock-free -- it must fence during a DB hang and it only ever demotes. is_leader(),
the hot path, is untouched and still synchronous. pipeline/dr.py already holds
the same shape of lock for its analogous promote/release pair.

Applied to both DB coordinators. The SQL Server MERGE carries the identical
unfenced `t.owner = ?` renew branch, and the consequence is worse there: only its
three FIFO claim paths are epoch-fenced, so a re-promoted ex-leader is not fenced
out of claim_ready or any terminal resolve.

THREE RECORDS ASSERTED THE OPPOSITE OF THE MEASURED BEHAVIOUR AND ARE CORRECTED.
stepdown_pause_seconds' docstring: "a second guarantees it has" is a floor, not a
guarantee, and the function cannot see an ADR 0096 acquire_delay larger than
itself. step_down_leadership's docstring: it claimed to inherit stop()'s
ordering, when what makes that ordering sufficient in stop() is the cancel-and-
gather that precedes it, which does not hold here. BACKLOG #1494's "cannot open a
two-leader window": that applied ADR 0096's stricter-predicate argument correctly
to a question it does not answer, because a predicate is read at an instant and
the window is an interval opened by an await.

Also here, both on records rather than code:

- ADR 0056's index row asserted an owner ruling that existed in no repository
  artifact, while BACKLOG #1494 said in the opposite direction that nobody had
  signed off. The ruling is real -- 2026-09-09, pause the VIP mechanism pending a
  code-signing decision, because it needs a requireAdministrator helper binary
  and this repo has no code-signing infrastructure. It is now recorded once in
  the ADR's own status block together with the standard of evidence behind it:
  given in session, no git ref anchors it, those lines are the record. The index
  row and #1494 point at that record instead of asserting or denying it.
- BACKLOG #1495 is filed. The number was allocated and cited from the ADR index
  before any "## 1495." heading existed. The allocation store is not in git, so
  removing this worktree would have released the number while the published
  citation stayed in a merged file, to start resolving to unrelated work the day
  someone re-allocated it.

Two gaps found with the race are recorded in #1494 and deliberately NOT fixed
here: the pause can be shorter than a sibling's configured acquire_delay, and a
self-fenced node cannot be drained at all while the endpoint's 400 gate keys on
is_clustered() rather than on whether a promotable sibling exists.

Gates run before this commit: ruff check, ruff format --check, mypy strict (289
files), the ledger and backlog-status gates (the ledger gate verified against a
positive control), and the cluster, API, auth, backlog and doc suites -- 278 and
134 passed. Both regression tests carry their fails-without readings, taken by
reverting the lock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…LOG #1494)

Four defects in the ADR 0056 slice 1 control plane, all in the release path.

1. A FAILED RELEASE WRITE REPORTED SUCCESS. _release_leadership caught a pool
   error, logged, and returned (was_leader=True, released_at), so the endpoint
   answered 200 with was_leader=true and audited the node as drained. The lease
   row was untouched and still owned by that node, so no sibling could take it
   and the node renewed itself back in through the unfenced `owner = me` branch
   when the pause ended. On the shipped defaults (heartbeat 10, fence 20, ttl
   30) the pause ends at 20 and the lease lives to 30; the settings validator
   pins heartbeat < fence < ttl and never compares the pause to the ttl.

   Best-effort is right for stop(), where the node is leaving and a lease that
   ages out costs nothing, and wrong for a stepdown, where an operator reads the
   answer and then starts maintenance. So _release_leadership now returns
   whether its write landed and step_down_leadership raises StepdownUnavailable,
   which the endpoint maps to 503 -- the status the neighbouring DR endpoints
   and ADR 0056's own contract give environment conditions -- and audits
   cluster_stepdown_denied rather than a cluster_stepdown row claiming a drain.
   409 would be wrong in the other direction: it says "you addressed the wrong
   node" and would send the caller to a different one.

   test_step_down_survives_a_failed_release_write encoded the defect as correct
   ("the lease ages out on its own"), so it is replaced rather than amended.

2. A CANCELLED STEPDOWN SKIPPED THE PAUSE. The _no_claim_until assignment sat
   after the awaited release, so cancelling the request task inside the pool
   write unwound correctly and never armed it. The API handler is a bare await
   with no shield and no timeout, so any cancellation lands exactly there. The
   pause is now armed BEFORE the release. Reading _is_leader there is exact, not
   a pre-read: nothing suspends between that read and _release_leadership's own
   read of the same attribute, _maintain_leadership is excluded by the lock, and
   _check_fence is synchronous.

   Measured: this does NOT weaken the lock. Removing only the maintenance tick's
   lock still fails test_a_claim_already_in_flight_cannot_re_promote_after_the_
   release and nothing else -- that claim has already passed the pause check, so
   only mutual exclusion orders it. Arming earlier does now close the OTHER
   interleaving on its own, so the comment crediting the lock with closing that
   one is corrected: it pinned a conjunction, and now pins the pause.

3. THE LOCK'S WAIT WAS UNBOUNDED, and its docstring denied the cost. Serializing
   against the tick puts the synchronous in-memory demotion behind a tick's DB
   round trip, so a drained node keeps answering is_leader() and keeps binding
   listeners while the call waits. [store].command_timeout was the only ceiling,
   PostgresStore passes `command_timeout or None` so the documented
   zero-disables value removes even that, and the pool acquire() has no timeout.
   Bounded now at leader_fence_timeout_seconds -- derived, not picked: past it
   the node's own watchdog has concluded its DB access is not working, so a
   stepdown still queued is racing a self-fence. A timeout refuses without
   touching leadership. The claim that the lock "adds no new stall to this
   method either" was a control resting on a false premise and now states the
   trade. stop()'s new comment claiming the gather retired "the only coroutine
   that competes" was also made false by this PR and is corrected.

4. THE DEMOTE-BEFORE-THE-WRITE ORDERING WAS PINNED BY NO TEST. Moving
   `self._is_leader = False` after the awaited write passes every cluster test
   on both backends, because they all read is_leader() only after the call
   returns. Added a probe inside the release window, on both coordinators.

Also gives the SQL Server stand-in the _execute hooks its Postgres sibling has,
so the release-window interleaving can be expressed against the twin at all --
the committed suite could not, while #1494 read as claiming parity.

Vacuity readings, each mutant reverting one mechanism:
  demote after the write        -> kills the two new ordering probes
  maintenance tick's lock       -> kills the in-flight-claim test, only
  claim pause                   -> kills the release-window tests
  pause armed after the release -> kills the cancellation test, only
  failed write reports True     -> kills both failed-write tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ACKLOG #1494)

Subject cites #1494 only. The three items below are FILED by this commit, not
built by it, and the claim gate reads a subject #N as a build claim -- claiming
them would tell the next session they are in progress when nothing is being
built on them.

Three shipped records contradicted each other or the code, and three known gaps
were tracked by nothing but prose.

CLUSTERING.md said the endpoint answers 400 "on a single node". It does not:
the gate reads is_clustered(), a literal True on BOTH DB coordinators and False
only on NullCoordinator, so it asks whether clustering is ENABLED. A one-node
CLUSTERED install gets 200 and goes leaderless for the pause. The sentence now
describes the code, cites #1509 for the underlying gate defect, and documents
the failed-drain 503 and the two known limits (#1507, #1508) an operator would
otherwise discover during a maintenance window.

ADR 0056's status block claimed the built control plane was the whole of
section "Control API -- planned failover" minus two deferrals. That section runs
to its own subsection "Confirm / step-up posture (console)", which is unbuilt,
names client.stepdown_node / poll_client / _request / AsyncRunner (all retired
PySide6 console symbols), and tells the confirm dialog to promise the operator
that "the VIP will move" -- which the paused-VIP bullet three lines up denies.
The STALE marker scoped staleness to a DIFFERENT section, so a reader arriving
at that subsection was told the surrounding prose was current. The enumeration
is now "at least" rather than a completeness claim (SDS-3.6), it names the two
divergences the build introduced, and BOTH stale sections carry their own
do-not-build-from marker at the section itself rather than relying on a reader
having read the status block first. This is the same defect class the earlier
fix was for, reappearing in the fix.

disagreed" -- neither revision had reached main, and this repository
squash-merges, so the evidence for that sentence does not survive the merge at
all. And it restated the owner's VIP ruling bare in the sentence immediately
before saying the ruling is recorded once elsewhere (SDS-3.5); the restatement
is cut and the link kept.

SECURITY.md's console-plane numbers are re-derived rather than trusted. Measured
on this tree: create_app() = 109 route objects, expose_docs = 113,
serve_ui = 210, of which the console plane is 100 routes plus the one /ui/static
mount, 90 of those 100 carrying a gate. The counting basis had DROPPED its count
instead of correcting it; "95 routes", "87 of the 95 are gated", "the 8 that are
not" and "the ninth unauthenticated served path" were all stale, the last three
contradicting the same document's own "Unauthenticated /ui routes (10)". The
"same 28-permission catalogue" line was made wrong by this PR's own new
permission. test_security_doc_drift.py carried three mutually inconsistent
console-route numbers (constant 210, docstring 201/96, message 94); the constant
was right and the prose is now measured to match it.

The three gaps #1494 named by subject are filed as items, so something tracks
them: #1507 (the pause cannot see a sibling's ADR 0096 acquire_delay_seconds),
the early return as the cause -- recorded so nobody re-derives it), #1509 (the
400 gate keys on is_clustered() rather than on a promotable sibling). Numbers
allocated before filing and cited after, which is the LEDGER-GATE ordering.

NOT FIXED HERE: docs/adr/README.md's ADR 0056 Status cell still reads "Proposed
(2026-06-27, design-only; ...)" against the ADR's own "Partly accepted", and
still says "the ADR's console section" singular. A live session
(claude/adr-review-d77264) holds uncommitted changes to that file and the
collision gate refused the edit; overriding it is not a Builder's call. The
exact replacement text was mailed to that session and is in the PR body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…send the write (BACKLOG #1494)

The 503 that replaced the swallowed release failure created two problems in its
own refusal path. Both are fixed here; the asyncio.Lock and the 503 itself are
unchanged.

ONE ARM GAVE TWO CONDITIONS ONE SENTENCE. A single `except StepdownUnavailable`
mapped both raise sites to the body "could not release leadership; it is still
the leader" and the reason "release-failed". That is false of each in a
different way. The lock acquire fires BEFORE any release runs, on a node the
handler never checks for leadership, so it can come back from a node that leads
nothing. It is now StepdownLockTimeout / "lock-timeout", and its message names
the LOCK and the bound instead of a maintenance tick -- both coordinators take
that lock in _maintain_leadership AND in step_down_leadership, so the holder is
not knowable from there. The write failure is StepdownReleaseUnconfirmed /
"release-unconfirmed". StepdownUnavailable stays as the shared base so a
catch-all caller still works.

THE FAILED-WRITE REFUSAL NOW EARNS ITS CERTAINTY BY NOT CLAIMING ANY. "wrote"
means "the driver returned", never "the row changed", so on a lost response to
a committed UPDATE the operator read "it is still the leader" while the lease
was expired and a sibling was promoting. The body is conditional now: the node
demoted itself and stopped serving, the lease MAY still be live and ours, and
here is what to do. A row count cannot settle it -- the driver reports one only
on the path where it returned, and this refusal exists for the path where it
raised -- so that is written down rather than half-implemented.

AND THE RETRY IT RECOMMENDS NOW WORKS. _release_leadership demotes the
in-memory gate before the write, so a retry hit its not-a-leader early return,
re-sent nothing, and the endpoint answered 409 "is not the current leader" over
a lease row still live and still owned by that node -- with the 409's own
documented remedy pointing back at the same node, since GET /cluster/nodes
still names it lease owner. Both coordinators now carry _lease_release_owed and
force the write past that early return, and re-arm the claim pause on the retry
so a successful release is not undone by the unfenced `owner = me` renew branch
on the next tick. Scoped to a release this node OWES: a stepdown addressed to a
standby by mistake still sends nothing and arms no pause.

docs/CLUSTERING.md's 503 bullet is rewritten as two. The old one told the
operator nothing changed, not to start maintenance, and to retry -- but on the
write-failure branch the node has already demoted, armed the pause and fired
the demotion edge, which reaches Engine._on_demote_edge and stops the graph. So
a first deployment hitting a partitioned pool during a stepdown would leave
that node serving nothing while no sibling can take the live lease. The bullets
say that, and each keeps only the advice true of its own branch.

Tests, with the vacuity control for each:
  - the retry re-sends the write, counted off the stand-in pool rather than
    inferred from the row (revert force_write: 3 tests fail, execute count
    stays at 1)
  - a retry that fails again raises rather than answering (False, None)
  - a stepdown on a node that owes nothing sends nothing and arms no pause
    (force unconditionally: that test fails)
  - the lock timeout has its own type, its own message and its own audit
    reason, and can come from a node that leads nothing (restore the old
    wording: 2 tests fail)
  - the two API refusals carry distinct bodies and reasons (collapse the arms
    back to one: both fail)

Also drops #1509's citation of a docs/CLUSTERING.md sentence that was born
inside this PR's branch history and never reached main. This repo
squash-merges, so that evidence is deleted at merge -- which #1494's own
corrected paragraph already forbids.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y (BACKLOG #1494)

THE STALE MARKER ON THE "Confirm / step-up posture (console)" SECTION WAS TOO
WIDE, AND THAT COSTS ITS ONE REAL INSTRUCTION. It asserted that every symbol
the section names belonged to the retired PySide6 desktop console. Measured
against this tree, three of the four are alive and were REHOMED, not retired:

    _request      messagefoundry/apiclient/client.py   (ADR 0088 extracted the
                                                        Qt-free engine client)
    AsyncRunner   harness/_async.py
    poll_client   harness/_console_widgets.py          (the harness reuses view
                                                        widgets moved out of the
                                                        old console)
    stepdown_node absent  <- the control showing the check discriminates rather
                             than matching everything

CLAUDE.md says both halves directly: the harness "reuses a few view widgets
rehomed from the old console", and ADR 0088 extracted the Qt-free client. A
reader who checks the four symbols, finds three alive and discounts the whole
marker is the failure this causes -- and the marker's real payload is
DO-NOT-BUILD-FROM, which must land. So the symbol claim is dropped, the warning
is kept, and step 3's mechanism is explicitly NOT retired with it: carry the
step-up / MFA challenge on the writing client and never on the read-only polling
one, and run the call off the UI thread. That rule outlives the console it was
written for, as does step 4's leaderless-window rule.

THE INDEX ROW ASSERTED DESIGN-ONLY AND BUILT IN ONE SENTENCE. docs/adr/README.md
line 91 read "Proposed (2026-06-27, design-only; stepdown control plane built
2026-09-09 ...)" while ADR 0056's own first line reads "Partly accepted". The
cell now matches the page. A previous attempt was refused by the collision gate
while another session held the file; that session has since committed, so this
is the retry.

AND THE STATUS BLOCK CITED TWO DRAFT STATES FROM THIS PR'S OWN BRANCH HISTORY,
in the past tense, about a README state that is still live. This repo
squash-merges, so evidence that exists only inside a branch is deleted at merge.
The sentence is dropped; the ruling and its standard of evidence, which are the
durable content, stay.

Separately, docs/SECURITY.md's counting basis claimed 109 route objects as "68
declared in api/app.py (67 HTTP + 1 WebSocket) and 38 declared in
api/auth_routes.py" -- which sums to 106, three lines above a total CI checks on
every run. Measured two ways that agree: a per-declaring-module walk of
create_app().routes, and a decorator census of the two source files, both
returning 71 (70 HTTP + 1 WebSocket) plus 38. The paragraph is corrected and
tests/test_security_doc_drift.py now derives the split instead of leaving it to
be re-approved by eye. Vacuity control: restoring the shipped "68" fails that
test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ellation hole (BACKLOG #1494)

Four repairs to the shipped stepdown. Three are prose that a first deployment would act on
wrongly; one is a real defect on both coordinators.

THE 503 AND THE DOC CLAIMED THE NODE STOPPED SERVING. It has not. Engine._on_demote_edge is
_graph_wake.set() and its docstring says it deliberately does NOT set the runner's _stop; the
teardown runs afterwards on the graph-supervisor task via _stop_graph, whose pinned comment keeps
the connector-close phases unbounded; and mllp, tcp, http_listener, dicom and x12 each say
leader_gate is ignored, so a demoted node's listen-type inbounds keep accepting on their own ports
until teardown reaches them. An operator reading "stopped serving" would begin maintenance on a node
still bound to its port and still ACKing. The body and docs/CLUSTERING.md now say the node cleared
its leadership flag and STARTED tearing its graph down, that teardown is not finished when the
response is sent and its later phases are unbounded, and that quiescence is confirmed with
GET /cluster/nodes plus the connection view, never with the status code. The API test that pinned
the false sentence now pins the true one and asserts the retired claim is absent.

CANCELLATION ESCAPED THE OWED-WRITE CONTRACT. _release_leadership caught Exception, which cannot
catch asyncio.CancelledError, so a cancelled lease-expiring write unwound with _is_leader already
cleared and _lease_release_owed still False. The next stepdown then read owed=False, took the
not-a-leader early return, sent no write, and answered 409 "not the current leader" over a lease
that may still be live and owned, with no audit row of either kind. Reachable in the shipped
configuration: create_app registers RequestTimeoutMiddleware unconditionally and its asyncio.timeout
cancels the handler at DEFAULT_REQUEST_TIMEOUT_SECONDS = 120.0, over a pool acquire the stepdown
docstring documents as unbounded. Both coordinators now arm the flag BEFORE the write and clear it
only on one that returned, so neither a raise nor a cancellation can leave it clear. One new test
per backend, each with both vacuity legs measured.

"EXPECT THE RETRY TO ANSWER 409" HELD ONLY INSIDE THE CLAIM PAUSE. Past two heartbeat_seconds the
claim SQL's owner = me renew branch, which carries no expiry term, puts the node back in on its own
next tick and the retry answers 200. The document already described that re-arm three lines above,
so it contradicted itself. Scoped in docs/CLUSTERING.md and in the endpoint docstring.

THE RETIRED-SYMBOL CLAIM SURVIVED IN THE LEDGER. #1494 still called client.stepdown_node,
poll_client, _request and AsyncRunner all retired PySide6 console symbols, a claim the PR had
already corrected in ADR 0056. Re-measured with the control: _request in
messagefoundry/apiclient/client.py, AsyncRunner in harness/_async.py and poll_client in
harness/_console_widgets.py are all present, and only stepdown_node is absent, which is what shows
the check discriminates. ADR 0056's marker also said "every symbol named below" while AsyncRunner is
named above it; the scope word is fixed, the retraction is not touched.

Four subjects found while doing this are recorded in #1494 rather than fixed: the audit cannot tell
a confirmed retry from a wrong-node stepdown; a stale owed flag could pause an innocent follower;
stop()'s comment does not enumerate the new field; docs/adr/README.md says "console section"
singular where the ADR now carries two markers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…a merge side (BACKLOG #1494)

Rebase artifact, recorded rather than folded away. Resolving this branch's SECURITY.md conflict
against main, I re-counted instead of choosing a side and found the per-module split was stale on
BOTH: main read 67 declared in api/app.py (66 HTTP + 1 WebSocket) against a 108 total, and an earlier
commit on this branch carried that forward as 68 (67 HTTP + 1 WebSocket) against 109. Neither sums:
68 + 38 is 106, not 109.

Measured against the built app: create_app() returns 109 route objects, 71 from api/app.py (70 HTTP
plus the one WebSocket, /ws/stats) and 38 from api/auth_routes.py; expose_docs yields 113; serve_ui
yields 210, which is 109 plus 100 console routes plus the /ui/static mount. A later commit on this
branch had already corrected the split to 71 and added
test_the_counting_basis_per_module_split_matches_the_declaring_modules, which derives the sentence
from the modules; that test is the independent confirmation, and it is what caught the naming of
/ws/stats I had added inside the sentence it matches exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es over (BACKLOG #1494)

Four sentences shipped in earlier rounds are true on one branch and false on
another. Each is corrected to the branch the code actually takes.

1. "Past the pause expect 200" is false once a standby takes over. The claim
   statement has two arms -- renew, WHERE leader_lease.owner = $2, and take-over,
   gated on lease_expires_at + $4 < clock_timestamp(). A sibling that acquires
   during the pause matches neither, so _claim_or_renew_lease reports not-held
   and the retry answers 409. The 409's own documented remedy then sends the
   operator to drain the healthy new leader. Both texts now split the branches.

2. "Listeners keep accepting until teardown completes" has the ordering
   backwards. _teardown_body runs _stop_sources_demote last of the three phases
   inside the demotion budget and only then reaches the unbounded connector,
   executor and sandbox phases; MLLP, TCP, HTTP and X12 each call server.close()
   in their stop()'s synchronous prologue. What survives is weaker: an
   overrunning source is abandoned, DICOM releases its port inside that call,
   and established connections drain afterwards.

3. The release-unconfirmed 503 no longer claims a teardown started on this call.
   _fire_on_demote runs under `if was_leader`, which a retry has already
   cleared. The body now states what holds on every branch reaching the raise.

4. #1494 said a mis-addressed stepdown "arms no pause" absolutely while its own
   not-fixed list said the opposite. step_down_leadership arms on
   `self._is_leader or owed`, so an owed retry does arm on a follower.

Also: drop "Leadership is exactly as you found it" from the lock-timeout bullet,
which the same bullet's next sentence contradicts; and make the ADR index row
say ADR 0056 carries two console do-not-build-from sections, not one.

Recorded in #1494 rather than fixed: a third undocumented 503 from
RequestTimeoutMiddleware that writes no audit row of either kind; the stepdown
docstring's closed True->False enumeration missing stop(); the stale-owed pause
reached through the documented remedy; and SECURITY.md's "two" /ui/oidc/* routes.

Severity in the conditional: zero deployments, so nothing is drained today.
A first deployment reading the retired text would have drained a healthy leader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wn suite

The rebase onto main brought ASVS packet D, which rotates the session on a
successful re-auth (auth/service.py audits auth.session_rotated). The stepdown
RBAC test re-authed and then kept using the OLD bearer, so every assertion after
that line silently became a test of an expired token: the deferred-force arm
arrived as 401 instead of 422, and the 200 and 409 arms never reached the
handler.

Rebinds the token through the same _rotated helper tests/test_step_up.py and
tests/test_api_auth.py already use, with the same wording, rather than a fourth
private spelling of it.

Control: without the rebind the arm reads `assert 401 == 422` and the suite is
1 failed, 10 passed; with it, 11 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ole budget (BACKLOG #1494)

Caught while re-tracing my own replacement text, before pushing it. The
corrected sentence said the listener stop "runs inside the bounded demotion
budget". _teardown_body hands _quiesce_workers_demote and
_quiesce_dispatchers_demote 0.7 of the budget EACH and _stop_sources_demote the
remaining 0.3 (_DEMOTE_QUIESCE_SHARE = 0.7), so each share is bounded and the
worst-case sum is 1.7x the budget, not the budget. Reading "inside the budget"
as a total is exactly the over-broad reading this round exists to stop.

All three texts and the pinning assertion now say "its own bounded share of the
demotion budget". The ordering claim they were carrying is unchanged and still
holds: the source stop is the last of those three bounded phases and the
unbounded connector-close, executor-shutdown and sandbox-close phases follow it.

Not a new finding filed against the runner: 1.7x is what the code has always
done and engine.py's own comment already scopes "bounded" to the source and
dispatcher phases rather than to a total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… still shipped it (BACKLOG #1494)

The correction landed in the endpoint body and docstring and stopped there. Two
prose sites still asserted, of a release-unconfirmed 503, that the node "STARTED
tearing its graph down" -- false on a repeat refusal, where _fire_on_demote is
skipped under `if was_leader` and nothing new is signalled.

- docs/CLUSTERING.md's parent bullet now says what holds on both branches: the
  node HAS cleared its leadership flag and THIS call stopped it claiming for two
  heartbeat_seconds, and it explicitly denies that a teardown just started.
- BACKLOG #1494's write-failure bullet quoted the round-6 body as current. It now
  quotes only the parts that survived and points at the two CORRECTED paragraphs
  for the teardown sentence, rather than describing a body that no longer ships.

Fixing one of three sites and leaving two is the same defect this round is
about, one level up: the sentence was corrected where it was being read, not
everywhere it was asserted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…raise

The teardown paragraph cited DbCoordinator.step_down_leadership alone. Its SQL
Server twin (pipeline/cluster_sqlserver.py) has the identical shape -- same
`self._is_leader or owed` arm, same `if was_leader: _fire_on_demote()`, same
unnested `if not wrote: raise` -- so a reader given one name could reasonably
infer the other differs. It does not, and that is worth one clause.

NullCoordinator never raises this exception, so those two are the whole set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…gres

The past-the-pause paragraph quoted `WHERE leader_lease.owner = $2` alone. That
is the Postgres spelling; SQL Server carries the same arm as
`WHEN MATCHED AND (t.owner = ? OR t.lease_expires_at + ? < @now)`
(pipeline/cluster_sqlserver.py:501). An operator on SQL Server would grep the
quoted literal, find nothing, and have no way to tell whether the paragraph
applied to them.

Both arms are now named as a shape with each backend's literal beside it, and
the paragraph says outright that nothing below it turns on the backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ACKLOG #1494)

The status line closed over "engine not started, authentication not configured,
or one of the two drain conditions". A third 503 reaches this route from outside
the handler: RequestTimeoutMiddleware is registered unconditionally in
create_app and answers 503 at DEFAULT_REQUEST_TIMEOUT_SECONDS with a body naming
no route. That was recorded on #1494 as found-not-fixed this round -- but leaving
the enumeration CLOSED over a set I had just measured as incomplete is the
SDS-3.6 defect, and it is a different thing from building the missing audit row.

So the gap stays filed and unfixed; only the closure is lifted, with the one
discriminator an operator can actually use: a 503 carrying neither a
cluster_stepdown nor a cluster_stepdown_denied row is the middleware's, not
either drain condition.

Beyond the round's brief, deliberately: the brief said file this and not fix it,
and I read that as covering the audit gap rather than licensing a sentence I
knew to be false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

A trap in the fix shape I gave you. Read this before the round lands.

The natural mirror of the first arm is a no-op on the ordinary stepdown, and it passes every existing test.

if self._is_leader or owed:      # placed AFTER the await -- never fires

_release_leadership clears both operands before it returns. Verified at df7b7aa70:

cluster.py:1377   was_leader = self._is_leader
cluster.py:1378   self._is_leader = False              <- 30 lines into the method
cluster.py:1417   self._lease_release_owed = False     <- on a write that returned

On a first-call stepdown, owed was already False going in and _is_leader is False coming out, so the post-await predicate is False or False. The second arm does not fire on exactly the path the defect lives on, and nothing goes red.

The predicate has to be a value captured before the await:

owed = self._lease_release_owed
arming = self._is_leader or owed     # capture once, use for BOTH arms

Three more from the same work

  1. Put the second arm before if not wrote: raise StepdownReleaseUnconfirmed(...). After it, the failed-write path gets no re-arm -- and that is the path where the lease row is still live and still yours, so it needs the pause more, not less. test_a_failed_release_write_reports_failure_instead_of_a_drain walks it.
  2. max() versus plain assignment is a formality under a real monotonic clock, which makes the second value larger by construction. It is not a formality under the suite injected _Clock. Either passes; max() states the invariant that the pause only ever moves later.
  3. Two docstrings assert the old ordering as a design statement and will contradict the code: cluster.py:1280-1284 ("armed BEFORE the release rather than after it") and cluster_sqlserver.py:558. No test catches either.

Measured with the write costing 25 simulated seconds at heartbeat=10.0: the pause lands at 45.0, not 20.0.

On finding 4, which is in a file you are editing right now

The or owed disjunct being untested reproduced exactly. Deleting it from both coordinators leaves 109 passed -- the whole cluster suite green. Its probe was written alongside the finding 5 work, so tests/test_cluster_lease.py will collide with your round. Say the word and I will extract it as its own patch.

Mutation results, all legs, actual pass and fail:

baseline, before any edit                      109 passed
or owed deleted (reproduction)                 109 passed   <- mutant survives
after fix + 4 new tests                        113 passed
second arm removed                               2 failed   assert 20.0 == 45.0, both backends
second arm removed, numeric leg silenced         2 failed   assert True is False, is_leader()
or owed deleted                                  2 failed   assert 20.0 == 120.0, both backends
or owed deleted, numeric leg silenced            2 failed   assert True is False
restored                                       113 passed

Nothing was pushed to this branch.

wshallwshall and others added 3 commits September 9, 2026 21:22
… returns

The pause was armed only from the instant BEFORE the release write, so the
write's own duration came out of it. The lease row does not become takeable
until that UPDATE commits, so the window a sibling actually gets was
(2 * heartbeat - write duration), not two heartbeats -- and nothing bounds that
duration from the coordinator: the pool acquire() carries no timeout and
[store].command_timeout, 30s by default, already outlasts the 20.0s pause at the
shipped heartbeat_seconds of 10.0. A write slower than the pause would leave
zero protection, and the drained node's own maintenance tick -- queued on
_leadership_lock for the whole call -- would then fall through the pause gate,
match the unfenced `WHERE leader_lease.owner = $2` renew arm over the row it had
just expired, and be leader again moments after the endpoint answered
200 {was_leader: true}.

Keep the existing arm ahead of the await, because a cancellation landing inside
the pool write must not skip it, and add a second arm after the release returns,
taking whichever expiry is later. Both coordinators, since the SQL Server MERGE
carries the identical unfenced `t.owner = ?` renew arm.

Two test-quality gaps in the same mechanism, both measured rather than reasoned:

- No test could see the defect above, because _Clock only moves when a test
  moves it and none moved it across the release, so every release cost zero
  simulated time. The new tests advance the injected monotonic clock inside the
  write via the stand-in's on_execute probe.
- The `or owed` disjunct that arms the pause on a retry was covered by nothing.
  The two retry tests assert _no_claim_until == 20.0 after the retry, but their
  clock never moves, so 20.0 is already there from the first, failed stepdown
  and the assertion reads identically with the disjunct deleted. Deleting it
  from both coordinators left all 109 tests in test_cluster_lease.py,
  test_cluster.py and test_api_cluster_stepdown.py passing. The new tests
  advance the clock past that first pause, which separates the two answers.

Four tests, one per defect per backend. Each was run against its own mutant:
removing the second arm fails them at `20.0 == 45.0` and, with that leg
silenced, at `True is False`; deleting `or owed` fails them at `20.0 == 120.0`
and then at `True is False`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd narrow the 503 discriminator

Two defects on POST /cluster/stepdown, both about the audit row.

The refusal's audit row went through the store that produced the refusal.
`release-unconfirmed` is raised only when the lease-expiring write did not
return, and build_coordinator hands DbCoordinator `store._pool` -- the same pool
PostgresStore.record_audit borrows. `_denied` was a bare await, so a store-level
failure would raise past the handler's own `raise HTTPException(503, ...)` and
leave the catch-all to answer a bare 500 "internal error": no reason, no remedy
text, and no row either, on a node that had already cleared its leadership flag
and might still own a live lease no standby could take.

Guard the write instead. The row is unwritable on both paths -- a store that
cannot take it before the raise cannot take it after -- so the guard costs
nothing and keeps the status, the reason and the remedy the operator acts on. A
failed write is logged with its reason. CancelledError is not an Exception, so a
request deadline still unwinds the handler as before. `_denied` is the shared
shape for all three refusals, so guarding the helper covers the 400 and both
503s in one place rather than singling out the arm that surfaced it.

And the docstring's audit-based 503 discriminator was false in its positive half.
It told a reader that a 503 with no row of either kind IS the middleware timeout.
Three other causes share that empty trail: the `engine not started` and
`authentication is not configured` 503s the same docstring names three lines
earlier are raised by the dependencies, so the body never runs; and the guard
above makes a drain refusal look the same when the store cannot take its row.
Only the negative half is entailed -- both drain arms call `_denied`, so an
absent row does rule them out. Narrowed to that, and pointed at the response
body, which differs on every one of them.

One test, run against its own mutant: removing the try/except fails it, though at
the propagated RuntimeError rather than the status, because Starlette re-raises
after ServerErrorMiddleware sends the 500 and httpx.ASGITransport surfaces the
raise. The comment records that, since it is what a future reader will see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A failed drain IS recorded as a drain, on one path the doc did not count. The
audited bullet named the handler-reached refusals as the 400 and both 503s and
concluded that "a failed drain is never recorded as a drain". The 409 is also
reached by the handler, and the handler writes its cluster_stepdown row from the
coordinator's return BEFORE raising it, so a call that released nothing lands a
row under the success action name. An auditor counting rows by action name --
which that sentence licenses -- would over-count handovers, and it bites hardest
on the path this section spends most of its words on: the retry after a
release-unconfirmed 503, which the section itself says answers 409.

The row discloses the truth in its own fields, so the fix is to the sentence.
Say that every call the handler completes is audited under that name, that a 409
therefore writes one reading was_leader false, and that counting drains means
reading was_leader rather than the action name. Note too that the denied row is
best-effort, since the endpoint now keeps a release-unconfirmed 503 rather than
losing both the answer and the row to a store that has already failed.

A self-fenced node is not "already not doing leader work". _check_fence clears
the leadership flag, fires the demotion edge, and nothing more; Engine's demote
edge only sets _graph_wake and deliberately does not set the runner's _stop, so
the teardown runs afterwards on the graph supervisor with its later phases
unbounded and an overrunning source abandoned rather than cancelled. The
watchdog's own docstring forbids the premise in as many words, and this same
document already says the opposite of the same self-fence in the crash-failover
bullet above. Say what is true of the state and point at that bullet rather than
restating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ms the pause (BACKLOG #1494)

Traced per branch before writing. Each claim cites the line it rests on.

WHAT WAS FALSE. docs/CLUSTERING.md said "past the pause the answer is 200 or
409, decided by who the lease row names by then". The row does not decide it.
The endpoint raises 409 on `not was_leader`, and `_release_leadership` clears
`_is_leader` on its first lines, so every call after the first reports
was_leader=false whatever the row holds.

WHAT IS TRUE, and the branch each part covers:

  * `_is_leader = True` is set in exactly ONE place, inside
    `_maintain_leadership` when `_claim_or_renew_lease` returns held. Control:
    5 assignments to `_is_leader` in that file, so an instrument finding only
    this one would not have been reading the False ones.
  * While the pause holds, `_claim_or_renew_lease` returns not-held at the
    pause gate BEFORE any database access, so no tick can promote and every
    retry answers 409.
  * The pause is armed on `self._is_leader or owed`, so a retry re-sending an
    owed write RE-ARMS it for another two heartbeat_seconds. An operator
    retrying faster than the pause expires never lets a tick through and holds
    themselves in 409. Operator-driven, and documented nowhere.
  * Once the pause lapses the next tick settles it: the renew arm (owner = me,
    no expiry term) matches if the row still names this node, which promotes
    it, and a stepdown issued AFTER that answers 200. If a standby acquired,
    the renew arm cannot match and the take-over arm needs an expiry that has
    not passed, so 409 is permanent and correct.

The api/app.py docstring already routed 200 through the tick correctly, so it
is NOT rewritten; only the missing livelock is added. Three earlier rounds
introduced a defect by rewriting prose that was already right.

Falsified by the handles-real-patient-data session tracing the sentence. The
ending was settled with the Lander after we disagreed: it read 409-forever
because it assumed no tick intervened, which holds inside the pause and is
wrong after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Lander: enqueuing. I re-read at c6b9e33b0 rather than diffing only what changed since my review.

All six findings resolved. Verified at the current head, not taken on report:

F5  captured predicate, cluster.py            1
F5  captured predicate, cluster_sqlserver.py  1
F4  the slow-write probe test                 present
F2  "is already not doing leader work"        0 occurrences
F3  "never recorded as a drain"               0 occurrences
F1/F6  my own commits 86c1bff70, 64f3f25a7    in

The one commit I had not read is c6b9e33b0, and I read all 26 lines of it. It is the retry-semantics correction we each got half of. Your half: only a maintenance tick can promote the node, and retrying prevents one. Mine: every retry answers 409 because the first call cleared the flag before it wrote. Together they give the livelock -- retry faster than the pause and you never let a tick through -- and the remedy line that follows from it: wait, do not retry. It is in CLUSTERING.md and in the endpoint docstring, and both match the code.

CI is a real population, not an empty read: total_count=44, 44 rows, 32 success and 12 skipped, 0 failing, 0 pending. Every required context green, read from live branch protection.

Three limits I am merging in full knowledge of, because you stated them and they are right:

  1. No full local suite. The interpreter lacks the vault extra, so every local run prints INCOMPLETE RUN. CI reads those legs; I did not.
  2. Nobody has driven either coordinator against a real Postgres or SQL Server. That is true of the whole cluster surface and is not this PR to fix.
  3. The or owed disjunct was untested until this PR, and deleting it left 109 tests green. That probe is now here.

The thing worth keeping from this one is not the fix. Round six moved the pause arming before the write to close a cancellation hole, and that fix opened this one. Ordinary. Round seven then ran under a brief declaring the code settled and out of scope, so its verifiers were told not to look at the place the defect was. A scope boundary in a review brief is a claim about where defects are not, and it needs the same evidence as any other claim.

Found by reading a diff by hand. No check on this repository could have.

@wshallwshall
wshallwshall added this pull request to the merge queue Sep 10, 2026
@github-actions

Copy link
Copy Markdown

CI failed while this pull request was in the merge queue, so the queue ejected it.

Its own head can still be green: the queue revalidates the merge, and the path gates that skip on a pull request run there. Read the run before retrying.

https://github.com/MEFORORG/MessageFoundry/actions/runs/34431613117

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 10, 2026
@wshallwshall

Copy link
Copy Markdown
Collaborator Author

Evicted from the queue. Re-enqueued once, and here is the control that clears this PR.

The batch failed one job, test (windows-2025, py3.14):

FAILED tests/test_asvs_login_deadline.py::test_every_login_failure_branch_answers_at_one_deadline
deadline depends on the branch taken:
  unknown_username    0.5000029
  wrong_password      0.5000029
  locked_account      0.5000018
  bootstrap_username  0.5000020
  ad_pathway_retired  1.0000043      <- the outlier

This PR does touch auth, so "unrelated" needed proving rather than asserting. Its whole auth delta is additive: one new enum value CLUSTER_CONTROL, added to the never-assignable set and to a docstring. Nothing touching password verification, the AD pathway, or any deadline.

The decisive control is the other PR this test failed on today. Over every failed CI run created 09-09 to 09-10 (reported 40, walked 40, exact match), 22 failing test (...) jobs, of which this test accounts for 2:

pr-1004   this one
pr-1001   files changed: .github/zizmor.yml, docs/BACKLOG.md

1001 has zero auth content and fails the same test in the same job on the same runner OS. A PR that cannot possibly affect login timing fails it, so the failure is environmental.

One thing I am flagging rather than dismissing. ad_pathway_retired came in at almost exactly 2.0x the other four, not at a jittery value. Four branches within 1.1 microseconds of each other and the fifth at double is not the shape of scheduler noise. The comment above that branch says #1137 retired directory password sign-in and it "refuses before any store lookup" -- so it may be paying a deadline twice while the others pay it once. That is worth its own measurement and I am not making it a condition of this merge, because the same doubling appears on a PR that changes nothing near it.

One re-run. If it evicts again on the same job I stop and treat it as signal.

@wshallwshall
wshallwshall added this pull request to the merge queue Sep 10, 2026
Merged via the queue into main with commit a653e89 Sep 10, 2026
44 checks passed
@wshallwshall
wshallwshall deleted the claude/manager-424d8b branch September 10, 2026 03:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant