Files
alkssh/docs/plans/implementation.md
T
glm-5.3-flash d1f755f2d9 docs(tasks): decompose wave 3 — the server mappings
Six implementation tasks + the gate under tasks/implementation/,
chained sequentially (the mappings replace stubs in the same Handler
impl and share the decline-matrix test binaries):

- w3-seam-config: the serve-config's per-feature seam handles
  (alktty backends/ownership/selector; the alkgit quartet/pipeline/
  push-options flag) + the optional sibling deps — wave 3's first
  course correction to the wave-1 config surface
- w3-decline-tracing: the wave-2 gate's booked finding — the
  decline/serve-path tracing vocabulary, landed before the mappings
- w3-git-mapping: the full exec grammar replaces the wave-2 probe +
  the serve_duplex_resolved dispatch + the store-and-take handoff
- w3-tty-session: the translation bridge + the exit-derivation
  contract (rules 1-6, the signum table)
- w3-tunnel-direct: the route-1 bridge (accept only after the fabric
  open succeeds) + set_identity propagation + the capacity-across-
  fabric-window mechanics
- w3-tunnel-listen: the -R listen flow + the forwarded-tcpip data
  leg (the POC-untested leg; the stop-and-book provision)
- review-wave-3: the gate

The plan carries the wave-3 decomposition record with four
interpretations flagged for the gate (the serve-config course
correction, the already-discharged server-side sftp bullet, the
w3-tunnel-listen open mechanics, the booked-items' wave-3 homes).
taskgraph validate: 26 tasks, clean.

Sibling pins verified against crates.io at decomposition time:
alktty 0.8.1 / alkgit 0.1.0 / alktunnels 0.4.1 / alkcall 0.10.1.
2026-10-28 12:32:13 +00:00

420 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
last_updated: 2026-10-28 (initial plan — five waves committed; wave 1
decomposed same day, see its decomposition record)
---
# alkssh — Implementation plan
Wave-based decomposition of the architecture (`docs/architecture/`)
into units of implementable work. The sibling pattern proven on
alkgit/alkstore/alkblobs, adopted deliberately as a deviation from the
SDD process's decompose-everything-upfront step: early waves change the
shape of later ones (wave 1's config/error/key-source types are what
every later task implements against; wave 2's server stack is what
every mapping composes into), and decomposing only the next wave at a
time keeps each session's task set reviewable and lets later
decompositions absorb earlier waves' course corrections — reviews
sometimes change things, and tasks downstream then have to be edited,
so decomposing too far ahead is paid rework.
Rhythm: decompose a wave → implement it → review gate → decompose the
next wave. Task files live in `tasks/implementation/` (frontmatter
carries the categorical estimates; the DAG ordering keeps parallel
branches from stepping on each other). Wave boundaries are also review
boundaries. Each wave's section here carries a **decomposition record**
added when that wave actually decomposes (not written upfront).
Scope authority: **v0.1.0 publishes the crate** — the complete
two-direction door (server stack + all mappings, client stack + op
rows, key sources, the decline matrix contract-tested per build). The
sftp decline placeholder *is* shipped behavior (OQ-13 resolved). **The
assembly stays out of these waves**: alkforge's mounting of
`SshAdapter::handle` at an alkcall `channel/open` boundary, its
feature/ACL selection, its `IdentityProvider` and serve-config values
are downstream composition work — the crate ships the door, not the
deployment. No binary, no workspace split (single crate, the alktty
root pattern; no tracks — one crate, one publish).
## The waves
Dependency logic in one line: foundations → server stack → (server
mappings ∥ client direction) → validation & release.
| Wave | Contents | Depends on | Status |
|---|---|---|---|
| [1](#wave-1--foundations) | Manifest landing (russh pin, feature graph); error model; config surface (serve-config + dial-config + `Limits`); vault-backed key sources; host-key policy seam; fingerprint; test doubles | — | decomposed |
| [2](#wave-2--the-server-stack) | `SshAdapter::handle` mount + `Config` construction; the auth bridge; parse-only dispatch + fail-closed decline core; spawn-and-return plumbing; bounded resources; loopback harness | wave 1 | decomposed |
| [3](#wave-3--server-mappings) | session → alktty (translation bridge + exit derivation); git → alkgit (exec grammar + dispatch); tunnel → alktunnels route 1; sftp placeholder declines | wave 2 | decomposed |
| [4](#wave-4--client-direction) | Dial surface; op rows (exec/pty, direct-tcpip, git exec); the `-R` leg (listen + acceptor registry); lifecycle primitives | wave 1 | pending |
| [5](#wave-5--validation--release) | Decline-matrix contract suite; real-tool e2e; fuzzing; docs; **the publish** | waves 3 + 4 | pending |
## Wave 1 — Foundations
The contract surface in code, engine-blind: everything later waves
implement *against*, and nothing that needs a live SSH session to
test. The alkssh analog of alkgit's seam wave.
- **Manifest landing** (ADR-002) — the dependency set lands with
implementation start: `russh = "0.65"` with `aws-lc-rs` (no `flate2`,
no `rsa` — the tree RC-free except russh's own `ssh-key` pin),
`alkcall 0.10` (identity/ACL types at the wire layer), `alkvault 0.1`
(the custody model), plus the standing toolkit (`tokio`, `thiserror`,
`tracing`, `parking_lot`, `zeroize`). `edition = "2024"`,
`rust-version = "1.89"`. The ADR-003 feature graph is declared from
the first commit: `session`, `git`, `tunnel`, `sftp`, `default = []`
— features gate nothing yet (the decline core references them from
wave 2); the per-feature sibling deps (`alktty` under `session`,
`alktunnels` under `tunnel`, `alkgit` under `git`) land with their
first consuming wave, not as dead manifest entries. All family deps
pinned by version, never sibling-checkout paths; pins verified
against crates.io before committing.
- **Error model** — the crate's `thiserror` taxonomy, fail-closed with
no catch-alls: the key-source failure matrix as distinct variants
(ADR-005 decision 4 — locked / absent / decryption failure / unknown
`key_version`, plus decode failure; the vault's taxonomy reshaped,
never collapsed). Auth declines are *dispositions* (`Auth::Reject`),
not errors; wire decline reasons are wave-2 shaped. What the config
surface needs beyond this lands with it.
- **Config surface** — the pure configuration types both directions
implement against: the **serve-config** (feature wiring; OQ-08's two
assembly-supplied fields — shell argv and exec wrapper argv, each
`Option`, unset → its row declines, never a fallback; the
`IdentityProvider` handle; the host-key source handle; the tunnel
`ChannelClient` handle; the `Limits`), the **dial-config** (address,
key source, `HostKeyPolicy` handle, keepalive/inactivity/window
knobs), and **`Limits`** — the bounded-resources budget model
(`overview.md`'s table: in-crate max-concurrent-channels cap,
exec-parse length caps, and the russh knob mapping with the
documented default postures — `max_auth_attempts` 10 sized for the
counting `none`-probe, keepalives 15 s / 3 client-side per the
reverse-runner evidence).
- **Vault-backed key sources** (ADR-005) — the custody model in code:
decrypt-at-use (`vault.decrypt` → `Zeroizing<String>` wrap at the
adapter seam → one-time `decode_secret_key(plain, None)`), the
client dial-key source and the server host-key load path
(decrypt-at-construction; locked vault → no server constructed),
`EncryptedData` opaque to the crate, plaintext never persists, every
failure mode mapped to its distinct error variant.
- **Host-key policy seam** (OQ-05) — the `HostKeyPolicy` trait the
client dial calls with (host, port, presented fingerprint): case-1
pins (hard-decline on mismatch) and case-2 assembly policies are the
implementor's; the crate ships the fail-closed default (reject
unknown) and is otherwise policy-blind — no accept-all built-in.
- **Fingerprint** (ADR-004) — the `SHA256:` + unpadded base64
computation via the `ssh-key` re-export (`HashAlg::Sha256`); the
string format is a contract invariant, proven against independent
issuers (`ssh-keygen -lf` vectors, the POC-1 §1 checkpoint shape).
- **Test doubles** — the in-memory seams every later wave's contract
tests run against: an in-memory `IdentityProvider` (registered
fingerprints → identities), an in-memory vault (encrypt/decrypt
round-trip + the failure postures), an in-memory `HostKeyPolicy`,
and ed25519 test-key fixtures. The convention (adapted from the
sibling crates) is established here even though the harness that
exercises them end to end lands with wave 2's server stack.
The wave-1 review gate is the cheap-place gate: the config types, the
error taxonomy, and the key-source seam are one-way doors in practice
(every later task implements against them; the published crate freezes
them). A shape error found at this gate costs a task revision; found
after the stack and mappings implement against them it costs churn
across both directions. The gate reads the landed surface against
ADR-002/003/004/005 and the OQ-08/05 resolutions line by line.
**Decomposition record** (2026-10-28): wave 1 decomposed into eight
task files under `tasks/implementation/` (flat — one track, no
per-track subdir). Shape: `w1-manifest` first (the ADR-002 dependency
landing + the ADR-003 feature graph declaration everything lands
inside); then four independent foundations in parallel —
`w1-error-model` (the `KeySourceError` five-variant taxonomy),
`w1-fingerprint` (the contract-invariant computation + parity
vectors), `w1-key-sources` (the ADR-005 custody chain, over manifest +
error model), and `w1-host-key-policy` (the OQ-05 seam + fail-closed
default); then `w1-config` (serve-config + dial-config + `Limits`)
over the key-source and policy seams it carries handles for;
`w1-test-doubles` (the in-memory seams + ed25519 fixtures) over error
model + key sources + policy; and `review-wave-1` (the cheap-place
gate) depending on all seven.
Interpretations recorded (flagged for the wave-1 gate):
- **Per-feature sibling deps land with their first consuming wave**,
not in the wave-1 manifest — `alktty`/`alktunnels`/`alkgit` would be
dead manifest entries in a build where the features gate nothing
yet; the wave-1 manifest lands russh + alkcall + alkvault + the
toolkit only. The feature *names* are declared from the first
commit; the wave-2 decline core is their first `cfg` consumer.
- **No config-error type ships in wave 1** unless the config task
finds one needed: the OQ-08 fields are `Option` and decline at
request time (never construction time), so the error model carries
only the key-source taxonomy; wire decline reasons are wave-2
shaped.
- **The policy verdict type** (bool vs a richer reason-carrying
verdict) is pinned in `w1-host-key-policy` against the russh
`check_server_key` hook shape at this pin — flagged for the gate.
- **The vault-double shape** (wrap alkvault's in-memory construction
if it ships one, else a local double behind the source trait) is
pinned in `w1-test-doubles` — flagged for the gate.
## Wave 2 — The server stack
The russh server substrate every mapping composes into — the door
frame, before any door is hung:
- **`SshAdapter::handle` + `Config` construction** (ADR-001/002) — the
mount point ADR-001 pins as the `alk/ssh` receiving endpoint: a
generic stream in, the full russh server stack per stream (edge
termination only — no ALPN code, no relay leg; the pin is a ledger
entry, already decided). `Config` built from the serve-config:
honest advertisement (`methods = {publickey}` exactly, ADR-004
decision 4), host keys from the vault-backed source (locked → no
server constructed), the bounded knobs from `Limits`.
- **The auth bridge** (ADR-004) — the dispositional handler surface:
`auth_publickey` fingerprint → `resolve_from_fingerprint` → accept
with the resolved identity / reject fail-closed; ed25519-only
(non-ed25519 keys declined explicitly); `auth_publickey_offered`
accept-and-delegate (counting stays honest); `auth_none` /
`auth_password` / `auth_keyboard_interactive` /
`auth_openssh_certificate` explicit declines (family contract, not
inherited defaults); the username carries no semantics (OQ-03).
- **Parse-only dispatch + the fail-closed decline core** (ADR-006 as
amended, ADR-003) — session-channel dispatch skeleton (parse path:
git-parseable exec → `git`, else → `session`; request-less session
channels decline), unfeatured channel types decline at open, x11 /
`auth-agent` always decline (never feature-gated), the sftp
subsystem decline-with-reason placeholder (OQ-13 — folded here,
server side), **decline first, alloc never** (the POC-3 stage-A
matrix: unknown repo → collapsed generic denial before any protocol
byte).
- **Spawn-and-return plumbing** (ADR-006 decision 4) — the structural
constraint in code: every serving path `tokio::spawn`ed, handlers
return fast, exit/status reporting from spawned tasks rides the
cloned `Session::handle()`. The reporting *mechanics* land here; the
derivation contract's rules are wave-3 session-mapping material.
- **Bounded resources** — the in-crate concurrent-channel cap enforced
at the handler surface, `inactivity_timeout`/`max_auth_attempts`
wired from `Limits`, per-length exec-parse limits reserved for the
grammar's consumption (wave 3).
- **Loopback harness** — the in-process client/server test pair the
decline matrix and every mapping's contract tests run through
(in-memory doubles from wave 1; real-tool smokes ride the mapping
waves).
Wave 2 decomposes after the wave-1 gate, absorbing whatever it
corrects.
**Decomposition record** (2026-10-28, after the wave-1 gate closed
green): wave 2 decomposed into six task files under
`tasks/implementation/` plus the gate. Shape: `w2-server-mount` first
(the `SshAdapter` mount + russh `Config` construction from the
serve-config — the substrate, with the Handler skeletoned at its
fail-closed defaults); then `w2-auth-bridge` (the ADR-004
dispositions fill the auth hooks, over the mount + the wave-1
fingerprint path and doubles); then `w2-loopback-harness` (the
in-process client/server pair — the contract-test vehicle — proving
the doubles live through the stack); then `w2-dispatch-decline-core`
(parse-only dispatch + the fail-closed decline core, contract-tested
per feature build through the harness); then `w2-spawn-return` (the
structural constraint pinned by test + the exit-reporting mechanics
via the cloned `Session::handle()`); then `w2-bounded-resources` (the
in-crate channel cap at the handler surface); and `review-wave-2`
(the gate) depending on all six. The chain is deliberately sequential
— the auth hooks, the dispatch, and the cap all edit the same
per-connection Handler impl, so no two tasks hold it in parallel.
Interpretations recorded (flagged for the wave-2 gate):
- **The git-parseability probe in wave 2 is skeleton-level** (service
prefix + path shape) — enough to contract-test the routing decision
and the session-build's git-gated decline; the full grammar
(quoted/unquoted paths, service-only → decline-with-reason,
per-length limits) is wave-3's git task, which replaces the probe.
- **Wave-2 routing stubs decline cleanly at the family-seam boundary**
— the seams land in wave 3; the stub behavior is documented as
skeleton so the routing decisions are contract-tested now. The
spawn-and-return mechanics are proven on a stub serving path.
- **The adapter error surface is shaped in `w2-server-mount`** — the
construction error wrapping `KeySourceError` (locked → no server)
plus the Handler `type Error` (`From<russh::Error>`); this is where
the wave-1 note's "wire decline reasons are wave-2 shaped" lands.
- **The harness transport and placement are pinned in
`w2-loopback-harness`** (duplex pair vs TCP loopback; module
placement beside the wave-1 doubles) — flagged for the gate.
## Wave 3 — Server mappings
Three parallel branches over the stack (plus the folded sftp row),
each carrying a minimal real-tool smoke (one clone, one `-L`, one pty
session — the POC-3 gates re-landed cheaply); the full matrix is
wave 5's:
- **session → alktty** — the translation bridge, not a byte pump
(POC-3 §D.1): `pty_request` args → `TerminalParamsWire` (SSH modes →
the reserved modes JSON); shell argv / exec wrapper from the
serve-config (`wrapper ++ [cmd]`, never naively split; unset → clean
decline — OQ-08); stdin/stdout/stderr chunk translation
(`extended_data(1)` for stderr); `window_change_request`/`signal`
rows; `env_request` declined unconditionally (OQ-09); the **exit
derivation contract** (`mapping.md` rules 1–6: `code ≥ 0` →
`exit-status(N)`, negative `−N` → `exit-signal(name)` via the
server-side Unix signal-number→name table, `−1` → `exit-status(255)`
never a signal claim, `core_dumped` never asserted, PTY-path rule-5
fidelity limit) — exit-status sent before close, from the spawned
task.
- **git → alkgit** — exec-only (ADR-006 decision 2): the parse grammar
(`git-{upload,receive}-pack '/repo'`, quoted/unquoted paths,
service-only → decline-with-reason, non-git → parse-then-fail,
per-length limits), unknown-repo → collapsed generic denial before
any protocol byte (no existence oracle), dispatch to
`serve_duplex_resolved(repo_id, identity, service, duplex, limits,
…)` over the channel duplex, process-mode dialect, exit-status both
directions.
- **tunnel → alktunnels route 1** (ADR-006 decision 1) — the
adapter-side bridge: the serve-config's `ChannelClient` into the
family fabric (identity propagated once via `set_identity` so
`AccessControl::check` gates the mapped opens — the visible-surface
invariant), `TunnelSession::open_direct` + `take_halves()` pumped
against the russh channel, accept only after the fabric open
succeeds (failure → explicit `ConnectFailed` reject),
`direct-streamlocal` runtime-Unix-gated (OQ-11: universal compile,
clean open-decline off-Unix), the `forwarded-tcpip` handler shape,
the `-R` listen flow (`tcpip_forward`/`streamlocal_forward` + the
same runtime-Unix gate).
- **sftp placeholder** — the decline-with-reason rows (folded into the
dispatch task server-side; the client-side placeholder folds into
wave 4's acceptor/op-row work; the wave-5 matrix covers both
explicitly).
Wave 3 decomposes after the wave-2 gate, absorbing whatever it
corrects.
**Decomposition record** (2026-10-28, after the wave-2 gate closed
green): wave 3 decomposed into six task files under
`tasks/implementation/` plus the gate. Shape: `w3-seam-config` first
(the serve-config's per-feature seam handles + the optional
`alktty`/`alkgit` deps — see the interpretations — the mappings
dispatch against them, so it lands before the mappings); then
`w3-decline-tracing` (the wave-2 gate's booked finding: the decline/
serve-path event vocabulary, landed **before** the mappings so they
inherit a uniform shape); then the three mapping branches
deliberately sequential — `w3-git-mapping` (the full grammar replaces
the wave-2 probe + the `serve_duplex_resolved` dispatch + the
store-and-take handoff it lands first for the session task to reuse),
`w3-tty-session` (the translation bridge + the exit-derivation
contract — the biggest single-row surface), `w3-tunnel-direct` (the
POC-proven route-1 bridge leg + identity `set_identity` propagation),
and `w3-tunnel-listen` (the `-R` listen flow + `forwarded-tcpip`
data leg — the POC-untested leg, sized with the stop-and-book
provision); and `review-wave-3` (the gate) depending on all six. As
with wave 2, the branch DAG is sequential: the three mappings replace
stubs in the same `Handler` impl and share the decline-matrix test
binaries, so no two tasks hold them in parallel — the branches are
parallel *conceptually* (independent seams), sequential
*logistically*.
Interpretations recorded (flagged for the wave-3 gate):
- **The serve-config extension is wave 3's first course correction to
the wave-1 config surface** — the assembly-supplied alktty/alkgit
seam handles the mappings' seam signatures take (the tty backends
map + selector + ownership provider; the alkgit backend quartet +
pipeline semaphore + push-options flag) were never carried by the
wave-1 config bullet, and `w3-seam-config` lands them feature-gated
with the optional sibling deps (`alktty = "0.8.1"`, `alkgit =
"0.1.0"` — crates.io-verified at decomposition) before the
mappings; `alktunnels = "0.4.1"` still lands with its first
consuming code (`w3-tunnel-direct`) to avoid a dead entry. The
unset-declines posture extends to the new handles (unset → the
owned rows decline even with the feature on).
- **The server-side sftp bullet is already discharged** — the OQ-13
decline rows landed with `w2-dispatch-decline-core` (every subsystem
name declines in every build) and are already contract-tested per
build in `tests/dispatch_core.rs`; no wave-3 task exists for it —
the client-side placeholder folds into wave 4's acceptor/op-row
work and the wave-5 matrix covers both rows explicitly.
- **The `-R` listen flow carries the wave-3 gate's biggest open** —
the fabric-side inbound-forwarded mechanics (who dials the
forwarded op at the door, whether the assembly-supplied
`Arc<ChannelClient>` composition can carry the door-side accept or
the serve-config's tunnel handle must grow) is decided in
`w3-tunnel-listen` against alkcall 0.10.1 / alktunnels 0.4.1's
published surfaces; if the topology does not close, the task stops
and books it for the gate — nothing is silently forked or absorbed.
- **The wave-2 gate's three booked items all have wave-3 homes** — the
decline-path tracing task (`w3-decline-tracing`), the forwarded-
tcpip acceptor vocabulary (`w3-tunnel-listen`, with the client-side
accept hook landing in the harness), and the deferred identity-
landing assertion (each mapping asserts the resolved identity
reaches its seam; the tunnel branch additionally propagates it via
`set_identity` — auth.md §Server direction step 4).
## Wave 4 — Client direction
The door outward (independent of wave 3 — shares only wave 1's
foundations), each branch carrying its real-tool smoke where cheap:
- **Dial surface** (`client.md`) — connect with address, key source
(decrypt-at-dial, fail-closed on locked/absent — never env vars),
host-key policy at `check_server_key` (the wave-1 seam consumed:
case-1 pins hard-decline on mismatch, case-2 policies decide), the
bounded config (keepalives 15 s / 3, inactivity, window knobs). The
authenticated connection handle everything else rides.
- **Op rows** — family op → vanilla translation per `client.md`'s
table: session exec/pty opens (the POC-3 §D.3 mechanics:
`Channel::exec()` not `request_exec`, Success/Failure via
`wait()`-drain not `Err`, `window_change`/`signal` before
`into_stream()`, never consume the queue before conversion);
`direct-tcpip` typed dial (reject → `Err(ChannelOpenFailure)`, the
connection stays usable); git exec consumption (the same grammar the
server parses, pointed upstream); the sftp subsystem placeholder
decline.
- **The `-R` leg** — `tcpip_forward(address, port) -> bound` (the
actually-bound port returned), server-initiated `forwarded-tcpip`
opens routed to the **acceptor registry**: served only under a
registered feature's acceptor, unregistered/unfeatured accepted
opens declined cleanly (never russh's accept-and-drop default),
late-install across a live connection (the alkminer dynamic),
`streamlocal_forward` with the runtime-Unix gate.
- **Lifecycle primitives** (OQ-12) — explicitly cancellable
listener/session handles, clean `Disconnect::ByApplication`; **no
reconnect or restore policy owned by the crate** (assembly policy;
the primitives make it possible).
Wave 4 decomposes after the wave-3 gate (it may start any time after
wave 1 in principle, but decomposing it after wave 3's gate keeps the
session task sets in rhythm and lets it absorb any seam corrections
the mappings surfaced).
## Wave 5 — Validation & release
- **The decline-matrix contract suite** (`mapping.md`'s table) — every
build (none / `git` / `session` / `tunnel` / `--all-features`) ×
every row it can appear in × both directions, including the sftp
placeholder rows and the never-features (x11, `auth-agent`). The
fail-closed invariant is contract territory on both sides of every
feature (AGENTS.md conventions 5–6).
- **Real-tool e2e** — the POC-3 gates re-landed against the shipped
crate: OpenSSH (pty/shell/exec, `-L`, `-R`, `-D`'s `direct-tcpip`
leg, decline observability), git 2.43+ (clone/push through the git
mapping), curl through a `-D` proxy; client direction against a real
`sshd` (the reverse-runner shape: exec, direct-tcpip dial, `-R`
listen + acceptor).
- **Fuzzing** — the family standing rule (anything worth fuzzing gets
fuzzed) on the crate's ingress surfaces: the exec parse grammar, the
fingerprint format path, the key-decode path, the TerminalParamsWire
conversion. The `fuzz/` sidecar convention (detached runner, corpus
gate joining the verification list).
- **Release readiness** — crate docs (public-API rustdoc
warning-free; docs honesty), the feature matrix (`default`, each
feature over it, `--all-features`), CHANGELOG/README, publish
dry-run, then **the publish of `alkssh 0.1`** — the gate review that
closes the plan.
## References
- `docs/architecture/` — overview, mapping, client, auth,
open-questions (all reviewed 2026-10-28), ADR-001…006
- `docs/research/` — vision, russh inventory, auth bridge, mapping
table, POC-1/2/3 findings, reverse-runner extraction
- Family ADR-003 (the structural decision); the alkgit implementation
plan (the pattern this follows)