Files
alkssh/docs/plans/implementation.md
T
glm-5.3-flash 0b1bc284fa 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
2026-10-28 06:13:26 +00:00

18 KiB
Raw Blame History

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" 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::spawned, 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)