Files
glm-5.3-flash f4b5f896b4 docs: sweep em dashes from all markdown prose
Renames the written-usage rule into practice across the contract surface:
docs/ (architecture, research, plans, sdd), tasks/ (all 43 task files,
taskgraph validate passes), AGENTS.md, README.md, CHANGELOG.md, and the
.opencode/agents/ prompts. Every replacement is a sense-preserving
rewrite (colons, semicolons, commas, parentheses, sentence splits, varied),
never a mechanical swap; no spaced hyphens were substituted. Enum fields,
frontmatter, tables, wire data, and cited identifiers survive verbatim;
en dashes inside numeric ranges keep their meaning.

Verification: taskgraph validate (43 tasks), repo grep clean (single
exempt hit: the verification command's own search pattern in AGENTS.md),
cargo test, clippy --all-targets -D warnings, fmt --check, doc all green.
2026-11-04 20:28:00 +00:00

227 lines
11 KiB
Markdown

---
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` ✓.