- 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
18 KiB
status, last_updated
| status | last_updated |
|---|---|
| draft | 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 | 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 | 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 | session → alktty (translation bridge + exit derivation); git → alkgit (exec grammar + dispatch); tunnel → alktunnels route 1; sftp placeholder declines | wave 2 | pending |
| 4 | Dial surface; op rows (exec/pty, direct-tcpip, git exec); the -R leg (listen + acceptor registry); lifecycle primitives |
wave 1 | pending |
| 5 | 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"withaws-lc-rs(noflate2, norsa— the tree RC-free except russh's ownssh-keypin),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 (alkttyundersession,alktunnelsundertunnel,alkgitundergit) 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
thiserrortaxonomy, fail-closed with no catch-alls: the key-source failure matrix as distinct variants (ADR-005 decision 4 — locked / absent / decryption failure / unknownkey_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; theIdentityProviderhandle; the host-key source handle; the tunnelChannelClienthandle; theLimits), the dial-config (address, key source,HostKeyPolicyhandle, keepalive/inactivity/window knobs), andLimits— 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_attempts10 sized for the countingnone-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-timedecode_secret_key(plain, None)), the client dial-key source and the server host-key load path (decrypt-at-construction; locked vault → no server constructed),EncryptedDataopaque to the crate, plaintext never persists, every failure mode mapped to its distinct error variant. - Host-key policy seam (OQ-05) — the
HostKeyPolicytrait 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 thessh-keyre-export (HashAlg::Sha256); the string format is a contract invariant, proven against independent issuers (ssh-keygen -lfvectors, 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-memoryHostKeyPolicy, 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/alkgitwould 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 firstcfgconsumer. - No config-error type ships in wave 1 unless the config task
finds one needed: the OQ-08 fields are
Optionand 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-policyagainst the russhcheck_server_keyhook 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+Configconstruction (ADR-001/002) — the mount point ADR-001 pins as thealk/sshreceiving 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).Configbuilt 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 fromLimits.- The auth bridge (ADR-004) — the dispositional handler surface:
auth_publickeyfingerprint →resolve_from_fingerprint→ accept with the resolved identity / reject fail-closed; ed25519-only (non-ed25519 keys declined explicitly);auth_publickey_offeredaccept-and-delegate (counting stays honest);auth_none/auth_password/auth_keyboard_interactive/auth_openssh_certificateexplicit 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-agentalways 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::spawned, handlers return fast, exit/status reporting from spawned tasks rides the clonedSession::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_attemptswired fromLimits, 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_requestargs →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/signalrows;env_requestdeclined unconditionally (OQ-09); the exit derivation contract (mapping.mdrules 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_dumpednever 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 toserve_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
ChannelClientinto the family fabric (identity propagated once viaset_identitysoAccessControl::checkgates 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 → explicitConnectFailedreject),direct-streamlocalruntime-Unix-gated (OQ-11: universal compile, clean open-decline off-Unix), theforwarded-tcpiphandler shape, the-Rlisten 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 atcheck_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()notrequest_exec, Success/Failure viawait()-drain notErr,window_change/signalbeforeinto_stream(), never consume the queue before conversion);direct-tcpiptyped 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
-Rleg —tcpip_forward(address, port) -> bound(the actually-bound port returned), server-initiatedforwarded-tcpipopens 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_forwardwith 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'sdirect-tcpipleg, decline observability), git 2.43+ (clone/push through the git mapping), curl through a-Dproxy; client direction against a realsshd(the reverse-runner shape: exec, direct-tcpip dial,-Rlisten + 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 ofalkssh 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…006docs/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)