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.
21 KiB
POC-3 findings: the mapping bake-off, channel bridge through real sibling seams
Status: Complete (phase 0). POC source: /workspace/alkssh-poc3
(standalone crate, disposable, never merges). All four stages ran
against vanilla clients (OpenSSH 9.6 ssh -L/-R, real git
2.43, real curl 8.5) and the published crates.io forms of every
sibling, with no path dependencies and no in-family forks. The doc records
the evidence: three corrections to mapping-table.md, the russh
0.64.1 runtime facts phase 1 must code around (the inline-dispatch
rule), the identity/scope live check, and the OQ seeds.
Setup exercised
- russh 0.64.1 (crates.io, ring backend; the same published tree POC-1/POC-2 pinned; re-verified still the newest release). Same dependency tree as both prior POCs across all four stages.
- Sibling seams, published forms: alkgit 0.1.0 +
alkgit-substrate 0.1.0 (the shipped backend composition, with
alkstore-memengine +alkblobs::Store::mem_only()object pool, the substrate's own mem-assembly shape,SubstrateAssembly::mem), alksocks 0.3.1 (localfeature, the shippedtcp_dial), alktunnels 0.4.1 (localfeature, the shippedlocal_dial), alktty 0.8.1 (localfeature, the shippedLocalTtyBackendover portable-pty), alkcall 0.10.1 (channels + identity types). - Servers per stage were built as feature-shaped builds: stage A's
server serves only git-parseable exec (a git-only build), stage B's
only SOCKS-on-session (a dynamic-forward shape), stage C's only
direct-tcpip (an egress-tunnel shape that also declines the
-Rglobal request), stage D's only pty/shell/pipe-exec (a session shape). The auth loopback is POC-1's verbatim shape (fingerprint registry fed fromssh-keygen -lf,auth_publickey_offeredaccept-and-decide, all other methods declined). - POC mechanics: single binary, stage functions returning check reports (POC-1's harness); full clean runs reproduced twice.
Stage A: git exec mapping (24/24 PASS)
1. Vanilla git works through the seam with zero seam fixups
git push → git clone → rev parity → second push (have/star
negotiation on the populated repo) all succeeded through:
vanilla git (GIT_SSH_COMMAND) → OpenSSH → russh server
→ exec_request parse (the alkssh-shaped grammar)
→ serve_duplex_resolved(repo_id, identity, service, channel.into_stream(),
Limits, registry, refs, packgen, ingest, false, pipeline)
with the real alkgit-substrate mem composition as the backend: registry/ref-rows/packgen/ingest, an in-memory alkstore engine, and an in-memory object pool. The published alkgit seam needed no wrapper, no adapter glue beyond the command parse, and no dialect nudges. The hypothesis that phase-1's mapping ADR gated on (stage A gates the ADR work) is confirmed.
2. The SSH exec-command grammar (the alkssh-side parse)
What real OpenSSH git actually sends: git-upload-pack '/owner/demo',
a single-quoted repo path with a leading / (the parse strips quotes
and the leading slash; no \0host= rides the SSH exec, so the
mapping-table's "same grammar as preamble.rs, without \0host=" is
exactly right). The POC grammar table (all rows verified):
| exec command | verdict |
|---|---|
git-upload-pack '/r' / git-receive-pack '/r' / git-upload-pack /r |
git parse, dispatched |
git-send-pack '/r', git-upload-archive '/r' |
declined (unknown service, honest surface) |
git-upload-pack (service-only, no repo) |
declined: SSH exec must carry the repo |
uptime (non-git) |
declined parse-then-fail, no resource alloc |
3. Subsystem mapping row (corrected at the detail level)
mapping-table.md says git subsystem names "arrive as either" (exec
or subsystem). On the SSH wire a subsystem request carries only the
name, and no repository is available to resolve. The POC proves the
forced decline: subsystem("git-upload-pack") and subsystem("sftp")
both fail cleanly with a channel failure, with the finding recorded:
over SSH, git always uses exec; a subsystem git request can never
dispatch (no repo id), so the correct mapping is decline-with-reason.
The mapping-table row needs the detail fix.
4. russh runtime facts phase 1 must code around (new, load-bearing)
- Server handler callbacks run inline inside the session's packet
loop. Blocking inside
exec_request/pty_request/shell_request(or any handler) suspends packet processing for the entire connection: the awaited channel data is only routed by that same loop, so an inline serve deadlocks (receive-pack pushes bytes immediately after the exec request). Confirmed by construction: the serving entry is spawned and the handler returns immediately. This is the binding implementation constraint for the alkssh adapter: every serving path istokio::spawn+ handler returns fast. - The spawned task gets exit reporting through
Session::handle(), a cloneable server-sideHandlecarryingdata/extended_data/eof/close/exit_status_request/exit_signal_request. The exit-status mapping row is implementable exactly as the table prescribes: sendexit-statusbeforeclosefrom the serving task. - The accepted-but-held
Channelcan be stored in the handler and taken later in the request callback (channels.remove(&id)→into_stream()in the spawned task), with nowait()anywhere (POC-1's caveat holds server-side too).
5. Exit-status fidelity, denial fidelity, vanilla diagnostics
- Vanilla git paths exit 0 (the exit-status request rode the wire);
an unknown repo produces the collapsed generic denial before any
protocol byte (no existence oracle) and vanilla git reports
fatal: remote error: access denied: owner/absent, exit 128. - A russh client exec against the absent repo observed
ExitStatus{1}after the denial band; the exit-status flow is proven in both directions, including the failure leg. - The git-only build's decline matrix held under vanilla ssh:
ssh host uptimefailed with the parse-then-fail decline logged;pty_request/shell_request/env_requestdeclined with failure replies (russh client observedChannelMsg::Failure); the fail- closed feature matrix of the mapping table is live end to end.
Stage B: SOCKS5-in-channel (6/6 PASS)
1. serve_socks5_over works over a russh session channel
Construction: server accepts the session channel, spawns the
SOCKS-detect (first-byte sniff via BufReader::fill_buf: 0x05
= RFC 1928 greeting), and routes to
serve_socks5_over(stream, dial, None, None, false) with the shipped
alksocks::tcp_dial() (the local feature's DialFn, resolving
domain-form targets). Client side: a local bridge listener pumps each
accepted TCP socket bidirectionally into one authenticated session
channel. Real curl --socks5-hostname through the whole chain
returned the endpoint body byte-identically: CONNECT over the SSH
wire, dialed at the server through the family seam.
2. OpenSSH's own -D never speaks SOCKS on the wire (mapping row corrected)
This is a mapping-table correction: vanilla ssh -D performs the
SOCKS5 handshake client-side and then opens plain direct-tcpip
channels per connection. The session → SOCKS-in-channel row is
therefore the family-native client shape (an embedder/alktty-style
client speaking SOCKS5 on the channel), not what a vanilla ssh -D
emits; vanilla -D traffic takes the stage-C row (direct-tcpip →
egress). Both rows are real; the table should say which client shape
each serves. (POC evidence: this stage's proxy uses the session
channel; stage C's -L-style row serves forwarded and SOCKS-originated
direct-tcpip alike.)
3. The dispatch split is real and needs a bounded detect window
Parse-then-route (stage A) and SOCKS-detect (this stage) are the two session-channel dispatch modes the mapping table predicted, and both work. The detect runs in a spawned task with a bounded window (500 ms in the POC): the first byte decides the route; a silent channel is declined. Two consequences for the ADR: (a) the detect must not block inline (the §A.4 deadlock rule applies: the sniffed byte arrives via the same loop that awaits it); (b) the detect window is a design shape to pin, an OQ seed: whether the dispatch is a first-byte sniff, an in-band preamble, or an exec-request marker chosen by the assembly.
4. Refusals are RFC-faithful through the seam
A CONNECT to a dead port: the dial seam returns
SocksDialError::Refused → alksocks replies the mapped RFC code →
curl exits 97 (proxy-connection-not-allowed class) with no data
crossing. Backpressure/throughput: 8 MiB through the session-channel
proxy in ~585 ms (in-process route; the 1 MiB channels-reassembly
leg is not on this path). Non-SOCKS first byte on a session channel →
declined, logged; no HTTP serving happens on the SOCKS route.
Stage C: direct-tcpip via alktunnels route 1 (8/8 PASS)
1. The bridged route works end to end with the published crates
Route 1 (adapter-side bridge) exercised as the mapping table's route
note describes: the russh server holds a real ChannelClient into
a family fabric, an in-process alk/channels connection whose
serving side (the ChannelsAdapter accept end) registers
ChannelOperations + register_tunnel_direct_openable with the
shipped alktunnels::local::local_dial() injected. Per vanilla ssh -L channel:
channel_open_direct_tcpip → TunnelSession::open_direct(
&client, SubstrateAddr::HostPort{host, port}, Substrate::Tcp)
→ take_halves() → pump_bidi(channel.into_stream(), read, write)
Vanilla ssh -L + curl to the bridged port returned the endpoint
body byte-identically; 8 MiB through the bridge in ~333 ms. The
direct_egress precedent in alksocks' composed feature is the same
shape; the family precedent survives contact with the SSH door.
2. Failure shapes at both ends of the bridge
- Fabric open failure → the SSH channel is rejected explicitly
(
ChannelOpenHandle::reject(ConnectFailed)); accept is possible only afteropen_directsucceeds becauseaccept(mut self)consumes the handle, so the handle moves into the spawned task. A vanilla client dials a dead target and gets a clean curl failure (exit 56) instead of a hang. - The
-Ringress direction is declined on this egress-only build:Handler::tcpip_forwardreturns false (the global request refuses), vanillassh -Rdies withremote port forwarding failed for listen port(exit 255), and the event log records the refusal: honest advertisement, gate-before-wire. - Unfeatured session-channel opens drop (auto-reject) unchanged from POC-1 on this and the other stage builds.
3. Identity/scope propagation observed live
The direct establisher's identity witness recorded u-writer;
the SSH-authenticated identity rides the transport connection into
the channels layer (CF-005 propagation), and the establisher's
per-call auth (CF-006) sees it. The visible-surface invariant's
alkcall half (opener identity resolved from the authenticated
transport) is confirmed inside the mapping, not just documented.
4. Wiring recipe worth carrying into phase 1
The in-process fabric leg (worth recording because it was the POC's
only real lift, and the alkforge assembly will build exactly this):
OperationRegistry + InstallChannelZero hook (spawn: accept channel
0's accept_bi, split_single_stream, set_identity(caller),
CallConnection::new_single_stream, register ops + openables,
Dispatcher::serve_single_stream) on the accept side;
CoreConnection::from_bidi on both ends of a tokio::io::duplex
transport; ChannelClient::from_connection on the alkssh side with
the identity attached before dialing.
Stage D: session/pty mapping (13/13 PASS)
1. The mapping is a translation bridge, not a pump (mapping-table note confirmed with a shape)
alktty's data plane is chunk-framed in both directions
(STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload;
alktty::wire). The SSH mapping therefore wraps the channel with
two small adapters: SSH data/EOF → stdin chunks (+ zero-length EOF
sentinel); alktty stdout chunks → SSH data, stderr chunks → SSH
extended_data(1), ctrl-out exit chunk → SSH exit-status request +
close. drive_session_pre_negotiated(writer, reader, NegotiateRequest built locally, backends, ownership, identity), the exact producer
construct the mapping table names, then runs the real session. Proven
against a real PTY (LocalTtyBackend over portable-pty):
pty_requestargs →TerminalParamsWire(term/cols/rows/ pixels; SSH modes → the reserved{}JSON, an alktty-side reservation, alktty OQ-44; modes are deferred there); live: bash on the PTY answeredstty sizewith the requested 24×80.- Keystrokes ride in, PTY echo and shell output ride out (real bash prompt captured in the run output).
window_change_request→ControlMessage::Resizeas a CTRL_IN chunk → the PTY resized:stty sizeflipped to40 120after the client sentwindow_change(120, 40), the mapping row proven behaviorally.- Pipe-mode
exec_request: stdout rode SSH data, stderr rode SSHextended_data(1)(client received both distinctly), exit chunk →exit-status(0). The PTY/pipe stderr split the mapping table describes (PTY merges, pipe separates) is exactly what the local backend produces. signal(TERM)→ControlMessage::SignalCTRL_IN chunk → the backend killed the session; the exit chunk carried the negative code and the SSH reply was anexit-signalrequest, matching the mapping-table's "only reachable in signal-termination cases" row, now with live wire evidence.- The git-parseable exec declined in the session-only build
(parse-then-route: the git parse is the
gitfeature's gate); the feature-split failure matrix holds in this build too.
2. Signal/exit fidelity over the alktty exit-code convention
The exit chunk is {"type":"exit","code":N} (alktty ADR-004, ported
from alknet ADR-055). Findings of this stage, verified against the
published sources:
- Pipe path: the negative code is the signal encoding. Pipe-mode
signal-terminated children arrive as the negative signal number
verbatim from std's
ExitStatusExt(pipe.rs::exit_code_frommapsExitStatus::signal()to−sig; the alknet ADR-055 convention). So SSHexit-signalneeds no wire change: the adapter's check mapped−15→exit-signal("TERM"); the name derivation is a server-side number→name table lookup, not an alktty ask. - PTY path: the signal number does not survive the backend.
portable-pty 0.9's
ExitStatusconversion recordscode = status.code().unwrap_or(1)→ 1 for signal-terminated children, keeping the signal only in a separate humanized (strsignal-text) string field that alktty's PTY waiter discards (pty.rsreadsexit_code()only; the number is unrecoverable through portable-pty 0.9's trait surface). Alktty's own PTY signal tests assertcode != 0, never the encoding. Consequence: faithfulexit-signalfor signal-killed PTY sessions needs the alktty backend to conform to its own ADR's negative-code convention, an alktty/portable-pty ask with tty/pty-grounded justification (upstream trackers; the alkssh mapping adapts automatically, no hedge needed). - The
−1sentinel (backend wait failure) collides with a real SIGHUP termination (signal 1), documented in alktty ADR-004 §4, with an additiveexit_errorcontrol type as its recorded extension seam. Alkssh renders−1asexit-status(255), fabricating no signal for a wait failure. - Pipe-mode signals are pid-only (a SIGTERM to
sh -c "sleep 60"leaves the orphanedsleepholding the stdout pipe, with no exit chunk until the whole group dies); PTY-mode signals reach the process group (correct terminal semantics). The signal row's fidelity depends on the tty/pipe mode split the table already notes.
3. Client-visible mechanics (russh 0.64 API facts from this stage)
- russh client has
Channel::exec()(there is norequest_exec); pty/subsystem request failures surface asChannelMsg::Failureviawait()(not asErrfrom the request call); the caller must read the channel until Success/Failure. window_change/signalare fire-and-forget (no want-reply), and must be sent beforeinto_stream()(after the conversion the Channel is consumed); server-side they carry no SSH reply, so the handler must not send one.- The server handler loop dispatches
CHANNEL_SUCCESS-style replies only through explicit handler calls; request-level failure replies in stage A/D all usedSession::channel_failure. - The channel message loop client-side delivers interleaved
Data/ExtendedData/Success/ExitStatus on one queue; drain to the
expected message,
continuepast the rest (the POC hit the break-on-first-message trap twice; noted so the alkssh consumer half doesn't).
Hypothesis verdicts (pocs.md §POC-3)
- "one-seam-per-mapping hypothesis" is confirmed across all four seams with zero seam fixups; the mapping glue is adapter-side (command parse, chunk translation, handle plumbing).
- "parse-then-route dispatch" is confirmed (stage A), with the SOCKS-detect variant confirmed in stage B plus the bounded-window and spawn-not-block constraints.
- "EOF/close/signal fidelity end to end" is confirmed: exec → serve → exit-status (0 and 1 legs), exit-signal for signal termination, close mapping, refusal diagnostics at both bridge ends.
- "stderr semantics in pipe vs PTY mode" is confirmed exactly as the table describes (separate extended_data in pipe; merged under PTY).
- "throughput sanity" is confirmed with margin: 8 MiB in ~0.3–0.6 s per bridged route.
- "stage D may surface PTY-merge stderr surprises that reshape the request-level mapping rows": partially. No surprises in the PTY/pipe split, but the signal/exit-fidelity details and the translation-bridge shape (two adapters, not a pump) do reshape the session row's implementation notes.
Corrections recorded against phase 0 research
mapping-table.md§channel-type rows: the git subsystem claim ("git-upload-pack etc. arrive as either") is wrong over SSH: subsystem cannot carry the repo id, so even a git service name must decline. Git over SSH is exec-only.mapping-table.md:ssh -Dfrom a vanilla client does not produce SOCKS5-in-channel traffic (OpenSSH does SOCKS client-side and opensdirect-tcpip). Thedynamic-forwardrow serves family-native clients; vanilla-Drides thetunnelrow.mapping-table.mdsession/pty row implementation notes: the alktty bridge is a translation bridge (chunk framing both directions), not a byte pump; plus the exit/signal fidelity findings above (pipe-path−Nencoding holds; PTY path reduces signal termination to a plain code, portable-pty's exit surface).
OQ seeds carried forward
- Session-channel dispatch shape for SOCKS (first-byte sniff vs in-band preamble vs exec marker; the detect window's bound).
- Exit/signal fidelity over the alktty exit-code convention (the
−Nencoding holds on the pipe path, and the name derivation is server-side; the PTY path's signal-identity loss is an alktty/portable-pty matter; pipe-mode pid-only vs PTY process-group scope). - Shell command convention (already seeded; the POC's stand-in,
bashfor shell andsh -c <cmd>for exec, is an explicit assembly policy, not wire-derived). - The
-Rlisten flow (forwarded-tcpip mapping): untouched by the POC beyond the refusal shape; route choice (ingress viaregister_tunnel_forwarded_openable) is phase 1 work with this stage's route-1 recipe as the template. - The identity-attachment seam (SSH fingerprint → alkcall
Identity→ transport identity → establisher witness) worked in-POC; whether the assembly composes these as one configuration surface is an assembly-layer question to keep explicit.
Reference run
Full clean run at findings time: **51 checks across 4 stages: 24 + 6
- 8 + 13, all PASS,
overall=true, exit 0** (reproduced twice; second run byte-identical PASS set with different ephemeral ports). Server-side event logs captured per stage in the run output (the diagnostics quoted above are verbatim from that run).
References
pocs.md§POC-3 (plan),mapping-table.md(the rows under test, three corrections recorded above),poc-1-auth-bridge.md(the auth loopback everything rides),russh.md(API inventory)- POC source:
/workspace/alkssh-poc3(src/stage_{a_git,b_socks, c_tunnel,d_tty}.rs; the fabric/serving recipes and the translation bridge are the reusable shapes) - russh 0.64.1, alkgit 0.1.0, alkgit-substrate 0.1.0, alksocks 0.3.1
(
local), alktunnels 0.4.1 (local), alktty 0.8.1 (local), alkcall 0.10.1 (crates.io, ring backend); git 2.43.0, OpenSSH 9.6p1, curl 8.5.0 (vanilla tooling)