Skip to content

Arca — HA Hardening (remediation after the Phase 29 review)

Progress Overview

R1
R2
R3
R4
R5
R6
R7
R8
R9

Context

The Phase 29 review (arca-phase-29-ha-review.md, passes of 2026-06-10 and 2026-06-11) produced the findings: 3 P0s (§2.1–§2.3), §2.4 + 9 P1s (§3.x, D1, D2, D3a, D9), a security-critical chain (§3.7), a series of P2s (M1–M8, §5, D3b/c, D4–D8) and P3/doc (D10–D12). This plan implements ALL of them, organized in 9 milestones (R1–R9) ordered by priority and technical dependency. It is a living document, published on the documentation site as an annex of the roadmap (Phase 29.1) via a symlink to the canonical file .claude/plans/arca-phase-29-ha-hardening.md.

Security workstream (review §3.7). The single most important security gap — a rogue peer receiving all new data with no secret, because the fan-out push direction authenticates no peer — plus its amplifiers (brute-forceable fingerprint, weak secrets allowed, plain-HTTP/unverified-TLS/no-replay) is spread across R3 (peer authentication of liveness + fan-out gating; fingerprint off the public endpoint) and R4 (verified inter-node TLS / TD-015; secret strength; anti-replay). On a deployment whose cluster network is not a trusted, isolated segment, treat the peer-authentication half (decision H12) with P0 urgency and consider pulling R4's TLS item forward to sit beside R3. The secret itself is well handled where it is used (never on the wire, HMAC-keyed SigV4, isolated credential); the gap is that authenticating peers — not just requests — was never designed.

How to use this document (process rules, valid for every session):

  1. At session start read: CLAUDE.md, this plan, the review. The traceability table at the bottom says what is done and what is not.
  2. Work TDD; every milestone must leave bin/test unit and bin/test cluster green. After code changes also rebuild the test images (docker compose -f docker/docker-compose.yml build unit-test test), otherwise tests run on stale images.
  3. For every completed item: tick the checkbox HERE and in the traceability table, update CHANGELOG.md (Unreleased section), update the documentation touched.
  4. If a decision changes the design, update the WHOLE document, not just the touched section (same rule as the Phase 29 plan).
  5. At the end of each milestone: turn the milestone's cell green in the progress bar at the top of THIS document, tick the milestone in the roadmap's Phase 29.1 section (documentation/docs/roadmap.md), rebuild the site with bin/docs-build (this plan is published there via symlink), then propose to Pietro a commit + possibly a release.
  6. The §x.y / Mx / Dx numbers refer to the review; the "plan lines" to .claude/plans/arca-phase-29-ha.md.

Design decisions (fixed before implementation)

