- AGENTS.md architecture bullet: spec exists (present tense), OQ tracker authoritative; convention 16 de-hedged (shape pinned by ADR-004/005); alkcall version drift fixed (v0.4.x/v0.5.x -> v0.7.x per Cargo.toml); Phase 1-work bullet -> Phase 1 complete, Phase 2 in tasks/ - phase-0-findings.md: promotion note atop the Open Questions section pointing at docs/architecture/open-questions.md; convergence checklist's final item checked - tasks: add architecture/oq-tn-12-tracker (external-trigger tracker for OQ-TN-12 deferred(scope), per sdd_process.md deferred-OQ two-halves rule); OQ-TN-12 Blocked-on text now references the tracker id - tasks: oq-promotion-sync completed (scope grew: 3 folded stale AGENTS.md refs found during review + the TN-12 tracker) Verified: taskgraph validate (13 tasks), grep sweep clean for stale 'not exist yet'/version-drift language, cargo test + cargo fmt --check
17 KiB
AGENTS.md
Operating instructions for opencode agents working in this repo. opencode
auto-loads this file as instructions, overriding the built-in defaults for
this project. Custom agents in .opencode/agents/ inherit these rules
unless their own prompts say otherwise.
Git Workflow
Commit and push when reasonable. When a change is complete and
verified (build + lint + tests pass), commit and push to origin/main
without asking. This overrides the built-in default of "only commit when
explicitly asked."
The workflow:
- Make the change
- Verify:
cargo test,cargo clippy --all-targets -- -D warnings,cargo fmt --check,cargo doc --no-depsif docs changed - Inspect
git statusandgit diffbefore staging — stage only the intended files, never secrets - Write a concise commit message matching the repo style (see
git log --oneline -10). For multi-point changes, use a summary line plus a body with bullet points and a verification block. git push origin main- Report the commit hash and the verification summary
Exceptions — do not commit or push without asking:
- The change is exploratory / speculative (you're not sure the user wants it kept)
- The user is actively reviewing the diff and may ask for changes
- The change touches the wire format or a trait shape that backends or consumers implement (one-way doors — see "Wire formats are stable" and "Backend trait shapes are one-way doors" below; once consumers exist, those signatures are wire-stable contracts)
- You'd be force-pushing, amending a published commit, creating an empty commit, or skipping hooks
Never commit secrets, keys, or credentials. If a commit fails or hooks reject it, fix the issue and create a new commit — do not amend the failed one.
Git identity is preconfigured (glm-5.3-flash <glm-5.3-flash@alk.dev>). Do
not change git config, skip hooks, or use git commit -i.
Project Conventions (Rust / tunnel protocol crate)
This is the tunnels protocol crate — arbitrary bidirectional tunnels in
the ssh -L / ssh -D sense (TCP, UDP, unix sockets, and other stream
or datagram substrates), riding alkcall channels the same way alktty
does. It is the sibling of alktty (alk/tty — terminal sessions) and
alkcall (call + channels — the substrate both build on). Where alktty
multiplexes one service with a fixed five-stream channel structure, this
crate generalizes the tunnel handler shape: the producer side dials or
accepts on some local substrate and pumps bytes to/from a channels data
channel; the consumer side bridges its own substrate onto that channel.
The conventions below apply to all work in src/ and tests/. They
mirror .opencode/agents/implementation-specialist.md §Project
Conventions and are repeated here so they apply to every session, not
just spawned implementation agents.
-
No comments in code unless the user explicitly asks. This is a project-wide convention. Doc comments (
///,//!) are fine and expected on public API. Inline//comments only when the user asks or when a non-obvious safety/correctness constraint would otherwise be missed (e.g., "a two-pump tunnel must shut down the opposite sink on pump completion —try_join!alone deadlocks; see alkcall ADR-050'spump_bidi/ alknet ADR-078"). -
Error handling —
thiserrorfor library error types (TunnelError;HandlerError/StreamErrorcome fromalkcall::core). No panics in library code. Nounwrap()orexpect()outside tests. If you reach forunwrap, the error path wasn't specified — stop and decide what should actually happen. For poisonedRwLock/Mutex, useunwrap_or_else(|e| e.into_inner())so a panic in one operation does not cascade to other operations. -
tokiois the async runtime — all I/O is async. The tunnel pumps, the channels integration, and the consumer session type are all async. Usetokio::syncprimitives (oneshot,mpsc) for lifecycle correlation and per-direction data flow. Any non-wasm backend module (local TCP/UDP sockets, process-spawned listeners) lives behind a feature flag like alktty'slocal. -
WASM target is load-bearing (by default) — the default crate (protocol-only) should compile to
wasm32-unknown-unknown, following alktty's precedent. This is what keeps the downstream TS/Python adapter story viable (a wasm-compiled alktunnels is the protocol layer for a sandboxed adapter). Use the wasm-clean tokio subset (rt,sync,io-util,macros,time) — do NOT usefeatures = ["full"]— and keep socket/platform I/O feature-gated. If a design decision ever forces the default crate off wasm (e.g. the wire format embeds a substrate-specific type), that is an ADR-worthy decision, not an accident. -
Wire format is stable — the tunnel wire format is a one-way door once consumers exist. Follow the alknet ADR-093/alktty ADR-008 precedent: the tunnel payload rides inside channels data channels as raw bytes (channels strips its 8-byte header transparently; the tunnel protocol owns whatever framing it puts inside the
BiStream). If a negotiation/setup frame is needed (like alktty'sNegotiateRequest), make it self-contained per alktty ADR-006 — length-prefixed JSON, not alkcall'sEventEnvelopeframing. Any wire-format ADR must be written before the first consumer exists; after that, changes are additive-only. -
Producer/consumer, not server/client — both sides of a channels connection can initiate. A producer exposes tunnels (registers openable channels via
ChannelCore::register_openable); a consumer opens tunnel channels (ChannelClient). Both sides can be both simultaneously — connection direction (who opened it) is independent of tunnel direction (who dials the target, who serves it). Avoid "server" and "client" framing in docs and API names; use "producer" and "consumer," or "accept side" / "connect side" for the connection-establishment half specifically. See alkcall ADR-022, ADR-037. -
Substrate-agnostic by construction — the protocol layer must not know whether the bytes come from TCP, UDP, a Unix socket, or a stdio pipe. The handler sees a
Connection/BiStreamfrom alkcall core and pumps bytes; the substrate-specific types (TcpStream,UdpSocket, etc.) are confined to backend modules behind feature flags, injected at the assembly layer — the same inversion-point pattern as alktty'sTtyBackend. "Arbitrary tunnels" means the bookkeeping (target addressing, direction, lifecycle) must not hardcode a substrate. See alknet ADR-078 §Where the pattern lives and the channels-adapter spec. -
Two-pump shutdown-on-completion is a contract — a tunnel is the canonical two-pump handler (one pump per direction). Each pump MUST shut down the opposite sink when it completes;
tokio::try_join!alone deadlocks. This was POC-validated in the alknet-channels POC (Target 3), pinned as alknet ADR-078, and the helper is pinned upstream as alkcallchannels::pump_bidi(ADR-050, review 007 R-03) — use it; do not hand-roll the two-pump shape. The channels layer drops all per-channel senders on transport EOF — rely on that for teardown, and emit EOF sentinels (zero-length chunks) on clean sink shutdown. Awaitpump_bidiinline inside theOpenHandler's task — the returnedJoinHandlemust track the data-plane lifetime (R-02; early return = teardown-at-birth). -
No forced local binding — a tunnel must not require the producer (or consumer) to bind a local port. The POC's TCP-tunnel shape dialed a target from the handler; SSH
-Rstyle flows need the reverse (the remote side asks the local side to dial or listen). The API surface must support both directions and unbound/listen- optional flows; the binding decision belongs to the caller (assembly layer), not the protocol crate. This is a spec-level requirement for the architecture docs, not just an implementation preference. -
Backpressure and limits are inherited, not redefined — bounded per-channel buffers, the 256-channel per-connection cap, monotonic channel IDs, and the zero-length-sentinel EOF convention are alkcall channels invariants (see alkcall ADR-040/041/034). The tunnel crate consumes them via
ChannelCore/ChannelClient; do not build a second demux/mux or re-derive limits. Datagram substrates (UDP) map onto the chunk stream with the same backpressure; datagram-boundary preservation, if required, is a wire-format decision for an ADR, not an accident. -
Vendored core types come from alkcall —
Connection,ProtocolHandler,BiStream,BidiStreamSource,AuthContext,Identity,IdentityProvider,AccessControl,OwnershipProvider,HandlerError,StreamErrorcome fromalkcall::core. Do not vendor copies into this crate. alkcall is v0.7.x — breaking changes are expected at this major-zero stage; this is an early consumer, so we find and fix issues upstream rather than working around them. Pinalkcall = "0.7.0"and bump deliberately. The 0.6.0 establishment surface (ADR-049 + amendment 2) is load-bearing for this crate: tunnel opens useregister_openable_with_establisherso a refused target dial is a typedchannel:open_failedcall error, never a phantom channel, and the establisher returns the dialed handle viaEstablishment::new(plan)(typed-opaqueChannelPlan— itsSend + Syncbound constrains plan payloads; socket handles carry+ Sync) — no side-channel handoff. The 0.7.0 identity surface (ledger CF-005/CF-006) is load-bearing for the serving side: a connect-side serving op resolves the caller identity in precedence order — payloadauth_token>ServingConfig.identity> transport identity (set viaConnection::set_identitybefore dialing) — and the establisher/ pump handler receive the per-call opener identity, not the install-time context. -
BAST document for the wire format — when the tunnel wire format gains binary framing (if any beyond pass-through), it carries a BAST (Binary Abstract Syntax Tree) document as its machine-readable spec under
docs/architecture/, conforming to the BAST meta-schema athttps://alk.dev/bast/v1/schema. BAST is plain JSON — no dependency required to author or consume it. alktunnels does not depend on alktype; hand-rolled codecs are fine for trivial formats, with the BAST doc as the contract. Do not roll your own offset map or validator for complex formats — use alktype (as an optional dep) when that surfaces. -
Access control — the producer's openable channels get authorization for free via
ChannelCore::register_openable, which wiresAccessControlinto the operation spec (the registry runs the ACL before the open handler). Scope-gate tunnel opens (e.g.TUNNEL_OPEN_SCOPE, shape following alktty'sTTY_OPEN_SCOPE) and optionally consultOwnershipProviderfor resource ownership (provider.owns(id_ref, kind, &id, "tunnel")— the 4-arg shape;OwnershipStore::recordis the 3-arg shape). Tunnels reach local networks — treat the open gate as the security boundary. See alknet ADR-024 (registry layering, alkcall ADR-019), alknet ADR-050 (ownership, alkcall ADR-011). -
Feature flags — substrate backends may be feature-gated if the need arises. The base crate should compile lean (no socket/platform deps unless the feature is on). Verify both
cargo test(default) andcargo test --all-featurespass if features are added. -
Naming — Rust standard:
snake_casefor functions/variables/ modules,PascalCasefor types/traits,SCREAMING_SNAKE_CASEfor constants. -
Module structure — one module per file under
src/, re-exported fromsrc/lib.rs. Public API surface islib.rsre-exports. The shape is pinned by the architecture spec (ADR-004, ADR-005): a producer half (adapter / open-handler foralk/tunnel-family ALPNs), a consumer half (a typed session/client that opens tunnel channels), and shared wire/target-addressing modules. Backend modules are feature-gated and never imported from the shared/producer/consumer modules. -
ALPN naming — this crate owns the
alk/tunnel-family ALPN(s). One ALPN per protocol per alkcall ADR-004; keep thealk/prefix convention. If multiple tunnel flavors need distinct ALPNs (e.g. stream vs datagram), decide via ADR before the first consumer — ALPN strings are wire-stable once published.
Verification Commands
Run these before committing. All must pass.
cargo test # full suite
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo doc --no-deps # if docs changed
cargo test --all-features # if features are added
cargo check --target wasm32-unknown-unknown # default crate stays wasm-clean
cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo publish --dry-run --allow-dirty # before a release
The wasm check is the structural guard for the "default crate is wasm-clean" invariant (same as alktty) — run it whenever a non-backend module changes.
Architecture Context
docs/architecture/— the authoritative spec (Phase 1 of the SDD process, complete): overview/producer/consumer/wire spec docs, ADRs 001..006 (all Accepted), the wire-format BAST document, andopen-questions.md(the authoritative OQ tracker). Read it before non-trivial changes. Phase 0 research remains indocs/research/(its OQ ledger is superseded by the tracker).- The prior art for this crate:
- alknet-channels POC —
/workspace/@alkdev/alknet/docs/research/alknet-channels/poc-summary.md(§POC Target 3: tunnel handler). Validates the demux→Connection→ handler→mux path and the two-pump tunnel shape over a stand-in transport. POC code at/workspace/alknet-channels-poc/. - alkcall —
/workspace/@alkdev/alkcall(v0.7.x, crates.io). The substrate: call protocol + channels multiplexing. The tunnel crate consumesalkcall::coretypes and the channelsChannelCore/ChannelClient/register_openablesurface. - alktty —
/workspace/@alkdev/alktty(v0.1, crates.io). The sibling pattern to follow: a producer/consumer protocol crate on alkcall channels, with the backend inversion point, wasm-clean default crate, and feature-gated local backend.
- alknet-channels POC —
- Key upstream ADRs that inform this crate's design (alknet numbers;
alkcall's ports live in
/workspace/@alkdev/alkcall/docs/architecture/ decisions/):- alknet ADR-093 / alkcall ADR-035 — channels pure channel
multiplexing (8-byte header, no
stream_type); the tunnel payload is raw bytes inside theBiStream - alknet ADR-078 / alkcall ADR-050 — two-pump shutdown-on-completion
(the tunnel handler pattern); the helper is pinned upstream as
alkcall::channels::pump_bidi— use it, do not hand-roll - alkcall ADR-049 (amendment 2) — the establishment phase; the
establisher returns the dialed handle via
Establishment::new(typed-opaqueChannelPlan), replacing the POC's side-channel handoff - alknet ADR-074 / alkcall ADR-038 —
ChannelConnectionas aBidiStreamSource; every handler (TTY, tunnel, call) receives aConnection - alknet ADR-075 / alkcall ADR-039 —
ChannelsAdapter/ChannelManager(substrate-agnostic demux;paramsis ALPN-specific — for tunnels, the target resource) - alknet ADR-071 / alkcall ADR-034 — channels wire format (one-way door)
- alkcall ADR-037 — channel lifecycle operations (
channel/openon channel 0;paramscarries the tunnel target) - alkcall ADR-042 — hub relay (byte-for-byte data-channel forwarding with ID rewrite — tunnels traverse relays transparently)
- alkcall ledger CF-005/CF-006 (0.7.0) — the connect-side serving
identity seam: caller identity precedence (token >
ServingConfig.identity> transport), per-call opener identity on the open-op hooks (the reverse-flow POC's W1, resolved) - alknet ADR-085 — workspace scope:
alknet/tunnelwas flagged "POC-validated, minimal spec needed, not yet specced"; this crate is that spec
- alknet ADR-093 / alkcall ADR-035 — channels pure channel
multiplexing (8-byte header, no
- What the POCs settled (Phase 0 complete — 2026-09-07): both POCs
ran clean (forward UDP POC + reverse-flow POC; see
docs/research/poc-summary.mdandreverse-poc-summary.md). Phase 1 is complete (2026-09-07): the OQ ledger is promoted, and the params, ALPN, codec, and API-surface decisions are pinned by ADR-001..006 (including consumer-session teardown ownership — reverse POC W3). Phase 2 (implementation) is decomposed intasks/. - If a TODO references a design direction that an ADR has since decided against, the TODO is stale — remove it and align with the ADR. Do not implement the rejected design.