--- id: w3-decline-tracing name: "Wave 3: decline-path tracing (the wave-2 gate's booked finding)" status: completed depends_on: [w3-seam-config] scope: narrow risk: low impact: component level: implementation --- ## Description Land the wave-2 review gate's booked finding: observability on the decline paths. `tracing` rides the manifest per the ADR-002 toolkit but so far nothing in `src/` emits, so a request-level decline's "reason" is today only the routing decision documented per build. The real serving paths land in this wave's mapping tasks, where serve/decline tracing matters; therefore the event **vocabulary** lands first and the mappings inherit it. - **The family-convention check first**: before choosing the event shape, read the family's established tracing posture (alktty / alkgit / alktunnels / alkcall `src/`: levels, event/field naming, what gets emitted on error and decline paths) and adopt it (no alkssh-private dialect); record what was read and chosen. - **The event vocabulary**, with the deciding context on every decline being row + feature build + channel id (plus the decline class: open-level vs request-level vs serving-path, and where applicable the failure shape carried). The mapping tasks then emit at their sites. - **Instrument what exists now**: the open-level declines (unfeatured / never-feature / cap-exceeded), the request-level capability declines (`subsystem`/`env`/`x11`/`agent`, the unfeatured-parse routes), the seam-stub serving declines, and the serve-path skeleton (spawned path started / ended, failure), with the stubs' events carrying into the mapping tasks' replaced paths. - **Safety posture** (conventions 6, OQ-03): no secret material ever enters an event, never the username; identity/fingerprint exposure on decline events is decided here against the family posture and recorded (auth.md: log exposure is deployment policy, and the crate's events must be safe-by-default at every level). A single small cross-cutting pass over the landed handler/dispatch sites with the vocabulary, so the three mapping branches never land inconsistent events. ## Acceptance Criteria - [x] The family tracing posture read and recorded; the event vocabulary established (row + build + channel + decline class) and used at every decline site in `src/` - [x] Serve-path skeleton events in place (start/ended/failure) so the mapping tasks' spawned paths inherit shaped rows - [x] The safety posture recorded (no secrets, no username; what identity detail may appear decided) - [x] A test pins the event presence at the decline sites (or the doc-test convention the family uses, decided and recorded) - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`, `cargo doc --no-deps` pass ## References - docs/plans/implementation.md §Wave 2 (the booked finding, review-wave-2 §Notes) - docs/architecture/mapping.md §Wire decline reasons (the two decline classes), §Decline matrix - docs/architecture/auth.md §Dispositions (log exposure posture), docs/architecture/decisions/002-russh-pin.md (the toolkit) - sibling `src/` tracing usage, the convention this task reads and adopts ## Notes ### The family posture read (2026-10-28) Read before choosing the event shape (the task's first bullet), across the family's `src/`: - **alktty** (`adapter.rs`, `session.rs`, `channels.rs`, `local/pipe.rs`, `local/pty.rs`): free `tracing::{debug, warn}` imports; message-last lowercase sentences with a component prefix (`"tty: backend stdin write failed: {e}"`); interpolation in messages, no spans. - **alktunnels** (`producer.rs`, `consumer.rs`, `params.rs`): the same free-fn posture, plus structured fields where identity exists (`listen = …`, `error = %e`, `reason = …`); levels `debug` for structural observations, `warn` for recoverable failures, `error` for abnormal pump terminations. - **alkcall** (`protocol/*`, `channels/*`): the widest use, with the same fields convention (`channel_id`, `request_id`, `error = %e`), component-prefixed messages (`"channel client: …"`, `"open wrapper: …"`), `debug`/`warn` only. - **alkhttp** (the closest structural analog): `warn`/`error` with `error = %e` (`"channels session ended"`). - **alkgit**: no tracing at all at this writing. Never observed: `info!`/`trace!` events, subscriber setup in library code, macro or span wrappers. Adopted verbatim in `src/observe.rs`: free `tracing` fn calls, message-last lowercase with the `"decline: …"` / `"serving path …"` phrasing, flat per-event fields. ### The event vocabulary (landed in `src/observe.rs`) - **`feature_build`**, the compile-time feature set as a stable string (`"none"`, `"git"`, `"session+git"`, …; features join in the order `session, git, tunnel, sftp`), so a captured event is self-describing per build; `sftp` ships in the string even though it gates no route (it is part of what the assembly deployed). - **`row`**, the greppable wire row: the channel type for open-level (`"session"`, `"direct-tcpip"`, `"x11"`, …), the request name for request-level (`"pty_request"`, `"tcpip_forward"`, …), the handler hook name for auth (`"auth_publickey"`, `"auth_none"`, …). - **`decline_class`**, `"open"` | `"request"` | `"serving"` | `"auth"`: the two wire decline classes (`mapping.md` §Wire decline reasons), plus the serving-path class (this wave the seam stub's decline *is* the serving path; the mappings emit the same class from their real spawned paths) and the auth class (the ADR-004 disposition declines, added by this task: the auth hooks are decline sites too, and `auth.md`'s dispositions are the crate's auth-decline contract). - **`reason`**, the decline cause mirroring the architecture's own classifications: `"unfeatured"`, `"never-feature"` (ADR-003 §5), `"cap-exceeded"`, `"connect-failed"` (tunnel stub / ADR-006 decision 1), `"seam-stub"` (session-row stubs), `"unimplemented"` (the subsystem rows under OQ-13 and the wave-3 listen forms), `"declined-by-design"` (env, OQ-09), and on the auth class `"method-unimplemented"` / `"cert-unsupported"` / `"non-ed25519"` / `"fingerprint-unresolved"`. - **`channel_id`**, the wire channel id (u32), on every decline of a channel (notably: the event's id is *russh's server-side* id, because `ChannelId` is not publicly constructible, so the helpers take the raw u32 via `ChannelId::number()`); the auth class and the global `-R` listen forms carry none (a separate `decline_global_request` helper, since global requests have no channel). - **`exec_route`**, only on `exec_request`-row events: the parse route (`"git"` | `"pipe"`), so a git row's decline is distinguishable from a pipe row's in combined builds. A `None` route field is not recorded at all by `tracing` (discovered by the unit rows; recorded in the module docs). The `message` field rides first (the `event!` macro's shape, the unit rows pinning the field order). Levels: every decline and the lifecycle's started/ended emit at `debug`, since a decline is the door's normal, honest posture (vanilla clients probe unfeatured rows routinely), matching the family's structural-observation level. Only [`serving_path_failed`] is `warn` (a serving path that could not complete its wire reporting is a real failure, which is the family's recoverable-failure level). ### The serving-path skeleton `src/serve.rs` stays the mechanics only (spawn + exit-report); the lifecycle events hang off the spawned task's body in `handler.rs`'s `seam_stub_decline`: started (the spawned task's first action, so "started" is the task's honest start, not the hook's spawn call) → the serving-path decline (class `serving`, reason `seam-stub`) → ended, or failed (`warn`, with the russh protocol error) when a report send could not complete. The wave-3 mapping branches (`w3-git-mapping`, `w3-tty-session`, `w3-tunnel-direct`) inherit the shaped rows by emitting the same helpers from their real serving tasks. ### The safety posture (conventions 6 and OQ-03, recorded) **No secret material ever enters an event; never the username; the fingerprint stays out too.** Structurally enforced, not recalled: the vocabulary helpers take only `&'static str` values and the wire channel id, leaving no parameter a username, command string, env name/value, repo path, key, or fingerprint could ride. The `reason` codes carry the decline's *cause class*, and the deciding context is the row + build + channel, deliberately *not* the identity behind the wire attempt. This is the recording of auth.md §Dispositions' standing rule at the crate level: log exposure is deployment policy, so the crate's events are safe-by-default at every level, with the fingerprint (and any identity detail) is a deployer-side computation over its own logs, never crate-emitted. If a future decline site needs request-argument detail (e.g. the git grammar's decline reasons in `w3-git-mapping`), it must add an explicitly-vocabulary'd field and record its safety reasoning, since dynamic strings do not ride `src/observe.rs` by default. The wire rows pin the posture by scanning every captured event: no username string, no `SHA256:` fingerprint, no `user*`-named field ever appears. ### The test convention (decided + recorded) The family has no event-capture precedent to adopt: alktunnels' `tracing-subscriber` dev-dep drives fmt output, not assertions. The convention these rows pin: **a hand-rolled minimal `tracing::Subscriber`** capturing events into a shared buffer, installed once as the global default (`tests/observe.rs`), because the server side runs on spawned tasks, so a thread-local `with_default` capture cannot see them, and a global capture needs no new dependency. Two levels of pinning: - Helper-level (`src/observe.rs` unit rows, thread-local `with_default`): exact field sets and order (message first), the `exec_route` presence semantics, the no-channel auth shape, the lifecycle messages, the `warn` failure arm, and the cfg-derived `feature_build` string. - Wire-level (`tests/observe.rs`, the loopback harness drives the real wire; capture installed up front via the mount helpers, since a lazily-installed global loses the earliest emissions): presence by field-matching at every decline site across the builds: x11 open, `-R` listen (global), the fingerprint-unresolved and auth_none dispositions (driven on fresh mounts, since a second auth exchange on an already-authenticated session is not a supported russh surface, as the probe discovered), the unfeatured session/pipe/pty rows, the git seam-stub's full lifecycle, the tunnel stub's `connect-failed`, the cap-exceeded row (zero-cap limits must ride the *server* config (`serve_config`), not just the client's `Limits`), and the username/fingerprint safety scan. Build-specific rows follow the `dispatch_core.rs` per-build cfg convention and were verified in the default, `git`, `session`, `tunnel`, and `--all-features` builds. ### Verification (all green) `cargo test` default build 110 rows across 7 binaries + doctests · `cargo test --all-features` ✓ · `cargo test --features git/session/tunnel` ✓ (each build runs its decline rows) · `cargo clippy --all-targets -- -D warnings` ✓ · `cargo clippy --all-features --all-targets -- -D warnings` ✓ · `cargo fmt --check` ✓ · `cargo doc --no-deps` 0 warnings · `taskgraph validate` ✓.