# Decision Status
H1 True quorum = option A of review §2.1: count the fan-out ACKs; if 1 + ack < write_quorum the write fails with 503 ServiceUnavailable + Retry-After. The local copy is NOT rolled back (anti-entropy propagates it): document that an error does not imply undo, as in every quorum system without distributed transactions. ✅ approved (review + Pietro's ok on the P0s)
H2 ACK = row applied + blob present: the response of POST /cluster/v1/object becomes {applied, has_blob}; the peer self-certifies (sidecar present for rows referencing a blob; for delete markers/tombstones applied suffices). Avoids threading state between write_sidecar and put_object. For composites has_blob = composite sidecar present (the parts already fan out individually; the rest is covered by D4). ✅ decided in planning
H3 PG commit-ordered cursor via counter table (like SQLite): table object_seq(v BIGINT) updated with UPDATE ... RETURNING in the same transaction as the row. The row lock serializes the assignment until commit → seq order = commit order, no deliverable gaps. Cost: serializes the final stretch of object writes on PG (acceptable; documented). Discarded alternative: a probabilistic guard window. ✅ decided in planning
H4 Scope of the ACK counting: object data-plane mutations (put_object, version-delete/tombstone, delete marker). Control-plane and tag ops remain best-effort fan-out + reconcile (rare mutations, reconciled; extension possible later). ✅ decided in planning
H5 Symmetric leader gate for the workers: only the node with the lowest node_id among the eligible nodes (membership + self) runs the gated worker. Automatic failover at the next tick. A double-execution window during membership disagreement: accepted and documented (idempotent work). Refined in R6 (2026-06-12, both points confirmed by Pietro): (a) the predicate is eligible() — not merely alive — for the same reason the quorum gates on it (an unauthenticated rogue with a low node_id must not steal the role and silence the workers cluster-wide); (b) the gate applies to the lifecycle worker only — the original idea of also gating the Phase 28 replication worker rested on a wrong premise in review §3.3 (see the R6 section). ✅ decided in planning; refined in R6
H6 D3a = gate, not just a warning: in quorum mode, if the observed live nodes exceed cluster_size, writes are refused (503 with a clear message) + flag in /admin/cluster. It is a misconfiguration that enables split-brain: the safety mode must fail closed. No config escape hatch (rejected as a foot-gun if left enabled); the R9 runbook documents the correct resize procedure instead. ✅ confirmed by Pietro (2026-06-11)
H7 Drift = out of the quorum: a peer with config_ok=false does not count toward the quorum (it stays visible in /admin/cluster with the drift flag). ✅ decided in planning
H8 Secret rotation via dual-secret: optional [cluster] secret_previous key; inbound auth accepts both, outbound uses only secret. Runbook: set secret_previous=old, secret=new, rolling restart, then remove secret_previous. ✅ decided in planning
H9 D6 console via server-side proxy: a ?node=<node_id> parameter on the per-node admin endpoints, internal proxying via ClusterClient. Avoids CORS and browser-unreachable endpoints (typical deployment: only the LB is exposed). As confirmed, three refinements: (a) transport = server-side proxy (direct browser-to-node calls rejected); (b) scope = ALL FOUR node-local view families — audit log, metrics history, notification event log, AND the Phase 28 replication journal (node-local by design, same behind-the-LB problem, added beyond the original plan); (c) the node selector also offers an "All nodes" merged view (entries from every live node fused by timestamp, each labeled with its source node), not just single-node selection. ✅ confirmed by Pietro (2026-06-12): proxy + all 4 views + "All nodes" merge
H10 Rolling upgrade across mixed versions: every wire change is additive (new JSON fields ignored by old nodes); the probe uses the new authenticated /cluster/v1/ping with a fallback to the old /cluster/v1/health on 404 (legacy peer, counts as alive with a warning). ✅ decided in planning
H11 Versions: a SINGLE release at the end of the full remediation plan (R1–R9); version number decided then (at least MINOR: the quorum changes observable behavior). Supersedes the original idea of v0.26.0 after R1+R2 with per-milestone releases. Milestones are still committed and pushed as they complete. ✅ revised and decided by Pietro (2026-06-11); version fixed at v0.26.0 (Pietro, 2026-06-12)
H12 Authenticate the peer, not just the request (closes review §3.7). Today the cluster authenticates the sender of every /cluster/v1/* request (inbound, via cluster_auth) but never the receiver of a fan-out: live_peers() filters on alive only, and membership admits an mDNS peer on a cluster_id match alone — so a rogue peer receives all new writes with no secret. Fix, in order of preference: (1) mutual TLS with a shared cluster CA — a node verifies the peer's CA-signed cert before adding it to membership / fanning out (also resolves TD-015 and the cleartext/MITM exposure). (2) Secret-only challenge-response: an app-layer exchange where the peer proves possession of the secret over a fresh nonce before being counted live — NOT merely "a signed /ping" (a rogue controls its own server and can return 200 unconditionally; the peer must prove possession to us). Either way, live_peers() (fan-out) AND the quorum count only authenticated and config_ok peers. CA origin (confirmed with Pietro): the CA is operator-distributed via config ([cluster] CA path + per-node cert/key) with a shipped generator command reusing the rcgen tls-init generator — NOT a K8s-style auto-enrollment (first node generates the CA, peers get CSRs signed over a secret-authenticated channel): issuance gated by the standing secret would collapse mTLS to secret strength, while an operator-distributed CA is an independent second factor (K8s mitigates with short-TTL join tokens; possible future evolution, not now). Layering (confirmed): (2) is not a fallback — it ships for ALL clusters as the baseline peer-auth layer in R3 (it also closes the rogue peer on plain-HTTP deployments, where no certificates exist; on a plain-HTTP network it stops the easy attack — a rogue mDNS registrant — while sniffing/MITM still requires TLS), with (1) on top as the independent second factor on TLS clusters. ✅ confirmed by Pietro (2026-06-11): BOTH layers — challenge-response baseline for every cluster (R3) + mutual TLS with the CA via config + shipped generator tooling (R4)

R1 — P0 correctness (true quorum, PG cursor, tombstone-first)

The review's three P0s plus the §2.4 prerequisite. No new test infrastructure required (unit + the existing cluster suite); partition-based verification arrives in R2.

  • §2.4 Parallel fan-out — replaced the sequential loops with futures_util::future::join_all: cluster_meta.rs (fan_out_object, fan_out_version_delete, fan_out_op), cluster_blob.rs (fan_out), cluster_control.rs (fan_out_op). ClusterClient already had a per-request reqwest timeout ([cluster].request_timeout_seconds, default 10 s) — verified, nothing to add.
  • §2.1 True quorum (decisions H1, H2, H4):
    • response of POST /cluster/v1/object (and /object/delete) → JSON ClusterObjectAck {applied, has_blob}; has_blob = sidecar present when the row references a blob, vacuously true otherwise (deletes/markers/tombstones). Legacy peers answering an empty 200 count as a full ack (H10 rolling upgrade, handled in ClusterClient::parse_ack).
    • ClusterMetadataStore::put_object: after the local write, parallel fan-out, acks = 1 + full_acks; shortfall in mode=quorum → 503 ServiceUnavailable + Retry-After: 5 (header added in s3_error_response for every ServiceUnavailable). Same enforcement in delete_object (marker + version-delete branches) and delete_object_version.
    • mode=available: unchanged (ACKs ignored).
    • pure function quorum_satisfied(acks, write_quorum) in arca-core::cluster with unit tests; cluster_meta.rs module doc rewritten (admission gate + ACK counting, no rollback, anti-entropy convergence; "no divergence" absolute removed). 8 new unit tests total (quorum_satisfied ×2, control_merge ×2, cluster_meta ×4 incl. a fake-peer ACK server; the old quorum_mode_writes_with_majority_despite_unreachable_peer flipped into quorum_mode_refuses_write_when_acks_below_quorum).
    • integration: full bin/test cluster green (phases A–F; B: 2 alive → writes OK with real ACKs; C: 1 alive → 503).
  • §2.2 PG commit-ordered cursor (decision H3) — pg migration 0009: object_seq single-row counter seeded GREATEST(MAX(seq), objects_seq.last_value); all write paths (put_object ×3 branches, delete marker ×2, tombstone UPDATEs ×5, apply_remote_object ×2) now take the seq via next_object_seq (UPDATE ... RETURNING) inside the row's transaction; sequence + column DEFAULT dropped. Commit-order guarantee + a uniform seq→rows lock-order rule (deadlock avoidance) documented on the helper. Concurrency smoke test added to the PG suite (40 parallel puts + mixed overwrite/delete) — bin/test postgres green (21 tests).
  • §2.3 Tombstone-first in apply_control_merge (control_merge.rs): tombstones adopted BEFORE upserts and deletes (clears stay last); TDD unit test with a recording mock store pinning the order (red on the old code, green after); a comment in reconcile_peer_control pins that bucket deletes also run after the adoption.
  • CHANGELOG (Unreleased: Changed ×2, Fixed ×2) + ha.md consistency section updated (ACK-time durability quorum, two enforcement layers, no-rollback warning box, LWW scope per mode) — the full doc pass remains in R9.

Outcome: quorum mode delivers what it promises at ACK time; the PG incremental sync loses no rows; the control merge no longer has the resurrection window.


R2 — Cluster test infrastructure (real partitions, available mode, catch-up)

The proving ground of the R1 fixes and of everything else. Extends bin/cluster, the dedicated compose file and tests/integration/test_cluster.py.

  • Partition helpers in bin/cluster: partition <n> / heal <n> via docker network disconnect/connect (both processes stay ALIVE: that is the difference from node-stop). Required a dual-network compose design: the cluster network carries inter-node + LB traffic and the seed list now uses arca-N-cluster aliases that exist ONLY there, so disconnecting a node severs peer traffic; the never-partitioned mgmt network keeps the test runner connected to every node by its plain service name (without it, the runner would lose the isolated node along with the peers). heal re-attaches WITH the alias (a manual network connect does not restore compose aliases); the node may return with a new IP — peers re-resolve per probe, but HAProxy (startup-resolved) may not track it, so the partition phases are self-contained (fresh up/down -v) and post-heal assertions target nodes directly. Test configs also gained request_timeout_seconds = 5 (not in the drift fingerprint — verified) so fan-out toward an unreachable peer fails fast.
  • Quorum window test (verifies §2.1) — phase G: partition arca-3 → it refuses writes with 503 with no wait after the disconnect, on purpose: within the window where its membership still sees the peers alive, the missing fan-out ACKs produce the 503 (observed: the failing PUT took ~5 s = the fan-out timeout, i.e. the in-window path); reads still served from the isolated node; the majority side keeps writing; heal → the majority-side write converges on arca-3 and a polled PUT proves it accepts writes again (its own membership view lags a few ticks).
  • Available-mode overlay (D8, §5.1)docker/cluster/config-available.toml (mode=available, everything else identical) + docker-compose.cluster.available.yml mounting it on all 3 nodes; phase H: (a) with 2/3 nodes stopped the survivor still accepts writes (same topology where quorum mode 503s in phase C); (b) partition, writes to the SAME key from both sides — both accepted — heal → a single LWW winner everywhere, the loser disappears with no errors (the basis of the §5 doc sentence, to be written in R9).
  • Control-plane catch-up test (§5.2) — folded into the existing lifecycle: phase B (node 3 down) creates cluster-catchup-cp bucket + a credential via the LB (new signed _admin_post helper); phase D polls node 3 directly until both appear via the control-plane reconcile.
  • Flakiness fixes (§5.3): wait_live post-convergence sleep 2 s → 5 s (HAProxy fall 2 inter 2s ≈ 4 s to evict); phase E precondition now requires live_node_count == 3 on /admin/cluster alongside the cluster-min free-space check (arca-3's disk stats are cleared while it is considered dead).
  • 7 new pytest markers registered in conftest.py (cluster_partition_before/minority/healed, cluster_available_full/split/converged/minority); "Integration — HA Cluster" row in README.md updated 11 → 24 (+ totals). Full suite green: 8 phases (A–H), 24/24.

Outcome: every consistency claim is exercised by a reproducible test, partitions included. (Release deferred to plan completion — H11 as revised.)


R3 — Membership and quorum integrity (ghost quorum, guards)

  • D1 / §3.7(A) Authenticate the peer, then gate fan-out AND quorum on it (decision H12): it is not enough to sign our outbound probe — the peer must prove it holds the secret (or presents a cluster-CA cert) before we trust it, else a rogue receives all fan-out with no secret. Implement the chosen H12 path:
    • mutual-TLS path (CONFIRMED 2026-06-11, see H12): → R4 — once R4 wires the shared cluster CA, membership only trusts a peer whose cert validates against it (independent second factor on top of the challenge-response below);
    • secret challenge-response (the baseline peer-auth layer for ALL clusters, plain-HTTP included): new GET /cluster/v1/ping under cluster_auth returning {node_id, config_fingerprint, disk_total, disk_available, max_seq, nonce_mac} where nonce_mac = HMAC(secret, our_nonce) (HMAC-SHA256, domain-separated, constant-time verify) proves possession to us (max_seq also serves D3c in R7, read from the seq counter, not MAX(seq) — purged tombstones would make the row maximum go backwards and false-alarm the rewind detection; new MetadataStore::current_object_seq on both backends). The membership probe sends a fresh UUID nonce per probe and verifies the MAC. As built, the probe distinguishes: valid MAC → authenticated; 200 with missing/bad MAC → alive, untrusted (a rogue answering 200 unconditionally); 403 → the peer rejects our secret: identity recovered via the public health, flagged config_ok=false (this replaces the fingerprint-based detection for wrong-secret drift, since §3.5 removed the public fingerprint); 404 → legacy pre-ping peer (H10), public-health fallback, alive-but-excluded with a once-per-transition warning (consequence, documented in ha.md: during a rolling upgrade the first upgraded node refuses writes until a second upgraded node is up — legacy nodes' own fan-out/anti-entropy keep data converging meanwhile); 409 (loop-prevention LoopDetected) → it is this node itself. Probing is now parallel across endpoints (§2.4 spirit: one dead peer must not serialize the tick).
    • live_peers() (fan-out target list in cluster_blob.rs/cluster_meta.rs) and has_write_quorum count only peers that passed peer-authentication AND are config_okPeerNode::eligible() is the single predicate; it also gates the control-plane fan_out_op, the anti-entropy pulls (manifest, control snapshot, blob repair — pulling from an unauthenticated endpoint is a data-poisoning vector, and a wrong-master-key peer's blobs would be undecryptable), and min_disk (a rogue advertising a tiny disk must not close the 507 capacity guard). /admin/cluster exposes per-node authenticated and the cluster-level eligible_node_count (what the quorum is measured against; live_node_count stays the visibility count).
  • §3.5 / §3.7(B) Minimized public health: the unauthenticated /cluster/v1/health answers only {status, node_id} — the config_fingerprint, disk stats and max_seq move to the authenticated ping. This removes the offline brute-force oracle for the secret (the truncated SHA-256 over mostly-guessable inputs). Consumers updated: membership reads the detail from the ping (and still reads it from a legacy peer's public health during the upgrade window); the console and tests use /admin/cluster, untouched.
  • H7 Drift out of the quorum: a peer with config_ok=false excluded from has_write_quorum (now ClusterState::write_gate() over eligible nodes); it stays in /admin/cluster with the drift flag; test phase F extended: with a diverging secret the node must NOT sustain the quorum (3 nodes, 1 drifted → quorum 2 still ok and writes succeed; 2 drifted, distinct wrong secrets → the aligned node 503s both object writes and bucket creation while reads keep working — new phase F2 with a second drift overlay on arca-2).
  • Failure-detector tolerance (D12.3): dead after 2 consecutive probe failures (DEAD_AFTER_FAILURES in membership.rs, with the why), alive at the first success; within the window the last live view is re-emitted. Dead peers now keep last_seen (= last successful contact) instead of clearing it — the §3.2 guard, M3 pruning and the admin view all reason about how long a peer has been unseen.
  • D3a cluster_size guard (decision H6, fail-closed, no escape hatch): in quorum mode, if the observed ELIGIBLE nodes (including self) exceed cluster_size → write gate closed (WriteGate::SizeExceeded, distinct 503 message pointing at the resize runbook) + size_exceeded: true in /admin/cluster + an error log on transitions (in membership, which owns the tick). Counting eligible — not merely alive — nodes is deliberate: an unauthenticated rogue must not be able to close the gate (write-DoS via mDNS registration), while a same-config 4th node is eligible and trips it.
  • §3.2 Liveness guard on the tombstone GC (anti_entropy.rs): before purge_tombstones/purge_control_tombstones, verify that every known peer has been seen within the grace window (pure tombstone_gc_blockers() in arca-core, unit-tested); otherwise skip the purge with a warning naming the blockers + the tombstone_gc_blocked flag in /admin/cluster. Soundness: a peer seen within the grace was alive after every purge-eligible tombstone's deletion (so it already pulled it); a tombstone recorded while the peer was already unreachable purges only after the peer has been unseen longer than the grace — which is exactly when the guard blocks.
  • M3 Membership pruning (membership.rs): remove peers unreachable for more than [cluster] peer_prune_days (validated ≥ 1; default = tombstone_grace_days); pruning also unblocks the §3.2 guard (a removed node does not block the GC forever); the residual risk of a beyond-grace re-entry is documented (plan note 2-bis + the pruning warn log + ha.md). The guard's memory is process-local (membership state): after a restart, peers that never came back are unknown and cannot block — documented limitation, acceptable because the grace ≫ restart frequency.
  • Unit tests for gates/guards: arca-core (MAC roundtrip/tamper, eligibility, quorum/size gates over eligible peers, min_disk eligibility, GC blockers, snapshot flags, legacy-payload serde) + membership (D12.3 transitions, drift verdicts, end-to-end probe against a fake ping peer with real/bogus MACs) + config (prune default/validation) + sqlite (current_object_seq tracks the counter). 805 unit tests total (was 780). Drift integration extension above.

Outcome: the quorum only counts nodes that can really receive replicas; no more ghost quorum; tombstone GC aware of liveness.


R4 — Inter-node transport security

  • §3.7(C) / TD-015 Verified inter-node TLS with a shared cluster CA (committed, not "evaluate"): the membership probe and ClusterClient reqwest clients verify peers against the cluster CA (added on top of the system roots, so a publicly-signed listener cert also works) and present the node's CA-signed client identity; danger_accept_invalid_certs DROPPED from both (membership.rs, client.rs). This is the H12 path confirmed by Pietro (2026-06-11): verifying the peer's CA-signed cert authenticates the receiver of a fan-out (closes §3.7(A) robustly) AND restores confidentiality against passive sniffing / active MITM on the cluster LAN (§3.7(C)). As built:
    • CA distributed via config: new [cluster.tls] ca_file/cert_file/key_file (all three together); verification in BOTH directions (client verifies server cert, server verifies client cert on cluster routes). Fail closed (Pietro, 2026-06-11): a cluster over HTTPS REFUSES TO START without [cluster.tls] — no insecure fallback, the accept-invalid-certs path is gone from the codebase; plain-HTTP clusters unaffected (R3 challenge-response stays their baseline). [cluster.tls] without [server.tls] is rejected; the combination with the global [tls].ca_file (required-for-all client mTLS) is rejected too — one listener cannot hold two client-cert policies (documented; support deferred until someone needs it).
    • Shipped generator tooling: arca tls generate-cluster --node name=san1,san2,... [--node ...] mints the cluster CA + per-node certs (distinct DistinguishedName CA vs nodes, pem feature, serverAuth+clientAuth EKUs so the same pair serves listener and client identity) and prints the config snippets.
    • Single-port constraint: client cert requested but optional at the TLS layer (WebPkiClientVerifier::allow_unauthenticated) and enforced at the route layer: the tokio-rustls accept loop records the verified presence as the ClusterPeerCertVerified request extension; cluster_auth 403s /cluster/v1/* without it when [cluster.tls] is on. In-process e2e test (real rustls handshake + reqwest): bare-TLS ok+unmarked, CA-signed identity ok+marked, foreign-CA identity fails the handshake, CA-untrusting client refused.
    • K8s-style auto-enrollment rejected (see H12 rationale); CA + cert distribution documented in ha.md (runbook consolidation stays in R9). TD-015 resolved in TECH_DEBT.md + roadmap.
  • §3.1 / §3.7(C) Anti-replay: in cluster_auth.rs, x-amz-date compared with the clock: outside ±15 minutes → 403 (unparseable → 403). Unit tests (fresh/boundary, stale/future, malformed).
  • §3.4 Dedicated body limit: explicit DefaultBodyLimit::max(2 MiB) on the /cluster/v1/{ping,object,object/delete,op,manifest,control-snapshot} routes (NOT on the blob route, which streams to disk).
  • M5 / §3.7(B) Secret strength (security-critical, not cosmetic): startup validation secret length ≥ 16 chars AND reject the shipped placeholders (dev-cluster-secret-change-me, CHANGEME-CLUSTER-SECRET); low-entropy heuristic warning (< 8 distinct chars or a single character class — emitted at cluster startup: config loads before tracing init); docs say openssl rand -hex 32. Dev/test cluster configs updated to a floor-compliant dev secret; deploy manifests keep CHANGEME-CLUSTER-SECRET, which now fails fast with a clear error until replaced.
  • M6: Uuid::parse_str on the path blob_id in the cluster handlers before write_raw/read_raw (defense in depth; the raw string is kept verbatim as the blob id — no canonicalization).
  • D12.1 Receive-side filter: ServerConfigSet/Delete application in handlers/cluster.rs drops node-local keys as a no-op 200 with a warning; the predicate moved to arca_core::cluster::is_node_local_server_config_key (with NODE_ID_KEY), shared by sender and receiver.
  • H8/D3b Dual-secret for rotation: optional [cluster] secret_previous; inbound auth tries current then previous (full constant-time verification each), outbound and fingerprint use only secret; config validation (≥ 16 chars for previous too; equal to current rejected). As built, the ping handler MACs the challenge with the secret that verified the request (stashed by cluster_auth as the MatchedClusterSecret extension), so a prober on either rotation side can always verify the response with its own current secret; the prober additionally accepts a MAC keyed by either rotation secret (belt-and-braces for mixed-version echo variants). Rotation window documented in ha.md (transient drift flags — the fingerprint includes the secret); runbook consolidation in R9.

Outcome: an inter-node surface with authenticated peers (verified TLS), a replay window, body limits, strong-secret enforcement and secret rotation without downtime — closing the §3.7 security chain together with R3. Note: the cluster integration suite runs plain-HTTP, so the mTLS chain is covered by the in-process e2e handshake test (and unit tests); a TLS-cluster compose overlay remains a possible §5.4 extension.


R5 — Reconcile completeness (TD-016, multipart, SSE-C)

  • D9 / TD-016 — extend snapshot + tombstones to the missing families:
    • migrations sqlite v22 / pg 0010: updated_at added to user_grants, team_grants, team_members, bucket_tags (backfill = now; bucket_config and server_config already carried it). Maintained in all write paths — attach/add refresh updated_at even on the idempotent re-attach (ON CONFLICT DO UPDATE), so a re-attach made while a peer concurrently detached beats the detach tombstone — plus new timestamp-preserving apply_*_at methods for the merge (no now() re-stamp → fixed point, no flapping).
    • control_tombstones on detach/remove/delete, recorded by the cluster decorators (origin-side, like the existing families): grant attachments and memberships under [pair_key] composite keys (user:grant, team:grant, team:user), bucket_config under bucket:key, bucket_tags keyed by bucket name ALONE — the whole tag SET is one LWW entity, matching the replace-all semantics of PutBucketTagging (per-key tombstones could not represent "key dropped by a replace"); an empty-set put records the tombstone like an explicit delete — and server_config keys (node-local ones excluded). Set/attach paths clear stale tombstones symmetrically.
    • ControlSnapshot extended with #[serde(default)] fields (H10: a pre-R5 snapshot reads as "no information", never as "everything deleted" — pinned by a serde unit test); build_control_snapshot on BOTH backends; node-local server_config keys excluded at build AND ignored on apply (triple defense with D12.1).
    • plan_control_merge extended per family with parent-dead filtering: parent families (users, teams, grants, buckets, multipart uploads) record which keys stay alive after resolution, and child upserts (attachments, memberships, bucket config/tags, parts) whose parent resolved dead are skipped. This REPLACES per-child cascade tombstones (the local cascade deletes children on every node; the filter stops a stale peer's child rows from resurrecting) and respects the join-table FK constraints on PostgreSQL. Apply order: parents before children. Unit tests per family (detach-vs-stale-attach, re-attach-beats-tombstone, parent-dead filter, set-level tags, node-local skip, legacy snapshot).
    • R2 catch-up integration test extended (phases A/B/D): a grant attachment seeded on all 3 nodes is revoked while node 3 is down → gone on node 3 at re-entry (and not resurrected); a versioning flip + a bucket-tag change while down → reconciled.
    • TD-016 closed in TECH_DEBT.md + roadmap; the TECHDEBT(TD-016) marker on reconcile_peer_control removed.
  • D4 Multipart:
    • multipart_uploads + parts in the reconcile snapshot (upload keyed by upload_id, alive ts = initiated_at, immutable rows; parts keyed upload_id:part_number, LWW on last_modified, no part tombstones — a part disappears only with its upload or by replacement under the same key); multipart tombstone recorded on delete_multipart_upload (BOTH Complete and Abort end there) so a closed upload cannot resurrect. Applied via the cache-aware metadata handle in reconcile_peer_control (like buckets), reusing apply_remote_multipart_upload/put_part/delete_multipart_upload verbatim (rows carry their own timestamps — no _at variants needed).
    • ClusterBlobStore::concat: pre-checks each part sidecar and repairs missing parts from peers (shared repair_from_peers, the read-repair path) before delegating to inner.concat. Unit test with a fake blob peer (sidecar in the signed header + raw bytes).
    • integration tests (cluster suite): an upload seeded with all 3 up is aborted while node 3 is down → closed on node 3 at re-entry AND not resurrected on nodes 1/2; an upload begun while node 3 was down is completed directly on node 3 (upload + part rows via the snapshot, part bytes fetched from a peer by the concat pre-check).
  • N1 Object-lock changes invisible to anti-entropy (found during R1, not in the review): both backends' set_object_retention / set_object_legal_hold now stamp a fresh seq (taken before the row UPDATE, per the lock-order rule) so the changed-since manifest re-delivers the row; the peer's apply_remote_object equal-tuple >= guard accepts it. Unit tests on sqlite (plain + versioned branch); integration test: a retention change with node 3 down → visible on node 3 at re-entry.
  • M7 Manifest churn: apply_remote_object (sqlite and pg) now skips identical incoming rows without a rewrite or a fresh seq — comparison via ObjectRecord::same_replicated_content (PartialEq on normalized clones, is_latest excluded as locally-derived state; a future field joins the comparison automatically). Without this, two caught-up nodes redelivered their whole object tables to each other on EVERY anti-entropy pass (each apply re-stamped a seq the peer then saw as new) — M7 was load-bearing, not cosmetic. The equal-tuple guard still lets lock-only changes through (N1 interplay, unit-tested).
  • §3.6 SSE-C in the cluster — spike outcome: FIXED in-scope, no TD-017 needed. The spike found SSE-C blobs already replicate: the handler's sidecar write goes through the cluster-wrapped BlobStore, whose fan-out ships the customer-key-encrypted on-disk bytes verbatim (peers never see the key), and the proactive blob repair is encryption-agnostic. The real gap was the READ side: get_with_key opens the blob file directly, bypassing the cluster wrapper's read-repair — a node holding the row but not the bytes errored until the next anti-entropy pass. Fix: ClusterSsecBlobStore wraps SsecBlobOps when clustering is enabled, adding the same synchronous read-repair as the plain path (e2e unit test: encrypt on origin, repair ciphertext from a fake peer, decrypt locally).
  • D12.2 Export/import and node_id: the export filters node-local keys out of settings AND the import skips them with a warning even when present in an old document (double defense); integration test in the export/import suite.

Outcome: all managed state converges after an absence, multipart included; TD-016 resolved; SSE-C read-repair shipped (no TD-017). Bonus finding fixed: the M7 churn was a perpetual full-table redelivery loop between caught-up nodes.


R6 — Cluster-aware workers (leader gate)

  • §3.3 / H5: ClusterState::is_worker_leader() (arca-core — the codebase has no ClusterContext; ClusterState is that object) = own node_id is the lowest among the eligible nodes (including self); always true on single-node. Eligible — not merely alive, refining the original H5 wording — for the same reason the quorum gates on it: an unauthenticated rogue (or a drifted peer) with a low node_id must not steal the role and silence the workers cluster-wide. Exposed as worker_leader on /admin/cluster and /admin/health?verbose=1 (in a stable cluster exactly one node claims it; the R6 tests and the runner's leader discovery consume it, the console can in R8).
  • Lifecycle worker (worker.rs): in a cluster, only the leader runs the tick (debug-level log when skipping). The deletes it produces stay replicated/tombstoned as today. New optional [lifecycle] interval_seconds TOML knob (default 3600, the previous hardcoded value; validated ≥ 1) — the test configs need a 5 s evaluation cadence, and the interval was not configurable at all.
  • Phase 28 replication worker — NOT gated (decision confirmed by Pietro 2026-06-12, deviating from the original plan): the review's §3.3 premise ("every node has its own journal and tries to replicate the same objects → duplicate deliveries") does not match the code: the journal is strictly node-local — entries are inserted only by maybe_emit in the S3 handlers of the node that served the client write; the cluster receive paths (/cluster/v1/*, anti-entropy apply) never emit, and the journal is not among the replicated families. Each S3 write is therefore journaled exactly once cluster-wide and duplicate deliveries cannot occur, with no leader needed; gating the worker would instead ORPHAN the entries journaled on non-leader nodes. Decision recorded in the worker's module doc; real RPO caveat documented in ha.md: a node lost for good takes its pending journal entries with it (a plain restart loses nothing — the journal is on disk).
  • Audit of the other workers (recorded in the worker.rs module doc): retention purge, metrics snapshot, notification delivery = NOT gated (they operate on node-local tables/events by design; the notification queue is fed only by the S3 handlers of the serving node, so deliveries are already exactly-once); the lifecycle's abort-incomplete-multipart = gated with the lifecycle (same tick).
  • Tests: arca-core unit tests (single-node, lowest-wins, failover-to-next-lowest, ineligible-lower-peer ignored, snapshot flag) + config validation; new cluster phase I (bin/test cluster, +4 tests → 41): with a short-interval lifecycle rule (past-Date expiration — Days: 0 is rejected) the expiry happens exactly once cluster-wide (sum of Lifecycle::ExpireObject audit entries across all nodes == 1; the audit log is node-local, peers apply the replicated delete without auditing) and exactly one node claims worker_leader (the lowest node_id); then the runner discovers the leader via verbose health, stops THAT container (node ids are random — the leader's index differs per run), and the failover sub-phase verifies a single new leader among the survivors and a second expiry, again exactly-once. The runner also gained dump_logs_on_failure: any failed cluster phase preserves the node logs to /tmp BEFORE down -v destroys them (the R5-known M1-class flake recurred during the R6 run and the evidence was lost again; this was pencilled for R7 and pulled forward).

Outcome: no work duplicated N times, with automatic failover of the role; external replication deliveries shown to be exactly-once by construction (per-node journal), not made so by a gate.


R7 — Synchronization state and operability

  • D2 "Syncing" readiness: ClusterState (the codebase's ClusterContext) tracks, per peer, the completion of the FIRST full reconcile pass since startup (objects drained to the manifest end + control snapshot merged, recorded by the anti-entropy worker as PeerSyncStatus.first_pass_done); until it is complete toward every eligible peer (not merely live — a rogue/drifted peer is never reconciled from, so requiring a pass toward it would deadlock readiness), the plain /admin/health answers 503 {"status":"syncing"} + Retry-After: 5 (drain takes precedence; no eligible peers → degraded ok, documented in ha.md; ?verbose=1 stays 200 with status: "syncing"). The LB and the k8s probes see it → a realigning node receives no traffic until it is coherent. As built, the per-peer HWM moved from the worker's local map into ClusterState so the endpoints can expose it; sync entries are pruned with membership (M3) and survive a peer's death (a returning peer resumes incrementally). Documented consequence: a brand-new peer joining flips the established nodes to syncing for up to one anti-entropy tick. Integration: new phase J (bin/test cluster) — a node restarted after downtime holds 503-syncing (observed by polling started right after start, no wait_live) and the catch-up object must be readable the INSTANT the health turns 200 (readiness implies correctness, no polling allowed); wait_live also gained an LB-routes-200 guard (on a cold cluster start ALL nodes briefly sync → HAProxy could have zero backends when tests begin).
  • Exposed lag: /admin/cluster with, per peer, sync: {hwm, lag, last_reconcile, first_pass_done, skipped_entries} (lag = peer's ping-reported max_seq − HWM) + the node-level syncing flag (also in the verbose-health cluster snapshot).
  • D3c Rewind detection: the ping's max_seq now travels ContactInfo → PeerNode.max_seq (None for legacy/dead peers); per-tick check via the pure sync_rewound() (arca-core) → HWM reset to 0 + warning. As built, with a freshness guard beyond the plan: probe and reconcile run on independent cadences, so the report must be NEWER than the last HWM advance (peer.last_seen > hwm_at) — otherwise a peer under sustained writes false-alarms with a stale ping taken before the rows we just pulled (a genuine restore keeps reporting the rewound counter, so detection is only deferred to the first post-reconcile probe, never lost).
  • M1 Stuck HWM: after K=5 consecutive passes failing on the same seq (STUCK_SKIP_AFTER, pure StuckTracker unit-tested per peer/reset/progress), the entry is skipped with a warning + skipped_entries per peer in /admin/cluster. apply_entries/reconcile_peer_objects now return the failing seq alongside the cursor.
  • M2 Repair budget: repair_blobs with a per-tick budget (new [cluster] blob_repair_budget, default 100, validated ≥ 1) + a resume cursor (sorted blob-id order; None = sweep complete). Budget counts peer FETCHES (the local existence stats are cheap); checked only BETWEEN blob ids so one composite is always processed to completion (bounded overshoot — a composite with more unfetchable parts than the whole budget cannot stall the sweep's progress). A mid-flight sweep continues on EVERY tick (only complete sweeps wait for the slow 10-tick cadence), so a backlog drains at budget/interval. Unit test with a fake blob peer: budget 2 over 4 missing blobs → exactly 2 fetched + cursor, resume completes.
  • M4 Retry-After: verified and pinned by unit tests in error_response.rs (every ServiceUnavailable carries Retry-After: 5; non-503s carry none — covers quorum, size-gate and any future cluster 503 in one place); the new syncing health 503 sets it explicitly.
  • M8: tracing::warn! on the mtime→now fallback in fs/blob.rs (walk_files), naming the file and the consequence (a filesystem without mtimes never reclaims blobs).
  • N2 — equal-timestamp LWW clobber erases Object-Lock state cluster-wide (found during R7; THE root cause of the known phase-D retention flake, which was previously mislabeled M1-class). Lock changes (N1) keep last_modified unchanged (S3 semantics), so two copies of the same version can differ ONLY in lock state while their whole LWW key ties — and the N1 >= equal-tuple guard passed the tie in BOTH directions: whichever copy was applied LAST won. Reproduced and confirmed at the bit level (rowid/seq-counter forensics on the preserved DBs): node 2 serves the retention PUT while node 3 is down (lock row stamped, audit-confirmed); at phase-D re-entry node 2 restarts, its in-memory HWM resets to 0, it re-pulls node 3's FULL manifest and re-applies the STALE lock-free copy over its own newer lock state (DELETE+INSERT, rowid reused = max+1, fresh seq); the fresh seq then propagates the regression — node 1 sees the clobbered row as "new" and loses its copy too. Final state: retention silently GONE on all three nodes (data loss of WORM state). No apply ever failed, so the M1 skip could never have fixed it. Fix: a dedicated LWW dimension for the lock state — new objects.lock_updated_at column (sqlite migration v23 / pg 0011, NULL = never changed; ObjectRecord.lock_updated_at serde-default → H10-additive on the wire), stamped by set_object_retention/set_object_legal_hold on both backends; apply_remote_object guards become strictly newer last_modified, OR tie + strictly newer lock_updated_at (versioned branch; the null-version register adds it as the final tiebreak after blob_id, whose own >= had the same bidirectional hole). Option ordering (None < Some) makes any post-upgrade lock change beat a never-locked copy; same_replicated_content includes the new field automatically (M7 no-op intact). Unit tests: stale-copy-cannot-clobber (versioned + null-version), lock-only change still applies, both mutations stamp. The phase-D retention integration test is the e2e regression test — it caught the bug.
  • Console topology card, minimal per plan: an amber "syncing" notice (exact replica of the config-drift warning pattern) + per-node amber sync lag N / N skipped lines (shown only when non-zero) in the node list's right column.

Outcome: a re-entering node serves no wrong answers; the operator sees lag and anomalies; the worker neither stalls nor monopolizes.


R8 — Console: per-node views behind the LB

Decision H9 confirmed by Pietro (2026-06-12) on all three axes: server-side proxy; all FOUR node-local view families (the replication journal joins the original three); an "All nodes" merged option in the selector.

  • D6 (decision H9): a ?node=<node_id> parameter on the four node-local admin endpoint families — /admin/audit, /admin/metrics/history, /admin/notifications/events, and /admin/replication/journal. As built: when present and ≠ self, the query is proxied server-side to the peer's new POST /cluster/v1/admin/{audit,metrics-history,notification-events,replication-journal} receive routes (behind cluster_auth + the R4 mTLS marker, 2 MiB body cap; the filter travels as a JSON body like the manifest request — never a signed query string). The transport is a new ClusterAdminProxy trait in arca-core implemented over ClusterClient in arca-server (same dependency rationale as RawBlobOps). Only ELIGIBLE peers are valid targets (every H12 gate): unknown node → 404, known-but-ineligible → 503, unreachable → 502, and a peer's own 4xx (e.g. "audit not enabled there") forwards with its original status and message. Every response carries a top-level node label — including the default LB path, so the operator always knows which node answered. The receive handlers reuse the exact local-page functions of the admin handlers (single source of truth), so a forwarded query can never re-proxy (the node field is skip_serializing).
  • ?node=all merged view: the entry node fans the same query out to every eligible peer in parallel (plus itself), merges the rows by timestamp — parsed, not string-compared (sub-second precision would order wrongly) — newest-first, labels each row with its source node_id, and reports a per-source sources array (total, or error for a failed source: a partially-failed fan-out is visible, never silently smaller). Pagination is per-source-page (the merge keeps the newest page-size rows across nodes); the approximation is documented in the API/manual rather than papered over with cross-node cursors. A dead peer is excluded from the fan-out (eligible-only), not reported as an error source.
  • Console: a node selector in the audit/monitoring/events/replication-journal views via a shared nodeSelectorMixin (console/js/node-selector.js), populated from /admin/cluster (eligible nodes + "All nodes"; hidden entirely when clustering is off; a stale selection falls back to the LB default). Default "This node (via LB)" with the responding node_id always visible as a colored mono badge ("via 1a2b3c4d"); in the merged view every row shows its source-node badge (one stable color per node) plus a "N nodes merged" chip and a red "N failed" chip with per-source errors in the tooltip. Monitoring draws ONE SERIES PER NODE in the merged view (x-axis re-mapped by timestamp instead of sample index, per-node legend). Coherence sweep: detail side panels gained a Node row; Clear All (audit/events/journal) and journal Retry are disabled with an explanatory tooltip while a node is selected — those mutations run on the node serving the request, not the viewed one. Gotcha pinned in the mixin: views spread it into their x-data literal and object spread copies a getter's VALUE at spread time, so the mixin exposes plain methods, never getters. Verified live against the 3-node compose cluster with Playwright (specific node, merged view, dead-node degradation).
  • Screenshots + console manual: documentation/docs/guide/console.md gained a "Per-node views in a cluster" section (manual text; the screenshot pipeline runs single-node where the selector is hidden, so the automated captures are unchanged); the ?node= API and the per-source-page approximation are documented in ha.md. Integration coverage: new bin/test cluster phase K (8 tests, markers cluster_node_views_full/degraded) — per-node audit trails really come from THAT node, self short-circuit, merged ordering/labels/sources, 404 unknown, all four families proxied, 503 dead target, merged shrink to eligible sources; audit+metrics enabled in the cluster test config (not in the drift fingerprint). 15 new unit tests (selector resolution, merge ordering/truncation/failed-source, peer-error mapping).

Outcome: the operator always knows WHICH node they are looking at and can pick it (or see all of them merged), without depending on round-robin luck.


R9 — Documentation, deploy and closure

Two R9 decisions taken with Pietro (2026-06-12): §5.4 leftovers = real integration tests (not doc-only) and D5 write-aware health = IMPLEMENTED (not doc-only) — both options the plan had left open. The final version (H11) is v0.26.0.

  • D5 write-aware health (implemented, upgrading the doc-only option): GET /admin/health?writable=1 answers 503 {"status":"read_only"} (+ Retry-After) while the cluster write gate is closed (no quorum / size exceeded); single-node and available mode always 200. Status precedence draining > syncing > read_only in the pure plain_health_status (unit-tested); both HAProxy cfgs ship a commented arca_writable backend + frontend ACL routing write methods to it; integration asserts in phases A (writable 200 everywhere) and C (the lone survivor: plain 200, writable 503 read_only).
  • §5.4 leftover tests — new cluster phase L (gc overlay: config-gc.toml + docker-compose.cluster.gc.yml, identical config except the new [cluster] tombstone_grace_seconds = 20, an advanced seconds-granularity override of the day-based grace added for exactly this — validated ≥ 1, documented as a testing knob): (a) proactive blob repair — the seeded object's payload file is deleted straight from arca-3's volume and must reappear via the proactive sweep, polled SHELL-SIDE on the volume (a throwaway alpine container) because any client GET would trigger the lazy read-repair and mask the sweep under test; then the restored blob must serve intact bytes; (b) §3.2 GC liveness guard — an object deleted while arca-3 is down beyond the grace must raise tombstone_gc_blocked on BOTH survivors (purge skipped, tombstone retained), and at arca-3's return the deletion is learned (object 404 on all three after full cycles — the resurrection the guard prevents) and the guard releases. 5 new markers; runner orchestration with the volume-side delete/poll between pytest steps.
  • ha.md (source + docs/ via bin/docs-build):
    • the available-mode §5-doc1 sentence verbatim (both acknowledged, only the LWW winner survives, the loser discarded with no error to the client) + D11 single-copy RPO window, both in the available bullet of the Consistency section (the post-R1 quorum semantics were already rewritten in R1; R9 left them as-is).
    • D7: the read-after-write note replaced by a "what quorum mode does and does not promise about reads" block — R=1 never intersects the write majority by construction; CP = no conflicting writes, NOT read linearizability; the staleness window is fan-out lag in normal operation but the partition's WHOLE duration on a minority node; sticky (balance source) as the commented reference in both shipped cfgs.
    • D5: "a read-only node stays in rotation — and what that costs" in the Load balancer section: ~1/N client-visible write 503s while degraded, SDK-retry mitigation, and the now-implemented ?writable=1 write pool.
    • D10: "Object Lock (WORM) in a cluster: the trust model" section — verbatim remote applies (no receive-side lock re-check), compliance immutability rests on secret + CA key + OS access on every node (each one a full copy), posture recommendations; supersedes the TD-015 link (resolved in R4 — mTLS is part of the posture).
    • §3.6 SSE-C: nothing to add — fixed in R5 (read-repair via ClusterSsecBlobStore), already documented in the prerequisites.
    • Operational runbooks (D3, §5-doc2): new top-level section — replacing a dead node (empty disk safe by construction, syncing gate, the lingering dead entry blocking GC until prune is expected); restore from backup (automatic D3c rewind handling; backup older than the grace → do NOT restore, use empty-disk replacement); cluster resize (cold procedure, why rolling cannot work — mixed quorums by construction — and the D3a guard failing closed); secret rotation (the existing H8 section moved under the runbooks); coherent backups (FS snapshot crash-consistency vs stopped-node copy, WAL caveat, PG backend, one-node-backs-up-the-dataset); forming a cluster from non-empty nodes (D12.4: union+LWW merge with silent losers, same-master-key prerequisite, discouraged — survivor + S3-level copy as the alternative).
    • clock-skew honestly declared a test gap (the §5.4 "skew = doc" half) in the Clock dependence trade-off; ?writable=1 and tombstone_grace_seconds added to the Observability section / TOML reference + configuration.md.
  • Deploy: fall/rise motivated rather than flattened — test/demo cfg keeps fall 2 rise 1 (the R2 wait_live calibration depends on ~4s eviction) and says so; production cfg keeps fall 3 rise 2 and says why; the ha.md snippet now matches the PRODUCTION values (it is production guidance). Commented sticky balance source + write-aware backend in both cfgs. K8s: readiness periodSeconds: 5, failureThreshold: 2 + comment that readiness reflects syncing (D2); stale "always 200" comment rewritten; bonus fix: the liveness probe moved to ?verbose=1 (always 200 on a live process) — sharing the readiness path would get a pod KILLED during a long catch-up (3 × 30s of 503-syncing), a real bug introduced by R7's semantics and never aligned here.
  • TECH_DEBT.md + roadmap: verified already in sync (TD-015/TD-016 resolved in R4/R5 in both places; no TD-017 — the §3.6 spike fixed SSE-C in scope); nothing to change.
  • Roadmap: R9 milestone line updated to the as-built scope (write-aware health + §5.4 tests, not just docs), R9 ticked, Phase 29.1 marked complete (progress bar, phase summary table with v0.26.0).
  • Review: resolution note at the top pointing to this plan and the per-finding traceability table.
  • README: Test Coverage table updated (+4 unit → 878: 3 health-status + 1 config-knob, +7 cluster → 59, totals).
  • CHANGELOG: Unreleased consolidated into v0.26.0 (decision H11: the single release closing R1–R9); versions synced (Cargo.toml workspace, Cargo.lock, console, roadmap) per RELEASING.md.

Outcome: the system declares exactly what it does, the operator has the runbooks, the debt is tracked, the v0.26.0 release closes the remediation plan.


Finding → milestone traceability

Update the Status column as work proceeds: ⬜ to do, 🔧 in progress, ✅ done, ➖ decided not to do (with a note).

Finding Short description Milestone Status
§2.1 Quorum = admission gate, not a write quorum R1
§2.2 seq cursor race on PostgreSQL R1
§2.3 Deletes before tombstones in the control merge R1
§2.4 Sequential fan-out R1
§3.1 No anti-replay window R4
§3.2 Tombstone GC blind to liveness R3
§3.3 Workers duplicated on every node R6 ✅ (lifecycle leader-gated on the lowest ELIGIBLE node_id; metrics/retention/notifications not gated by design — node-local state; the replication-worker half of the finding was a wrong premise: its journal is node-local, deliveries were already exactly-once, gating would orphan non-leader entries — confirmed by Pietro 2026-06-12)
§3.4 Cluster endpoints without a body limit R4
§3.5 Public health exposes disk/fingerprint R3
§3.6 SSE-C not replicated R5 (spike) + R9 (doc) ✅ (spike outcome: replication already worked via the cluster-wrapped sidecar write; the read side gained ClusterSsecBlobStore synchronous read-repair — no TD-017)
§3.7(A) Rogue peer receives all new data with no secret (fan-out authenticates no peer) R3 (H12: peer auth, gate fan-out+quorum) + R4 (mutual TLS) ✅ (R3: challenge-response peer auth gates fan-out, anti-entropy pulls, quorum and min_disk; R4: mutual-TLS second factor — client certs enforced on /cluster/v1/*)
§3.7(B) Secret brute-forceable from public fingerprint; weak secrets allowed R3 (§3.5 fingerprint off public) + R4 (M5 secret strength) ✅ (R3: fingerprint off the public health; R4: M5 floor + placeholder rejection + entropy warning)
§3.7(C) Plain-HTTP/unverified-TLS/no-replay enable sniff/MITM/replay R4 (TD-015 verified TLS + §3.1 anti-replay) ✅ (verified mTLS mandatory over HTTPS; ±15 min replay window; plain-HTTP remains an explicit operator choice documented for trusted segments only)
TD-015 Inter-node TLS accepts invalid certs (now committed, not deferred) R4
M1 HWM stuck on a failing entry R7 ✅ (skip after 5 consecutive failures on the same seq, warning + per-peer skipped_entries in /admin/cluster)
M2 Repair without a budget R7 ✅ ([cluster] blob_repair_budget default 100 fetches/tick + resume cursor; mid-flight sweeps continue every tick)
M3 Membership without eviction R3
M4 503 without Retry-After R7 ✅ (pinned by unit tests on s3_error_response: every ServiceUnavailable carries it, non-503s do not; the syncing health 503 sets it explicitly)
M5 1-character secret accepted R4
M6 Path blob_id not validated R4
M7 seq churn on identical rows R5 ✅ (identical-row no-op in apply_remote_object, both backends — the churn was a perpetual full-table redelivery ping-pong between caught-up nodes)
N1 Lock changes (retention/legal-hold) don't bump seq → invisible to anti-entropy (found during R1) R5 ✅ (fresh seq stamped in both backends' lock UPDATEs + catch-up integration test)
N2 Equal-timestamp LWW clobber erases lock state cluster-wide: the N1 >= guard passes the tie in both directions, so a restarted peer's stale full-manifest re-pull silently deletes retention/legal-hold everywhere (found during R7 — the real root cause of the phase-D retention flake) R7 ✅ (lock_updated_at LWW dimension on both backends — sqlite v23 / pg 0011 — stamped by lock mutations, strict tiebreak in apply_remote_object; unit + phase-D integration regression)
M8 Silent mtime fallback R7 ✅ (warning on the fallback, naming the file and the never-reclaims consequence)
§5.1 No available-mode test R2
§5.2 No control-plane catch-up test R2
§5.3 Latent flakiness (wait/phase E) R2
§5.4 Listed test debt (GC, repair, bootstrap, skew) R2 (bootstrap in the R7 test) + R9 (GC + repair tests; skew = doc) ✅ (R9 phase L: proactive blob repair proven volume-side without a masking GET + §3.2 GC guard blocked/no-resurrection/release, on a gc overlay with the new tombstone_grace_seconds knob; clock skew declared a test gap in ha.md — Pietro chose real tests over doc-only, 2026-06-12)
§5-deploy Inconsistent HAProxy fall/rise; k8s probes R9 ✅ (fall/rise motivated per environment + ha.md snippet aligned to production values; sticky + write-aware backends commented in both cfgs; k8s readiness 5s/2 + liveness moved to ?verbose=1 so a long catch-up cannot get the pod killed)
§5-doc1/2/3 Available LWW, runbooks, SSE-C/quorum R9 ✅ (LWW loser-discarded-silently sentence + RPO in the available bullet; six operational runbooks; SSE-C and quorum semantics were already documented by R5/R1)
D1 Ghost quorum (unauthenticated liveness, drift ignored) R3
D2 No syncing state (404s/partial listings at re-entry) R7 ✅ (first-pass readiness gate on /admin/health, per-peer sync detail in /admin/cluster, console notice, phase J integration test)
D3a No nodes > cluster_size guard R3
D3b Secret rotation without dual-secret R4 (+ runbook R9) ✅ (dual-secret shipped + ha.md rotation section; R9 consolidates the runbooks)
D3c Restore from backup: seq rewind vs HWM R7 (+ runbook R9) ✅ (ping max_seqPeerNode, sync_rewound() with a ping-fresher-than-last-advance guard, automatic HWM reset; restore runbook stays in R9)
D4 Multipart without reconcile; local-only concat R5 ✅ (uploads + parts in the snapshot, multipart tombstone on Complete/Abort, concat repairs missing parts from peers; integration: abort-while-down closes everywhere, Complete succeeds on the returned node)
D5 LB blind to writability (client-visible 503s) R9 ✅ IMPLEMENTED (Pietro, 2026-06-12): ?writable=1 → 503 read_only while the write gate is closed, commented write-pool backends in both cfgs, cost+mitigation documented in ha.md; integration asserts in phases A and C
D6 Console incoherent on per-node views R8 ✅ (?node= server-side proxy on all four node-local families + all merged view with per-row source labels; console selector in the four views, mutations gated off proxied views; 8 integration + 15 unit tests)
D7 Quorum reads weaker than the CP label R9 (doc + sticky reference) ✅ (CP = no conflicting writes, not read linearizability; partition-long staleness on a minority node stated; sticky balance source commented in both cfgs and named the reference in ha.md)
D8 No real-partition test R2
D9 TD-016 underestimated (RBAC/bucket_config) R5 ✅ (all 6 families in the snapshot merge with LWW timestamps + tombstones; parent-dead filtering instead of cascade tombstones; TD-016 resolved)
D10 WORM in cluster: trust model undocumented R9 ✅ (dedicated ha.md section: verbatim remote applies, secret/CA/OS-access surface, compliance posture recommendations)
D11 Available RPO undeclared R9 ✅ (single-copy durability window named RPO > 0 in the available bullet, with the when-to-choose-it guidance)
D12.1 Receive side without a node-local key filter R4
D12.2 Export/import rewrites node_id R5 ✅ (export omits node-local keys, import refuses them — double defense + integration test)
D12.3 Failure detector without tolerance R3
D12.4 Non-empty single-node merge undocumented R9 ✅ (runbook: union+LWW with silent losers, same-master-key prerequisite, discouraged + survivor alternative)

Estimate and sequence

R1 ≈ 2-3 d · R2 ≈ 1-2 d · R3 ≈ 1-2 d · R4 ≈ 1 d · R5 ≈ 2-3 d · R6 ≈ 1 d · R7 ≈ 1-2 d · R8 ≈ 1 d · R9 ≈ 1 d → ~11-16 effective days. The R1→R9 order is binding only where there is a technical dependency (R2 verifies R1; R3 provides the ping used by R7; the rest can be reordered if needed).

Out of scope (explicitly deferred)

  • Read-quorum / linearizable reads (stays future hardening, as per the Phase 29 plan).
  • HLC instead of NTP+LWW.
  • ~~A write-aware health check~~ — implemented in R9 (?writable=1, decision of 2026-06-12); what stays out of scope is only making it the LB default (the shipped configs keep the read-friendly default check, write pool commented).
  • Automated clock-skew tests (documented as a limit, the NTP constraint is already in ha.md).
  • Erasure coding, sharding, gossip: out of scope as per the Phase 29 plan.