docs(plans): add wave-based implementation plan; decompose wave 1

- docs/plans/implementation.md: five-wave decomposition of the
  architecture (foundations -> server stack -> server mappings ||
  client direction -> validation & release), following the alkgit
  sibling pattern (decompose-implement-gate rhythm, per-wave
  decomposition records, review gates at wave boundaries)
- scope authority: v0.1.0 publishes the crate; assembly wiring stays
  out; sftp decline placeholder is shipped behavior
- wave 1 decomposed into eight task files under tasks/implementation/
  (manifest, error model, key sources, host-key policy, fingerprint,
  config surface, test doubles, review gate) with recorded
  interpretations flagged for the gate

Verification: taskgraph validate (12 tasks, no cycles); cargo test,
clippy -D warnings, fmt --check green
This commit is contained in:
glm-5.3-flash committed 2026-10-28 06:13:26 +00:00
1 parent ab6fb9de87
commit 0b1bc284fa
9 files changed
+882

No files matched your search

+322
View File
@@ -0,0 +1,322 @@
---
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 | pending |
| [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 | pending |
| [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 url-safe 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.
## 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.
## 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)