commit 1a0915151417f4fd558b34e8f666225e8714e21d
Author: Xe Iaso <me@xeiaso.net>
Date:   Wed Jun 17 12:33:30 2026 -0400

    feat(leveva): +S TLS-only channel mode — P11 slice 233
    
    A leveva-native channel flag porting charybdis chm_sslonly (MODE_SSLONLY): a
    JOIN of a +S channel by a client NOT on a secure (TLS) transport is refused
    519 ERR_SSLONLYCHAN :Cannot join channel (+S) - SSL/TLS required and the client
    is not added. Builds on slice 227's +Z secure user mode and the +C/+c flag-mode
    wiring (220/232).
    
    A hard JOIN gate like +O: checked in Channels::check_join alongside +O, before
    the invite override, so INVITE/+I do not bypass it (a transport requirement,
    not an access list); ops not exempt; existing members keep their seat (only new
    joins gated, matching charybdis's can_join hook). KNOCK to a +S channel is
    refused like +O.
    
    The secure signal is the joiner's +Z (UserMode::Secure) bit via
    mode::is_secure(attrs.umodes), NOT attrs.is_ssl — the latter is certfp-based
    and undercounts TLS users with no client cert (the gap slice 227 closed). $z
    extban semantics are left untouched. check_join runs for local joins only;
    remote joins arrive via NJOIN and bypass it.
    
    ChanMode::SslOnly ('S', bit 0x4000000, MODE_SSLONLY, Flag) wired through both
    flag-mode allowlists (mode.rs ALL/as_char/bit/mode_name/from_char +
    command::mode::supported_flag). JoinReject::SecureOnly → join_reject maps to the
    new ErrSslonlychan = 519. CHANMODES/004/MYINFO auto-derive S.
    
    Pure seam channel::sslonly_blocks(set, secure). Tests: 2 unit
    (check_join_enforces_ssl_only incl. INVITE-no-bypass + -S round-trip;
    sslonly_blocks_truth_table), sslonly_proptest (gate keys only off +Z over
    arbitrary u32 umode masks; plain channel never gates; toggling +Z flips),
    golden_sslonly end-to-end (plaintext JOIN → 519, not added, NAMES confirms; -S
    re-opens), 9 regenerated 004/005 snapshots (chanmode string only).
    
    cargo test -p leveva 2298 pass; clippy clean; workspace builds 0 warnings.

diff --git a/PLAN.md b/PLAN.md
index 2faf80a9..d40111ee 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -90,7 +90,7 @@ The leverage point: every `foo.c` already ships `foo_ext.h` (its public `extern`
 | **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.** |
 | **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. |
 | **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). |
-| **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: 232 slices.** The slices span the full client command surface (registration, channels + channel/user modes, WHO/WHOIS/WHOX, OPER/STATS), the UID-based S2S link/burst/netsplit protocol (+ keepalive, live introduction, NJOIN chunking), `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident), config live-rehash, a channel-mode rework, CertFP, an ongoing **IRCv3 track**, and the native cross-daemon command-skeleton differential harness; an emerging **STS/TLS** track; 195 = generalize the restricted-reop ban into the `enforce_reop` chokepoint so a restricted user is never auto-reopped by *any* trigger; 196 = a restricted user cannot change nick (`484`), completing the IRCnet `IsRestricted` family; 197 = close the slice-191 follow-on — a `kline-exempt` I-line (the C `FLAGS_EXEMPT`) now survives a *runtime* `TKLINE` reap, not just the registration-time K-line gate; 198 = `KNOCK` (leveva-native, charybdis-style) — a user kept out of a `+i`/`+k`/`+l` channel asks its operators for an invite (`710`/`711`), gated by the shared JOIN `check_join` so KNOCK and JOIN never disagree (banned/`+O`/open/already-member all refused), S2S propagation deferred; 199 = close that follow-on — KNOCK now propagates as `ENCAP * KNOCK` (modelled on `s2s::metadata`/`su`) so a channel operator on *any* server is notified, not just local ops (the shared `deliver_knock` fan; `712` throttle still deferred); 200 = close that last follow-on — a per-channel KNOCK flood throttle (`knock_throttle::KnockThrottle`, charybdis `knock_delay_channel`, 60s): a channel admits at most one KNOCK per window, a knock inside it is refused `712 ERR_TOOMANYKNOCK` and notifies nobody (the KNOCK family 198/199/200 is now complete); 201 = command-rate flood protection (the IRCnet "fakelag" penalty clock, deferred since the start) — every registered client's command charges `1 + len/100` onto a per-connection clock that idle-decays to real time, and a sustained flood is closed with `ERROR :Closing Link: <host> (Excess Flood)`, lighting up the previously-dead `can-flood`/`no-penalty` oper exemptions; pure `flood::FloodClock`, armed by the serve loop (the `heartbeat` opt-in precedent), kill-only (fakelag *delay* + per-command cost table deferred); 202 = close that follow-on — a per-command penalty cost table (`flood::command_penalty`): `PONG` is exempt, whole-table scans (`WHO`/`WHOIS`/`WHOWAS`/`LIST`/`NAMES`) cost +4 and channel ops (`JOIN`/`KNOCK`/`INVITE`) +2 over the base, so a flood of expensive commands trips Excess Flood faster than a flood of cheap ones (leveva-native cost tiers; the fakelag *delay* half + a pre-registration count guard remain the follow-ons); 203 = close the pre-registration guard follow-on — the slice-201 penalty clock only billed *registered* clients, so an unregistered connection could spam commands unboundedly (every unknown verb `451`-replied); now `Session::dispatch` charges the same `FloodClock` for the `Registering` phase too (after QUIT, before CAP; `PONG` exempt, no oper exemption pre-reg), closing a pre-registration flood with the oracle's exact `ERROR ... (Excess Flood)`; 204 = close the last flood follow-on but one — the kill ceiling (`flood::FLOOD_KILL_AHEAD`, was a fixed `const 60`) is now the tunable `options { flood-kill-ahead N }` (`charge` takes it as a parameter; parser rejects `0`; default 60), hot-reloaded by `REHASH` via the slice-91/175 `ConfStore` live-or-default seam (no `ServerContext` field — flood is serve-loop-armed, so `main` always seeds the live store first) — only the fakelag *delay/smoothing* half now remains deferred; 205 = **phase 13 (refinement)** opens — `REHASH` hot-reloads `options { auto-connect }`: the outgoing auto-connect master switch (was read once at boot to *spawn* the dial loop) is now read **per tick** via the slice-91/204 `ConfStore` live seam (`live_auto_connect`), so the loop always spawns but skips dialing while off and an operator can start/stop the dialer with no restart — no `AbortHandle` task-management (an idle ticker is cheap), no `ServerContext` field (slice-204 precedent, `main` seeds before spawning); 206 = enforce + REHASH-able `options { connection-accept on|off|split }` — the previously parsed-but-dead new-connection admission gate is now enforced at registration finalize (`On` accepts, `Off` refuses everyone with a bare `ERROR :Closing Link: <host> (Server is not accepting connections)`, `Split` accepts only while the net is split per the now-live `split-min-servers`/`split-min-users` thresholds), live via the canonical `live_*().unwrap_or(ctx.…)` `ConfStore` seam (a ctx field this time — the gate's read site is unit-tested, unlike 204/205 — bundled into `StatsConf` so the 62 hand-rolled `ServerContext` literals stay churn-free via `StatsConf::default()`, the slice-191 `bans` precedent); pure `connaccept::is_split`/`admits` (no `options` field remains boot-immutable); 207 = `REHASH` hot-reloads the `iauth` block (ident flag, per-probe timeout, and the dnsbl/socks/webproxy/pipe/ident module set) — the **last** boot-immutable config block — via the `ConfStore` live seam: `ConfBlocks` carries the plain `Iauth` struct while the *built* module set (`Arc<dyn Module>`, not `Debug/Eq`) is cached in a separate `ArcSwapOption` rebuilt only at seed/REHASH (`build_auth_modules` made `pub(crate)`, the server name supplied by a new boot `IRCD_NAME` OnceLock mirroring `CONFIG_PATH`), so live connections keep one shared resolver/DNS cache and a REHASH swaps the set atomically; read sites = the serve loop's auth task (modules+timeout), `resolve_username` (the `~` rule's ident flag), and `STATS a` (the 239 module list); no config block remains boot-immutable now (only the unchangeable `server {}` identity stays fixed by nature); 208 = close the **last** deferred flood follow-on — the fakelag *delay/smoothing* half (kill-only since slice 201): while the penalty clock runs more than `flood::MAX_PENALTY` (10s, the oracle's fixed `MAXPENALTY`) ahead but below the kill ceiling, the serve loop now **delays** the connection's next read (`tokio::time::sleep_until` on the `select!` read arm, output arms still live) instead of disconnecting, pacing a flooder's *input* toward one command per second while its fan-out/keepalive keep flowing — pure `FloodClock::delay_secs` + `Session::flood_delay_secs` (0 when disabled/within-budget/exempt), `charge` unchanged so all 201–204 tests hold; the flood family is now complete (only a `MAX_PENALTY` config knob remains as an optional future refinement); 209 = close the **last** deferred KNOCK gap — the per-*user* throttle (charybdis `knock_delay`, 300s) alongside slice 200's per-*channel* one: a single knocker may deliver at most one KNOCK per window across *all* channels (keyed by UID in a second `knock_user_throttle: KnockThrottle`), so one user can no longer spray operators on many fresh channels; the command peeks both throttles (new `cooling`/`arm` split, arming neither unless the knock is delivered — charybdis updates its clocks on success only) and answers `712 ERR_TOOMANYKNOCK` if either is cooling — the KNOCK throttle family (200/209) is now complete; 210 = turn the last hard-coded throttle thresholds into `REHASH`-able `options` knobs — `flood::MAX_PENALTY`→`max-penalty`, charybdis `knock_delay`→`knock-delay`, `knock_delay_channel`→`knock-delay-channel` (all via the slice-204 `ConfStore` live seam; `0` legal = aggressive-smoothing / throttle-disabled), so no throttle budget remains a boot-immutable const; 211 = `sts` (IRCv3 Strict Transport Security) — leveva advertises the advisory-only `sts` cap in `CAP LS` to a `302+` client (a plaintext client gets `sts=port=<tls-port>` to upgrade, a TLS client `sts=duration=<n>[,preload]` to persist), gated on a real TLS client listener + `options { sts-duration N; sts-preload }` (`0`/absent = off); never in `SUPPORTED` so `CAP REQ sts` NAKs and it never shows in `CAP LIST` (pure `sts::StsPolicy::ls_value` + a new `Session.secure` flag set by `serve_client`; REHASH-ability of the knobs + wss-as-secure deferred); 212 = close the REHASH follow-on — the whole `sts` policy now rides the live config seam: `sts-duration`/`sts-preload` move onto the `ConfStore` (slice-204/210 idiom, `live_sts_duration`/`live_sts_preload`) and the TLS port is re-derived **live** from the running listener set (`session::live_sts_port` over `listeners::specs()`, which slice 137 already rebinds on REHASH), so an operator enables/retunes/disables STS — including binding a fresh TLS listener — with no restart (only wss-as-secure now remains deferred from the `sts` track); 213 = close that last STS deferral (wss-as-secure) — a TLS-protected WebSocket is now flagged `secure` so its `CAP LS` `sts` token is the persistence `duration=` not the upgrade `port=`: `serve_ws` finally calls `Session::set_secure`, true when the listener TLS-terminated the `wss://` connection **or** a trusted reverse proxy (`proxy-ranges`) forwarded `X-Forwarded-Proto: https`/`wss` (the pure spoof-resistant `websocket::forwarded_proto_secure` reusing the `resolve_host` trust gate), making the `sts` track complete; 214 = close the deferred extban "PRIVMSG/quiet plane" (slice 164) — a `+b` ban (or extban) now **mutes** a non-voiced channel member on the speech path, not just at JOIN: `Channels::can_send` builds the same `extban::Subject`/`LiveChannels` view `check_join` uses and returns a new `CanSend::Banned` (→ `404 ERR_CANNOTSENDTOCHAN`) when the sender matches a `+b` with no `+e` and is below voice rank (voice+ bypasses, exactly as for `+m`), so a ban set *after* a user joined — or a dynamic extban (`$a:account`/`$r:realname`/`$x`) they later match — silences them; wired into `PRIVMSG`/`NOTICE` (`command::message`), `TAGMSG`, and `REDACT` via the shared `command::speech_subject` helper (a dedicated `+q` quiet *list* mode is out of scope — `+q` is the elemental OWNER prefix); 215 = fix a production relink desync (updating a server made it strip channel modes that should have survived a merge) — a channel learned **for the first time in a burst** was re-clocked to the receiver's local nanosecond clock instead of adopting the origin's `created_at` (the leading `CHANTS` is dropped on an unknown channel, and the burst `NJOIN` was TS-less), so two servers disagreed on a channel's TS by *when each learned it* and the next merge deopped the later learner (slice 167's divergence, left unfixed on the burst path); the burst `NJOIN` now carries the channel TS as a middle param (`:<sid> NJOIN <chan> <ts> :<members>`, the live form since slice 167) so the receiver adopts the origin's TS, with the chunk budget + boot-snapshot TS-mask updated to match; 216 = caller-id — a leveva-native charybdis-style `+g` ("server-side ignore") user mode + `ACCEPT` command: a `+g` user receives private `PRIVMSG`/`NOTICE` only from clients on its accept list, else the sender gets `716` (+ once-per-sender `717`) and the target is told once (`718`), NOTICE silently dropped; the `ACCEPT` list (add/`-`remove/`*`list, `281`/`282`/`456`/`457`/`458`/`401`) is the pure `callerid::CallerId` store owned inside `Registry` (the `metadata` zero-churn precedent), entries are stable UIDs, `+g` is purely local (not in `SEND_UMODES` — the gate runs on the target's home server) so remote-sender gating + `+G` soft caller-id are documented follow-ons; `CALLERID=g` ISUPPORT; 217 = close the slice-216 remote-sender follow-on — a *remote* sender's `PRIVMSG`/`NOTICE` to a local `+g` target arrives via `s2s::relay::inbound_message`, which delivered it with no caller-id check; now the gate runs there too (the `+g` design is "enforce on the target's home server", which is us), bouncing `716` (+ once-per-`(target,sender)` `717`) back to the sender's uplink and telling the local target once (`718`) for a blocked PRIVMSG, silently dropping a blocked NOTICE, reusing the same `callerid` store + once-per-pair notify rate-limit as the local path (the KNOCK 198→199 local-then-S2S precedent); only `+G` soft caller-id now remains deferred from the caller-id track; 218 = close that **last** caller-id deferral — `+G` soft caller-id (charybdis `UMODE_SCALLERID`): a new client-settable, purely-local `UserMode::SoftCallerId` identical to `+g` except a sender sharing a channel with the target passes the gate without an accept; the block decision is now the pure `callerid::blocks(strict, soft, accepted, common_channel)` (single source of truth + fuzz target) wrapped by `command::message::caller_id_blocks`, shared by the local and remote-sender (slice 217) gates, with `Channels::shares_channel` the common-channel predicate — the caller-id track is now complete; 219 = server-split `+s` netsplit notice — closes the `SQUIT` "no server notices" divergence by firing `*** Notice -- Netsplit <s1> <s2> (<n> users)` to local `+s` watchers from the single `s2s::squit::squit_subtree` teardown chokepoint, so every split cause (oper `SQUIT`, inbound S2S `SQUIT`, **abnormal link termination** — socket drop / ping timeout) notifies exactly once (guarded on `departed.is_some()` so a no-op re-teardown stays silent; pure `split_notice_text` fuzz seam); 220 = `+C` no-CTCP channel mode (leveva-native, charybdis `MODE_NOCTCP`) — a CTCP query other than `ACTION` sent as a **PRIVMSG** to a `+C` channel is refused `404 ERR_CANNOTSENDTOCHAN :Channel doesn't accept CTCP` and not delivered (NOTICE/CTCP-reply never blocked, ops not exempt, distributed enforcement at the sender's home server like `+n`/`+m`/`+b`), extending the slice-214 speech-plane `can_send` work; pure `channel::is_blocked_ctcp` fuzz seam paired with `Channels::blocks_ctcp` (the mode half), the second `supported_flag` allowlist updated so the MODE command parser recognizes `C`; 221 = `760 RPL_WHOISKEYVALUE` (IRCv3 `draft/metadata-2`) — a `WHOIS` on a user with metadata set now exposes that metadata as `760 <requester> <target> <key> * :<value>` lines **to a requester that negotiated the `draft/metadata-2` cap** (no cap → no `760`, the inverse), one per key in sorted order, visibility always `*` (no privileged-key/restricted-visibility system — now the only remaining metadata-2 deferral), firing for remote targets too (metadata is UID-keyed + network-wide via the slice-189 burst/slice-177 propagate); pure `command::whois::whois_keyvalue_lines` fuzz seam, placed before the 317/318 tail, closing the deferral named verbatim in `command::metadata`'s out-of-scope list; 222 = close the deferred S2S nick-collision "full both-sides timestamp arbitration" (`s2s/save.rs`/`unick.rs`) — an **equal**-signon-timestamp `UNICK` collision is unresolvable, so leveva now `SAVE`s **both** sides to their UIDs (the IRCnet rule), vacating the contested nick network-wide, instead of the old "newcomer loses, holder keeps the nick" (which let two servers disagree on the winner); pure `s2s::collision::resolve` three-way decision (oldest-wins / equal-save-both / can't-compare-keeps-first-claimant) with `evicts_holder`/`saves_newcomer` predicates driving the `unick` arm, fuzzed by an inline `proptest` over arbitrary `Option<u64>` pairs; 223 = close the **last** `draft/metadata-2` deferral (named verbatim by slice 221) — the **privileged-key / restricted-visibility system**: a metadata key is *oper-restricted* (settable/viewable only by `+o`/`+O`, visibility token `oper` not `*`) iff it begins with the built-in `priv.` prefix **or** matches a `REHASH`-able `options { oper-keys "<glob>" }` allowlist (the user-locked *prefix+config* scheme via the slice-204/210/212 `ConfStore` `live_oper_keys` seam); a non-oper is refused SET/GET (`KEY_NO_PERMISSION`), omitted from LIST/`760` WHOIS, skipped by CLEAR, and never pushed a restricted key via the notify plane (covers the local + inbound-S2S apply paths); pure `metadata::key_visibility(key, oper_keys) -> Visibility{Everyone,Oper}` fuzz seam, plus a config-driven `golden_metadata_priv` proving the live seam end-to-end (also corrected two stale "no metadata burst"/"no privileged-key" doc comments); 224 = `ELIST` search extensions for `LIST` (RFC 2812 §3.2.6) — a `LIST` arg token may now be a search condition (`>n`/`<n` user count, `mask`, `!mask`) filtering the visible sweep, not only an exact channel name, advertised as `ELIST=MNU` + `SAFELIST`; pure `elist::classify`/`matches_all` fuzz seam with its own `*`/`?` glob (charybdis-style; `matching`'s `#`-as-digit dialect is wrong for channel names), `C`/`T` clock-volatile letters deferred; 225 = close that deferral — the `C` (channel creation age) and `T` (topic age) `ELIST` letters (`C>n`/`C<n`/`T>n`/`T<n`, whole minutes), advertised as `ELIST=CMNTU`; the deferral was a plumbing gap not a data gap (`Channel::created_at`/`Topic::set_at` already stored, `clock::unixnano()` read once in the command layer and passed into the pure `elist::matches(cond, &Candidate, now)` matcher so it stays deterministic); a no-topic channel takes its creation age for `T` (divergence from charybdis's epoch-based `topic_time=0`); 226 = `+s` client connect/exit server notices — the IRCnet/charybdis `SCH_NOTICE` connection report, the last classic operator notice still missing from the `+s` surface (which already covered KILL/SQUIT/REHASH/SA*): a local `+s` watcher now sees `*** Notice -- Client connecting: nick (user@host) [ip] [realname]` at registration-finalize and `Client exiting: nick (user@host) [reason] [ip]` at release (pure `snotice::connect_notice`/`exit_notice` fuzz seams; local-only — a remote client's report fires on its home server; single `+s` mask not charybdis's split `+c`, `{class}` token omitted); 227 = `+Z` secure-connection user mode + `671 RPL_WHOISSECURE` (charybdis `UMODE_SSLCLIENT`) — a TLS connection gets the leveva-native, server-set-only `+Z` (set at registration from the slice-213 `secure` flag, never client-toggleable like `+W`) which propagates network-wide (`SEND_UMODES`) so `WHOIS` on a `+Z` user — local or remote — emits `:srv 671 <req> <nick> :is using a secure connection` (after `276` CertFP, before the `379`/`317`/`318` tail, not privacy-gated), surfacing TLS even for a no-client-cert user that `276` misses; pure `mode::is_secure` fuzz seam, `+Z` now in the `004` user-mode string (wss-as-secure still `false` so a websocket gets `+W` not `+Z`, a documented follow-on); 228 = `METADATA SET`/`CLEAR` rate limiting — a client that floods metadata mutations is refused the excess with `FAIL METADATA RATE_LIMITED <target> <key> <retry-after>` and the mutation is not performed (closes the `draft/metadata-2` follow-on named verbatim in `command::metadata`'s divergence list), via the pure `metadata::rate_charge` penalty-clock (a per-user allowance clock modelled on `flood::FloodClock`, burst 61 ≫ `MAX_KEYS` so legit profile setup is never throttled, then ~1/s sustained; opers exempt, inbound-S2S naturally exempt; thresholds are leveva-native consts, a `REHASH` knob a follow-on); 229 = `METADATA <target> SYNC` — closes the **last** `draft/metadata-2` gap (named verbatim in `command::metadata`'s divergence list): re-sends the issuer the current value of every key it is *subscribed to* on the target as a `metadata` batch of `METADATA` server-message events (not `761`), and for a channel target also every member's subscribed user metadata; oper-restricted keys (223) are withheld from a non-oper via the pure `metadata::sync_emits` seam; `774 RPL_METADATASYNCLATER` is never emitted (a small in-memory store always syncs immediately — a deliberate divergence, the spec makes 774 a load-shedding option) so the `draft/metadata-2` track is now **complete**; 230 = turn the slice-228 `METADATA SET`/`CLEAR` rate-limit budget (was two hard-coded consts) into the `REHASH`-able `options { metadata-rate-cost / metadata-rate-burst }` knobs via the slice-204/210/212 `ConfStore` live seam (`rate_charge`/`charge_rate` grew `cost_secs`/`burst_secs` params, the consts became defaults; `live_meta_rate_cost`/`live_meta_rate_burst` read at the `rate_gate`; `0`-cost disables, `0`-burst caps at one mutation/sec, negative rejected); 231 = complete the per-class **keepalive cadence** hand-off named deferred in `main.rs`/`heartbeat.rs` — the session heartbeat already adopted the matched `allow → class` block's `ping-freq` at finalize (the *decision*), but the serve-loop `tokio` check **ticker** was seeded once from the *default* (first) class and never updated, so a client in a non-default class was *checked* at the wrong rate (its ping/timeout detection latency keyed off the default, e.g. a 60s-`ping-freq` class checked on a 300s-class cadence); now the new pure `heartbeat::class_ping_freq(class, classes)` seam is the single resolution both the finalize re-arm and the loop key off, and `serve_client` re-arms its ticker (top of loop, covering the `feed` + deferred `resolve_auth` finalize paths) whenever `session.keepalive_freq()` changes — pure `class_ping_freq` fuzz seam (matched-wins / unknown-⇒-default / tick-always-≥1s); 232 = `+c` no-control-codes (no-color) channel mode (leveva-native, charybdis `MODE_NOCOLOR`) — a PRIVMSG/NOTICE carrying mIRC text-formatting control codes (bold/color/italic/…) to a `+c` channel is **blocked** `404 ERR_CANNOTSENDTOCHAN :Channel doesn't accept control codes` (PRIVMSG) or silently dropped (NOTICE), never delivered (leveva blocks rather than charybdis's strip — the utf8only/`+C` "reject, don't mangle" stance), ops not exempt, distributed enforcement at the sender's home server like `+n`/`+m`/`+C`; extends the slice-220 speech-plane `can_send` work with the pure `channel::has_control_codes` fuzz seam paired with `Channels::blocks_color`, the second `supported_flag` allowlist + `ChanMode::ALL` updated (also fixed a pre-existing stale `golden_setname` `CHANMODES` snapshot — missing the slice-220 `C`). **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. |
+| **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: 233 slices.** The slices span the full client command surface (registration, channels + channel/user modes, WHO/WHOIS/WHOX, OPER/STATS), the UID-based S2S link/burst/netsplit protocol (+ keepalive, live introduction, NJOIN chunking), `leveva-iauth` (dnsbl/socks/webproxy/pipe/ident), config live-rehash, a channel-mode rework, CertFP, an ongoing **IRCv3 track**, and the native cross-daemon command-skeleton differential harness; an emerging **STS/TLS** track; 195 = generalize the restricted-reop ban into the `enforce_reop` chokepoint so a restricted user is never auto-reopped by *any* trigger; 196 = a restricted user cannot change nick (`484`), completing the IRCnet `IsRestricted` family; 197 = close the slice-191 follow-on — a `kline-exempt` I-line (the C `FLAGS_EXEMPT`) now survives a *runtime* `TKLINE` reap, not just the registration-time K-line gate; 198 = `KNOCK` (leveva-native, charybdis-style) — a user kept out of a `+i`/`+k`/`+l` channel asks its operators for an invite (`710`/`711`), gated by the shared JOIN `check_join` so KNOCK and JOIN never disagree (banned/`+O`/open/already-member all refused), S2S propagation deferred; 199 = close that follow-on — KNOCK now propagates as `ENCAP * KNOCK` (modelled on `s2s::metadata`/`su`) so a channel operator on *any* server is notified, not just local ops (the shared `deliver_knock` fan; `712` throttle still deferred); 200 = close that last follow-on — a per-channel KNOCK flood throttle (`knock_throttle::KnockThrottle`, charybdis `knock_delay_channel`, 60s): a channel admits at most one KNOCK per window, a knock inside it is refused `712 ERR_TOOMANYKNOCK` and notifies nobody (the KNOCK family 198/199/200 is now complete); 201 = command-rate flood protection (the IRCnet "fakelag" penalty clock, deferred since the start) — every registered client's command charges `1 + len/100` onto a per-connection clock that idle-decays to real time, and a sustained flood is closed with `ERROR :Closing Link: <host> (Excess Flood)`, lighting up the previously-dead `can-flood`/`no-penalty` oper exemptions; pure `flood::FloodClock`, armed by the serve loop (the `heartbeat` opt-in precedent), kill-only (fakelag *delay* + per-command cost table deferred); 202 = close that follow-on — a per-command penalty cost table (`flood::command_penalty`): `PONG` is exempt, whole-table scans (`WHO`/`WHOIS`/`WHOWAS`/`LIST`/`NAMES`) cost +4 and channel ops (`JOIN`/`KNOCK`/`INVITE`) +2 over the base, so a flood of expensive commands trips Excess Flood faster than a flood of cheap ones (leveva-native cost tiers; the fakelag *delay* half + a pre-registration count guard remain the follow-ons); 203 = close the pre-registration guard follow-on — the slice-201 penalty clock only billed *registered* clients, so an unregistered connection could spam commands unboundedly (every unknown verb `451`-replied); now `Session::dispatch` charges the same `FloodClock` for the `Registering` phase too (after QUIT, before CAP; `PONG` exempt, no oper exemption pre-reg), closing a pre-registration flood with the oracle's exact `ERROR ... (Excess Flood)`; 204 = close the last flood follow-on but one — the kill ceiling (`flood::FLOOD_KILL_AHEAD`, was a fixed `const 60`) is now the tunable `options { flood-kill-ahead N }` (`charge` takes it as a parameter; parser rejects `0`; default 60), hot-reloaded by `REHASH` via the slice-91/175 `ConfStore` live-or-default seam (no `ServerContext` field — flood is serve-loop-armed, so `main` always seeds the live store first) — only the fakelag *delay/smoothing* half now remains deferred; 205 = **phase 13 (refinement)** opens — `REHASH` hot-reloads `options { auto-connect }`: the outgoing auto-connect master switch (was read once at boot to *spawn* the dial loop) is now read **per tick** via the slice-91/204 `ConfStore` live seam (`live_auto_connect`), so the loop always spawns but skips dialing while off and an operator can start/stop the dialer with no restart — no `AbortHandle` task-management (an idle ticker is cheap), no `ServerContext` field (slice-204 precedent, `main` seeds before spawning); 206 = enforce + REHASH-able `options { connection-accept on|off|split }` — the previously parsed-but-dead new-connection admission gate is now enforced at registration finalize (`On` accepts, `Off` refuses everyone with a bare `ERROR :Closing Link: <host> (Server is not accepting connections)`, `Split` accepts only while the net is split per the now-live `split-min-servers`/`split-min-users` thresholds), live via the canonical `live_*().unwrap_or(ctx.…)` `ConfStore` seam (a ctx field this time — the gate's read site is unit-tested, unlike 204/205 — bundled into `StatsConf` so the 62 hand-rolled `ServerContext` literals stay churn-free via `StatsConf::default()`, the slice-191 `bans` precedent); pure `connaccept::is_split`/`admits` (no `options` field remains boot-immutable); 207 = `REHASH` hot-reloads the `iauth` block (ident flag, per-probe timeout, and the dnsbl/socks/webproxy/pipe/ident module set) — the **last** boot-immutable config block — via the `ConfStore` live seam: `ConfBlocks` carries the plain `Iauth` struct while the *built* module set (`Arc<dyn Module>`, not `Debug/Eq`) is cached in a separate `ArcSwapOption` rebuilt only at seed/REHASH (`build_auth_modules` made `pub(crate)`, the server name supplied by a new boot `IRCD_NAME` OnceLock mirroring `CONFIG_PATH`), so live connections keep one shared resolver/DNS cache and a REHASH swaps the set atomically; read sites = the serve loop's auth task (modules+timeout), `resolve_username` (the `~` rule's ident flag), and `STATS a` (the 239 module list); no config block remains boot-immutable now (only the unchangeable `server {}` identity stays fixed by nature); 208 = close the **last** deferred flood follow-on — the fakelag *delay/smoothing* half (kill-only since slice 201): while the penalty clock runs more than `flood::MAX_PENALTY` (10s, the oracle's fixed `MAXPENALTY`) ahead but below the kill ceiling, the serve loop now **delays** the connection's next read (`tokio::time::sleep_until` on the `select!` read arm, output arms still live) instead of disconnecting, pacing a flooder's *input* toward one command per second while its fan-out/keepalive keep flowing — pure `FloodClock::delay_secs` + `Session::flood_delay_secs` (0 when disabled/within-budget/exempt), `charge` unchanged so all 201–204 tests hold; the flood family is now complete (only a `MAX_PENALTY` config knob remains as an optional future refinement); 209 = close the **last** deferred KNOCK gap — the per-*user* throttle (charybdis `knock_delay`, 300s) alongside slice 200's per-*channel* one: a single knocker may deliver at most one KNOCK per window across *all* channels (keyed by UID in a second `knock_user_throttle: KnockThrottle`), so one user can no longer spray operators on many fresh channels; the command peeks both throttles (new `cooling`/`arm` split, arming neither unless the knock is delivered — charybdis updates its clocks on success only) and answers `712 ERR_TOOMANYKNOCK` if either is cooling — the KNOCK throttle family (200/209) is now complete; 210 = turn the last hard-coded throttle thresholds into `REHASH`-able `options` knobs — `flood::MAX_PENALTY`→`max-penalty`, charybdis `knock_delay`→`knock-delay`, `knock_delay_channel`→`knock-delay-channel` (all via the slice-204 `ConfStore` live seam; `0` legal = aggressive-smoothing / throttle-disabled), so no throttle budget remains a boot-immutable const; 211 = `sts` (IRCv3 Strict Transport Security) — leveva advertises the advisory-only `sts` cap in `CAP LS` to a `302+` client (a plaintext client gets `sts=port=<tls-port>` to upgrade, a TLS client `sts=duration=<n>[,preload]` to persist), gated on a real TLS client listener + `options { sts-duration N; sts-preload }` (`0`/absent = off); never in `SUPPORTED` so `CAP REQ sts` NAKs and it never shows in `CAP LIST` (pure `sts::StsPolicy::ls_value` + a new `Session.secure` flag set by `serve_client`; REHASH-ability of the knobs + wss-as-secure deferred); 212 = close the REHASH follow-on — the whole `sts` policy now rides the live config seam: `sts-duration`/`sts-preload` move onto the `ConfStore` (slice-204/210 idiom, `live_sts_duration`/`live_sts_preload`) and the TLS port is re-derived **live** from the running listener set (`session::live_sts_port` over `listeners::specs()`, which slice 137 already rebinds on REHASH), so an operator enables/retunes/disables STS — including binding a fresh TLS listener — with no restart (only wss-as-secure now remains deferred from the `sts` track); 213 = close that last STS deferral (wss-as-secure) — a TLS-protected WebSocket is now flagged `secure` so its `CAP LS` `sts` token is the persistence `duration=` not the upgrade `port=`: `serve_ws` finally calls `Session::set_secure`, true when the listener TLS-terminated the `wss://` connection **or** a trusted reverse proxy (`proxy-ranges`) forwarded `X-Forwarded-Proto: https`/`wss` (the pure spoof-resistant `websocket::forwarded_proto_secure` reusing the `resolve_host` trust gate), making the `sts` track complete; 214 = close the deferred extban "PRIVMSG/quiet plane" (slice 164) — a `+b` ban (or extban) now **mutes** a non-voiced channel member on the speech path, not just at JOIN: `Channels::can_send` builds the same `extban::Subject`/`LiveChannels` view `check_join` uses and returns a new `CanSend::Banned` (→ `404 ERR_CANNOTSENDTOCHAN`) when the sender matches a `+b` with no `+e` and is below voice rank (voice+ bypasses, exactly as for `+m`), so a ban set *after* a user joined — or a dynamic extban (`$a:account`/`$r:realname`/`$x`) they later match — silences them; wired into `PRIVMSG`/`NOTICE` (`command::message`), `TAGMSG`, and `REDACT` via the shared `command::speech_subject` helper (a dedicated `+q` quiet *list* mode is out of scope — `+q` is the elemental OWNER prefix); 215 = fix a production relink desync (updating a server made it strip channel modes that should have survived a merge) — a channel learned **for the first time in a burst** was re-clocked to the receiver's local nanosecond clock instead of adopting the origin's `created_at` (the leading `CHANTS` is dropped on an unknown channel, and the burst `NJOIN` was TS-less), so two servers disagreed on a channel's TS by *when each learned it* and the next merge deopped the later learner (slice 167's divergence, left unfixed on the burst path); the burst `NJOIN` now carries the channel TS as a middle param (`:<sid> NJOIN <chan> <ts> :<members>`, the live form since slice 167) so the receiver adopts the origin's TS, with the chunk budget + boot-snapshot TS-mask updated to match; 216 = caller-id — a leveva-native charybdis-style `+g` ("server-side ignore") user mode + `ACCEPT` command: a `+g` user receives private `PRIVMSG`/`NOTICE` only from clients on its accept list, else the sender gets `716` (+ once-per-sender `717`) and the target is told once (`718`), NOTICE silently dropped; the `ACCEPT` list (add/`-`remove/`*`list, `281`/`282`/`456`/`457`/`458`/`401`) is the pure `callerid::CallerId` store owned inside `Registry` (the `metadata` zero-churn precedent), entries are stable UIDs, `+g` is purely local (not in `SEND_UMODES` — the gate runs on the target's home server) so remote-sender gating + `+G` soft caller-id are documented follow-ons; `CALLERID=g` ISUPPORT; 217 = close the slice-216 remote-sender follow-on — a *remote* sender's `PRIVMSG`/`NOTICE` to a local `+g` target arrives via `s2s::relay::inbound_message`, which delivered it with no caller-id check; now the gate runs there too (the `+g` design is "enforce on the target's home server", which is us), bouncing `716` (+ once-per-`(target,sender)` `717`) back to the sender's uplink and telling the local target once (`718`) for a blocked PRIVMSG, silently dropping a blocked NOTICE, reusing the same `callerid` store + once-per-pair notify rate-limit as the local path (the KNOCK 198→199 local-then-S2S precedent); only `+G` soft caller-id now remains deferred from the caller-id track; 218 = close that **last** caller-id deferral — `+G` soft caller-id (charybdis `UMODE_SCALLERID`): a new client-settable, purely-local `UserMode::SoftCallerId` identical to `+g` except a sender sharing a channel with the target passes the gate without an accept; the block decision is now the pure `callerid::blocks(strict, soft, accepted, common_channel)` (single source of truth + fuzz target) wrapped by `command::message::caller_id_blocks`, shared by the local and remote-sender (slice 217) gates, with `Channels::shares_channel` the common-channel predicate — the caller-id track is now complete; 219 = server-split `+s` netsplit notice — closes the `SQUIT` "no server notices" divergence by firing `*** Notice -- Netsplit <s1> <s2> (<n> users)` to local `+s` watchers from the single `s2s::squit::squit_subtree` teardown chokepoint, so every split cause (oper `SQUIT`, inbound S2S `SQUIT`, **abnormal link termination** — socket drop / ping timeout) notifies exactly once (guarded on `departed.is_some()` so a no-op re-teardown stays silent; pure `split_notice_text` fuzz seam); 220 = `+C` no-CTCP channel mode (leveva-native, charybdis `MODE_NOCTCP`) — a CTCP query other than `ACTION` sent as a **PRIVMSG** to a `+C` channel is refused `404 ERR_CANNOTSENDTOCHAN :Channel doesn't accept CTCP` and not delivered (NOTICE/CTCP-reply never blocked, ops not exempt, distributed enforcement at the sender's home server like `+n`/`+m`/`+b`), extending the slice-214 speech-plane `can_send` work; pure `channel::is_blocked_ctcp` fuzz seam paired with `Channels::blocks_ctcp` (the mode half), the second `supported_flag` allowlist updated so the MODE command parser recognizes `C`; 221 = `760 RPL_WHOISKEYVALUE` (IRCv3 `draft/metadata-2`) — a `WHOIS` on a user with metadata set now exposes that metadata as `760 <requester> <target> <key> * :<value>` lines **to a requester that negotiated the `draft/metadata-2` cap** (no cap → no `760`, the inverse), one per key in sorted order, visibility always `*` (no privileged-key/restricted-visibility system — now the only remaining metadata-2 deferral), firing for remote targets too (metadata is UID-keyed + network-wide via the slice-189 burst/slice-177 propagate); pure `command::whois::whois_keyvalue_lines` fuzz seam, placed before the 317/318 tail, closing the deferral named verbatim in `command::metadata`'s out-of-scope list; 222 = close the deferred S2S nick-collision "full both-sides timestamp arbitration" (`s2s/save.rs`/`unick.rs`) — an **equal**-signon-timestamp `UNICK` collision is unresolvable, so leveva now `SAVE`s **both** sides to their UIDs (the IRCnet rule), vacating the contested nick network-wide, instead of the old "newcomer loses, holder keeps the nick" (which let two servers disagree on the winner); pure `s2s::collision::resolve` three-way decision (oldest-wins / equal-save-both / can't-compare-keeps-first-claimant) with `evicts_holder`/`saves_newcomer` predicates driving the `unick` arm, fuzzed by an inline `proptest` over arbitrary `Option<u64>` pairs; 223 = close the **last** `draft/metadata-2` deferral (named verbatim by slice 221) — the **privileged-key / restricted-visibility system**: a metadata key is *oper-restricted* (settable/viewable only by `+o`/`+O`, visibility token `oper` not `*`) iff it begins with the built-in `priv.` prefix **or** matches a `REHASH`-able `options { oper-keys "<glob>" }` allowlist (the user-locked *prefix+config* scheme via the slice-204/210/212 `ConfStore` `live_oper_keys` seam); a non-oper is refused SET/GET (`KEY_NO_PERMISSION`), omitted from LIST/`760` WHOIS, skipped by CLEAR, and never pushed a restricted key via the notify plane (covers the local + inbound-S2S apply paths); pure `metadata::key_visibility(key, oper_keys) -> Visibility{Everyone,Oper}` fuzz seam, plus a config-driven `golden_metadata_priv` proving the live seam end-to-end (also corrected two stale "no metadata burst"/"no privileged-key" doc comments); 224 = `ELIST` search extensions for `LIST` (RFC 2812 §3.2.6) — a `LIST` arg token may now be a search condition (`>n`/`<n` user count, `mask`, `!mask`) filtering the visible sweep, not only an exact channel name, advertised as `ELIST=MNU` + `SAFELIST`; pure `elist::classify`/`matches_all` fuzz seam with its own `*`/`?` glob (charybdis-style; `matching`'s `#`-as-digit dialect is wrong for channel names), `C`/`T` clock-volatile letters deferred; 225 = close that deferral — the `C` (channel creation age) and `T` (topic age) `ELIST` letters (`C>n`/`C<n`/`T>n`/`T<n`, whole minutes), advertised as `ELIST=CMNTU`; the deferral was a plumbing gap not a data gap (`Channel::created_at`/`Topic::set_at` already stored, `clock::unixnano()` read once in the command layer and passed into the pure `elist::matches(cond, &Candidate, now)` matcher so it stays deterministic); a no-topic channel takes its creation age for `T` (divergence from charybdis's epoch-based `topic_time=0`); 226 = `+s` client connect/exit server notices — the IRCnet/charybdis `SCH_NOTICE` connection report, the last classic operator notice still missing from the `+s` surface (which already covered KILL/SQUIT/REHASH/SA*): a local `+s` watcher now sees `*** Notice -- Client connecting: nick (user@host) [ip] [realname]` at registration-finalize and `Client exiting: nick (user@host) [reason] [ip]` at release (pure `snotice::connect_notice`/`exit_notice` fuzz seams; local-only — a remote client's report fires on its home server; single `+s` mask not charybdis's split `+c`, `{class}` token omitted); 227 = `+Z` secure-connection user mode + `671 RPL_WHOISSECURE` (charybdis `UMODE_SSLCLIENT`) — a TLS connection gets the leveva-native, server-set-only `+Z` (set at registration from the slice-213 `secure` flag, never client-toggleable like `+W`) which propagates network-wide (`SEND_UMODES`) so `WHOIS` on a `+Z` user — local or remote — emits `:srv 671 <req> <nick> :is using a secure connection` (after `276` CertFP, before the `379`/`317`/`318` tail, not privacy-gated), surfacing TLS even for a no-client-cert user that `276` misses; pure `mode::is_secure` fuzz seam, `+Z` now in the `004` user-mode string (wss-as-secure still `false` so a websocket gets `+W` not `+Z`, a documented follow-on); 228 = `METADATA SET`/`CLEAR` rate limiting — a client that floods metadata mutations is refused the excess with `FAIL METADATA RATE_LIMITED <target> <key> <retry-after>` and the mutation is not performed (closes the `draft/metadata-2` follow-on named verbatim in `command::metadata`'s divergence list), via the pure `metadata::rate_charge` penalty-clock (a per-user allowance clock modelled on `flood::FloodClock`, burst 61 ≫ `MAX_KEYS` so legit profile setup is never throttled, then ~1/s sustained; opers exempt, inbound-S2S naturally exempt; thresholds are leveva-native consts, a `REHASH` knob a follow-on); 229 = `METADATA <target> SYNC` — closes the **last** `draft/metadata-2` gap (named verbatim in `command::metadata`'s divergence list): re-sends the issuer the current value of every key it is *subscribed to* on the target as a `metadata` batch of `METADATA` server-message events (not `761`), and for a channel target also every member's subscribed user metadata; oper-restricted keys (223) are withheld from a non-oper via the pure `metadata::sync_emits` seam; `774 RPL_METADATASYNCLATER` is never emitted (a small in-memory store always syncs immediately — a deliberate divergence, the spec makes 774 a load-shedding option) so the `draft/metadata-2` track is now **complete**; 230 = turn the slice-228 `METADATA SET`/`CLEAR` rate-limit budget (was two hard-coded consts) into the `REHASH`-able `options { metadata-rate-cost / metadata-rate-burst }` knobs via the slice-204/210/212 `ConfStore` live seam (`rate_charge`/`charge_rate` grew `cost_secs`/`burst_secs` params, the consts became defaults; `live_meta_rate_cost`/`live_meta_rate_burst` read at the `rate_gate`; `0`-cost disables, `0`-burst caps at one mutation/sec, negative rejected); 231 = complete the per-class **keepalive cadence** hand-off named deferred in `main.rs`/`heartbeat.rs` — the session heartbeat already adopted the matched `allow → class` block's `ping-freq` at finalize (the *decision*), but the serve-loop `tokio` check **ticker** was seeded once from the *default* (first) class and never updated, so a client in a non-default class was *checked* at the wrong rate (its ping/timeout detection latency keyed off the default, e.g. a 60s-`ping-freq` class checked on a 300s-class cadence); now the new pure `heartbeat::class_ping_freq(class, classes)` seam is the single resolution both the finalize re-arm and the loop key off, and `serve_client` re-arms its ticker (top of loop, covering the `feed` + deferred `resolve_auth` finalize paths) whenever `session.keepalive_freq()` changes — pure `class_ping_freq` fuzz seam (matched-wins / unknown-⇒-default / tick-always-≥1s); 232 = `+c` no-control-codes (no-color) channel mode (leveva-native, charybdis `MODE_NOCOLOR`) — a PRIVMSG/NOTICE carrying mIRC text-formatting control codes (bold/color/italic/…) to a `+c` channel is **blocked** `404 ERR_CANNOTSENDTOCHAN :Channel doesn't accept control codes` (PRIVMSG) or silently dropped (NOTICE), never delivered (leveva blocks rather than charybdis's strip — the utf8only/`+C` "reject, don't mangle" stance), ops not exempt, distributed enforcement at the sender's home server like `+n`/`+m`/`+C`; extends the slice-220 speech-plane `can_send` work with the pure `channel::has_control_codes` fuzz seam paired with `Channels::blocks_color`, the second `supported_flag` allowlist + `ChanMode::ALL` updated (also fixed a pre-existing stale `golden_setname` `CHANMODES` snapshot — missing the slice-220 `C`); 233 = `+S` TLS-only channel mode (leveva-native, charybdis `chm_sslonly`/`MODE_SSLONLY`) — a JOIN of a `+S` channel by a client **not** on a secure (TLS) transport is refused `519 ERR_SSLONLYCHAN :Cannot join channel (+S) - SSL/TLS required` and not added; a *hard* JOIN gate like `+O` (not bypassed by INVITE/`+I`, ops not exempt, existing members keep their seat), keyed off the slice-227 `+Z` (`UserMode::Secure`) bit via `mode::is_secure(attrs.umodes)` in `check_join` (deliberately *not* the certfp-based `attrs.is_ssl`, which undercounts no-client-cert TLS users); KNOCK to a `+S` channel is refused like `+O`; pure `channel::sslonly_blocks(set, secure)` fuzz seam, `ErrSslonlychan = 519` numeric, the second `supported_flag` allowlist + `ChanMode::ALL` updated. **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. |
 | **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`.** |
 
 ---
diff --git a/docs/claude-memory/leveva-noctcp-and-flag-mode-wiring.md b/docs/claude-memory/leveva-noctcp-and-flag-mode-wiring.md
index 0429ec7b..1a1c6014 100644
--- a/docs/claude-memory/leveva-noctcp-and-flag-mode-wiring.md
+++ b/docs/claude-memory/leveva-noctcp-and-flag-mode-wiring.md
@@ -47,5 +47,25 @@ deliberate divergence (the utf8only/`+C` "reject, don't mangle" stance). Unlike
 gate in `command/message.rs` right after the `+C` check. Bit `0x2000000`. Followed the two-allowlist
 gotcha above exactly. `golden_nocolor.rs` end-to-end.
 
+**Slice 233 added `+S` (TLS-only join), a port of charybdis `chm_sslonly`/`MODE_SSLONLY`** — and
+is a useful counter-example: a flag mode that is a **JOIN gate**, not a speech-plane gate. A JOIN
+of a `+S` channel by a non-secure client is refused `519 ERR_SSLONLYCHAN :Cannot join channel (+S)
+- SSL/TLS required`. The gate lives in `Channels::check_join` (NOT `command/message.rs`), placed
+alongside `+O` *before* the invite override → a hard gate: INVITE/`+I` do not bypass, ops not
+exempt, existing members keep their seat (only new joins gated). KNOCK to a `+S` channel is refused
+like `+O` (`command/knock.rs` `JoinReject::SecureOnly` arm). New numeric `ErrSslonlychan = 519`;
+new `JoinReject::SecureOnly` (mapped in `command/join::join_reject`). Bit `0x4000000`. Followed the
+two-allowlist gotcha exactly. Pure seam `channel::sslonly_blocks(set, secure)` (fuzz
+`tests/sslonly_proptest.rs`), golden `tests/golden_sslonly.rs`.
+
+**Gotcha specific to a transport-gated mode:** the secure signal is the joiner's `+Z`
+(`UserMode::Secure`) bit via `mode::is_secure(attrs.umodes)` — NOT `attrs.is_ssl`. `attrs.is_ssl`
+is certfp-based (`record.certfp.is_some()` at the `command/join`/`command/knock` call sites) and
+**undercounts** TLS users with no client cert; `+Z` is set at registration from the real TLS
+transport (slice 227) so it is the authoritative "over TLS" flag. (`check_join` only runs for
+LOCAL joins; remote joins arrive via NJOIN and bypass it. `$z` extban still reads the certfp-based
+`is_ssl` — left untouched, a known undercount.)
+
 Related: [[leveva-extbans]] (slice 214 speech-plane ban mute via `CanSend::Banned`),
-[[leveva-elemental-channel-modes]], [[leveva-channel-mode-rework-slice77]].
+[[leveva-elemental-channel-modes]], [[leveva-channel-mode-rework-slice77]],
+[[leveva-restricted-enforcement]] (the `+Z` secure user mode lineage).
diff --git a/docs/progress-log/p11.md b/docs/progress-log/p11.md
index 0521e6c6..0620377c 100644
--- a/docs/progress-log/p11.md
+++ b/docs/progress-log/p11.md
@@ -13007,3 +13007,45 @@ golden_metadata/metadata_s2s suites); `cargo clippy -p leveva --tests` clean; `c
     delivered intact, +c→404, plain passes, op not exempt, -c re-opens with the body intact. Nine
     golden snapshots regenerated (chanmode string). `cargo test -p leveva --lib` 2110 pass;
     `cargo clippy -p leveva --tests` clean; `cargo build --workspace` 0 warnings.
+
+- **2026-06-17 — P11 slice 233 (`+S` TLS-only channel mode) merged.** A leveva-native channel
+  flag porting charybdis `chm_sslonly` (`extensions/chm_sslonly.c`, `MODE_SSLONLY`): a JOIN of a
+  `+S` channel by a client **not** on a secure (TLS) transport is refused
+  `519 ERR_SSLONLYCHAN :Cannot join channel (+S) - SSL/TLS required` and the client is not added.
+  Builds directly on slice 227's `+Z` (secure user mode) and the `+C`/`+c` flag-mode wiring
+  (220/232).
+  - **Gate semantics.** A *hard* JOIN gate like `+O`: checked in `Channels::check_join` alongside
+    `+O`, **before** the invite override, so an INVITE or matching `+I` does **not** bypass it (a
+    transport requirement, not an access list). Chanops are not exempt; rank does not bypass.
+    `+S` only gates *new* joins (charybdis is a `can_join` hook) — an existing member, including a
+    plaintext op who set the mode, keeps their seat. KNOCK to a `+S` channel is refused like `+O`
+    (`cannot_knock … SSL/TLS required`), since an INVITE wouldn't admit a plaintext user.
+  - **Secure signal.** The joiner's `+Z` (`UserMode::Secure`) bit, resolved by `mode::is_secure`,
+    read from the subject's umodes (already carried in `extban::SubjectAttrs`/`Subject`, populated
+    from `joiner.modes` at the `command::join` call site). This is the authoritative "connected
+    over TLS" flag for a local joiner (`check_join` only runs for local joins; remote joins arrive
+    via NJOIN, which bypasses it). **Deliberately not** `attrs.is_ssl`, which is certfp-based and
+    undercounts TLS users with no client cert — the same gap slice 227's `+Z`/`671` closed. `$z`
+    extban semantics (still certfp-based) are left untouched.
+  - **Mode plumbing.** `ChanMode::SslOnly` (`'S'`, bit `0x4000000` past the oracle range,
+    `MODE_SSLONLY`, Flag kind) added to `mode.rs` (`as_char`/`bit`/`mode_name`/`from_char`/`ALL`,
+    after `NoColor`) and to the `command::mode::supported_flag` second allowlist (the BOTH-allowlists
+    gotcha from [[leveva-noctcp-and-flag-mode-wiring]] — a flag mode needs both or MODE 472s).
+    `JoinReject::SecureOnly` → `command::join::join_reject` maps it to the new `ErrSslonlychan = 519`
+    numeric (name + `TryFrom<u16>` added). `CHANMODES`/`004`/MYINFO absorb `S` automatically (derived
+    from `ChanMode::ALL` + `kind()`): `chan_modes_string()` → `psmntiOCcSovqahYbeIRkl`,
+    `chanmodes_spec()` → `beIR,k,l,psmntiOCcS`.
+  - **Pure seam.** `channel::sslonly_blocks(sslonly_set, joiner_secure) = set && !secure` —
+    the fuzz target, paired with `mode::is_secure` at the `check_join` call site.
+  - **Gate.** TDD red→green; fuzzing required. Unit: `channel::check_join_enforces_ssl_only`
+    (a `+Z` client clears the gate; a plaintext client → `SecureOnly`; an INVITE does not bypass;
+    `-S` re-opens) + `channel::sslonly_blocks_truth_table`. Fuzz (`tests/sslonly_proptest.rs`):
+    `sslonly_blocks` vs an independent model over every pair; the real `check_join` `+S` gate keys
+    **only** off the `+Z` bit over arbitrary `u32` umode masks (a spurious unrelated bit can never
+    sneak a plaintext client past, and the gate is total); a plain channel never applies the gate;
+    toggling `+Z` flips admission. Golden (`tests/golden_sslonly.rs`): end-to-end through the real
+    binary — a plaintext JOIN of `+S` bounces `519` and is not added (NAMES confirms only the op),
+    `-S` re-opens (the admit case needs a TLS client → covered by the unit test + proptest,
+    mirroring `golden_sts`'s plaintext/secure split). Nine 004/005 golden snapshots regenerated
+    (chanmode string only — `git diff` filtered to confirm). `cargo test -p leveva` 2298 pass;
+    `cargo clippy -p leveva --tests` clean; `cargo build --workspace` 0 warnings.
diff --git a/docs/superpowers/plans/2026-06-17-p11-slice233-sslonly-chanmode.md b/docs/superpowers/plans/2026-06-17-p11-slice233-sslonly-chanmode.md
new file mode 100644
index 00000000..90c51950
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-17-p11-slice233-sslonly-chanmode.md
@@ -0,0 +1,59 @@
+# P11 slice 233 — `+S` TLS-only channel mode
+
+## Goal
+
+Add a leveva-native `+S` channel mode (charybdis `chm_sslonly` / `MODE_SSLONLY`): a JOIN of a
+`+S` channel by a client **not** on a secure (TLS) transport is refused
+`519 ERR_SSLONLYCHAN :Cannot join channel (+S) - SSL/TLS required`, and the client is not added.
+Builds directly on slice 227's `+Z` (secure user mode), which already tracks the TLS transport,
+and the `+C`/`+c` flag-mode wiring (slices 220/232).
+
+## Scope decisions
+
+- **Hard JOIN gate**, like `+O`: an INVITE or `+I` does **not** bypass it (a transport
+  requirement, not an access list). Checked alongside `+O` in `check_join`, before the invite
+  override. Chanops are not exempt; rank does not bypass.
+- **Existing members stay**: `+S` only gates *new* joins (charybdis chm_sslonly is a can_join
+  hook). A plaintext op who set `+S` keeps their membership.
+- **Secure signal = the `+Z` (`UserMode::Secure`) bit**, resolved by `mode::is_secure`, read from
+  the joiner's umodes (already carried in the `extban::SubjectAttrs`/`Subject`). This is the
+  authoritative "connected over TLS" flag for a local joiner (`check_join` only runs for local
+  joins; remote joins arrive via NJOIN). Deliberately *not* `attrs.is_ssl` (certfp-based), which
+  undercounts TLS users with no client cert — the same gap slice 227's `+Z`/`671` closed.
+- **KNOCK**: `+S` is treated like `+O` — a transport gate an INVITE can't bypass, so KNOCK to a
+  `+S` channel is refused (`cannot_knock … SSL/TLS required`), not delivered to ops.
+- Leveva-native → leveva-only golden, no differential against the (deleted) mechanical port.
+
+## Pure seam (the fuzz target)
+
+`channel::sslonly_blocks(sslonly_set: bool, joiner_secure: bool) -> bool` = `set && !secure`.
+Paired with `mode::is_secure(umodes)` at the `check_join` call site.
+
+## Edits
+
+1. `mode.rs` — `ChanMode::SslOnly` (`'S'`, bit `0x4000000`, `MODE_SSLONLY`, Flag); add to `ALL`
+   (after `NoColor`), `as_char`, `bit`, `mode_name`, `from_char`.
+2. `channel.rs` — `JoinReject::SecureOnly`; `sslonly_blocks` pure seam; the `+S` gate in
+   `check_join` (hard, near `+O`); unit tests (`check_join_enforces_ssl_only`,
+   `sslonly_blocks_truth_table`).
+3. `command/join.rs` `join_reject` — `SecureOnly => (ErrSslonlychan, "Cannot join channel (+S) -
+   SSL/TLS required")`.
+4. `command/knock.rs` — `Some(JoinReject::SecureOnly)` refuse arm (mirrors `OperOnly`).
+5. `command/mode.rs` `supported_flag` — `'S' => Some(ChanMode::SslOnly)` (the BOTH-allowlists
+   gotcha from [[leveva-noctcp-and-flag-mode-wiring]]).
+6. `numeric.rs` — `ErrSslonlychan = 519` (+ name + `TryFrom<u16>`).
+7. `isupport.rs`/`registration.rs` — chan-mode-string assertions absorb the new `S` flag
+   (`psmntiOCcS…` / `…,psmntiOCcS`); `CHANMODES`/`004` auto-derive from `ChanMode::ALL`.
+8. `tests/sslonly_proptest.rs` — proptest fuzz: `sslonly_blocks` vs an independent model; the real
+   `check_join` `+S` gate keys only off the `+Z` bit over arbitrary `u32` umode masks; a plain
+   channel never applies the gate; toggling `+Z` flips admission.
+9. `tests/golden_sslonly.rs` — boot-level end-to-end: a plaintext JOIN of `+S` bounces `519` and
+   is not added (NAMES confirms), `-S` re-opens it. (The admit case needs a TLS client → covered
+   by the unit test + proptest, mirroring `golden_sts`'s plaintext/secure split.)
+
+## Gate
+
+TDD red→green; fuzzing required. `cargo test -p leveva --lib`; the new proptest + golden; nine
+004/005 golden snapshots regenerated (chanmode string only — `git diff` filtered to confirm);
+`cargo build --workspace` 0 warnings; `cargo clippy -p leveva --tests` clean. Record: this plan,
+`docs/progress-log/p11.md` heredoc append, PLAN.md slice-count bump (232 → 233).
diff --git a/leveva/src/channel.rs b/leveva/src/channel.rs
index 4d545f83..cb67b736 100644
--- a/leveva/src/channel.rs
+++ b/leveva/src/channel.rs
@@ -463,6 +463,22 @@ pub enum JoinReject {
     /// `+O` (oper-only) set and the joiner is not an IRC operator →
     /// `520 ERR_CANTJOINOPERSONLY`. A hard gate: not overridable by an INVITE.
     OperOnly,
+    /// `+S` (TLS-only) set and the joiner is not on a secure transport →
+    /// `519 ERR_SSLONLYCHAN`. A hard gate like `+O`: not overridable by an INVITE (a transport
+    /// requirement, not an access list). See [`sslonly_blocks`].
+    SecureOnly,
+}
+
+/// Whether a `+S` ([`crate::mode::ChanMode::SslOnly`]) channel refuses this joiner (charybdis
+/// `chm_sslonly`, slice 233): the mode is set **and** the joiner is not on a secure (TLS)
+/// transport. A pure decision seam — the secure signal is the joiner's `+Z`
+/// ([`crate::mode::UserMode::Secure`]) flag, resolved by [`crate::mode::is_secure`] at the
+/// [`Channels::check_join`] call site — paired here for unit + fuzz coverage
+/// (`tests/sslonly_proptest.rs`). Returns `false` whenever the channel is not `+S` (a non-secure
+/// user joins freely) or the joiner is secure (a TLS user joins a `+S` channel).
+#[inline]
+pub fn sslonly_blocks(sslonly_set: bool, joiner_secure: bool) -> bool {
+    sslonly_set && !joiner_secure
 }
 
 /// The outcome of a channel-message permission check ([`Channels::can_send`]).
@@ -883,6 +899,13 @@ impl Channels {
         if m.has(ChanMode::OperOnly) && !attrs.is_oper {
             return Some(JoinReject::OperOnly);
         }
+        // +S (TLS-only, charybdis chm_sslonly) is likewise a hard transport gate: a client not
+        // on a secure connection cannot join even with an INVITE or key. The secure signal is
+        // the joiner's `+Z` user mode (set at registration from the TLS transport, slice 227),
+        // carried here in the subject's umodes.
+        if m.has(ChanMode::SslOnly) && sslonly_blocks(true, crate::mode::is_secure(attrs.umodes)) {
+            return Some(JoinReject::SecureOnly);
+        }
         // +k is enforced for everyone, invited or not.
         if let Some(want) = &m.key {
             if key != Some(want.as_str()) {
@@ -3029,6 +3052,68 @@ mod tests {
         );
     }
 
+    /// `+S` (TLS-only) admits a client carrying the `+Z` secure user mode and rejects a
+    /// plaintext client with [`JoinReject::SecureOnly`] — a hard transport gate not overridden
+    /// by an INVITE; clearing `-S` reopens the channel to a non-secure client.
+    #[test]
+    fn check_join_enforces_ssl_only() {
+        let ch = Channels::new();
+        ch.join("#rust", &alice(), 0).unwrap();
+        assert!(ch.apply_modes_as_server(
+            "#rust",
+            &[ModeChange::Flag {
+                add: true,
+                mode: ChanMode::SslOnly,
+            }],
+        ));
+        // A secure joiner carries the +Z (Secure) user mode in its umodes.
+        let secure = crate::extban::SubjectAttrs {
+            umodes: crate::mode::UserMode::Secure.bit(),
+            ..crate::extban::SubjectAttrs::plain(false)
+        };
+        let plaintext = crate::extban::SubjectAttrs::plain(false);
+        assert_eq!(
+            ch.check_join("#rust", &bob(), "bob!u@host", None, &secure),
+            None,
+            "a +Z (TLS) client may join a +S channel"
+        );
+        assert_eq!(
+            ch.check_join("#rust", &bob(), "bob!u@host", None, &plaintext),
+            Some(JoinReject::SecureOnly),
+            "a plaintext client is refused from a +S channel"
+        );
+        // Hard gate: an INVITE does NOT let a plaintext client in.
+        ch.invite("#rust", &alice(), &bob());
+        assert_eq!(
+            ch.check_join("#rust", &bob(), "bob!u@host", None, &plaintext),
+            Some(JoinReject::SecureOnly),
+            "an invite must not bypass +S"
+        );
+        // Inverse: -S reopens the channel to a plaintext client.
+        assert!(ch.apply_modes_as_server(
+            "#rust",
+            &[ModeChange::Flag {
+                add: false,
+                mode: ChanMode::SslOnly,
+            }],
+        ));
+        assert_eq!(
+            ch.check_join("#rust", &bob(), "bob!u@host", None, &plaintext),
+            None,
+            "-S admits a plaintext client again"
+        );
+    }
+
+    /// The pure [`sslonly_blocks`] decision: blocks only when the mode is set and the joiner
+    /// is not secure (the full truth table).
+    #[test]
+    fn sslonly_blocks_truth_table() {
+        assert!(sslonly_blocks(true, false), "+S + plaintext → blocked");
+        assert!(!sslonly_blocks(true, true), "+S + secure → admitted");
+        assert!(!sslonly_blocks(false, false), "no +S → admitted");
+        assert!(!sslonly_blocks(false, true), "no +S → admitted");
+    }
+
     /// Seed a `+R` reop mask onto an existing channel (via the trusted server path, so no
     /// op-gate gets in the way of the fixture).
     fn set_reop(ch: &Channels, name: &str, mask: &str) {
diff --git a/leveva/src/command/join.rs b/leveva/src/command/join.rs
index fe5a7a28..f8f351c4 100644
--- a/leveva/src/command/join.rs
+++ b/leveva/src/command/join.rs
@@ -12,6 +12,10 @@ fn join_reject(ctx: &ServerContext, nick: &str, chan: &str, reject: JoinReject)
             Numeric::ErrCantjoinopersonly,
             "Cannot join channel (+O is for IRC operators only)",
         ),
+        JoinReject::SecureOnly => (
+            Numeric::ErrSslonlychan,
+            "Cannot join channel (+S) - SSL/TLS required",
+        ),
     };
     Message::builder(num)
         .prefix(&ctx.name)
diff --git a/leveva/src/command/knock.rs b/leveva/src/command/knock.rs
index b334e49c..d1eedbba 100644
--- a/leveva/src/command/knock.rs
+++ b/leveva/src/command/knock.rs
@@ -118,6 +118,15 @@ pub(crate) fn knock(client: &Registered, msg: &Message, ctx: &ServerContext) ->
                 "oper-only channel",
             )]
         }
+        // +S (TLS-only) is a transport gate an INVITE cannot bypass either — refuse.
+        Some(JoinReject::SecureOnly) => {
+            vec![cannot_knock(
+                ctx,
+                &client.nick,
+                &display,
+                "SSL/TLS required",
+            )]
+        }
         // Invite-only / wrong-or-missing key / full: an INVITE would let them in — deliver.
         Some(JoinReject::InviteOnly | JoinReject::BadKey | JoinReject::Full) => {
             // Two flood throttles, both of which must be clear for the knock to be delivered:
diff --git a/leveva/src/command/mode.rs b/leveva/src/command/mode.rs
index c894c5eb..e6c0b775 100644
--- a/leveva/src/command/mode.rs
+++ b/leveva/src/command/mode.rs
@@ -54,6 +54,7 @@ fn supported_flag(c: char) -> Option<ChanMode> {
         'O' => Some(ChanMode::OperOnly),
         'C' => Some(ChanMode::NoCtcp),
         'c' => Some(ChanMode::NoColor),
+        'S' => Some(ChanMode::SslOnly),
         _ => None,
     }
 }
diff --git a/leveva/src/isupport.rs b/leveva/src/isupport.rs
index 9d6d4453..99ef4eb7 100644
--- a/leveva/src/isupport.rs
+++ b/leveva/src/isupport.rs
@@ -134,9 +134,9 @@ mod tests {
     #[test]
     fn mode_strings_come_from_tables() {
         assert_eq!(user_modes_string(), "oOiwraWBxsgGZ");
-        // ChanMode::ALL order: p s m n t i O C c o v q a h Y b e I R k l (C=no-CTCP slice 220,
-        // c=no-color slice 232)
-        assert_eq!(chan_modes_string(), "psmntiOCcovqahYbeIRkl");
+        // ChanMode::ALL order: p s m n t i O C c S o v q a h Y b e I R k l (C=no-CTCP slice 220,
+        // c=no-color slice 232, S=TLS-only slice 233)
+        assert_eq!(chan_modes_string(), "psmntiOCcSovqahYbeIRkl");
     }
 
     #[test]
@@ -161,9 +161,9 @@ mod tests {
 
     #[test]
     fn chanmodes_spec_is_grouped_by_kind() {
-        // A=list(b,e,I,R) B=key(k) C=limit(l) D=flags(p,s,m,n,t,i,O,C,c — C=no-CTCP slice 220,
-        // c=no-color slice 232)
-        assert_eq!(chanmodes_spec(), "beIR,k,l,psmntiOCc");
+        // A=list(b,e,I,R) B=key(k) C=limit(l) D=flags(p,s,m,n,t,i,O,C,c,S — C=no-CTCP slice 220,
+        // c=no-color slice 232, S=TLS-only slice 233)
+        assert_eq!(chanmodes_spec(), "beIR,k,l,psmntiOCcS");
     }
 
     #[test]
diff --git a/leveva/src/mode.rs b/leveva/src/mode.rs
index 8cd54b45..bd26522d 100644
--- a/leveva/src/mode.rs
+++ b/leveva/src/mode.rs
@@ -361,6 +361,18 @@ pub enum ChanMode {
     /// oracle range). Enforced on the speech plane by [`crate::command::message`] /
     /// [`crate::channel::Channels::blocks_color`].
     NoColor,
+    /// `+S` — only TLS/SSL-connected users may JOIN (`MODE_SSLONLY`).
+    ///
+    /// **leveva-native — a port of charybdis's `chm_sslonly` (`extensions/chm_sslonly.c`).** A
+    /// JOIN of a `+S` channel by a client *not* on a secure transport is refused with
+    /// `519 ERR_SSLONLYCHAN :Cannot join channel (+S) - SSL/TLS required`. A *hard* JOIN gate
+    /// like `+O`: it is **not** overridable by an INVITE or a matching `+I` (a transport
+    /// requirement, not an access list), and chanops are not exempt. The secure signal is the
+    /// joiner's `+Z` ([`UserMode::Secure`]) flag ([`is_secure`]) — set at registration from the
