`cargo test --workspace` could not run at all: dependency resolution failed with "aws-types@1.3.16 requires rustc 1.91.1" on the 1.91.0 default toolchain, so the gate the project documents was dead. Making it run exposed a compile break and two wrong tests that had been invisible for months. Now green end to end: 143 suites, 3155 tests, exit 0. Toolchain - rust-toolchain.toml pins the DEV toolchain to 1.91.1. The published MSRV stays `rust-version = "1.91"` (the engine builds on 1.91.0); only tidalctl's AWS SDK chain needs the patch release, and it now declares that itself. Consumer crates migrated to the current engine API (clean cutover) - iknowyou-engine: `AgentPolicy` gained five m10 read/profile-override fields; the literal now spreads `..AgentPolicy::default()` as the engine's own doc example does, so future fields do not break it again. - forage-engine: `RetrieveResult` gained p1 `reasons`. The app builds its own candidate pool, so it now tags what it knows: PreferenceMatch for the preference-vector blend, SemanticMatch (with the seed item) for similar-to-saved, ExplorationBudget for pinned discoveries. - forage-engine: `url_to_item_id` folded into the u32 item universe. The engine narrows item IDs to a u32 slot in durable per-user state and rejects anything above u32::MAX rather than alias two items forever, so every add_item with a 64-bit FNV hash failed. 9 of 28 smoke tests were failing on this alone. - forage-engine: bridge items read the top-2 preference CLUSTERS via `query_vectors`, not the single centroid from `preference_vectors().get()`. Since m12 that accessor returns only the strongest cluster, so a tech+jazz user whose interests split into two clusters looked single-interest and never bridged. Falls back to top-2 dimensions when a user has one cluster. Reconcile tests corrected to the shipped contract - tidal/tests/m8p3_reconcile_production.rs asserted `3 + 5 == 8` for a windowed count after heal. `take_crdt_snapshot` deliberately keys signal contributions to ONE canonical contributor (ShardId::SINGLE) because signals are relayed from a single writer, so per-node attribution double-counted every replicated event on every reconcile. Merge is therefore LWW on (last_update_ns, score) plus PN-counter per-node max: nodes converge on the more complete accumulator. The old expectation was asserting the bug that fix removed. - Rewrote to assert convergence, count survival (not 0), and no inflation, and added `repeated_reconcile_of_converged_nodes_does_not_creep` - the regression guard for the creep itself, which nothing covered. Pre-commit hook unified - hooks/pre-commit dropped `-D warnings`: each crate's `[lints]` table is the source of truth (`clippy::all`/`unwrap_used` deny, `pedantic` warn), and the flag promoted ~58 deliberate pedantic warnings in integration tests to errors, making every Rust commit impossible. - It now lints all five tidal crates instead of path-matching `tidal/`, which silently skipped tidal-server, tidal-net, tidal-stress, tidalctl and applications/ - the rot above lived in exactly those crates. Ported the CODING_GUIDELINES file-length, println, and unsafe-SAFETY checks from the divergent untracked copy that this replaces. - CONTRIBUTING.md now documents the real commands and the toolchain/MSRV split. Fleet recovery and soak - scripts/restore-fleet.sh: the fail-closed selective restore, promoted out of an ignored tmp/ directory into the repository. Preflights retained storage, digest-pinned images, parked state, and aggregate plus per-PV-node scheduler headroom before the first scale; writes a durable transcript under tmp/restore-logs/ with structured start/error/rollback/complete events. - k8s manifests park the standalone store, the RF3 cluster, and the soak monitor at zero replicas with restore-fleet.sh as the only supported scale-up path. - soak-eval/soak-watch and the nightly CronJob fail closed on stale or missing restart evidence instead of silently skipping the restart-aware half of the gate. - docs/ops/capacity-planning.md corrects the RAM envelope to the real hot-tier formula and separates analytic totals from the measured process envelope.
16 KiB
m11p9 — Continuous Correctness (COMPLETE — 2026-06-13)
Phase spec and exit gate: docs/roadmap-to-cluster.md §4/m11p9. Closes the roadmap's G-C (Continuous correctness) — trust is a pipeline, not a milestone — and wires the named owner-test for every other §2 guarantee (guarantee-traceability.md). Predecessors: every prior m11 phase (this phase industrializes their tier-3 suites and adds the fault classes they lacked).
Goal: make correctness continuous — new fault classes (disk-full, slow-fsync, asymmetric partition) as REAL faults; the invariant checks the durability and election gates carried inline promoted to first-class reusable checkers; a nightly chaos + soak pipeline that fails on a regression; and a guarantee→test matrix so the GA bar is auditable.
Design (as adopted)
1. Fault injection compiled out of production (tidal/src/fault.rs)
Two new fault classes need REAL faults at the storage boundary, not engine flags:
TIDAL_FAULT_FSYNC_DELAY_MS— sleep before every durable WAL fsync (wal::sync_file_durable). Models a slow disk: data still reaches stable storage, each group-commit fsync costsdelay + real.TIDAL_FAULT_DISK_FULL_AFTER_BYTES— after N cumulative segment bytes this process lifetime, every segment write returns a realENOSPC(std::io::Error::from_raw_os_error(28)— the errno on Linux AND macOS, wrapped in the sameWalError::Ioa genuine full disk produces) atsegment::write_batch_bytes.
Both are behind the non-default fault-injection cargo feature (tidaldb +
a tidal-server passthrough). Production builds (docker/standalone/Dockerfile:
cargo build -p tidal-server --release --locked) never pass it, so the hooks
are compiled out entirely — a disk-write or fsync fault a stray env var could
trip in production is exactly the 3am footgun we refuse to ship, so the safety is
structural, not a default. Even compiled in, faults are inert until an env var
arms them (the posture of TIDAL_HLC_SKEW_MS / the kill-point knobs). The
MultiProcCluster harness's tidal_server_bin() builds the spawned binary with
the feature; every non-fault suite spawns the same binary and is unaffected.
The injection returns ENOSPC before the partial write_all (the standard
clean-fault model). The follower apply path already handles a WAL write error
correctly (proven by the suite below): the receiver halts (degraded, alive),
NEVER advances its applied frontier past the fault (no gap-swallowing), and a
restart re-pulls the suffix via the catch-up stream.
2. First-class invariant checkers (tidal-server/tests/support/invariants.rs)
The roadmap's named invariants were inline copies in three suites; m11p9 promotes them to one audited module every suite consumes:
AckLedger— no acknowledged-write loss: records every write the client saw a 2xx +x-tidal-seqfor, then proves (A) no acked seqno exceeds the max-applied survivor's contiguous frontier, and (B) every acked item is present on the promoted/recovered leader via/search. Extracted verbatim from the m11p3 ledger gate, which now CONSUMES it (re-verified: 3/3 kill-points zero acked loss after the refactor).assert_feed_parity/feed_pairs— cross-replica decay/score parity to1e-6between continuously-running replicas.feed_item_ids— cross-replica DATA parity (the materialized item set), robust across a node RESTART where a velocity/rate score legitimately differs (its time-bucketed windowed counts are rebuilt from a burst WAL-replay rather than continuous accumulation, even with identical durable data — see the disk-full test).assert_single_leader_per_term— membership safety: at most one leader per(shard, term)across a set of status snapshots (reads the m11p6shards[]rows or the flat fields). A second leader at the same term is a split brain.MonotonicCounters— per-entity monotonic counters: a node's applied frontier and the leader's commit index never move backward across observations (legitimate epoch resets — a reseeding node, a leader change — are forgotten, not flagged), so a regression means durably-acked state was lost.
3. New fault-class suite (tidal-server/tests/cluster_faults.rs)
Four tier-3 tests over real OS processes, asserting through the checkers:
mp_disk_full_follower_degrades_no_acked_loss— a follower hits ENOSPC mid-replication, its receiver halts (degraded, alive), the healthy majority keeps acking quorum writes, NO acked write is lost, and a restart with space recovers it to full content + item-set parity.mp_slow_fsync_follower_lags_but_quorum_holds— a slow disk lags one follower; the fast follower supplies quorum so every write commits; the slow node converges once the burst ends; no loss.mp_slow_fsync_both_followers_force_honest_quorum_timeout— neither follower can confirm inside the budget ⇒ack=quorumreturns a retryable 503 naming the laggards (never a false 2xx) whileack=leaderis unaffected; the followers recover.mp_asymmetric_partition_no_split_brain_no_loss— a follower that loses INBOUND links (can still send) cannot disrupt the cluster: pre-vote + check-quorum keep the standing leader, there is never a second leader at the same term, no acked write is lost, and it rejoins cleanly on heal.
The asymmetric partition reuses the harness's directed-edge proxies
(proxied_rewrite(&["ap-south"]) + region("ap-south").sever_all() severs only
inbound to ap-south; its outbound dials are unproxied, so it keeps sending the
disruptive RequestVotes pre-vote neutralizes) — no new mechanism, the existing
PartitionProxy was already directional.
4. Soak + regression gates (tidal-stress)
tidal-stress gained, for the nightly soak:
--json-summary <path>— a flat, machine-readable per-stage roll-up (p99 / throughput / error-rate / client-shed + the gate verdict), hand-rolled (no serializer dep, matching the metrics module's posture) for trend-line archival.--max-p99-ms,--max-error-pct,--fail-on-knee— PASS/FAIL regression gates. A breach returnsStressError::Gate→ non-zero exit (the tool always exited 0 before, so a regression scrolled past in green). Unarmed = the original informational behavior, byte-for-byte.
5. Nightly pipeline (.woodpecker.yaml) — Woodpecker, never GitHub Actions
The pipeline now carries two event-routed flows (workflow-level when: event: [push, cron], per-step when):
- push — the m11p8 rolling-upgrade release gate, then the Kaniko image build (unchanged).
- cron
nightly—nightly-chaosruns every tier-3 suite serially with elevated kill-points (TIDAL_QUORUM_KILLPOINTS=25,TIDAL_ELECTION_KILLPOINTS=15) and thefault-injectionfeature (socluster_faultsruns);nightly-soakboots a target and runstidal-stresswith the regression gates, archiving the JSON summary. PointTIDAL_SOAK_TARGETat the live Ref-A cluster and raiseTIDAL_SOAK_SECS=3600for the GA-bar 1-hour 100k-DAU soak.
6. Guarantee traceability
guarantee-traceability.md maps every §2 guarantee (G-D, G-A, G-S, G-E, G-Sec, G-O, G-Op, G-C) to its named automated test(s), the fault each injects, and the checker each asserts through.
Exit gate (from the roadmap)
- Every guarantee in §2 maps to a named automated test.
- Nightly suite green 30 consecutive days before GA.
How the streak is computed (and what resets it)
The bar is NOT "30 green soak Job exits". It is 30 consecutive nights where the soak passed its SLO gates AND no cluster pod restarted under load. The two halves come from two streams on the shared result PVC:
ledger.tsv— one row per UTC date from the nightly CronJob:PASS/FAILon the armed gates (--fail-on-knee,--max-p99-ms 150,--max-error-pct 1), plus exactstart_utc/end_utcload bounds.restarts.tsv— the monitor's one-minute snapshot of eachtidaldb-{0,1,2}pod UID and cumulativerestartCount.
soak-eval (the proven core in tidal_stress::soak_eval,
table-driven-tested; the binary runs in the monitor's evaluator container and
once per night in the CronJob) joins them into streak.tsv:
- a night is GREEN iff its ledger row is
PASS, every expected pod has a fresh sample immediately before and after the measured load window, and neither its pod UID nor restart count changes between those samples; - the streak is the trailing run of unique, consecutive UTC calendar dates;
- a gate breach, restart, pod replacement, missing/stale boundary sample, duplicate date, or date gap cannot advance the streak. This closes both fail-open paths: a green load exit with a recovery event and multiple rows masquerading as multiple nights.
The evaluator emits exactly one alert per non-green transition (a durable
ALERT-<date>.txt the http surface serves, plus an optional NOTIFY_WEBHOOK
POST). Dependency: the streak is only reachable once the graceful-restart
reseed-on-rejoin churn is fixed — before that, every rollout/upgrade/node reboot
increments a restart count and would reset the streak forever. The production-
readiness divergence fix (tasks 01/02) is what makes a restart-in-window a real,
rare anomaly worth failing on.
Exit-gate evidence (local; debug builds, real OS processes)
| Gate | Target | Measured |
|---|---|---|
| New fault classes pass | disk-full / slow-fsync / asymmetric | cluster_faults 4/4 green (73s): disk-full follower froze at applied 71 while node1 reached 240, recovered after restart to 120-item parity, zero acked loss; slow follower lagged 15 vs 0 then converged; both-slow → honest 503 naming ["eu-west","ap-south"] while ack=leader 204'd; asymmetric → leader held us-east, single-leader-per-term, ap-south term bounded (0→0), rejoined. |
| First-class checkers faithful | extraction = no regression | cluster_quorum::mp_quorum_ledger_zero_acked_loss_across_killpoints refactored onto AckLedger: 3/3 kill-points zero acked loss (29s) — extraction verified against the live gate. |
| Soak regression gate works | PASS exits 0, breach exits ≠0 | Against a real standalone server: a within-threshold run exited 0 with a valid JSON summary (p99 39.7ms, 0% error); an impossible --max-p99-ms 0.01 run exited 1 with the breach in stderr and "passed": false in the JSON. |
| Every guarantee → named test | §2 coverage | guarantee-traceability.md: G-D/G-A/G-E/G-Sec/G-Op each map to a green named test; G-S to the m11p6 owner-test (in progress); G-O to the m11p8 metric/dashboard/alert artifacts; G-C to this apparatus. |
| Nightly green 30 days | calendar | Pipeline established, not yet accrued — this is a wall-clock criterion the cron pipeline produces over 30 nights; it cannot be completed in one session. Honestly out-of-session (like the Ref-A k3s runs). |
Status
fault-injectionfeature (slow-fsync + disk-full WAL hooks), compiled out of production- First-class invariant checkers (
support/invariants.rs): ledger, decay parity, single-leader-per-term, monotonic counters cluster_quorumrefactored ontoAckLedger(extraction re-verified)cluster_faults.rs: disk-full, slow-fsync (×2), asymmetric partition — 4/4 greentidal-stress--json-summary+--max-p99-ms/--max-error-pct/--fail-on-knee+ non-zero exit (verified vs a real server)- Woodpecker nightly cron pipeline (chaos suites + gated soak) beside the push release gate
- Guarantee → automated-test traceability matrix
- Docs (this phase, runbook chaos/soak section, monitoring soak note) + CHANGELOG + ROADMAP + roadmap-to-cluster as-built
- Calendar: nightly green for 30 consecutive days before GA (the pipeline accrues this; not an in-session deliverable)
Adversarial review (6-dimension, read-only)
A read-only review fanned out over six dimensions (production-safety, checkers, fault-tests, soak-gates, Woodpecker, doc-honesty), each finding adversarially verified before adjudication. 10 confirmed findings, all fixed:
- Woodpecker (blocker): the soak step referenced
environment:-block vars as${VAR}, which Woodpecker's preprocessor blanks BEFORE the shell — the gate would have run with an empty ramp/thresholds (never executing). Fixed with the$${VAR}escape so the container shell expands them at runtime. - Gate threshold (blocker): a
--max-p99-ms nan/infreported the gate ARMED while every> NaNcomparison is false — silently passing a regression. Now rejected (finite, non-negative) at the CLI boundary. - JSON validity (blocker): a non-finite
target_rps(--ramp "inf:30") emitted bareinf/NaN, invalid JSON. Fixed at the parse boundary + ato_jsonnull-sanitizer (defense in depth). - Checkers (warnings):
assert_single_leader_per_termfalse-failed on a duplicate same-region snapshot (assert_ne!→ gatedif prev != region);MonotonicCountersnow re-baselines a region's role-relative applied frontier on a leader↔follower flip (not just reseed);assert_items_presentdistinguishes a transport/non-2xx probe failure from true absence (no false ACKNOWLEDGED LOSS). - Coverage (warning): added a
summary.rstest module (gate breach/pass, fail-on-knee, JSON round-trip through a real parser, null-sanitize) — 6 tests. - Doc honesty (warnings): the G-Sec/G-Op owner-tests (mTLS,
cluster_security,tidalctlbackup/restore, WAL-archival) were NOT in the nightly — ADDED anightly-security-opscron step so the "runs nightly" claim is true; the G-S row is marked "(NOT YET WRITTEN — m11p6 L4)" so it doesn't read as delivered. - Cron name (nit, external): the nightly is silently-green until a cron named
nightlyis created in Woodpecker repo settings — documented in the pipeline header; a one-time repo-settings step.
After the fixes the fault suite re-ran 4/4 green and the ledger gate 3/3 (the checker changes are faithful); the slow-fsync and asymmetric tests moved to item-SET parity (a burst catch-up reconstructs velocity differently — the same restart-robustness rationale as disk-full).
Formal seven-dimension review (/review-code → fix-all)
A second pass ran the code-reviewer seven-dimension protocol (each dimension a
distinct frame, each finding adversarially verified): 0 blockers, 0 critical, 7
warnings, 12 suggestions (APPROVE-with-fixes). All 7 warnings + 10 of 12
suggestions fixed; final score 90/100. Notable fixes: a real diagnostic bug
(assert_items_present's two guards were swapped — a true acknowledged-loss
printed "PROBE UNREACHABLE" and vice-versa); StressError::Gate was overloaded
across breach / invalid-threshold / IO-write and split into Gate/BadGate/
Summary so the operator-facing message names the real cause; the soak port
moved off iknowyou's 59521 to a TIDAL_SOAK_PORT (59526); the soak step now
fails fast on a boot failure and reaps its background server; doc-truthing the
"every suite consumes the shared checkers" claim (only cluster_quorum +
cluster_faults do — the legacy suites' migration is a tracked follow-up);
S=1-scope notes on the flat-field checkers; a real from_stage unit test; and
the observe_cluster_frontiers double-status-fetch removed. Two suggestions
deferred with justification: consolidating the role-vs-is_leader leadership
read into one helper (steady-state-equal, >5min, regression risk on green gates)
and Woodpecker YAML anchors for the repeated apt-get/cargo lines (untestable CI
refactor, benign duplication).
Verification status
- Workspace
cargo fmt --all -- --check: clean. cargo clippy-D warnings: clean on tidaldb (fault-injectionon + off), tidal-server (cluster-e2e fault-injection), and tidal-stress (all targets).- Production safety:
cargo check -p tidaldbwith the feature OFF compiles the fault module and both WAL hooks out entirely (the shipped binary is unchanged). - The tree is UNCOMMITTED (continues the m11p1–p8 uncommitted tree; the user commits).