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.
10 KiB
id, name, status, depends_on, scope, risk, impact, level
| id | name | status | depends_on | scope | risk | impact | level | ||
|---|---|---|---|---|---|---|---|---|---|
| w4-lifecycle | Wave 4: lifecycle primitives (cancellable handles + clean disconnect) | completed |
|
narrow | low | component | implementation |
Description
Land the OQ-12 resolution in code (client.md §Lifecycle
primitives): the primitives that make assembly-side reconnect/restore
policy possible, and nothing more:
- Explicitly cancellable handles, so every listener/handle the
client direction returns (the
-Rlistener handles fromw4-reverse-listen, the connection handle fromw4-dial) carries an explicit cancellation/clean-close path; no handle relies on drop timing alone for wire-visible state. - Clean disconnect, namely
Disconnect::ByApplication(russh's clientHandle::disconnect): the explicit-close path leaves no half-open channels, observed clean by the peer (row-pinned through the server double). - No reconnect or restore policy owned by the crate, hence the module-doc posture: reconnect-and-restore (rebinding persisted tunnel ports, the alkminer flow, per reverse-runner fact 4) is assembly policy; the crate provides bound ports, handles, cancellation, clean close. The docs state the boundary explicitly so no later task drifts into baking policy in.
- Contract rows through the server double: cancel a listener mid-connection (the server's forward is torn down, the bound port released); clean disconnect observed by the peer; the connection-handle cancellation path; the no-policy posture is a docs-honesty item for the gate, not a row.
Acceptance Criteria
- Every client-direction listener/handle is explicitly cancellable; no drop-timing-only wire state
Disconnect::ByApplicationclean-close lands; the peer observes a clean disconnect, no half-open channels (row-pinned)- The no-reconnect-policy boundary stated in module docs (the primitives-not-policy posture)
- Contract rows green through the server double
cargo test(+ per-feature +--all-features), clippy ×2 (-D warnings), fmt,cargo doc --no-depspass
References
- docs/architecture/client.md §Lifecycle primitives (the OQ-12 resolution), docs/architecture/open-questions.md (OQ-12, which resolved as primitives only)
- docs/research/reverse-runner.md (fact 4, where reconnect-and-restore is assembly policy, kept in view so the crate never bakes it in)
- docs/research/russh.md §Client surface (
Handle::disconnect, the forward cancel forms) - src/tunnel_map.rs (the server-side teardown posture, the cancel semantics mirror)
Notes
What landed (2026-10-29)
src/client_accept.rs: the listener handles, namely the [ReverseListener] + [ReverseEndpoint] (the handles' surface thew4-reverse-listenblock deferred to this task):reverse_listen/reverse_listen_unixreturn a handle carrying the actually-bound endpoint (thebound_port()accessor,Nonefor the unix shape) with the explicitcancel(); the shared cancel helpers (cancel_reverse_tcp/cancel_reverse_unix), and both the handles'cancel()and the stateless wire forms compose into the same paths; the confirmed cancel releases the connection's listener count exactly once (the repeat is the wire's unknown-listener refusal, no release). The recorded decisions (module docs): noDrophides a wire op (async cancel cannot ride a syncDrop); the handle shares the connection's internals through the arc so itscancel()works against a live stack regardless of the connection handle's own holders; the stateless forms ride beside 1:1.src/client.rs: theSshConnectionrestructure, where the russh handle, limits/labels, acceptor registry and listener budget ride oneConnectionSharedbehind theArc(the concurrency-safe sharing the handles' independentcancel()s need; the russh client request methods all take&selfat this pin, so the arc is the sharing shape, with no mutex),SshConnection's public surface unchanged (close()consumes the handle and sendsDisconnect::ByApplication), and the module docs' new §Lifecycle posture (the primitives-not-policy boundary, reverse-runner fact 4; the explicit-vs-drop facts, row-pinned).src/lib.rs: theReverseListener/ReverseEndpointexports (undertunnel).docs/architecture/client.md: the §Lifecycle primitives landing block (the handle shape + the abrupt-drop/clean-close boundary + the modules-docs posture anchors).tests/client_dial.rs: the every-build rowdropping_the_connection_is_an_abrupt_teardown_close_is_the_only_clean_path(thenext_session_outcomeobservable: drop → the peer's session ends with an error wire class, never the cleanOk, the boundary pinned both ways: the cleanOkis onlyclose()'s).tests/client_reverse_listen.rs: three new lifecycle rows:dropping_the_listener_handle_leaves_no_drop_timing_wire_state(drop → the peer's listen still serves a round-tripping data leg; only the explicit cancel tears down),the_listener_handle_cancel_tears_the_forward_down_and_releases_the_budget(ceiling → refuse pre-wire; the handle's cancel confirms and releases; the next listen admits; the repeat rides the wire and refuses, with no budget manufactured),cancel_mid_data_leg_tears_the_forward_down_mid_connection(the cancel lands while a data leg is mid-pump; the forward tears down, the connection stays usable, ends clean); the existing rows adapted to the handle returns (the endpoint assertions ride the handles now); the unix row's cancel exercises the handle'scancel()(the streamlocal flavor).
Decided + recorded
- The handles' shape, a single
ReverseListener, oneReverseEndpointenum, not two handle types. The endpoint enum mirrors the wire's two bind shapes (an actually-bound address+port / a socket path); the tcp-flavoredbound_port()accessor returnsOption(the unix shape carries no port, honestly with no panic path). A second handle type would have duplicated the cancel plumbing for a shape the cancel forms already treat as raw strings. - The abrupt-drop fact (row-pinned, corrected mid-task): at this
russh pin dropping the last wire-stack handle closes the
Handle's sender; the client runner'sreceiver.recv()→Nonesetsdisconnectedand the transport tears down abruptly; the peer's session outcome is an error wire class (Err(Disconnect)observed through the double). So the honest boundary is not "drop does nothing" but "drop is never the clean path", since the cleanOksession end is onlyclose()'s (Disconnect::ByApplication), row-pinned in both directions (abrupt-on-drop, clean-on-close) through the server double. No handle relies on drop timing for clean wire state, and the listener handles' drop is a pure local no-op (noDrophook; a cancellation is async and cannot hide in one, and the listen lives at the peer until an explicit cancel or the connection's end). - The
SshConnectionrestructure is a shape change, not a surface change: the public surface (the wrapped methods,limits()/address()/username()/is_closed()/acceptors()/close()) is unchanged; only the internals moved behind the arc (the handles' independent cancel). NoClonewas added, which is not in the contract, and the arc means a cloned handle'sclose()semantics would need their own contract; wave-5 review can add it if an assembly shape needs it. - A live
ReverseListenerkeeps the wire stack alive after the connection handle's drop, an arc ownership side effect recorded (the alkminer flow's shape: the listener handle outlives the connection slot; itscancel()composes on the still-live stack).
Verification
cargo test (default), --features session, --features git,
--features tunnel, --all-features: all green (16 test binaries
each); cargo clippy --all-targets -- -D warnings and
cargo clippy --all-features --all-targets -- -D warnings clean;
cargo fmt --check clean; cargo doc --no-deps warning-free in the
default build (the pre-existing client_ops.rs private-link warnings
remain under --all-features, unchanged).
Summary
The OQ-12 primitives landed: the ReverseListener handles carrying
the actually-bound endpoint with the explicit wire-honest cancel()
(releases the listener count exactly once per confirmed cancel; the
stateless cancel forms compose into the same shared paths), the
SshConnection internals restructured behind the arc so handles
cancel against a live stack independently, the public surface
unchanged, the abrupt-drop/clean-close boundary row-pinned both ways
(drop is never the clean path; close() is), and the
primitives-not-policy posture stated in the module docs and
client.md's landing block. No reconnect/restore policy in the crate.
Details in the Landed block below.
Landed (feat(client): w4-lifecycle):
src/client_accept.rs, the listener handles:ReverseListener(+ReverseEndpoint), whichreverse_listen/reverse_listen_unixnow return; the actually-bound endpoint carried, the explicitcancel()the wire-honest teardown, the listener count released exactly once per confirmed cancel through the shared cancel paths the stateless wire forms compose into too; noDrophides a wire op (recorded, row-pinned no-drop-timing wire state).src/client.rs, theSshConnectioninternals behind the arc (ConnectionShared) + the §Lifecycle posture module docs (the primitives-not-policy boundary, the abrupt-drop/clean-close facts); the public surface unchanged (close()is the clean path).src/lib.rsfor the handle exports;docs/architecture/ client.mdfor the §Lifecycle primitives landing block.- Rows, with 3 new tunnel-build rows through the server double
(
tests/client_reverse_listen.rs: the drop-handle no-wire-state row, the handle-cancel + budget-release row, the cancel-mid-data-leg row) + 1 new every-build row (tests/client_dial.rs: the abrupt- drop row); the existing reverse rows adapted to the handles; the unix cancel row exercises the handle flavor.
Verification: cargo test / --features <each> / --all-features
all green; clippy ×2 (-D warnings) clean; cargo fmt --check
clean; cargo doc --no-deps clean (default build).