--- id: w2-dispatch-decline-core name: "Wave 2: parse-only dispatch + the fail-closed decline core" status: completed depends_on: [w2-loopback-harness] scope: moderate risk: medium impact: phase level: implementation --- ## Description Land the dispatch skeleton and the fail-closed decline core (ADR-006 as amended, ADR-003), which is to say the routing decisions and the declines; the family-seam serving paths are wave-3 material and arrive as clean declining stubs this task shapes the routes for. - **Channel-open gates** (the `channel_open_*` hooks): `session` channels open only where a session-serving feature exists (`session` ∨ `git`, since the parse path lives inside session channels); `direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on the `tunnel` feature; **`x11` and `auth-agent` always decline** (never feature-gated, per ADR-003 §5); every other/unimplemented channel type declines. Unfeatured → decline at open with a clean wire reason; never a half-open channel. - **Parse-only session dispatch** (ADR-006 point 3 as amended): `exec_request` parses first: a git-parseable command routes to the git row, anything else to the session pipe row; `pty_request` / `shell_request` route to the session rows. **The git-parseability probe here is skeleton-level** (service prefix + path shape); the full grammar (quoted/unquoted paths, service-only → decline-with-reason, per-length limits) is wave-3's git task, which replaces this probe. Parsing happens before dispatch and before any resource allocation: **decline first, alloc never** (a leaked channel alloc is a capability leak; the POC-3 stage-A rule). - **Request-level declines**: `subsystem_request`, where the `sftp` name declines-with-reason (the OQ-13 placeholder, folded here server side); git service names decline-with-reason (a subsystem request carries only a name, never a repo id); everything else declines. `env_request` declines unconditionally (OQ-09; no allowlist ships). - **Routing stubs**: the git row and the session rows (pty/shell, pipe-exec) route to stubs that decline cleanly at the family-seam boundary, since the seams land in wave 3; the stub shape is documented as skeleton behavior so the routing decisions are contract-tested now, not after the mappings exist. - **Wire decline reasons are shaped here** (the wave-1 error-model note's reservation): channel-open failure codes vs `channel_failure` per decline class, chosen for vanilla-client observability (POC-1 §5: channel-scoped decline, auth intact, no hangs). - **Contract tests through the loopback harness, per feature build**: the skeleton rows of the decline matrix (`mapping.md` §Decline matrix), where none-build declines everything; git-build routes git-parseable exec (stub at the seam) and declines pty/shell and plain exec; session-build declines git-parseable exec (the git parse is its gate) and serves its rows only to the stub; tunnel rows decline (the route-1 bridge is wave 3) even with the feature on; x11/auth-agent decline in every build. The full per-build matrix suite is wave-5's; this task tests the rows it lands. ## Acceptance Criteria - [x] Every `channel_open_*` hook gates per the feature graph; x11/auth-agent decline in every build; unfeatured types decline at open with clean wire reasons - [x] `exec_request` takes the parse path (git-parseable → git row, else → session pipe row) before any allocation; request-less session channels decline as unfeatured per ADR-006 - [x] `subsystem_request` declines-with-reason for `sftp` (OQ-13 placeholder) and git service names; `env_request` declines unconditionally (OQ-09) - [x] Decline-first-alloc-never holds structurally (declines precede any channel/resource allocation on every path) - [x] Feature-gated code paths exercised per build (`cargo test --features git/session/tunnel` rows + `--all-features`); the skeleton decline-matrix rows green through the harness - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo clippy --all-features --all-targets -- -D warnings`, `cargo fmt --check` pass ## References - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (points 2–3; the amendment is parse-only) - docs/architecture/decisions/003-feature-graph.md (the feature graph; §5 never-features) - docs/architecture/mapping.md §Session-channel dispatch, §Request-level rows, §Decline matrix - docs/plans/implementation.md §Wave 2 (the dispatch bullet) - docs/research/poc-3-mapping-bakeoff.md §stage A (the decline matrix evidence; decline-first-alloc-never) ## Notes - `src/dispatch.rs` (new): the parse-only dispatch core with the `ExecRoute` decision (`Git` / `Pipe`), the skeleton exec-parse probe (`classify_exec`: exact `git-upload-pack`/`git-receive-pack` service prefix + space + path-shaped argument: leading `'` or `/`; everything else → the pipe row), and the two wire-decline reason shapers (`unfeatured_open_decline` → `AdministrativelyProhibited`, `tunnel_seam_stub_decline` → `ConnectFailed`). Seven probe unit rows pin the POC-3 stage-A grammar facts the skeleton carries. The full grammar (quoted/ unquoted paths, per-length limits, service-only decline-with-reason unknown-service rows) is wave-3's to replace it with. - `src/handler.rs`: every `channel_open_*` hook gates per the feature graph (session open: `session` ∨ `git`; the three tunnel opens: `tunnel`; x11: always declines). Request hooks: `exec_request` parses first then routes on the feature graph; `pty`/`shell` route under `session`; `subsystem_request` declines every name (sftp = the OQ-13 placeholder, `git-*` = no repository id in a name-only request per ADR-006 decision 2); `env_request` declines unconditionally (OQ-09); `x11_request`/`agent_request` decline (ADR-003 §5); the four listen requests decline (the flow is wave 3; never advertise a listen that cannot serve). Decline-first- alloc-never holds structurally: no hook allocates before its decline; a confirmed session open accepts and drops the channel handle (nothing is held this wave; the store-and-take handoff is wave-3 mapping material, and the routing decisions around it are already pinned by the seam stubs). - **Wire decline reasons (the wave-1 error-model reservation, now spent):** open-level declines carry channel-open failure codes; unfeatured and never-feature rows answer `AdministrativelyProhibited` (1) is the honest class: without the feature there is no capability to distinguish; the tunnel seam stub answers `ConnectFailed` (2), the explicit reject shape ADR-006 decision 1 pins for failed fabric opens (accept only after the fabric open succeeds; the skeleton never opens the fabric). Request-level declines carry `channel_failure`: the SSH request vocabulary has no failure text, so the "reason" is the routing decision, documented per build. Within request declines, two shapes: a *capability* decline answers failure and leaves the channel usable (POC-1 §5 posture, proven live by the channel-alive rows); a *serving-path* decline (the seam stubs) answers failure then closes the channel (vanilla clients never hang on a row that will spawn next wave). - **russh 0.65 facts coded around:** only `agent_request` and the global requests auto-reply from the handler's `bool`; every other request hook must call `session.channel_failure` itself. The bare `auth-agent@openssh.com` (and `forwarded-streamlocal@openssh.com`) channel *types* have no handler hook, so russh rejects those opens inline (administratively prohibited); that is the decline the never-feature contract needs but it is an inherited posture flagged in the handler docs (pin-bump change would appear as accepted-then-dropped, an upstream note). `forwarded-tcpip` has the same gate/stub code shape as its tcp sibling but no client-side open helper (the server opens it toward the client), so its wire rows are wave-3 acceptor material. - **Harness:** the helper vocabulary extended (tunnel/x11 opens; pty/ env/x11/agent-forward requests; `tcpip_forward`), discharging the loopback-harness task's recorded expectation that the request helpers' live rows arrive here. One vehicle row (`authenticated_session_serves_and_declines_with_an_observed_reason`) recorded the *skeleton* decline-only behavior; the dispatch core makes session opens serve under their features, so that row is now cfg-pinned to the featureless builds it speaks about (its `ChannelOpenFailure`-reason observation remains the none-build's proof). - **Request-less session channels** (ADR-006 as amended): parse-only means nothing serves before a request arrives (the SOCKS-detect is retired); a request-less channel is served by nothing and dies at the wall clock. The wire-observable rows are the unfeatured opens (none/tunnel builds decline session channels at the open); the no-detect posture is structural (no serving path exists outside the request hooks). ## Summary Landed the parse-only dispatch + the fail-closed decline core (`w2-dispatch-decline-core`) in three files plus the harness: - **`src/dispatch.rs`**: the `ExecRoute` decision, the skeleton exec-parse probe (service prefix + path shape; wave-3 grammar replaces), the wire-reason shapers (`AdministrativelyProhibited` for unfeatured/never-feature opens, `ConnectFailed` for the tunnel seam stub), 7 probe unit rows. - **`src/handler.rs`**: the `channel_open_*` gates (session ∨ git; tunnel; x11/auth-agent never), the parse-only `exec_request` dispatch, the session-row routes, the request-level declines (subsystem all-names with the sftp/git named classes, env, x11, agent), the four listen-request declines, and the named seam stubs (`git_row_stub`, `pipe_row_stub`, `pty_row_stub`, `shell_row_stub`, `tunnel_row_stub`), which are the wave-3 replacement points, each documented with its family seam. Module docs now carry the decline-core contract (gates, decline classes, wire reasons, decline-first-alloc-never, the parse-only posture, the inherited-auth-agent-open upstream note). - **`tests/dispatch_core.rs`**: the skeleton decline-matrix rows per feature build, 26 rows total, each with a `Contract stamp:`: shared rows in every build (x11 open, `tcpip-forward` decline, x11/agent requests where sessions open); none-build (everything declines at open); git-only (sessions open, git exec → seam-stub failure+close, plain exec/unknown services → pipe-row decline with channel alive, pty/shell/subsystem/env/capability declines, tunnel unfeatured); session-only (git-parseable exec declines as gate, stub rows failure+close, capability declines, tunnel unfeatured); tunnel-only (session opens decline, featured tunnel rows → `ConnectFailed` stubs); all-features (routes hold with the full graph: git row, pipe row, pty/shell stubs, `ConnectFailed` stubs, capability declines). - **`tests/common/harness.rs`**: the declined-vocabulary helpers (tunnel/x11 opens, pty/env/x11/agent requests, listen requests), module doc updated to record the discharge of the `w2-loopback-harness` note. Verification (all green): `cargo test` (91 rows across 7 binaries) · `cargo test --features git/session/tunnel/sftp --test dispatch_core` (each build's rows) · `cargo test --all-features` · `cargo clippy --all-targets -- -D warnings` · `cargo clippy --all-features --all-targets -- -D warnings` · `cargo fmt --check` · `cargo doc --no-deps` (no warnings). One pre-existing harness row pinned to the skeleton's decline-only open behavior was cfg-gated to the featureless builds it describes.