--- id: w2-bounded-resources name: Wave 2 — bounded resources (the in-crate channel cap at the handler surface) status: completed depends_on: [w2-spawn-return] scope: narrow risk: low impact: component level: implementation --- ## Description Enforce the in-crate bounded-resources posture at the handler surface (`overview.md` §Bounded-resources policy; AGENTS.md convention 9 — SSH servers are internet-facing; unbounded loops and unbounded buffers are bugs): - **The in-crate concurrent-channel cap** — a count of active channels enforced at every `channel_open_*` hook: opens beyond `Limits::max_concurrent_channels` are declined fail-closed (a clean refuse, never queued, never a half-open channel). The count decrements on channel close so the cap recovers. Short-held internal state (`parking_lot`, the project convention). - **The russh knobs are already wired** (`w2-server-mount` mapped `inactivity_timeout` always-`Some`, `max_auth_attempts`, `window_size`, `maximum_packet_size`, `channel_buffer_size` from `Limits`) — this task verifies them behaviorally where cheap (e.g., the auth-attempt ceiling disconnects an exceeding client through the harness) and records anything not cheaply observable as covered by the config-mapping tests. - **Per-length exec-parse limits stay reserved** for the wave-3 grammar's consumption (`Limits::max_exec_command_len` is carried, not yet enforced — the grammar owns it). The per-identity policy layer (when the alkcall bridge carries it) is additional and arrives with the bridge itself (wave 3's tunnel leg) — not from this task. ## Acceptance Criteria - [x] The concurrent-channel cap is enforced at every `channel_open_*` hook: beyond `Limits::max_concurrent_channels` → clean decline; the count recovers on close - [x] Harness tests: cap reached → next open declines; a close → an open succeeds again; the cap is `Limits`-tunable - [x] The wired russh knobs verified behaviorally where cheap (auth-attempt ceiling through the harness) or recorded as covered by the config-mapping tests - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` pass ## References - docs/architecture/overview.md §Bounded-resources policy (the knob table) - docs/architecture/mapping.md §Data-plane / limits - docs/plans/implementation.md §Wave 2 (the bounded-resources bullet) - src/config.rs (`Limits` — the budget model and its documented postures) ## Notes - **The count is handler-owned, not locked.** New private module `src/channel_cap.rs` (`ChannelCap`): the per-session active-channel count with `admit` (check+increment at accept sites) / `has_room` (pure check) / `release` (saturating decrement). The only writer is the connection's russh packet loop — every hook runs inline with exclusive `&mut self` (ADR-006 decision 4 puts serving paths on spawned tasks, but their closes cycle back through the packet loop as `CHANNEL_CLOSE`, firing the hook) — so the `parking_lot` convention's short-held *shared* state does not apply: no lock exists because no shared mutation does. Saturating `release` means a stray decrement cannot manufacture capacity. - **Gate ordering** (handler.rs): feature gate first, then the cap. Session-channel opens admit-and-count exactly at the accept point. The tunnel rows check the cap *before* the seam stub — the stub declines without opening a channel, so a counted admission it never opens would leak against the cap forever (decline-first-alloc-never applies to the budget, not just capability). Wave 3's accept-after-fabric-open flow replaces the stub and moves admission to the real accept point; the wire behavior this wave: a cap-exhausted tunnel row answers the cap reason instead of the seam's `ConnectFailed` (pinned by a row, not by convention). x11 declines in every build and never consumes a count. - **Decline reason**: `dispatch::cap_exceeded_open_decline()` → `AdministrativelyProhibited` — the cap is the assembly's configured policy ceiling; OpenSSH's own `MaxSessions` answers the same code, so vanilla clients see the vanilla-shaped policy refusal (POC-1 §5). - **Decrement invariant**: every fired `channel_close` corresponds to exactly one previously-admitted open — russh only routes `CHANNEL_CLOSE` for channels it considers established (a declined open never established one) and consumes the close at most once (the channel is removed from russh's map before the hook). - **Recovery observability (russh-pin fact, recorded in the test doc)**: a *client-initiated* channel close is not observable client-side at this pin — russh removes a locally-closed channel from its client map before the server's `CHANNEL_CLOSE` arrives back, so `Channel::wait()` never delivers the close (the harness's `await_close` observes only *server-initiated* closes, which is what the spawn-return/dispatch rows use). The recovery row therefore rests on wire ordering (deterministic): close and open ride the client loop's single FIFO event queue, are written in that order, and the server's packet loop processes them in order — the counted release precedes the recovery admission; had the release not happened, the recovery open declines and the row fails. - **`max_auth_attempts` verified behaviorally**: ceiling `2` (tunable) → two within-ceiling failures stay ordinary `AuthResult::Failure` riding the `{publickey}` advertisement; the attempt past the ceiling is answered with the server's `Disconnect::NoMoreAuthMethodsAvailable`, whose client-side shape is russh's synthesized `Failure` with an *empty* remaining-methods set (no reply came; the advertisement is gone) — the session ends cleanly server-side (row: `tests/bounded_resources.rs` `auth_attempt_ceiling_disconnects_an_exceeding_client`). - **The size/window/clock knobs recorded as config-mapping-covered**: `window_size`, `maximum_packet_size`, `channel_buffer_size`, `max_auth_attempts` mapping and the always-set `inactivity_timeout` are pinned one-for-one by the adapter's config-mapping unit tests (`adapter.rs` `limits_knobs_map_one_for_one_with_inactivity_always_set`, `default_limits_map_to_the_documented_postures`); the client direction's keepalive/wall-clock postures are pinned by the harness (`client_config_maps_the_limits_postures`). Re-driving them through the wire buys no new information (russh reads the `Config` fields it owns); not cheaply behavioral, so recorded, per the task's split. - **`max_exec_command_len` stays carried, not enforced** (wave 3's grammar owns it) — unchanged from `src/config.rs`/`src/dispatch.rs` documentation; nothing here consumes it. - **Verification**: `cargo test` (default), and `--features session` / `git` / `tunnel` / `--all-features` (the feature-gated cap rows run in the builds whose opens can be accepted), `cargo clippy --all-targets -- -D warnings` (+ `--all-features`), `cargo fmt --check`, `cargo doc --no-deps` — all green. ## Summary Landed the in-crate concurrent-channel cap at the handler surface: new private `channel_cap` module (count + admit/release semantics, unit-tested), wired into `SessionHandler`'s every open hook — the session accept path admits-and-counts at the accept, the tunnel rows cap-gate before the seam stub, x11 is unaffected — and decremented through the new `channel_close` hook (single release point, single writer). Cap-exceeded opens decline fail-closed with `AdministrativelyProhibited` (`dispatch::cap_exceeded_open_decline`, OpenSSH `MaxSessions` parity). Contract rows in `tests/bounded_resources.rs`: cap reached → clean decline; close → open succeeds again (wire-order deterministic); zero cap refuses everything without poisoning; exhausted cap answers the tunnel row with the cap reason; the `max_auth_attempts` ceiling verified behaviorally through the harness. The per-identity policy layer stays with the wave-3 bridge; the per-length exec-parse cap stays the grammar's. All five feature-build test runs, clippy (both) and fmt green.