···9090| **P8 — Delete the C + retire the oracle harness ✅ DONE** (per-sub-phase: [`PLAN-P8-progress.md`](PLAN-P8-progress.md) + [`docs/progress-log/p8.md`](docs/progress-log/p8.md)) | remaining C (the ~25 variadic sender trampolines + residual data globals) + `ircd-sys` C build + `ircd-golden` (L2) + `ircd-testkit`/`cref_*` archives (L1) + the frozen reference‑C tree | With all C _logic_ already Rust (P7 exit), delete the last C: replace the variadic sender trampolines with a non‑variadic Rust sender API at every call site (faithful `wire!`/`reply_one!`/`format!` first; leveva typed messages adopted alongside the IRC senders), drop the residual data globals via the data‑symbol seam (bindgen `extern` decls resolve to `#[no_mangle]` Rust defs at link), then drop **all** C compilation from `ircd-sys` (no more `cc::Build`), retire the differential harness (`cref_*`/L1 `ircd-testkit`/`ircd-golden` L2 can no longer be built once the oracle is gone), and **migrate the load‑bearing tests into `ircd-common`** as self‑contained Rust tests (own fixtures, no `cref_` oracle). **Progress (P8a–P8r — full entries in the progress logs):** **every variadic sender trampoline is now Rust** (P8a–P8m, incl. the keystones `sendto_one`/`sendto_flag`/`esendto_*`), and the five data‑global `.o`s are dropped outright (`s_auth.o` iauth globals P8n, `send.o` P8o, `s_bsd.o` `local[]`/`highest_fd`/… P8p, `ircd.o` `me`/`client`/timers/… P8q). **P8r dropped the last C‑logic TU `support.o`** — `dgets`/`make_isupport` ported to Rust + the `ipv6string`/`minus_one` data globals defined as `#[no_mangle]` statics (`snprintf_append` is dead in Rust — WHOX is field‑by‑field — so it died with the `.o`; `irc_sprintf` is deleted not ported, in P11). **Every ircd C TU is now Rust:** the link set holds only the generated `version.o` + the L1 harness's `ctruth.o`. **P8s migrated all 115 L1 `cref_` differentials into self‑contained `ircd-common` `insta` snapshot tests** (drive only the Rust port via `link_anchor()`, no oracle; soundness via the capture chain) — the suite is green + 0 warnings. **P8t ported the last *generated* C TU `version.c` to Rust** (`ircd-common/src/version.rs`): the data globals `generation`/`creation`/`pass_version`/`infotext`/`isupport` are now `#[no_mangle]` statics via the data‑symbol seam — `generation`/`creation` sourced from crate metadata at build time (`CARGO_PKG_VERSION` + an `ircd-common/build.rs` UTC build stamp), `pass_version`/`infotext` copied verbatim. `version.c.SH` generation + the `version.o` compile/archive are gone from `ircd-sys/build.rs`; `libircd_c.a` now holds **only** the L1 harness `ctruth.o`. (`creation` is now genuinely build‑time volatile, so the golden canonicalizer masks RPL_CREATED 003, matching the existing 371 Birth‑Date rule.) **P8u (FINAL) retired the oracle.** Ran the differential suite a last time green — L1 `ircd-testkit` all pass; L2 `ircd-golden` 86 pass + only the documented reference‑C‑garbage `s_serv_stats` flake (where Rust is the *correct* side) — then **dropped all C compilation** from `ircd-sys/build.rs`: the `make` object build, the `cref_*`/`ctruth.o`/`res.o` recompiles, the `libircd_c.a` archive, and the whole‑archive link + `-lz`/`-lm`/`-lcrypt` (none needed by Rust — `pow` resolves from glibc 2.29+). `build.rs` now only runs `configure` + bindgen over the C *headers* (the struct view `ircd-common` still uses until P9–P12) and expands the install‑path Makefile vars. `ircd-testkit` (L1) + `ircd-golden` (L2) are `exclude`d from the workspace (mothballed, git‑recoverable); the `ctruth.c`/`layout.rs` drift‑net is deleted. **The workspace is now 100% Rust — zero C TUs compiled.** **P8v (literal end of C) deleted the C *source tree* itself + retired `ircd-sys`:** froze the generated `bindings.rs` into a committed `ircd-common/src/bindings.rs` (a self‑alias `extern crate self as ircd_sys;` keeps every `ircd_sys::bindings::*` path resolving, zero src churn) + the install‑path consts as literals, then deleted `ircd-sys`, the mothballed `ircd-testkit`/`ircd-golden` oracle crates, the `iauth-rs` C‑iauth differential, and **all** C source (`common/ ircd/ iauth/ support/ contrib/ cbuild/ configure .clang-format* clangformat.yml`). `ircd-rs` calls `ircd_common::ircd::c_ircd_main` directly. **Not one line of C remains in the repo.** | **MET. Final differential run green; oracle mothballed; `cargo build --workspace` 0 warnings links pure Rust; `cargo test --workspace` 695 pass / 0 fail (P8v; was 698 before deleting the C‑oracle tests); the `ircd-common` snapshot tests carry the reference‑C correctness forward. C source tree deleted — repo is 100% Rust at the file level.** |
9191| **P9 — std‑library cleanup ❌ RETIRED (2026‑06‑08)** | — (was: whole Rust tree) | **Skipped by decision — superseded by the `leveva` greenfield.** P9 assumed an *in‑place* idiomatic transformation of the mechanical port (`MyMalloc`/`MyFree`→`Box`/`Vec`, `dbuf` freelist→owned, raw `libc`→`std`/`nix`, `[c_char;N]`→`[u8;N]`). Because the idiomatic end‑state is now a separate greenfield crate (`leveva`), `ircd-common` is throwaway code (the oracle, deleted at parity), so polishing it earns nothing. Nothing was implemented; the row is kept for provenance. | n/a — retired. |
9292| **P10 — `leveva` greenfield foundations 🔶 IN PROGRESS** ([`docs/progress-log/p10.md`](docs/progress-log/p10.md)) | new `leveva` crate (lib) + `leveva-integration` differential tests | The idiomatic‑Rust product, built from scratch (not transformed from the port). **Done so far:** RFC 1459 case‑folding (`casemap`); typed identifier strings (`IrcStr`/`IrcString` + `Nick`/`ChanName`/`Uid`/`Sid`/`Cid`/`ServerName`/`UserName`/`HostName` validating newtypes via `ident_newtype!`); `Message`/`MessageBuilder`; the full `Numeric` reply/error enum (184 codes, discriminant = wire code); a generic safe `patricia` trie; glob `matching`; `mode`; KDL `config` (model/parse/password/privilege); `rustls`/`tokio` `tls` endpoint with hot reload; `ident`. The async boot path (`main.rs`) binds listeners + watches certs but currently drops accepted connections — no protocol layer yet. Idiomatic Rust allowed everywhere; pulls real crates (tokio/rustls/kdl/nom/clap). | `cargo test -p leveva` green + clippy clean per module; `leveva-integration` pins each greenfield reimplementation against `ircd-common` (the oracle) within their shared domain (documented divergences asserted explicitly). |
9393-| **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 281 slices** (281 = S2S `KLINE`/`UNKLINE` propagation — the K-line analogue of slice 280: a `TKLINE`/`UNTKLINE` (leveva's temporary K-line family, there is no separate permanent `KLINE`) now propagates network-wide as `ENCAP * KLINE <duration> <user> <host> :<reason>`/`ENCAP * UNKLINE <user> <host>` (server-prefixed, leveva-native, the **remaining** seconds on the wire so each server re-clocks `expires = now + duration` against its own clock — no absolute-timestamp skew), wired into `command/tkline.rs` after the param/privilege/format gates; every server applies the ban to its own `KlineStore` (gating future registrations) **and reaps its own local matching clients** via the now-`is_local_uid`-guarded `reap_matching` helper (which also fixed a latent over-reap of remote users in the local `TKLINE`); `kline-exempt` clients are spared; `UNKLINE` lifts the gate only (never un-kills) and propagates unconditionally; re-asserted to a freshly-linked peer by `s2s/burst.rs::kline_burst` (active bans only, with remaining time; expired bursts nothing); new `s2s/kline.rs` modelled on `s2s/resv.rs`, ENCAP dispatch in `s2s/forward.rs`; 280 = S2S `RESV`/`UNRESV` propagation — an operator-laid nick/channel reservation (and its lift) now propagates network-wide as `ENCAP * RESV`/`UNRESV` (server-prefixed, kind re-derived from the mask prefix on inbound apply), wired into `command/resv.rs` after the param/privilege gates (a `461`/`481` reject propagates nothing; `UNRESV` propagates unconditionally), applied + split-horizon-relayed by `s2s/forward.rs`, and re-asserted to a freshly-linked peer via `s2s/burst.rs::resv_burst` (runtime `conf == false` reservations only — config `resv {}` blocks stay per-server local policy); closes the slice-272/273 S2S-propagation follow-on, mirroring the METADATA/KNOCK ENCAP carriers; 279 = remote-originated JOIN/PART/QUIT membership events are now recorded into chat-history — the membership-event parallel to slice 276 (which did PRIVMSG/NOTICE): a **live** `NJOIN` (`s2s/njoin.rs`, never a burst — bursts are bulk state-sync), a bare `JOIN`/`PART` (`s2s/relay.rs`), and a **discrete** remote `QUIT`/`KILL` (`s2s/squit.rs::quit_one_remote_user`, *not* a netsplit mass teardown) each record an event keyed by a locally-minted msgid + clock, gated on `has_local_member` (only a local member can `CHATHISTORY` it; a pure hub stores nothing); an unmirrored `NJOIN`-only ghost has no mask so its QUIT is skipped; replay/query side unchanged (slice 274 already gates these behind `draft/event-playback`); 278 = `+R` auto-reop is now enforced for **local** members only — the `enforce_reop` chokepoint gained an explicit `is_local_uid` filter, so a `+R` mask matching a `UNICK`-introduced remote user's `nick!user@host` no longer wrongly ops it locally and relays a `+o` we have no authority to emit (the locality guarantee previously rested on the false premise that remote members lack a registry record); 277 = netsplit batches are no longer duplicated to `batch`-capable clients under concurrent teardown — when a dying peer is detected on two paths at once (abrupt socket drop + a sibling's inbound `SQUIT`, in separate tasks), the `NJOIN`-only ghost-member sweep raced (`co_members` then `remove_everywhere`, no single-shot gate) and double-relayed a casualty's `QUIT`; new atomic `Channels::detach_member` fuses recipient-gather + removal in one critical section so only the first teardown to reach a member relays it (the loser gets `None`), mirroring the already-atomic `remove_behind_sid` mirror drain; new `s2s_netsplit_concurrency_proptest` fuzz; 276 = remote-originated channel messages are now recorded into chat-history — the S2S relay's inbound channel fan (`s2s::relay::inbound_message`) records the delivered line on this server too, keyed by the carried network msgid (slice 130) + `@time` (slice 134), gated on this server having a local member of the channel (only a local member can `CHATHISTORY` it; no whole-network accumulation on a hub); the `+z` op-redirect/non-channel branches never record, mirroring the local plane; closes the slice-274 gap where `CHATHISTORY` held only locally-sent lines; 275 = IRCv3 `message-redaction` now deletes from chat-history — an accepted channel `REDACT <chan> <msgid>` deletes the slice-274 stored line (`History::delete_by_msgid`, keyed `(target, msgid)`) so `CHATHISTORY` can never replay it; wired in both the local `CanSend::Ok` path and inbound `ENCAP * REDACT`, per-server; mirrors recording — a `+z` op-moderated/"shown to ops" attempt is never recorded so never deleted; authorization stays a stateless relay; 274 = IRCv3 `draft/chathistory` — SQLite-backed (`rusqlite`, bundled) channel message history behind an optional `ircd.kdl` `database {}` block; all channels auto-enrolled (PRIVMSG/NOTICE + JOIN/PART/QUIT recorded at the delivery plane), joining clients auto-replayed `replay-lines` recent lines unless they negotiated `draft/chathistory` (then exempt, per spec), full `CHATHISTORY LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS` (timestamp=/msgid= selectors) framed as a `chathistory` batch, `draft/chathistory`+`draft/event-playback` dynamic caps + `CHATHISTORY`/`MSGREFTYPES` ISUPPORT, age-based `retention-days` prune ticker; `Message` gained serde; 273 = registration-time nick `RESV` enforcement — a connection may no longer *register* directly as a reserved nick, only the post-registration `NICK`/JOIN were gated before; refuse `432` + reopen registration, mirroring the `433` nick-in-use path; 272 = charybdis `RESV`/`UNRESV` nick/channel reservation — JOIN→`437`/NICK→`432` gates, opers exempt, new `OperPrivilege::Resv`; defaults declared in `resv {}` config blocks, boot-seeded + REHASH-re-seeded keeping runtime `RESV`s). The slices span the full client command surface (registration; channels + the complete channel-mode surface; WHO/WHOIS/WHOX; OPER/STATS; and the query/util commands incl. OPERWALL/LOCOPS/USERIP); the UID-based S2S link/burst/netsplit protocol (keepalive, live introduction, NJOIN chunking, TS merge arbitration); `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident); config live-rehash across every block; CertFP; the **IRCv3 cap track** (message-tags/server-time/msgid/account/bot/oper/echo/labeled-response/CAP, `draft/metadata-2`, caller-id `+g`/`+G`, the **STS/TLS** sub-track); elemental channel modes + extbans; KNOCK; command-rate flood protection (fakelag); the **complete charybdis channel-mode set** (`+C/+c/+S/+j/+z/+T/+g/+r/+f/+Q/+F/+P/+L`, with `+L` the last standard one) and **user-mode set** (`+D/+R/+Q/+G/+z/+S/+l`, plus the product decision that leveva has no local-only umodes — `SEND_UMODES` is every umode); and the `+s` server-notice **snomask** (a charybdis category mask with a producer wired for every category — the matrix is complete). Most const limits (`max-penalty`/`max-bans`/`service-string`/`default-snomask`/…) have been promoted to REHASH-able `options { … }` knobs. **Full per-slice detail (scope, mechanism, divergences, gate) for every slice lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing testing plan is [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). This row stays thin.** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. |
9393+| **P11 — `leveva` protocol layer → feature parity 🔶 IN PROGRESS** ([`docs/progress-log/p11.md`](docs/progress-log/p11.md)) | `leveva` (client/server state machine + handlers) | The bulk of the remaining work: the connection + registration state machine, the post‑registration command handlers (JOIN/PRIVMSG/MODE/KICK/TOPIC/WHO/WHOIS/…), channels + channel/user modes, the S2S link/burst protocol (UID‑based: UNICK/NJOIN/SAVE/EOB), and `leveva-iauth` (native async auth — dnsbl/socks/webproxy/pipe/ident). Each slice is built idiomatically (owned per‑connection state, typed identifiers end‑to‑end, `Result`/`thiserror`, `format!`/typed builders — **no** `sprintf`/global `buf`), unit‑tested + boot‑golden + proptest‑fuzzed, and **differentially pinned against `ircd-common`** (or `iauth-rs` for auth) wherever a pure oracle entry point exists — structural skeleton + documented divergences, since leveva emits a clean modern burst rather than a byte copy. **Done: 282 slices** (282 = SQLite persistence for K-lines + reservations — operator-laid `TKLINE` bans and `RESV` reservations (lost on restart before) now persist to the **same `database {}` SQLite file** that backs chat-history (slice 274), so they survive a reboot: each `KlineStore`/`ResvStore` gains an optional write-through connection (`open(path)` creates a `kline`/`resv` table + loads existing rows; `add`/`remove`/`prune` mirror to it; a `new()`/no-`database{}` store stays in-memory only, every persistence path a no-op), the in-memory `Vec` staying the hot-path authority; K-lines drop `expires <= now` rows on load + prune; only **runtime** (`conf == false`) reservations persist (config `resv {}` re-derives from `ircd.kdl`, so `seed_config` bypasses persistence and a `REHASH` never drops a learned reservation); `COLLATE NOCASE` primary keys make a case-differing re-add upsert not duplicate; open/write errors log and fall back to in-memory (never fatal); inbound S2S `apply_encap_kline`/`apply_encap_resv` persist what a server learns from the network too (all via `add`); 281 = S2S `KLINE`/`UNKLINE` propagation — the K-line analogue of slice 280: a `TKLINE`/`UNTKLINE` (leveva's temporary K-line family, there is no separate permanent `KLINE`) now propagates network-wide as `ENCAP * KLINE <duration> <user> <host> :<reason>`/`ENCAP * UNKLINE <user> <host>` (server-prefixed, leveva-native, the **remaining** seconds on the wire so each server re-clocks `expires = now + duration` against its own clock — no absolute-timestamp skew), wired into `command/tkline.rs` after the param/privilege/format gates; every server applies the ban to its own `KlineStore` (gating future registrations) **and reaps its own local matching clients** via the now-`is_local_uid`-guarded `reap_matching` helper (which also fixed a latent over-reap of remote users in the local `TKLINE`); `kline-exempt` clients are spared; `UNKLINE` lifts the gate only (never un-kills) and propagates unconditionally; re-asserted to a freshly-linked peer by `s2s/burst.rs::kline_burst` (active bans only, with remaining time; expired bursts nothing); new `s2s/kline.rs` modelled on `s2s/resv.rs`, ENCAP dispatch in `s2s/forward.rs`; 280 = S2S `RESV`/`UNRESV` propagation — an operator-laid nick/channel reservation (and its lift) now propagates network-wide as `ENCAP * RESV`/`UNRESV` (server-prefixed, kind re-derived from the mask prefix on inbound apply), wired into `command/resv.rs` after the param/privilege gates (a `461`/`481` reject propagates nothing; `UNRESV` propagates unconditionally), applied + split-horizon-relayed by `s2s/forward.rs`, and re-asserted to a freshly-linked peer via `s2s/burst.rs::resv_burst` (runtime `conf == false` reservations only — config `resv {}` blocks stay per-server local policy); closes the slice-272/273 S2S-propagation follow-on, mirroring the METADATA/KNOCK ENCAP carriers; 279 = remote-originated JOIN/PART/QUIT membership events are now recorded into chat-history — the membership-event parallel to slice 276 (which did PRIVMSG/NOTICE): a **live** `NJOIN` (`s2s/njoin.rs`, never a burst — bursts are bulk state-sync), a bare `JOIN`/`PART` (`s2s/relay.rs`), and a **discrete** remote `QUIT`/`KILL` (`s2s/squit.rs::quit_one_remote_user`, *not* a netsplit mass teardown) each record an event keyed by a locally-minted msgid + clock, gated on `has_local_member` (only a local member can `CHATHISTORY` it; a pure hub stores nothing); an unmirrored `NJOIN`-only ghost has no mask so its QUIT is skipped; replay/query side unchanged (slice 274 already gates these behind `draft/event-playback`); 278 = `+R` auto-reop is now enforced for **local** members only — the `enforce_reop` chokepoint gained an explicit `is_local_uid` filter, so a `+R` mask matching a `UNICK`-introduced remote user's `nick!user@host` no longer wrongly ops it locally and relays a `+o` we have no authority to emit (the locality guarantee previously rested on the false premise that remote members lack a registry record); 277 = netsplit batches are no longer duplicated to `batch`-capable clients under concurrent teardown — when a dying peer is detected on two paths at once (abrupt socket drop + a sibling's inbound `SQUIT`, in separate tasks), the `NJOIN`-only ghost-member sweep raced (`co_members` then `remove_everywhere`, no single-shot gate) and double-relayed a casualty's `QUIT`; new atomic `Channels::detach_member` fuses recipient-gather + removal in one critical section so only the first teardown to reach a member relays it (the loser gets `None`), mirroring the already-atomic `remove_behind_sid` mirror drain; new `s2s_netsplit_concurrency_proptest` fuzz; 276 = remote-originated channel messages are now recorded into chat-history — the S2S relay's inbound channel fan (`s2s::relay::inbound_message`) records the delivered line on this server too, keyed by the carried network msgid (slice 130) + `@time` (slice 134), gated on this server having a local member of the channel (only a local member can `CHATHISTORY` it; no whole-network accumulation on a hub); the `+z` op-redirect/non-channel branches never record, mirroring the local plane; closes the slice-274 gap where `CHATHISTORY` held only locally-sent lines; 275 = IRCv3 `message-redaction` now deletes from chat-history — an accepted channel `REDACT <chan> <msgid>` deletes the slice-274 stored line (`History::delete_by_msgid`, keyed `(target, msgid)`) so `CHATHISTORY` can never replay it; wired in both the local `CanSend::Ok` path and inbound `ENCAP * REDACT`, per-server; mirrors recording — a `+z` op-moderated/"shown to ops" attempt is never recorded so never deleted; authorization stays a stateless relay; 274 = IRCv3 `draft/chathistory` — SQLite-backed (`rusqlite`, bundled) channel message history behind an optional `ircd.kdl` `database {}` block; all channels auto-enrolled (PRIVMSG/NOTICE + JOIN/PART/QUIT recorded at the delivery plane), joining clients auto-replayed `replay-lines` recent lines unless they negotiated `draft/chathistory` (then exempt, per spec), full `CHATHISTORY LATEST/BEFORE/AFTER/BETWEEN/AROUND/TARGETS` (timestamp=/msgid= selectors) framed as a `chathistory` batch, `draft/chathistory`+`draft/event-playback` dynamic caps + `CHATHISTORY`/`MSGREFTYPES` ISUPPORT, age-based `retention-days` prune ticker; `Message` gained serde; 273 = registration-time nick `RESV` enforcement — a connection may no longer *register* directly as a reserved nick, only the post-registration `NICK`/JOIN were gated before; refuse `432` + reopen registration, mirroring the `433` nick-in-use path; 272 = charybdis `RESV`/`UNRESV` nick/channel reservation — JOIN→`437`/NICK→`432` gates, opers exempt, new `OperPrivilege::Resv`; defaults declared in `resv {}` config blocks, boot-seeded + REHASH-re-seeded keeping runtime `RESV`s). The slices span the full client command surface (registration; channels + the complete channel-mode surface; WHO/WHOIS/WHOX; OPER/STATS; and the query/util commands incl. OPERWALL/LOCOPS/USERIP); the UID-based S2S link/burst/netsplit protocol (keepalive, live introduction, NJOIN chunking, TS merge arbitration); `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident); config live-rehash across every block; CertFP; the **IRCv3 cap track** (message-tags/server-time/msgid/account/bot/oper/echo/labeled-response/CAP, `draft/metadata-2`, caller-id `+g`/`+G`, the **STS/TLS** sub-track); elemental channel modes + extbans; KNOCK; command-rate flood protection (fakelag); the **complete charybdis channel-mode set** (`+C/+c/+S/+j/+z/+T/+g/+r/+f/+Q/+F/+P/+L`, with `+L` the last standard one) and **user-mode set** (`+D/+R/+Q/+G/+z/+S/+l`, plus the product decision that leveva has no local-only umodes — `SEND_UMODES` is every umode); and the `+s` server-notice **snomask** (a charybdis category mask with a producer wired for every category — the matrix is complete). Most const limits (`max-penalty`/`max-bans`/`service-string`/`default-snomask`/…) have been promoted to REHASH-able `options { … }` knobs. **Full per-slice detail (scope, mechanism, divergences, gate) for every slice lives in [`docs/progress-log/p11.md`](docs/progress-log/p11.md); the standing testing plan is [`docs/superpowers/specs/2026-06-10-leveva-testing-plan.md`](docs/superpowers/specs/2026-06-10-leveva-testing-plan.md). This row stays thin.** | Per‑slice: leveva boots and serves the slice; leveva golden snapshot + proptest green; the `leveva-integration` differential agrees with `ircd-common` on the shared skeleton with divergences asserted. Across the eventual matrix: registration, channels, WHO/WHOIS/WHOX, OPER/STATS, server burst, netsplit, rehash, restart; iauth end‑to‑end. |
9494| **P12 — Retire the mechanical port; `leveva` is the product ✅ DONE (2026‑06‑13)** ([`docs/progress-log/p12.md`](docs/progress-log/p12.md)) | deleted `ircd-common`/`ircd-rs` + C‑era `iauth-rs` + `leveva-integration` + the mechanical port's deployment rigging; workspace = `leveva` + `leveva-iauth` | Ran the differential suite a **final time green** (`cargo test -p leveva-integration`, skeleton‑identical to the oracle), then **deleted the mechanical port** (`ircd-common`, `ircd-rs`), its C‑era auth scaffolding (`iauth-rs`, long since subsumed by the native `leveva-iauth`), and the oracle differential crate (`leveva-integration`). The oracle tests retired with their oracle — the load‑bearing behavior was already carried by leveva's self‑contained 212‑file golden + proptest suite (verified: leveva/leveva-iauth have zero Cargo/`use` dep on the deleted crates). Also stripped the mechanical port's **deployment rigging** (`docker/Dockerfile.ircd`, the `ircd` bake target, the entire `docs/k8s/` example network) and updated the README to describe the finished strangler. This is the literal end of the strangler — same lifecycle the C tree had at P8. | **MET. Final differential run green; `ircd-common`/`ircd-rs`/`iauth-rs`/`leveva-integration` deleted; `cargo build --workspace` 0 warnings; `cargo clippy --workspace --tests` clean; `cargo test -p leveva -p leveva-iauth` green — the behavioral guarantees carry forward. The workspace is now `leveva` + `leveva-iauth`.** |
95959696---
···343343matches charybdis (config RESVs not clustered). Fuzz: 2 in-crate proptests (apply total/consistent
344344+ kind-matches-prefix; junk never panics + UNRESV targeted). Remaining slice-272 follow-ons: timed
345345`RESV <time>` + reaping an already-present holder.
346346+347347+## Slice 281 — KLINE/UNKLINE S2S propagation (`ENCAP * KLINE`/`UNKLINE`)
348348+349349+The K-line analogue of slice 280. A `TKLINE`/`UNTKLINE` (leveva's temporary K-line family — there is
350350+NO separate permanent `KLINE` command) was single-server. New `s2s/kline.rs` (modelled on
351351+`s2s/resv.rs`): `propagate_kline`/`propagate_unkline` broadcast `:<sid> ENCAP * KLINE <duration>
352352+<user> <host> :<reason>` / `:<sid> ENCAP * UNKLINE <user> <host>` (no-op peerless);
353353+`apply_encap_kline`/`apply_encap_unkline` apply inbound. **Duration not absolute expiry on the wire**
354354+— each server computes `expires = now + duration` against its own clock (no skew); the live command
355355+propagates the clamped `secs`, `s2s/burst.rs::kline_burst` re-asserts the **remaining** time
356356+(`expires - now`, active bans only — expired burst nothing), inbound re-clamps `[0, TKLINE_MAXTIME]`.
357357+`<user> <host>` ride as two params (no `@`-resplit). **Inbound apply reaps LOCAL clients** (network
358358+ban reaps everywhere; a remote user is reaped by its own server) — this fixed a latent over-reap in
359359+the local `TKLINE` command by routing both through one `command/tkline.rs::reap_matching` helper now
360360+carrying the slice-278 `is_local_uid` guard (+ `kline-exempt` skip). `UNKLINE` does not un-kill
361361+(lifts the gate only), propagates unconditionally. `TKLINE_MAXTIME` + `now_secs()` promoted to
362362+`pub`/`pub(crate)` in `kline.rs`. `forward.rs` gained `KLINE`/`UNKLINE` arms. Fuzz: 2 in-crate
363363+proptests (apply total/consistent; junk never panics + UNKLINE targeted).
364364+365365+## Slice 282 — SQLite persistence for K-lines + reservations
366366+367367+`TKLINE` bans + `RESV` reservations (lost on restart) now persist to the **same `database {}` SQLite
368368+file** that backs chat-history ([[leveva-chathistory]]). Each `KlineStore`/`ResvStore` gains
369369+`db: Option<Mutex<Connection>>` (manual Debug; `new()`/`default()` = in-memory only, every persist
370370+path a no-op — used by all tests + when no `database {}`): `open(path)` creates a `kline`/`resv` table
371371+(WAL, `COLLATE NOCASE` PK so a case-differing re-add upserts not duplicates) + **loads** existing rows;
372372+`add`/`remove`/`prune` write through after the Vec mutation (Vec stays hot-path authority). K-lines
373373+drop `expires <= now` rows on load + prune. Resvs persist **only runtime** (`conf == false`) — `add`
374374+persists, `seed_config` does NOT (config `resv {}` re-derives from `ircd.kdl`; loaded rows land
375375+`conf == false` so REHASH never drops them); boot order `open` (load runtime) → `seed_config`.
376376+`server.rs::from_config` opens each store against `cfg.database.path` (log + fall back to `new()` on
377377+error). Inbound S2S `apply_encap_kline`/`apply_encap_resv` persist what a server learns (all via
378378+`add`). Errors log, never fatal. `examples/`+`dist/ircd.kdl` `database {}` comment extended. Fuzz: 1
379379+proptest per store (`the_persisted_set_matches_after_reopen` — file-backed add/remove reloads to the
380380+in-memory set, 24 cases, unique temp DB + WAL/SHM cleanup). NO new config key — the existing
381381+`database {}` block transparently gained this.
+57
docs/progress-log/p11.md
···1543315433**Plan:** [`docs/superpowers/plans/2026-06-19-p11-slice281-s2s-kline-propagation.md`](../superpowers/plans/2026-06-19-p11-slice281-s2s-kline-propagation.md)
15434154341543515435**Gate:** `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build -p leveva` 0 warnings.
1543615436+1543715437+## P11 slice 282 — SQLite persistence for K-lines + reservations
1543815438+1543915439+**2026-06-19.** Operator-laid temporary K-lines (`TKLINE`) and nick/channel reservations (`RESV`)
1544015440+were lost on restart — they lived only in the in-memory `KlineStore`/`ResvStore`. They now persist to
1544115441+the **same `database {}` SQLite file** that already backs chat-history (slice 274), so an operator's
1544215442+bans/reservations survive a reboot. No new config: the existing `database {}` block now also opens the
1544315443+`kline`/`resv` tables. Without the block both stores stay in-memory only — exactly as before.
1544415444+1544515445+- **Reuse the `database {}` file, new tables** — each store opens its own write-through SQLite
1544615446+ connection to `cfg.database.path` (multiple connections to one WAL file is standard), with a
1544715447+ `kline`/`resv` table. Mirrors the self-contained `History` pattern: the in-memory `Vec` stays the
1544815448+ hot-path authority, the connection is a durable mirror.
1544915449+- **Write-through on mutate, load on boot** — `add`/`remove`/`prune` (kline) and `add`/`remove` (resv)
1545015450+ mirror to the table; `open(path)` creates the table then loads existing rows. A disabled store
1545115451+ (`db == None` — the `new()`/`default()` path used in tests and when no `database {}` is configured)
1545215452+ makes every persistence call a cheap no-op.
1545315453+- **K-lines drop expired on load + prune** — a stored absolute `expires` stays valid across a reboot;
1545415454+ `load` deletes `expires <= now` rows up front, and `prune` deletes them from disk too.
1545515455+- **Reservations: only runtime (`conf == false`) persist** — `add` (always a command/inbound runtime
1545615456+ reservation) is persisted; `seed_config` is **not** (config `resv {}` re-derives from `ircd.kdl` each
1545715457+ boot — persisting it would double-seed). `open` loads stored rows as `conf == false`, so a `REHASH`
1545815458+ (which replaces only `conf == true`) never drops a persisted reservation. Boot order: `open` (load
1545915459+ runtime) → `seed_config` (config alongside).
1546015460+- **Case-insensitive primary key** — `COLLATE NOCASE` on `(user, host)` / `(mask, channel)` makes an
1546115461+ `INSERT OR REPLACE` of a case-differing re-add refresh the row rather than duplicate it, matching the
1546215462+ in-memory case-folded dedup.
1546315463+- **Errors are logged, never fatal** — an open failure falls back to an in-memory store (boot
1546415464+ continues); a write error is logged. Persistence must never break a command or abort boot.
1546515465+- **Inbound S2S bans/reservations persist too** — `apply_encap_kline`/`apply_encap_resv` call `add`, so
1546615466+ a server persists what it *learned* from the network (survives *its* restart) with no special
1546715467+ handling — persistence lives entirely in `add`/`remove`.
1546815468+1546915469+**Mechanism.** `kline.rs`/`resv.rs` each gain a `db: Option<Mutex<Connection>>` field (manual `Debug`,
1547015470+`#[derive(Default)]` keeps `new()` in-memory), an `open(path)` (WAL + busy_timeout + DDL + `load`), a
1547115471+`load` (kline: delete-expired-then-SELECT; resv: SELECT as `conf == false`), and `persist_add`/
1547215472+`persist_remove` write-through helpers called from `add`/`remove`/`prune` after the Vec mutation;
1547315473+`server.rs::from_config` opens each store against `cfg.database.path` (log + fall back to `new()` on
1547415474+error), resv then `seed_config`s alongside; the `database {}` block comment in `examples/`+`dist/ircd.kdl`
1547515475+notes the extended use.
1547615476+1547715477+**Tests (TDD + fuzzing).**
1547815478+- `kline.rs` units (5): a persisted ban survives a reopen (+ inverse a removed one is gone); a
1547915479+ case-differing re-add does not duplicate on disk; an expired ban is not reloaded; `prune` drops
1548015480+ expired rows from disk; an in-memory store writes nothing.
1548115481+- `resv.rs` units (3): a persisted reservation (channel + nick) survives a reopen (kind + `conf ==
1548215482+ false` round-trip; inverse a removed one gone); config reservations are **not** persisted; an
1548315483+ in-memory store writes nothing.
1548415484+- `server.rs` end-to-end (2): a `database {}` block persists a kline + resv across two `from_config`
1548515485+ boots; **inverse** with no block nothing persists.
1548615486+- **Fuzz** (2, one per store): an arbitrary add/remove sequence on a file-backed store reloads to
1548715487+ exactly the in-memory live set (`the_persisted_set_matches_after_reopen`), 24 cases, unique temp DB
1548815488+ per case cleaned up with its WAL/SHM sidecars.
1548915489+1549015490+**Plan:** [`docs/superpowers/plans/2026-06-19-p11-slice282-sqlite-ban-resv-persistence.md`](../superpowers/plans/2026-06-19-p11-slice282-sqlite-ban-resv-persistence.md)
1549115491+1549215492+**Gate:** `cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build -p leveva` 0 warnings.
···11+# P11 slice 282 — SQLite persistence for K-lines + reservations
22+33+## Goal
44+55+Operator-laid temporary K-lines (`TKLINE`) and nick/channel reservations (`RESV`) are lost on
66+restart — they live only in the in-memory `KlineStore`/`ResvStore`. Persist them to the **same
77+`database {}` SQLite file** that already backs chat-history (slice 274), so an operator's bans and
88+reservations survive a reboot. No new config: the existing `database {}` block now also opens the
99+ban/resv tables. Without the block, both stores stay in-memory only — exactly as before.
1010+1111+## Scope decisions (locked)
1212+1313+- **Reuse the `database {}` file, new tables.** Each store opens its own write-through SQLite
1414+ connection to `cfg.database.path` (multiple connections to one WAL file is standard), with a
1515+ `kline` / `resv` table. Mirrors the self-contained `History` pattern; the in-memory `Vec` stays
1616+ the hot-path authority, the connection is a durable mirror.
1717+- **Write-through on mutate, load on boot.** `add`/`remove`/`prune` (kline) and `add`/`remove`
1818+ (resv) mirror to the table; `open(path)` creates the table then loads existing rows into the Vec.
1919+ Disabled stores (`db == None`, the `new()`/`default()` path used everywhere in tests and when no
2020+ `database {}` is configured) make every persistence call a cheap no-op.
2121+- **K-lines: drop expired on load + prune.** Stored absolute `expires` stays valid across a reboot;
2222+ `load` deletes `expires <= now` rows up front so an expired ban neither reloads nor lingers, and
2323+ `prune` deletes them from disk too.
2424+- **Reservations: only runtime (`conf == false`) persist.** `add` (always a command/inbound runtime
2525+ reservation) is persisted; `seed_config` is **not** (config `resv {}` re-derives from `ircd.kdl`
2626+ each boot — persisting it would double-seed). `open` loads stored rows as `conf == false`, so a
2727+ `REHASH` (which replaces only `conf == true`) never drops a persisted reservation. Boot order:
2828+ `open` (load runtime) → `seed_config` (add config alongside).
2929+- **Case-insensitive primary key.** `COLLATE NOCASE` on `(user, host)` / `(mask, channel)` makes an
3030+ `INSERT OR REPLACE` of a case-differing re-add refresh the row rather than duplicate it, matching
3131+ the in-memory case-folded dedup.
3232+- **Errors are logged, never fatal.** An open failure falls back to an in-memory store (boot
3333+ continues); a write error is logged — persistence must never break a command or abort boot.
3434+- **Inbound S2S bans/reservations persist too** — `apply_encap_kline`/`apply_encap_resv` call
3535+ `add`, so a server persists what it *learned* from the network (survives *its* restart). No
3636+ special handling — persistence lives entirely in `add`/`remove`.
3737+3838+## Mechanism
3939+4040+### `kline.rs` (`KlineStore`)
4141+- `db: Option<Mutex<Connection>>` field; manual `Debug`; `#[derive(Default)]` keeps `new()`
4242+ in-memory.
4343+- `open(path)` (WAL + busy_timeout + DDL + `load`); `load` (delete-expired then SELECT into Vec);
4444+ `persist_add` (`INSERT OR REPLACE`), `persist_remove` (`DELETE … WHERE user=? AND host=?`).
4545+- `add`/`remove`/`prune` call the write-through after mutating the Vec (Vec guard dropped first).
4646+4747+### `resv.rs` (`ResvStore`)
4848+- Same shape: `db` field, manual `Debug`, `open`/`load`/`persist_add`/`persist_remove`. `load`
4949+ reads rows as `conf == false`. `add`/`remove` write through; `seed_config` does **not**.
5050+5151+### `server.rs` (`from_config`)
5252+- `klines`: `KlineStore::open(path)` when `cfg.database` is `Some` (log + fall back to `new()` on
5353+ error); else `new()`.
5454+- `resvs`: `ResvStore::open(path)`-or-`new()`, then `seed_config(config entries)`.
5555+5656+### `examples/ircd.kdl` + `dist/ircd.kdl`
5757+- Extend the `database {}` block comment to note it now also persists K-lines + reservations.
5858+5959+## Tests (TDD + fuzzing)
6060+6161+- **`kline.rs` units:** a persisted ban survives a reopen (+ inverse: a removed one is gone); a
6262+ case-differing re-add does not duplicate on disk; an expired ban is not reloaded; `prune` drops
6363+ expired rows from disk; an in-memory store writes nothing. **Fuzz:** an arbitrary add/remove
6464+ sequence on a file-backed store reloads to exactly the in-memory live set.
6565+- **`resv.rs` units:** a persisted reservation (channel + nick) survives a reopen (kind + `conf ==
6666+ false` round-trip; inverse: a removed one gone); config reservations are **not** persisted; an
6767+ in-memory store writes nothing. **Fuzz:** add/remove sequence reloads to the in-memory set.
6868+- **`server.rs` end-to-end:** a `database {}` block persists a kline + resv across two
6969+ `from_config` boots; **inverse:** with no block nothing persists.
7070+7171+Each test uses a unique temp DB path (`temp_dir()` + pid + atomic counter) cleaned up with its
7272+WAL/SHM sidecars; proptests are capped at 24 cases (file I/O).
7373+7474+## Gate
7575+7676+`cargo test -p leveva` green; `cargo clippy -p leveva --tests` clean; `cargo build -p leveva`
7777+0 warnings. Then PLAN.md P11 row slice count bump, `docs/progress-log/p11.md` entry, `kline.rs`/
7878+`resv.rs` module-doc persistence note, a memory touch.
+5
leveva/dist/ircd.kdl
···332332// NOTICE and each JOIN / PART / QUIT is appended to the database — and a
333333// joining client is replayed recent lines (unless it negotiated
334334// draft/chathistory, in which case it queries history itself).
335335+//
336336+// This same database file ALSO persists operator-laid K-lines (TKLINE) and
337337+// nick/channel reservations (RESV) so they survive a restart: each is written
338338+// through on change and reloaded at boot. Without this block they are kept in
339339+// memory only (lost on restart), exactly as before.
335340// ===========================================================================
336341database {
337342 // SQLite database file (created if absent). ":memory:" keeps history only
+5
leveva/examples/ircd.kdl
···332332// NOTICE and each JOIN / PART / QUIT is appended to the database — and a
333333// joining client is replayed recent lines (unless it negotiated
334334// draft/chathistory, in which case it queries history itself).
335335+//
336336+// This same database file ALSO persists operator-laid K-lines (TKLINE) and
337337+// nick/channel reservations (RESV) so they survive a restart: each is written
338338+// through on change and reloaded at boot. Without this block they are kept in
339339+// memory only (lost on restart), exactly as before.
335340// ===========================================================================
336341database {
337342 // SQLite database file (created if absent). ":memory:" keeps history only
+315-19
leveva/src/kline.rs
···1515//! **own local** matching clients (a remote user is reaped by *its* server). The store itself is
1616//! unchanged; the network plane lives entirely in [`crate::s2s::kline`].
1717//!
1818+//! **Persistence (P11 slice 282).** When opened against a `database {}` SQLite file
1919+//! ([`KlineStore::open`]) the store is write-through: bans survive a restart (see the
2020+//! [`KlineStore`] docs). A `new()` store is in-memory only, as before.
2121+//!
1822//! This mirrors the IRCnet 2.11 `s_conf.c` TKLINE cluster (the `tkconf` list +
1923//! `do_kline`/`tkline_expire`), but idiomatically: an owned [`Vec`] behind a
2024//! [`Mutex`] rather than an intrusive `aConfItem` chain, glob masks matched with
···3438//! arithmetic is `i32`-wrapping before widening to `i64`, exactly as the C does.
35393640use std::sync::Mutex;
4141+4242+use rusqlite::{params, Connection};
37433844use crate::matching;
3945···143149 }
144150}
145151152152+/// The DDL for the optional persistence table. `COLLATE NOCASE` makes the `(user, host)` primary
153153+/// key case-insensitive, matching the in-memory store's case-folded dedup, so an `INSERT OR REPLACE`
154154+/// of a case-differing re-`TKLINE` refreshes the row rather than stacking a duplicate.
155155+const KLINE_SCHEMA: &str = "
156156+CREATE TABLE IF NOT EXISTS kline (
157157+ user TEXT NOT NULL COLLATE NOCASE,
158158+ host TEXT NOT NULL COLLATE NOCASE,
159159+ reason TEXT NOT NULL,
160160+ expires INTEGER NOT NULL,
161161+ PRIMARY KEY (user, host)
162162+);
163163+";
164164+146165/// The shared temporary-ban list. Authority on who is banned, consulted both when a
147166/// ban is laid (to reap matching connections) and at every registration (to refuse
148167/// matching clients). Cheap to clone-by-`Arc` via [`crate::server::ServerContext`].
149149-#[derive(Debug, Default)]
168168+///
169169+/// **Persistence (P11 slice 282).** When opened against a `database {}` file ([`KlineStore::open`])
170170+/// the store carries a write-through SQLite connection: every [`add`](Self::add)/[`remove`](Self::remove)/
171171+/// [`prune`](Self::prune) is mirrored to the `kline` table, and [`open`](Self::open) loads the
172172+/// surviving (non-expired) bans back into memory at boot, so an operator's bans outlive a restart.
173173+/// A store built with [`new`](Self::new)/[`default`](Self::default) (`db == None`) is in-memory only
174174+/// — every persistence path is a no-op — exactly as before the slice.
175175+#[derive(Default)]
150176pub struct KlineStore {
151177 inner: Mutex<Vec<Kline>>,
178178+ /// `None` for an in-memory-only store; otherwise the write-through SQLite connection to the
179179+ /// `database {}` file. The in-memory [`inner`](Self::inner) Vec stays the hot-path authority;
180180+ /// this is a durable mirror updated on every mutation.
181181+ db: Option<Mutex<Connection>>,
182182+}
183183+184184+impl std::fmt::Debug for KlineStore {
185185+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186186+ f.debug_struct("KlineStore")
187187+ .field("len", &self.len())
188188+ .field("persistent", &self.db.is_some())
189189+ .finish()
190190+ }
152191}
153192154193impl KlineStore {
155155- /// An empty store.
194194+ /// An empty, in-memory-only store (no persistence).
156195 pub fn new() -> Self {
157196 Self::default()
158197 }
159198199199+ /// Open (creating if absent) the SQLite database at `path`, initialise the `kline` table, and
200200+ /// **load** the surviving (not-yet-expired) bans into memory — so a `TKLINE` laid before a
201201+ /// restart is still in force after it. `path` may be `:memory:` (an ephemeral per-connection
202202+ /// database, used by tests). Returns the rusqlite error on failure so the caller can log and
203203+ /// fall back to an in-memory store.
204204+ pub fn open(path: &str) -> rusqlite::Result<KlineStore> {
205205+ let conn = Connection::open(path)?;
206206+ let _ = conn.pragma_update(None, "journal_mode", "WAL");
207207+ let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
208208+ conn.execute_batch(KLINE_SCHEMA)?;
209209+ let store = KlineStore {
210210+ inner: Mutex::new(Vec::new()),
211211+ db: Some(Mutex::new(conn)),
212212+ };
213213+ store.load();
214214+ Ok(store)
215215+ }
216216+217217+ /// Load the persisted bans into memory at boot: drop any row that has already expired, then
218218+ /// read the rest into the in-memory Vec. A no-op for an in-memory-only store. Errors are logged,
219219+ /// never propagated — a corrupt history row must not abort boot.
220220+ fn load(&self) {
221221+ let Some(lock) = &self.db else { return };
222222+ let now = now_secs();
223223+ let loaded: Vec<Kline> = {
224224+ let conn = lock.lock().unwrap();
225225+ // Reap rows that expired while we were down, so they neither load nor linger.
226226+ let _ = conn.execute("DELETE FROM kline WHERE expires <= ?1", params![now]);
227227+ let mut stmt = match conn.prepare("SELECT user, host, reason, expires FROM kline") {
228228+ Ok(s) => s,
229229+ Err(e) => {
230230+ tracing::warn!(error = %e, "kline: prepare load failed");
231231+ return;
232232+ }
233233+ };
234234+ let rows = stmt.query_map([], |r| {
235235+ Ok(Kline {
236236+ user: r.get(0)?,
237237+ host: r.get(1)?,
238238+ reason: r.get(2)?,
239239+ expires: r.get(3)?,
240240+ })
241241+ });
242242+ match rows {
243243+ Ok(it) => it.filter_map(Result::ok).collect(),
244244+ Err(e) => {
245245+ tracing::warn!(error = %e, "kline: load query failed");
246246+ return;
247247+ }
248248+ }
249249+ };
250250+ *self.inner.lock().unwrap() = loaded;
251251+ }
252252+253253+ /// Write-through helper: upsert one ban into the `kline` table. A no-op when not persistent.
254254+ fn persist_add(&self, user: &str, host: &str, reason: &str, expires: i64) {
255255+ let Some(lock) = &self.db else { return };
256256+ let conn = lock.lock().unwrap();
257257+ if let Err(e) = conn.execute(
258258+ "INSERT OR REPLACE INTO kline (user, host, reason, expires) VALUES (?1, ?2, ?3, ?4)",
259259+ params![user, host, reason, expires],
260260+ ) {
261261+ tracing::warn!(error = %e, "kline: persist add failed");
262262+ }
263263+ }
264264+265265+ /// Write-through helper: delete one ban (by case-insensitive `(user, host)`). No-op when not
266266+ /// persistent.
267267+ fn persist_remove(&self, user: &str, host: &str) {
268268+ let Some(lock) = &self.db else { return };
269269+ let conn = lock.lock().unwrap();
270270+ if let Err(e) = conn.execute(
271271+ "DELETE FROM kline WHERE user = ?1 AND host = ?2",
272272+ params![user, host],
273273+ ) {
274274+ tracing::warn!(error = %e, "kline: persist remove failed");
275275+ }
276276+ }
277277+160278 /// Add a ban, or **refresh** an existing one. The key is `(user, host)` matched
161279 /// case-insensitively (the C `do_kline` walks `tkconf` with `strcasecmp` on both
162280 /// fields); a re-`TKLINE` of the same mask updates its reason and expiry rather
163281 /// than stacking a duplicate.
164282 pub fn add(&self, user: &str, host: &str, reason: &str, expires: i64) {
165165- let mut v = self.inner.lock().unwrap();
166166- if let Some(k) = v
167167- .iter_mut()
168168- .find(|k| k.user.eq_ignore_ascii_case(user) && k.host.eq_ignore_ascii_case(host))
169283 {
170170- k.reason = reason.to_string();
171171- k.expires = expires;
172172- return;
284284+ let mut v = self.inner.lock().unwrap();
285285+ if let Some(k) = v
286286+ .iter_mut()
287287+ .find(|k| k.user.eq_ignore_ascii_case(user) && k.host.eq_ignore_ascii_case(host))
288288+ {
289289+ k.reason = reason.to_string();
290290+ k.expires = expires;
291291+ } else {
292292+ v.push(Kline {
293293+ user: user.to_string(),
294294+ host: host.to_string(),
295295+ reason: reason.to_string(),
296296+ expires,
297297+ });
298298+ }
173299 }
174174- v.push(Kline {
175175- user: user.to_string(),
176176- host: host.to_string(),
177177- reason: reason.to_string(),
178178- expires,
179179- });
300300+ // Mirror to the durable store (no-op when in-memory-only). The case-insensitive PK upserts
301301+ // a refresh in place, matching the in-memory dedup above.
302302+ self.persist_add(user, host, reason, expires);
180303 }
181304182305 /// Remove the ban whose mask equals `(user, host)` (case-insensitively).
183306 /// Returns whether anything was removed (the C `m_untkline` only notices on a
184307 /// real deletion).
185308 pub fn remove(&self, user: &str, host: &str) -> bool {
186186- let mut v = self.inner.lock().unwrap();
187187- let before = v.len();
188188- v.retain(|k| !(k.user.eq_ignore_ascii_case(user) && k.host.eq_ignore_ascii_case(host)));
189189- v.len() != before
309309+ let removed = {
310310+ let mut v = self.inner.lock().unwrap();
311311+ let before = v.len();
312312+ v.retain(|k| !(k.user.eq_ignore_ascii_case(user) && k.host.eq_ignore_ascii_case(host)));
313313+ v.len() != before
314314+ };
315315+ // Always mirror the delete (cheap, idempotent) so a persisted ban is dropped even if the
316316+ // in-memory copy was already gone.
317317+ self.persist_remove(user, host);
318318+ removed
190319 }
191320192321 /// The first **active** (not-yet-expired at `now`) kline whose mask covers the
···204333 /// entries, so this only reclaims memory.
205334 pub fn prune(&self, now: i64) {
206335 self.inner.lock().unwrap().retain(|k| now < k.expires);
336336+ // Drop the same expired rows from the durable store (no-op when in-memory-only).
337337+ if let Some(lock) = &self.db {
338338+ let conn = lock.lock().unwrap();
339339+ if let Err(e) = conn.execute("DELETE FROM kline WHERE expires <= ?1", params![now]) {
340340+ tracing::warn!(error = %e, "kline: persist prune failed");
341341+ }
342342+ }
207343 }
208344209345 /// A snapshot of the current bans (for tests / introspection).
···459595 let hit = config_ban_match(&bans, "bob", "10.0.0.7", "r").expect("matched");
460596 // The broad earlier block wins (config order), not the narrower later one.
461597 assert_eq!(hit.mask, "*@*");
598598+ }
599599+600600+ // ── slice 282: SQLite persistence (write-through + boot reload) ────────────────
601601+602602+ /// A unique temp DB path per call, so parallel tests never collide on one file.
603603+ fn unique_db_path(tag: &str) -> std::path::PathBuf {
604604+ use std::sync::atomic::{AtomicU64, Ordering};
605605+ static N: AtomicU64 = AtomicU64::new(0);
606606+ let n = N.fetch_add(1, Ordering::Relaxed);
607607+ std::env::temp_dir().join(format!(
608608+ "leveva-kline-{tag}-{}-{n}.db",
609609+ std::process::id()
610610+ ))
611611+ }
612612+613613+ /// Remove a temp DB and its WAL/SHM sidecars.
614614+ fn cleanup(p: &std::path::Path) {
615615+ let _ = std::fs::remove_file(p);
616616+ let _ = std::fs::remove_file(format!("{}-wal", p.display()));
617617+ let _ = std::fs::remove_file(format!("{}-shm", p.display()));
618618+ }
619619+620620+ #[test]
621621+ fn a_persisted_kline_survives_a_reopen() {
622622+ let path = unique_db_path("survive");
623623+ cleanup(&path);
624624+ let ps = path.to_str().unwrap();
625625+ let now = now_secs();
626626+ {
627627+ let s = KlineStore::open(ps).unwrap();
628628+ s.add("bob", "*.evil.net", "spam", now + 3600);
629629+ s.add("eve", "*.bad.org", "abuse", now + 7200);
630630+ } // drop → connection closed, like a restart
631631+ // Reopen: both bans are back and active.
632632+ let s2 = KlineStore::open(ps).unwrap();
633633+ assert_eq!(s2.len(), 2, "both bans reloaded");
634634+ assert_eq!(
635635+ s2.find_active("bob", "mail.evil.net", now).unwrap().reason,
636636+ "spam"
637637+ );
638638+ // Inverse: lift one, reopen again — only it is gone.
639639+ assert!(s2.remove("bob", "*.evil.net"));
640640+ drop(s2);
641641+ let s3 = KlineStore::open(ps).unwrap();
642642+ assert_eq!(s3.len(), 1);
643643+ assert!(s3.find_active("bob", "mail.evil.net", now).is_none());
644644+ assert!(s3.find_active("eve", "x.bad.org", now).is_some());
645645+ cleanup(&path);
646646+ }
647647+648648+ #[test]
649649+ fn a_refreshed_kline_does_not_duplicate_on_disk() {
650650+ let path = unique_db_path("refresh");
651651+ cleanup(&path);
652652+ let ps = path.to_str().unwrap();
653653+ let now = now_secs();
654654+ {
655655+ let s = KlineStore::open(ps).unwrap();
656656+ s.add("bob", "*.evil.net", "first", now + 3600);
657657+ // Case-differing re-add refreshes in place (NOCASE primary key), not a 2nd row.
658658+ s.add("BOB", "*.EVIL.NET", "second", now + 9999);
659659+ }
660660+ let s2 = KlineStore::open(ps).unwrap();
661661+ assert_eq!(s2.len(), 1, "the refresh upserted, not duplicated");
662662+ assert_eq!(
663663+ s2.find_active("bob", "x.evil.net", now).unwrap().reason,
664664+ "second"
665665+ );
666666+ cleanup(&path);
667667+ }
668668+669669+ #[test]
670670+ fn an_expired_kline_is_not_reloaded() {
671671+ let path = unique_db_path("expired");
672672+ cleanup(&path);
673673+ let ps = path.to_str().unwrap();
674674+ let now = now_secs();
675675+ {
676676+ let s = KlineStore::open(ps).unwrap();
677677+ s.add("live", "*.h", "r", now + 3600);
678678+ s.add("dead", "*.h", "r", now - 10); // already expired when written
679679+ }
680680+ // Reopen well after `dead` expired: only the live ban loads (boot drops expired rows).
681681+ let s2 = KlineStore::open(ps).unwrap();
682682+ assert_eq!(s2.len(), 1, "the expired ban is not reloaded");
683683+ assert!(s2.find_active("dead", "x.h", now).is_none());
684684+ assert!(s2.find_active("live", "x.h", now).is_some());
685685+ cleanup(&path);
686686+ }
687687+688688+ #[test]
689689+ fn prune_drops_expired_rows_from_disk_too() {
690690+ let path = unique_db_path("prune");
691691+ cleanup(&path);
692692+ let ps = path.to_str().unwrap();
693693+ let now = now_secs();
694694+ {
695695+ let s = KlineStore::open(ps).unwrap();
696696+ s.add("a", "h1", "r", now + 100);
697697+ s.add("b", "h2", "r", now + 3600);
698698+ s.prune(now + 200); // expires the first
699699+ }
700700+ // The pruned row is gone from disk, not just memory.
701701+ let s2 = KlineStore::open(ps).unwrap();
702702+ let users: Vec<String> = s2.entries().iter().map(|k| k.user.clone()).collect();
703703+ assert_eq!(users, vec!["b".to_string()]);
704704+ cleanup(&path);
705705+ }
706706+707707+ #[test]
708708+ fn an_in_memory_store_writes_nothing() {
709709+ // A `new()` store has no DB — add/remove never touch disk and never panic.
710710+ let s = KlineStore::new();
711711+ s.add("bob", "h", "r", 100);
712712+ assert!(s.remove("bob", "h"));
713713+ s.prune(0); // no-op
714714+ }
715715+716716+ proptest::proptest! {
717717+ #![proptest_config(proptest::test_runner::Config::with_cases(24))]
718718+719719+ /// After an arbitrary add/remove sequence on a file-backed store, **reopening** it yields
720720+ /// exactly the live `(user, host)` set that was in memory before the close — write-through
721721+ /// persistence is faithful and total.
722722+ #[test]
723723+ fn the_persisted_set_matches_after_reopen(
724724+ ops in proptest::collection::vec(
725725+ (proptest::bool::ANY, "[a-c]{1,2}", "[a-c.]{1,3}"),
726726+ 1..14,
727727+ ),
728728+ ) {
729729+ use std::collections::BTreeSet;
730730+ let path = unique_db_path("fuzz");
731731+ cleanup(&path);
732732+ let ps = path.to_str().unwrap();
733733+ let now = now_secs();
734734+ let in_memory: BTreeSet<(String, String)> = {
735735+ let s = KlineStore::open(ps).unwrap();
736736+ for (is_add, user, host) in &ops {
737737+ if *is_add {
738738+ s.add(user, host, "r", now + 3600);
739739+ } else {
740740+ s.remove(user, host);
741741+ }
742742+ }
743743+ s.entries()
744744+ .iter()
745745+ .map(|k| (k.user.to_ascii_lowercase(), k.host.to_ascii_lowercase()))
746746+ .collect()
747747+ };
748748+ // Reopen and compare.
749749+ let reloaded: BTreeSet<(String, String)> = KlineStore::open(ps)
750750+ .unwrap()
751751+ .entries()
752752+ .iter()
753753+ .map(|k| (k.user.to_ascii_lowercase(), k.host.to_ascii_lowercase()))
754754+ .collect();
755755+ cleanup(&path);
756756+ proptest::prop_assert_eq!(reloaded, in_memory);
757757+ }
462758 }
463759464760 proptest::proptest! {
+256-18
leveva/src/resv.rs
···2525//! network-wide** as `ENCAP * RESV`/`UNRESV` and re-assert to a freshly-linked peer via the
2626//! netburst (slice 280, [`crate::s2s::resv`]); config `resv {}` reservations stay per-server local
2727//! policy. Timed `RESV <time>` and reaping an already-present holder remain documented follow-ons.
2828+//!
2929+//! **Persistence (P11 slice 282).** When opened against a `database {}` SQLite file
3030+//! ([`ResvStore::open`]) the store is write-through: runtime (`conf == false`) reservations survive a
3131+//! restart (config-seeded ones re-derive from `ircd.kdl`; see the [`ResvStore`] docs). A `new()`
3232+//! store is in-memory only, as before.
28332934use std::sync::Mutex;
3535+use std::time::Duration;
3636+3737+use rusqlite::{params, Connection};
30383139use crate::matching;
3240···6169 mask.replace('#', "\\#")
6270}
63717272+/// The DDL for the optional persistence table. Only **runtime** (operator-laid, `conf == false`)
7373+/// reservations are stored — config `resv {}` entries are re-derived from `ircd.kdl` each boot, so
7474+/// persisting them would double-seed. `COLLATE NOCASE` on the mask makes the `(mask, channel)`
7575+/// primary key case-insensitive, matching the in-memory dedup.
7676+const RESV_SCHEMA: &str = "
7777+CREATE TABLE IF NOT EXISTS resv (
7878+ mask TEXT NOT NULL COLLATE NOCASE,
7979+ reason TEXT NOT NULL,
8080+ channel INTEGER NOT NULL,
8181+ PRIMARY KEY (mask, channel)
8282+);
8383+";
8484+6485/// The shared reservation list. Authority on which names are reserved; consulted at every
6586/// non-operator JOIN (channel reservations) and NICK (nick reservations). Cheap to
6687/// clone-by-`Arc` via [`crate::server::ServerContext`].
6767-#[derive(Debug, Default)]
8888+///
8989+/// **Persistence (P11 slice 282).** When opened against a `database {}` file ([`ResvStore::open`])
9090+/// the store carries a write-through SQLite connection: every runtime [`add`](Self::add)/
9191+/// [`remove`](Self::remove) is mirrored to the `resv` table, and [`open`](Self::open) loads the
9292+/// stored reservations back at boot, so an operator's `RESV`s outlive a restart. Config-seeded
9393+/// reservations ([`seed_config`](Self::seed_config)) are **not** persisted (they re-derive from
9494+/// `ircd.kdl`). A store built with [`new`](Self::new)/[`default`](Self::default) (`db == None`) is
9595+/// in-memory only — every persistence path is a no-op.
9696+#[derive(Default)]
6897pub struct ResvStore {
6998 inner: Mutex<Vec<Resv>>,
9999+ /// `None` for an in-memory-only store; otherwise the write-through SQLite connection to the
100100+ /// `database {}` file. The in-memory Vec stays the hot-path authority; this is a durable mirror
101101+ /// of the **runtime** reservations only.
102102+ db: Option<Mutex<Connection>>,
103103+}
104104+105105+impl std::fmt::Debug for ResvStore {
106106+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107107+ f.debug_struct("ResvStore")
108108+ .field("len", &self.len())
109109+ .field("persistent", &self.db.is_some())
110110+ .finish()
111111+ }
70112}
7111372114impl ResvStore {
7373- /// An empty store.
115115+ /// An empty, in-memory-only store (no persistence).
74116 pub fn new() -> Self {
75117 Self::default()
76118 }
77119120120+ /// Open (creating if absent) the SQLite database at `path`, initialise the `resv` table, and
121121+ /// **load** the stored runtime reservations into memory (as `conf == false`). `path` may be
122122+ /// `:memory:` (an ephemeral per-connection database, used by tests). Returns the rusqlite error
123123+ /// on failure so the caller can log and fall back to an in-memory store. Call
124124+ /// [`seed_config`](Self::seed_config) **after** `open` so config reservations sit alongside the
125125+ /// loaded runtime ones.
126126+ pub fn open(path: &str) -> rusqlite::Result<ResvStore> {
127127+ let conn = Connection::open(path)?;
128128+ let _ = conn.pragma_update(None, "journal_mode", "WAL");
129129+ let _ = conn.busy_timeout(Duration::from_secs(5));
130130+ conn.execute_batch(RESV_SCHEMA)?;
131131+ let store = ResvStore {
132132+ inner: Mutex::new(Vec::new()),
133133+ db: Some(Mutex::new(conn)),
134134+ };
135135+ store.load();
136136+ Ok(store)
137137+ }
138138+139139+ /// Load the persisted runtime reservations into memory at boot. A no-op for an in-memory-only
140140+ /// store. Errors are logged, never propagated.
141141+ fn load(&self) {
142142+ let Some(lock) = &self.db else { return };
143143+ let loaded: Vec<Resv> = {
144144+ let conn = lock.lock().unwrap();
145145+ let mut stmt = match conn.prepare("SELECT mask, reason, channel FROM resv") {
146146+ Ok(s) => s,
147147+ Err(e) => {
148148+ tracing::warn!(error = %e, "resv: prepare load failed");
149149+ return;
150150+ }
151151+ };
152152+ let rows = stmt.query_map([], |r| {
153153+ Ok(Resv {
154154+ mask: r.get(0)?,
155155+ reason: r.get(1)?,
156156+ channel: r.get::<_, i64>(2)? != 0,
157157+ conf: false,
158158+ })
159159+ });
160160+ match rows {
161161+ Ok(it) => it.filter_map(Result::ok).collect(),
162162+ Err(e) => {
163163+ tracing::warn!(error = %e, "resv: load query failed");
164164+ return;
165165+ }
166166+ }
167167+ };
168168+ *self.inner.lock().unwrap() = loaded;
169169+ }
170170+171171+ /// Write-through helper: upsert one runtime reservation into the `resv` table. No-op when not
172172+ /// persistent.
173173+ fn persist_add(&self, mask: &str, reason: &str, channel: bool) {
174174+ let Some(lock) = &self.db else { return };
175175+ let conn = lock.lock().unwrap();
176176+ if let Err(e) = conn.execute(
177177+ "INSERT OR REPLACE INTO resv (mask, reason, channel) VALUES (?1, ?2, ?3)",
178178+ params![mask, reason, channel as i64],
179179+ ) {
180180+ tracing::warn!(error = %e, "resv: persist add failed");
181181+ }
182182+ }
183183+184184+ /// Write-through helper: delete reservation(s) by case-insensitive mask (either kind). No-op
185185+ /// when not persistent.
186186+ fn persist_remove(&self, mask: &str) {
187187+ let Some(lock) = &self.db else { return };
188188+ let conn = lock.lock().unwrap();
189189+ if let Err(e) = conn.execute("DELETE FROM resv WHERE mask = ?1", params![mask]) {
190190+ tracing::warn!(error = %e, "resv: persist remove failed");
191191+ }
192192+ }
193193+78194 /// Add a reservation, or **refresh** an existing one of the same mask **and kind**
79195 /// (case-insensitive mask key). A re-`RESV` of the same mask updates its reason rather
80196 /// than stacking a duplicate. A channel and a nick reservation that happen to share a
81197 /// mask string are kept as separate entries (different `channel` flag).
82198 pub fn add(&self, mask: &str, reason: &str, channel: bool) {
8383- let mut v = self.inner.lock().unwrap();
8484- if let Some(r) = v
8585- .iter_mut()
8686- .find(|r| r.channel == channel && r.mask.eq_ignore_ascii_case(mask))
87199 {
8888- r.reason = reason.to_string();
8989- return;
200200+ let mut v = self.inner.lock().unwrap();
201201+ if let Some(r) = v
202202+ .iter_mut()
203203+ .find(|r| r.channel == channel && r.mask.eq_ignore_ascii_case(mask))
204204+ {
205205+ r.reason = reason.to_string();
206206+ } else {
207207+ v.push(Resv {
208208+ mask: mask.to_string(),
209209+ reason: reason.to_string(),
210210+ channel,
211211+ conf: false,
212212+ });
213213+ }
90214 }
9191- v.push(Resv {
9292- mask: mask.to_string(),
9393- reason: reason.to_string(),
9494- channel,
9595- conf: false,
9696- });
215215+ // Mirror to the durable store (no-op when in-memory-only). A command/inbound `add` is
216216+ // always a runtime reservation, so it is persisted; config seeding bypasses this path.
217217+ self.persist_add(mask, reason, channel);
97218 }
9821999220 /// Replace all **config-sourced** reservations with `entries` (each `(mask, reason)`; the
···119240 /// kind. Returns whether anything was removed (charybdis `m_unresv` only confirms on a
120241 /// real deletion; leveva sends no confirmation either way — see [`crate::command::resv`]).
121242 pub fn remove(&self, mask: &str) -> bool {
122122- let mut v = self.inner.lock().unwrap();
123123- let before = v.len();
124124- v.retain(|r| !r.mask.eq_ignore_ascii_case(mask));
125125- v.len() != before
243243+ let removed = {
244244+ let mut v = self.inner.lock().unwrap();
245245+ let before = v.len();
246246+ v.retain(|r| !r.mask.eq_ignore_ascii_case(mask));
247247+ v.len() != before
248248+ };
249249+ // Always mirror the delete (cheap, idempotent). A config reservation isn't in the table, so
250250+ // a delete of its mask is a harmless no-op.
251251+ self.persist_remove(mask);
252252+ removed
126253 }
127254128255 /// The first **channel** reservation whose mask glob-matches `name`, if any. Consulted by
···276403 // … and the operator-laid runtime reservation survives the rehash.
277404 assert!(s.nick_reserved("Runtime").is_some());
278405 assert_eq!(s.len(), 2);
406406+ }
407407+408408+ // ── slice 282: SQLite persistence (write-through + boot reload) ────────────────
409409+410410+ fn unique_db_path(tag: &str) -> std::path::PathBuf {
411411+ use std::sync::atomic::{AtomicU64, Ordering};
412412+ static N: AtomicU64 = AtomicU64::new(0);
413413+ let n = N.fetch_add(1, Ordering::Relaxed);
414414+ std::env::temp_dir().join(format!("leveva-resv-{tag}-{}-{n}.db", std::process::id()))
415415+ }
416416+417417+ fn cleanup(p: &std::path::Path) {
418418+ let _ = std::fs::remove_file(p);
419419+ let _ = std::fs::remove_file(format!("{}-wal", p.display()));
420420+ let _ = std::fs::remove_file(format!("{}-shm", p.display()));
421421+ }
422422+423423+ #[test]
424424+ fn a_persisted_resv_survives_a_reopen() {
425425+ let path = unique_db_path("survive");
426426+ cleanup(&path);
427427+ let ps = path.to_str().unwrap();
428428+ {
429429+ let s = ResvStore::open(ps).unwrap();
430430+ s.add("#warez", "no warez", true);
431431+ s.add("Clone*", "no clones", false);
432432+ } // drop → like a restart
433433+ let s2 = ResvStore::open(ps).unwrap();
434434+ assert_eq!(s2.len(), 2, "both reservations reloaded");
435435+ // The kind round-trips: the channel one answers a channel query, the nick one a nick query.
436436+ assert!(s2.channel_reserved("#warez").is_some());
437437+ assert!(s2.nick_reserved("Clone7").is_some());
438438+ // Both are runtime (conf == false), so a later REHASH never drops them.
439439+ assert!(s2.entries().iter().all(|r| !r.conf));
440440+ // Inverse: lift one, reopen — only it is gone.
441441+ assert!(s2.remove("#warez"));
442442+ drop(s2);
443443+ let s3 = ResvStore::open(ps).unwrap();
444444+ assert_eq!(s3.len(), 1);
445445+ assert!(s3.channel_reserved("#warez").is_none());
446446+ assert!(s3.nick_reserved("Clone7").is_some());
447447+ cleanup(&path);
448448+ }
449449+450450+ #[test]
451451+ fn config_reservations_are_not_persisted() {
452452+ let path = unique_db_path("config");
453453+ cleanup(&path);
454454+ let ps = path.to_str().unwrap();
455455+ {
456456+ let s = ResvStore::open(ps).unwrap();
457457+ s.seed_config(&[("#services", "svc"), ("NickServ", "svc nick")]);
458458+ s.add("Clone*", "runtime", false); // the only persisted entry
459459+ assert_eq!(s.len(), 3);
460460+ }
461461+ // Reopen: only the runtime reservation is reloaded; config ones come from `ircd.kdl`.
462462+ let s2 = ResvStore::open(ps).unwrap();
463463+ assert_eq!(s2.len(), 1, "only the runtime RESV persisted");
464464+ assert!(s2.nick_reserved("Clone7").is_some());
465465+ assert!(s2.channel_reserved("#services").is_none());
466466+ cleanup(&path);
467467+ }
468468+469469+ #[test]
470470+ fn an_in_memory_store_writes_nothing() {
471471+ let s = ResvStore::new();
472472+ s.add("Clone*", "r", false);
473473+ assert!(s.remove("Clone*"));
474474+ s.seed_config(&[("#x", "y")]); // no panic, no disk
475475+ }
476476+477477+ proptest::proptest! {
478478+ #![proptest_config(proptest::test_runner::Config::with_cases(24))]
479479+480480+ /// After an arbitrary add/remove sequence on a file-backed store, **reopening** yields
481481+ /// exactly the live reservation set that was in memory before the close.
482482+ #[test]
483483+ fn the_persisted_set_matches_after_reopen(
484484+ ops in proptest::collection::vec(
485485+ (proptest::bool::ANY, "[#a-c*]{1,4}"),
486486+ 1..14,
487487+ ),
488488+ ) {
489489+ use std::collections::BTreeSet;
490490+ let path = unique_db_path("fuzz");
491491+ cleanup(&path);
492492+ let ps = path.to_str().unwrap();
493493+ // (mask_lc, channel) is the persisted identity.
494494+ let in_memory: BTreeSet<(String, bool)> = {
495495+ let s = ResvStore::open(ps).unwrap();
496496+ for (is_add, mask) in &ops {
497497+ if *is_add {
498498+ s.add(mask, "r", is_channel_target(mask));
499499+ } else {
500500+ s.remove(mask);
501501+ }
502502+ }
503503+ s.entries()
504504+ .iter()
505505+ .map(|r| (r.mask.to_ascii_lowercase(), r.channel))
506506+ .collect()
507507+ };
508508+ let reloaded: BTreeSet<(String, bool)> = ResvStore::open(ps)
509509+ .unwrap()
510510+ .entries()
511511+ .iter()
512512+ .map(|r| (r.mask.to_ascii_lowercase(), r.channel))
513513+ .collect();
514514+ cleanup(&path);
515515+ proptest::prop_assert_eq!(reloaded, in_memory);
516516+ }
279517 }
280518281519 #[test]
+106-4
leveva/src/server.rs
···329329 preload: cfg.options.sts_preload,
330330 },
331331 },
332332- klines: KlineStore::new(),
332332+ // Temporary K-lines (slice 282): persist to the `database {}` file if configured, so
333333+ // an operator's bans survive a restart; else an in-memory-only store. An open failure
334334+ // is logged and falls back to in-memory rather than aborting boot.
335335+ klines: match &cfg.database {
336336+ None => KlineStore::new(),
337337+ Some(db) => match KlineStore::open(&db.path) {
338338+ Ok(s) => {
339339+ tracing::info!(path = %db.path, "persistent K-lines enabled");
340340+ s
341341+ }
342342+ Err(e) => {
343343+ tracing::error!(path = %db.path, error = %e,
344344+ "cannot open K-line database; K-lines in-memory only");
345345+ KlineStore::new()
346346+ }
347347+ },
348348+ },
333349 net: crate::s2s::Network::new(),
334350 peers: crate::s2s::PeerLinks::new(),
335351 auth: AuthConfig {
···342358 linking: crate::link::LinkingSet::new(),
343359 knock_throttle: crate::knock_throttle::KnockThrottle::new(),
344360 knock_user_throttle: crate::knock_throttle::KnockThrottle::new(),
345345- // Seed the default `resv {}` reservations (slice 272). `REHASH` re-seeds via
346346- // `command::rehash` (replacing this config-sourced set, keeping runtime `RESV`s).
361361+ // Reservations: persist runtime `RESV`s to the `database {}` file if configured (slice
362362+ // 282; `open` loads the stored runtime reservations), else an in-memory store. Then seed
363363+ // the config `resv {}` defaults (slice 272) **alongside** the loaded runtime ones —
364364+ // `seed_config` retains the runtime entries. `REHASH` re-seeds via `command::rehash`
365365+ // (replacing the config-sourced set, keeping runtime `RESV`s).
347366 resvs: {
348348- let store = crate::resv::ResvStore::new();
367367+ let store = match &cfg.database {
368368+ None => crate::resv::ResvStore::new(),
369369+ Some(db) => match crate::resv::ResvStore::open(&db.path) {
370370+ Ok(s) => {
371371+ tracing::info!(path = %db.path, "persistent reservations enabled");
372372+ s
373373+ }
374374+ Err(e) => {
375375+ tracing::error!(path = %db.path, error = %e,
376376+ "cannot open reservation database; reservations in-memory only");
377377+ crate::resv::ResvStore::new()
378378+ }
379379+ },
380380+ };
349381 let entries: Vec<(String, String)> = cfg
350382 .resvs
351383 .iter()
···404436 #[test]
405437 fn version_is_leveva_prefixed() {
406438 assert!(VERSION.starts_with("leveva-"));
439439+ }
440440+441441+ /// End-to-end (slice 282): a `database {}` block wires write-through persistence into the
442442+ /// `klines`/`resvs` stores, so a ban/reservation laid on one boot is reloaded on the next.
443443+ #[test]
444444+ fn a_database_block_persists_klines_and_resvs_across_a_reboot() {
445445+ use std::sync::atomic::{AtomicU64, Ordering};
446446+ static N: AtomicU64 = AtomicU64::new(0);
447447+ let n = N.fetch_add(1, Ordering::Relaxed);
448448+ let path = std::env::temp_dir().join(format!(
449449+ "leveva-ctx-persist-{}-{n}.db",
450450+ std::process::id()
451451+ ));
452452+ let _ = std::fs::remove_file(&path);
453453+ let kdl = format!(
454454+ r#"
455455+ server {{ name "leveva.test"; description "T"; sid "0ABC" }}
456456+ admin {{ name "A"; email "a@test"; network "Net" }}
457457+ class "c" {{ ping-freq 90; max-links 10; sendq 1000 }}
458458+ database {{ path "{}" }}
459459+ "#,
460460+ path.display()
461461+ );
462462+ let cfg = crate::config::Config::from_kdl(&kdl).expect("valid config");
463463+464464+ // Boot 1: lay a kline and a resv (both runtime), then drop the context (a "restart").
465465+ {
466466+ let ctx = ServerContext::from_config(&cfg, "now".into(), None);
467467+ ctx.klines
468468+ .add("bob", "*.evil.net", "spam", crate::kline::now_secs() + 3600);
469469+ ctx.resvs.add("Clone*", "no clones", false);
470470+ }
471471+472472+ // Boot 2: a fresh context from the same config reloads both from disk.
473473+ let ctx2 = ServerContext::from_config(&cfg, "now".into(), None);
474474+ assert!(
475475+ ctx2.klines
476476+ .find_active("bob", "mail.evil.net", crate::kline::now_secs())
477477+ .is_some(),
478478+ "the kline reloaded across the reboot"
479479+ );
480480+ assert!(
481481+ ctx2.resvs.nick_reserved("Clone7").is_some(),
482482+ "the reservation reloaded across the reboot"
483483+ );
484484+485485+ let _ = std::fs::remove_file(&path);
486486+ let _ = std::fs::remove_file(format!("{}-wal", path.display()));
487487+ let _ = std::fs::remove_file(format!("{}-shm", path.display()));
488488+ }
489489+490490+ /// Inverse: with **no** `database {}` block the stores are in-memory only — nothing persists,
491491+ /// and a second boot starts clean (no panic, no shared file).
492492+ #[test]
493493+ fn without_a_database_block_nothing_persists() {
494494+ let kdl = r#"
495495+ server { name "leveva.test"; description "T"; sid "0ABC" }
496496+ admin { name "A"; email "a@test"; network "Net" }
497497+ class "c" { ping-freq 90; max-links 10; sendq 1000 }
498498+ "#;
499499+ let cfg = crate::config::Config::from_kdl(kdl).expect("valid config");
500500+ {
501501+ let ctx = ServerContext::from_config(&cfg, "now".into(), None);
502502+ ctx.klines.add("bob", "h", "x", crate::kline::now_secs() + 3600);
503503+ ctx.resvs.add("Clone*", "x", false);
504504+ }
505505+ let ctx2 = ServerContext::from_config(&cfg, "now".into(), None);
506506+ assert!(ctx2.klines.is_empty(), "no persistence without a database block");
507507+ // The resv store still seeds any config reservations (none here) but no runtime ones.
508508+ assert!(ctx2.resvs.runtime_entries().is_empty());
407509 }
408510}