Skip to content

feat(#10 pr2c): FINAL ACCEPTANCE — READY→ACTIVE + Redis Sentinel failover + SIGKILL matrix + RPO/RTO - #22

Merged
rblake2320 merged 6 commits into
masterfrom
feat/10-pr2c-acceptance
Jul 19, 2026
Merged

feat(#10 pr2c): FINAL ACCEPTANCE — READY→ACTIVE + Redis Sentinel failover + SIGKILL matrix + RPO/RTO#22
rblake2320 merged 6 commits into
masterfrom
feat/10-pr2c-acceptance

Conversation

@rblake2320

@rblake2320 rblake2320 commented Jul 19, 2026

Copy link
Copy Markdown
Owner

PR2c — FINAL ACCEPTANCE (closes #10)

The acceptance milestone per the merged design §5. Frozen scope. Reviews R1 (a2f3a7b) + R2 (8d448ef) close all acceptance gates codex-1 flagged.

1. READY → ACTIVE + GOVERNED B source-authority activation

  • activate() — terminal signed forward-CAS; B becomes the live authority.
  • activateSource() — mints B's unforgeable, governed source capability using the configured control authority (policy.sourceGuard, never a per-call key). It derives the holder from the ratified B identity (== BFinalizedReceipt.bKeyId, which must equal the bKeyId ratified into the ACTIVE head), uses a deterministic leaseId, and persists the exact signed grant into durable evidence (tsk_ha_source_activation). A retry rehydrates the byte-identical grant; a conflicting activation is quarantined.
  • test:b-activation (3/3): B installs the governed lease, seeds its checkpoint to the verified (N, head@N), mints its SourceFenceReadyToken, and originates sequence N+1 through the real fenced outbox while the old A (prior epoch, revoked lease) is DENIED.

2. Real Redis Sentinel/quorum + ENFORCED, UNAMBIGUOUS WAIT — test:sentinel (3/3)

  • 1 master + 2 replicas + 3 sentinels (quorum 2), AOF, min-replicas-to-write 1.
  • RedisFencingStore.claim() issues Redis WAIT after the epoch CAS. RPO=0 is enforced; a successful CAS whose replica quorum did not ACK throws a typed FenceDurabilityUncertainError (carrying the stored tuple to reconcile) — never conflated with an ordinary false (CAS refused). Hermetic regression proves both.
  • Master crash is an abrupt docker kill -s KILL (deterministic). RTO measured to durably writable.

3. Live split-brain PARTITION — test:partition (4/4)

Old master isolated alive; refuses writes (NOREPLICAS) while the quorum promotes a new durably writable + monotonic master (fence intact, RPO=0); on heal the old master demotes to a replica and reconciles.

4. Child-process SIGKILL / restart matrix — test:sigkill (12)

Full cutover through child processes killed by a real SIGKILL: before-commit → PG rollback; after-commit → idempotent resume to ACTIVE. The handoff (control HMAC secret) is created 0600 in the OS temp dir and removed in an outer try/finally on every exit; a final check proves cleanup.

5. Measured per-fault RPO / RTO

Each fault drill measures RPO = 0 and RTO = fault → new authority durably writable (successful enforced-WAIT epoch-2 claim), with fence-readable / old-master-refusal reported as labelled sub-times (sentinel ~4.5s, partition ~12s).

Safety

No new architecture. HA_CONTROL_MANIFEST_DIGEST re-pinned on postgres:16-alpine for the one added evidence table. Regression: control-cutover 10/10, ha-fencing 20/20, redis 7/7, receiver 10/10, unit 23/23, typecheck clean. Mechanism-only on a single host (processes, not independent physical failure domains).

rblake2320 and others added 6 commits July 18, 2026 19:47
…ver, SIGKILL matrix, RPO/RTO

The PR2c acceptance milestone that closes #10, per the merged design §5. Frozen scope, one draft.

1. READY→ACTIVE activation: `HaControlFencing.activate()` — the terminal signed forward-CAS on the
   cutover head; B becomes the live write authority. Carries the ratified READY binding forward as the
   durable go-live proof; idempotent only if it reproduces byte-identical signed ACTIVE evidence; ACTIVE
   is terminal (a new promotion needs a new command+epoch). `cutover()` reader already public.

2. Real Redis Sentinel/quorum (tsk-ha-sentinel-drill, 4/4): 1 master + 1 replica + 3 sentinels (quorum 2),
   AOF everysec, on a dedicated network with STATIC IPs so sentinels monitor the master by fixed IP and
   fail over even when the crashed master's container exits (Docker DNS drops). A real master crash
   (SHUTDOWN NOSAVE) → automatic promotion; the committed+replica-ack'd (WAIT) fence SURVIVES (RPO=0),
   stays monotonic (epoch 2 claims, epoch 1 refused — no rollback), RTO measured. Whole topology + drill
   run via docker compose (ci/redis-sentinel/, run.sh docker-waits on the drill so the intentional master
   exit doesn't abort the run). RedisFencingStore needed NO change — Sentinel is a client-wiring concern.

3. Child-process SIGKILL/restart matrix (tsk-cutover-sigkill-drill + tsk-cutover-worker, 11/11): the full
   cutover (bind→advance→import→ready→activate) driven through CHILD processes killed by a REAL SIGKILL.
   before-commit (SIGKILL inside the tx via the transactor's onBeforeCommit seam) → PostgreSQL rolls back,
   phase UNCHANGED, no torn state. after-commit → a fresh child re-runs the SAME transition and resumes
   IDEMPOTENTLY (phase advanced exactly once, byte-identical signed evidence). Reaches ACTIVE. advanceEpoch
   crashes on its 2nd tx (Redis claimed, FENCED not committed) and still resumes correctly.

4. Failover/partition recovery: covered by (2) master failover + (3) process-crash resume.

5. Measured per-fault RPO/RTO: printed by both drills (Sentinel: RPO=0, RTO measured; SIGKILL: RPO=0,
   RTO ~avg-ms per phase).

CI: test:sigkill (3 independent PG16 + Redis) + test:sentinel (docker compose) wired fail-not-skip.
No production code beyond `activate()`; no DDL change (ACTIVE was already in the cutover CHECK/terminal
set) → HA_CONTROL_MANIFEST_DIGEST unchanged. Regression: control-cutover 10/10, ha-fencing 20/20, unit
23/23, typecheck clean. Handoff file (test key material) is gitignored + deleted by the drill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…node quorum + enforced WAIT; live partition

Addresses codex-1's 3 frozen-acceptance blockers on 173c55d:

CRIT — activation was incomplete (only control READY→ACTIVE). Added GOVERNED B source-authority
activation: `HaControlFencing.activateSource()` requires the ACTIVE head for this exact command/epoch,
re-verifies B's BFinalizedReceipt, asserts the ACTIVE evidence pins the SAME B (bReceiptDigest+n+bSystemId),
then GUARD-signs (ed25519) B's genesis source lease AT THE PROMOTED epoch (leaseEpoch==targetEpoch,
holder==B, commandId==cutover). B verifies it with the guard PUBLIC key (unforgeable). New drill
tsk-b-activation-drill (3/3): B installs the governed lease, seeds its source checkpoint to the verified
(N, head@N), mints its SourceFenceReadyToken, and ORIGINATES sequence N+1 through the real fenced outbox
(chaining from head@N) — while the OLD A, at the prior epoch with a revoked lease, is DENIED (no split-brain).

HIGH — Sentinel was 2 data nodes + a caller-side WAIT. Now 1 master + 2 REPLICAS + 3 sentinels (quorum 2),
AOF everysec, min-replicas-to-write 1 + min-replicas-max-lag. RPO=0 is now ENFORCED IN THE CLAIM PATH:
`RedisFencingStore` takes an optional FenceDurabilityPolicy {waitReplicas, waitTimeoutMs} and issues Redis
WAIT after the epoch CAS, failing closed if the replica quorum did not ACK. Sentinel drill uses it (no
manual WAIT); RTO measured against a fresh reconnecting client (data-plane, ~5s) not one socket's slow follow.

HIGH — added a LIVE split-brain partition (not a crash). tsk-ha-partition-drill (4/4): the old master is
isolated ALIVE (docker network disconnect); it REFUSES writes (min-replicas-to-write → NOREPLICAS, no
progress) while the surviving quorum promotes a new writable+monotonic master (fence intact, RPO=0); on HEAL
the old master is demoted to a replica and reconciles to the new epoch. Per-fault RPO/RTO reported.

Topology now runs on the host (published ports + ioredis natMap) with static IPs + healthchecks (no startup
false-failover); run.sh brings up per-drill and waits for replica sync. No DDL change (activateSource signs
off-DB; ACTIVE unchanged) → HA_CONTROL_MANIFEST_DIGEST unchanged. Regression: control-cutover 10/10,
sigkill 11/11, ha-fencing 20/20, redis 6/6, unit 23/23, typecheck clean. #10 stays OPEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…idence; typed WAIT; abrupt crash; RTO truth; secret hygiene

Addresses codex-1's 5 bounded blockers on a2f3a7b:

1 (CRIT) activateSource authorization/recovery: it no longer accepts a per-call guard private key or
  caller-chosen holder/leaseId/expiry. It signs with the CONFIGURED control authority (policy.sourceGuard,
  held as deployment config), DERIVES the holder from the ratified B identity (== BFinalizedReceipt.bKeyId,
  which must equal the bKeyId ratified into the ACTIVE head), uses a deterministic leaseId, and PERSISTS the
  exact signed grant tuple+digest into new durable evidence `tsk_ha_source_activation`. A retry REHYDRATES
  the byte-identical grant; a different/conflicting activation for the stream is quarantined. HA_CONTROL
  manifest re-pinned on alpine (a80069c5…). Drill asserts rehydration + holder==ratified B.

2 (HIGH) Redis WAIT post-effect ambiguity: after a SUCCESSFUL CAS whose replica quorum did not ACK,
  claim() no longer returns an ordinary false (which means "CAS refused"). It throws a typed
  FenceDurabilityUncertainError carrying the exact stored tuple → the caller reconciles + fails closed.
  Hermetic regression (single node, no replicas) proves the typed throw AND that a refused CAS still returns
  plain false.

3 (HIGH) nondeterministic crash: the sentinel drill now crashes the master with an ABRUPT `docker kill -s
  KILL` (real crash, deterministic exit) wrapped for exit-code robustness, replacing the graceful/flaky
  redis-cli SHUTDOWN NOSAVE (which exited 137 and threw).

4 (HIGH) RTO truth: both fault drills now measure RTO from fault → NEW AUTHORITY DURABLY WRITABLE (a
  successful enforced-WAIT epoch-2 claim on the promoted master), with the fence-readable / old-master-refusal
  times reported as labelled SUB-times. A fresh reconnecting client is created AFTER promotion (data-plane RTO,
  not one socket's slow follow): sentinel ~4.5s, partition ~12s.

5 (HIGH) secret hygiene: the SIGKILL handoff (carries the control HMAC secret) is now created in the OS temp
  dir with mode 0600 and removed in an OUTER try/finally on EVERY exit path; a final check proves it is gone.

No new architecture. Regression: b-activation 3/3, sentinel 3/3, partition 4/4, sigkill 12, control-cutover
10/10, ha-fencing 20/20, redis 7/7, receiver 10/10, unit 23/23, typecheck clean. #10 stays OPEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…connection (durability continuity)

Addresses codex-1's final HIGH on 8d448ef (verified against official Redis docs: WAIT tracks prior writes
in the context of the CURRENT connection / per-client replication offset, and ioredis auto-reconnects).

RedisFencingStore.claim() previously ran the CAS eval() and the WAIT as two separate round-trips on the
auto-reconnecting Sentinel client. If the socket/master switched between them, WAIT could bind a fresh
server connection whose replication offset does not cover the prior CAS write — a durability hole the typed
under-ack signal did not cover.

Fix: the durable path now issues a SINGLE pipeline — [CLIENT ID, EVAL(CAS), WAIT, CLIENT ID] — written to
ONE socket (a pipeline is not split across a reconnect), so WAIT's offset binds the same connection that ran
the CAS. After the CAS wrote, ANY anomaly is durability-UNKNOWN → FenceDurabilityUncertainError: a WAIT-side
error (cut between EVAL and WAIT), an aborted pipeline (connection lost), a changed CLIENT ID (path switch),
or an under-ack. A refused CAS is still an ordinary false; the non-durable path is unchanged.

Hermetic deterministic regression (stubbed pipeline exec vector) proves each path — WAIT-cut, identity change,
under-ack, aborted pipeline → typed Uncertain; acked-same-connection → true; refused → false. redis 8/8.
Real-cluster: sentinel 3/3 (RTO ~3.7s), partition 4/4 (RTO ~11.2s) — unchanged. No new architecture. #10 OPEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… CLIENT ID bracket is load-bearing

Evidence-backed (Redis WAIT is per-connection; ioredis resends unfulfilled commands on reconnect by
default) — the CLIENT ID bracket catches a WAIT resent on a fresh Sentinel-promoted connection. Comment only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…S+WAIT (close both continuity bypasses)

The R3 pipeline + CLIENT-ID bracket did NOT satisfy "dedicated non-retrying physical connection" (codex-1,
exact 9e925af). Two concrete bypasses remained: (1) pipeline.exec() REJECTION escaped raw before wrapping,
not FenceDurabilityUncertainError; (2) ioredis can REPLAY the whole unfulfilled pipeline on a new master —
both CLIENT IDs then match on the new connection, the replayed EVAL returns 0 (the first already wrote), and
claim() returned an ordinary false before continuity validation. CLIENT IDs are server-local and can coincide
across masters. Verified against official Redis WAIT docs (per-connection / per-client offset) — an ordinary
auto-reconnecting ioredis client is insufficient.

Surgical closure: the durable claim now acquires a DEDICATED physical connection to the current master via
redis.duplicate() with ALL reconnect/retry/resend DISABLED (retryStrategy:()=>null, maxRetriesPerRequest:0,
enableOfflineQueue:false, autoResendUnfulfilledCommands:false, reconnectOnError:()=>false, role:master,
lazyConnect). It connects (pre-dispatch failure propagates — the claim never started), dispatches EVAL then
WAIT on that ONE socket (WAIT binds this connection's offset, which covers the CAS), and treats ANY
disconnect/rejection AFTER the CAS was dispatched as FenceDurabilityUncertainError; the connection is closed
in finally. No pipeline, no CLIENT-ID bracket, no replay path — bypass 2 is structurally impossible.

Regressions (hermetic, dedicated-connection stub): EVAL-reject-after-dispatch, WAIT-reject-after-dispatch,
under-ack → typed Uncertain; acked-same-connection → true; genuinely-refused CAS0 → false; pre-dispatch
connect failure PROPAGATES (not a silent false/uncertain); and a STRUCTURAL assertion that the connection is
created with reconnect/retry/offline-queue/RESEND all disabled (so the replay-to-CAS0 path cannot occur).
redis 8/8. Real cluster: sentinel 3/3 (RTO ~4.4s), partition 4/4 (RTO ~11.3s) — duplicate() resolves the
promoted master correctly across failover. No new architecture. #10 stays OPEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rblake2320
rblake2320 marked this pull request as ready for review July 19, 2026 16:37
@rblake2320
rblake2320 merged commit 00e7457 into master Jul 19, 2026
2 checks passed
@rblake2320
rblake2320 deleted the feat/10-pr2c-acceptance branch July 19, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HA: make tumbler replication outbox and receiver checkpoint transactionally durable

1 participant