From 1ccd5bbe0c68ec7e31d79833897be6901ed33a73 Mon Sep 17 00:00:00 2001 From: "glm-5.3-flash" Date: Wed, 23 Sep 2026 15:17:20 +0000 Subject: [PATCH] params/error: the wire-stable open-op surface + the typed open-error vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - params: the ADR-001/ADR-006 constants (`channels/socks5/sub`, `alk/socks5`, `socks5:open`, `socks5:bind`), `socks5_open_spec()` (Sub/External/channel-open marker; trivial empty-object input schema with loud `additionalProperties: false`; reply `{channel_id: integer, minimum 1}` required; `socks5:open` scope), `Socks5Params {}` (deny_unknown_fields — empty stays valid forever), the `establishment_reason` helper re-export. - error: `SocksOpenError` (the ADR-049 §4 typed surface, shaped on alktunnels' `TunnelOpenError`), the typed `SocksEstablishmentReason` vocabulary (the five reason codes, parse/as_str; unknown codes never aliased), `SocksOpenError::reason()` as the typed view, and `SocksSessionError` (open vs in-stream `Rfc` vs `Io` — a refused target is an RFC reply code through the session type, consumer.md §Error surface). `SocksError`/`SocksServerError`/ `ReplyError` re-exported from the forked core. - lib.rs re-exports the new public surface (convention 15). - 9 new tests: spec shape, constants, serde round-trip + unknown-field rejection, the reason mapping table, the non-establishment classes (`FORBIDDEN`/`INVALID_INPUT`/ `too_many_channels`), future-reason non-aliasing, the three-plane session surface. - task `implementation/core-params-errors`: status → completed; Notes + Summary filled. Verification: cargo test (104 green), clippy --all-targets -D warnings clean, fmt --check clean, doc --no-deps zero warnings, both wasm checks (check + clippy on wasm32-unknown-unknown) clean. --- src/error.rs | 231 ++++++++++++++++++++- src/lib.rs | 8 + src/params.rs | 222 +++++++++++++++++++- tasks/implementation/core-params-errors.md | 69 +++++- 4 files changed, 522 insertions(+), 8 deletions(-) diff --git a/src/error.rs b/src/error.rs index 79f309a..f2b4350 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,3 +1,230 @@ -//! `SocksError` + the typed open-error surface (alkcall ADR-049 §4). +//! The crate's error surfaces: the typed open-error vocabulary +//! ([`SocksOpenError`], alkcall ADR-049 §4), the session-level error +//! ([`SocksSessionError`]) for the consumer sessions, and the +//! re-exported RFC data-plane vocabulary ([`SocksError`], +//! [`SocksServerError`], [`ReplyError`]). //! -//! Stub: filled by the `implementation/core-params-errors` task. +//! Open errors are channels-level: a failed open never yields a +//! phantom channel (the no-phantom-channel property, ADR-049 — the +//! session never exists opener-side). Target-level refusal happens +//! later, in-stream, as an RFC reply code surfaced through +//! [`SocksSessionError::Rfc`] (consumer.md §Error surface) — never as +//! an open failure. + +use alkcall::channels::client::ChannelOpenError; + +pub use crate::rfc1928::server::SocksServerError; +pub use crate::rfc1928::{ReplyError, SocksError}; + +/// The typed open-failure vocabulary (alkcall ADR-049 §4): a failed +/// SOCKS5 channel open. A failure never yields a session — no phantom +/// channel (the channel is torn down accept-side and no `channel_id` +/// was ever returned). +/// +/// The establisher-driven reasons (`dial_failed` / `unknown_resource` +/// / `resource_shortage` / `handler_error` / `timeout`) arrive inside +/// the [`SocksOpenError::Open`] variant, keyed by the wire reason +/// code; the registry's own gate runs *before* the establisher and +/// keeps its RFC-unrelated codes (`FORBIDDEN` for a scope-less +/// identity, `INVALID_INPUT` for a schema-mismatched params payload, +/// `channel:too_many_channels` for the per-connection cap). Branch on +/// [`SocksOpenError::reason`] for the typed establishment view, or +/// [`SocksOpenError::open_ref`] for the full raw +/// [`ChannelOpenError`]. +#[derive(Debug, thiserror::Error)] +pub enum SocksOpenError { + /// The open op failed — the typed [`ChannelOpenError`] carries the + /// wire [`alkcall::protocol::wire::CallError`] verbatim + /// (`channel:open_failed` + `details.reason` per ADR-049 §3 when + /// the establisher refused; `FORBIDDEN`, `INVALID_INPUT`, + /// `channel:too_many_channels`, … for the registry-gate classes). + #[error("socks5 open failed: {0}")] + Open(#[from] ChannelOpenError), +} + +impl SocksOpenError { + /// The underlying [`ChannelOpenError`] (the ADR-049 typed surface). + pub fn open_ref(&self) -> &ChannelOpenError { + match self { + SocksOpenError::Open(e) => e, + } + } + + /// The typed establishment-failure reason of a + /// `channel:open_failed` error (`dial_failed` / `unknown_resource` + /// / `resource_shortage` / `handler_error` / `timeout` — alkcall + /// ADR-049 §3's vocabulary, parsed). `None` for any other failure + /// shape: the registry's `FORBIDDEN`/`INVALID_INPUT` classes, + /// `channel:too_many_channels`, or local-only failures + /// ([`ChannelOpenError::MissingChannelId`] / + /// [`ChannelOpenError::AdoptFailed`]). + pub fn reason(&self) -> Option { + SocksEstablishmentReason::parse(self.open_ref().establishment_reason()?) + } +} + +/// The typed establishment-reason vocabulary (alkcall ADR-049 §3's +/// reason codes, parsed from the wire string). One-way once consumers +/// branch on it (ADR-049's door-type note) — the string codes are the +/// wire truth; this enum is the typed view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SocksEstablishmentReason { + /// The backend/target could not be reached or refused. + DialFailed, + /// The requested resource does not exist. + UnknownResource, + /// The backend is out of capacity (ports, fds, slots). + ResourceShortage, + /// Establisher-internal failure not covered above. + HandlerError, + /// Establishment exceeded the deadline. + Timeout, +} + +impl SocksEstablishmentReason { + /// Parse the wire `details.reason` string (ADR-049 §3's + /// vocabulary). Unknown strings are `None` — a *new* reason code + /// from a newer producer must not silently alias an old variant + /// (the vocabulary is one-way; additive codes need a migration). + pub fn parse(reason: &str) -> Option { + match reason { + "dial_failed" => Some(Self::DialFailed), + "unknown_resource" => Some(Self::UnknownResource), + "resource_shortage" => Some(Self::ResourceShortage), + "handler_error" => Some(Self::HandlerError), + "timeout" => Some(Self::Timeout), + _ => None, + } + } + + /// The canonical wire reason code (the inverse of [`Self::parse`]). + pub fn as_str(self) -> &'static str { + match self { + Self::DialFailed => "dial_failed", + Self::UnknownResource => "unknown_resource", + Self::ResourceShortage => "resource_shortage", + Self::HandlerError => "handler_error", + Self::Timeout => "timeout", + } + } +} + +impl std::fmt::Display for SocksEstablishmentReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// The consumer session's error surface (consumer.md §Error surface): +/// open failures are channels-level and typed +/// ([`SocksOpenError`]); in-stream RFC failures (a refused target — +/// the reply code) are [`SocksSessionError::Rfc`]; the data-plane +/// codec failures are [`SocksSessionError::Rfc`]'s inner +/// [`SocksError`] and [`SocksSessionError::Io`] is the plain I/O +/// failure. +/// +/// A refused session never yields a phantom channel (an open failure +/// is `Err` before any session exists); a refused *target* is an RFC +/// reply code surfaced through the session type. +#[derive(Debug, thiserror::Error)] +pub enum SocksSessionError { + /// The channel open failed (the session never came to exist). + #[error("socks5 open failed: {0}")] + Open(#[from] SocksOpenError), + /// The in-stream RFC 1928 conversation failed — a refused target + /// (the reply code, e.g. `ConnectionNotAllowed` 0x02 for a BIND + /// without `socks5:bind`), an auth rejection, a malformed + /// negotiation, or the UDP-frame codec. The RFC-faithful surface: + /// never coerced to a channels-level shape. + #[error("socks5: {0}")] + Rfc(#[from] SocksError), + /// An I/O failure on the session's data plane (the channel + /// `BiStream` itself). + #[error("io: {0}")] + Io(#[from] std::io::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + use alkcall::protocol::wire::CallError; + + fn open_failed_with(reason: &str) -> SocksOpenError { + SocksOpenError::Open(ChannelOpenError::CallFailed { + error: CallError::new("channel:open_failed", "establishment failed", false) + .with_details(serde_json::json!({ "reason": reason })), + }) + } + + #[test] + fn open_error_maps_the_adr_049_reason_vocabulary() { + // The reason-mapping table (alkcall ADR-049 §3's five codes). + for (wire, typed) in [ + ("dial_failed", SocksEstablishmentReason::DialFailed), + ( + "unknown_resource", + SocksEstablishmentReason::UnknownResource, + ), + ( + "resource_shortage", + SocksEstablishmentReason::ResourceShortage, + ), + ("handler_error", SocksEstablishmentReason::HandlerError), + ("timeout", SocksEstablishmentReason::Timeout), + ] { + let err = open_failed_with(wire); + assert_eq!(err.reason(), Some(typed)); + assert_eq!(typed.as_str(), wire); + } + } + + #[test] + fn open_error_non_establishment_shapes_are_none() { + // The registry's own gate classes run before the establisher — + // they are NOT establishment reasons (ADR-049 §3). + for code in ["FORBIDDEN", "INVALID_INPUT", "channel:too_many_channels"] { + let err = SocksOpenError::Open(ChannelOpenError::CallFailed { + error: CallError::new(code, "gate", false), + }); + assert!( + err.reason().is_none(), + "{code} is not an establishment reason" + ); + } + // Local-only failures (no wire error at all). + assert!(SocksOpenError::Open(ChannelOpenError::MissingChannelId) + .reason() + .is_none()); + } + + #[test] + fn unknown_reason_code_does_not_alias_a_variant() { + // A newer producer's additive reason code must not silently + // alias an old variant (the vocabulary is one-way, ADR-049). + let err = open_failed_with("some_future_reason"); + assert!(err.reason().is_none()); + // The raw string stays reachable for consumers that want it. + assert_eq!( + crate::params::establishment_reason(err.open_ref()), + Some("some_future_reason") + ); + } + + #[test] + fn session_error_surfaces_the_three_planes() { + // Open plane: a refused session never yields a phantom channel. + let open = SocksSessionError::from(open_failed_with("dial_failed")); + assert!(matches!(open, SocksSessionError::Open(_))); + // In-stream RFC refusal — the reply code rides the session type + // (consumer.md §Error surface), never an open failure. + let refused = + SocksSessionError::from(SocksError::ReplyError(ReplyError::ConnectionNotAllowed)); + assert!(matches!(refused, SocksSessionError::Rfc(_))); + // Plain I/O on the data plane. + let io = SocksSessionError::from(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "channel cut", + )); + assert!(matches!(io, SocksSessionError::Io(_))); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3636112..b2854ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,4 +29,12 @@ pub mod params; pub mod producer; pub mod wire; +pub use error::{ + ReplyError, SocksError, SocksEstablishmentReason, SocksOpenError, SocksServerError, + SocksSessionError, +}; +pub use params::{ + establishment_reason, socks5_open_spec, Socks5Params, OP_SOCKS5_OPEN, SOCKS5_ALPN, + SOCKS5_BIND_SCOPE, SOCKS5_OPEN_SCOPE, +}; pub use wire::{addr_encoding_len, frame, DatagramReader, WireError, MAX_INNER_LEN}; diff --git a/src/params.rs b/src/params.rs index 0dc3c4d..ce4c39c 100644 --- a/src/params.rs +++ b/src/params.rs @@ -1,4 +1,220 @@ -//! The trivial open-op params schema + the scope constants -//! (`SOCKS5_OPEN_SCOPE`, `SOCKS5_BIND_SCOPE`) — ADR-001/ADR-006. +//! The trivial open-op params, the wire-stable constants, and the +//! `OperationSpec` builder — `wire.md` §The Open Op, ADR-001, ADR-006. //! -//! Stub: filled by the `implementation/core-params-errors` task. +//! This module is the crate's wire surface (the one-way door): the op +//! id, the ALPN string, the two scope strings, and the trivial params +//! shape are frozen at first publish (ADR-001). The open-op params are +//! **trivially empty** — the SOCKS5 target is unknown at open time (it +//! arrives in-stream post-negotiation); the establisher is a session +//! gate, not a dial. The empty-object schema with +//! `additionalProperties: false` keeps `{}` valid forever: any future +//! per-session option is an *additive* schema extension (ADR-001's +//! extension rule) — consumers that send `{}` stay valid. +//! +//! All three RFC 1928 commands (CONNECT, BIND, UDP ASSOCIATE) ride the +//! one open op, selected in-stream at command dispatch (ADR-001); BIND +//! is scope-gated separately at that dispatch (`SOCKS5_BIND_SCOPE`, +//! ADR-006) — the command is an RFC-1928 wire fact, not a wire-format +//! choice of ours. +//! +//! The typed open-error vocabulary ([`crate::error::SocksOpenError`], +//! the `channel:open_failed` reason mapping) lives in [`crate::error`] +//! (alkcall ADR-049 §4); the establishment-reason extraction helper is +//! re-exported here's-error-shaped via [`establishment_reason`]. + +use alkcall::channels::client::ChannelOpenError; +use alkcall::registry::spec::{ + AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +/// The open-op operation id (the one SOCKS5 session op; ADR-001 — +/// flavor-form per alkcall ADR-047: openable ALPNs are operations). +/// All three RFC 1928 commands ride this one op. Wire-stable. +pub const OP_SOCKS5_OPEN: &str = "channels/socks5/sub"; + +/// The data-plane ALPN the SOCKS5 channel carries (ADR-001; the +/// `alk/` prefix per the alkcall ADR-004 ALPN convention). One ALPN +/// per protocol. Wire-stable. +pub const SOCKS5_ALPN: &str = "alk/socks5"; + +/// The scope gating SOCKS5 opens (ADR-006): proxy egress — CONNECT +/// and UDP ASSOCIATE. The scope-string convention +/// `:`. Stable once published. +pub const SOCKS5_OPEN_SCOPE: &str = "socks5:open"; + +/// The scope gating TCP BIND at command dispatch (ADR-006): the +/// *inbound* capability — ingress listeners. A separate grant from +/// [`SOCKS5_OPEN_SCOPE`], **never implied by it**: an identity with +/// proxy egress must not implicitly get ingress listeners. Checked +/// in-stream at command read, from the per-call opener identity +/// (CF-005/CF-006); a scope-less identity gets the RFC-faithful +/// `ConnectionNotAllowed` (0x02) refusal while CONNECT still serves +/// it. Stable once published. +pub const SOCKS5_BIND_SCOPE: &str = "socks5:bind"; + +/// The open-op input params (ADR-001): the trivial object. Wire-stable +/// — the shape is frozen (empty object, nothing required, nothing +/// else); a future per-session option is an additive schema extension, +/// never a format change. +/// +/// `deny_unknown_fields` is the loud-rejection posture: unknown fields +/// fail at parse time, never silently ignored (matching the input +/// schema's `additionalProperties: false` — the registry's gate and +/// this serde pin agree). Empty params stay valid forever. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Socks5Params {} + +impl Socks5Params { + /// The canonical wire form: the empty object — the only valid + /// params value (ADR-001). + pub fn as_json() -> serde_json::Value { + json!({}) + } +} + +/// The open-op spec (`channels/socks5/sub`, ADR-001/ADR-006): +/// `Sub`-typed, external, channel-open marker on the `alk/socks5` +/// ALPN, scope-gated by `socks5:open`, trivial params. +/// +/// - Input schema: `{type: object}` + `additionalProperties: false` — +/// empty params stay valid forever (ADR-001's additive-only +/// extension rule); unknown fields are rejected loudly. +/// - Reply schema: `{"channel_id": , minimum 1}` — the +/// `Sub`-typed operation reply. No extra reply fields in v1 (the +/// establishment phase is a session gate and has nothing to +/// project; alkcall's `with_reply_field` seam remains available +/// additively if a future need appears — ADR-001). +/// - `required_scopes = ["socks5:open"]` — the registry runs the ACL +/// before the open handler (ADR-006; the open gate is the +/// boundary). +/// +/// `error_schemas` follow alkcall ADR-049 §4 + ADR-016 (the typed +/// `channel:open_failed` contract with the reason vocabulary, plus +/// the registry's own `FORBIDDEN` class). +/// +/// The typed open-error vocabulary lives in [`crate::error`] +/// ([`crate::error::SocksOpenError`] + its +/// [`crate::error::SocksOpenError::reason`] typed view). +pub fn socks5_open_spec() -> OperationSpec { + OperationSpec::new( + OP_SOCKS5_OPEN, + OperationType::Sub, + Visibility::External, + json!({ + "type": "object", + "additionalProperties": false + }), + json!({ + "type": "object", + "properties": { + "channel_id": { "type": "integer", "minimum": 1 } + }, + "required": ["channel_id"] + }), + vec![], + AccessControl { + required_scopes: vec![SOCKS5_OPEN_SCOPE.to_string()], + required_scopes_any: None, + resource_type: None, + resource_action: None, + }, + None, + ) + .with_channel_open(ChannelOpenSpec::new(SOCKS5_ALPN)) + .with_description( + "Open a SOCKS5 proxy channel (RFC 1928 rides the channel BiStream; \ + trivial params — the target arrives in-stream post-negotiation)", + ) +} + +/// The typed open-failure reason of a `channel:open_failed` error +/// (alkcall ADR-049 §4): the consumer-side establishment-failure +/// vocabulary. `None` for any other failure shape (the registry's +/// `FORBIDDEN`, `channel:too_many_channels`, local-only failures). +/// +/// Delegates to [`ChannelOpenError::establishment_reason`] — the +/// alktunnels helper posture. The typed vocabulary lives in +/// [`crate::error`] ([`crate::error::SocksEstablishmentReason`], the +/// parsed view a consumer branches on via +/// [`crate::error::SocksOpenError::reason`]); use this directly for +/// the raw reason string. +pub fn establishment_reason(err: &ChannelOpenError) -> Option<&str> { + err.establishment_reason() +} + +#[cfg(test)] +mod tests { + use super::*; + use alkcall::protocol::wire::CallError; + + #[test] + fn open_spec_matches_adr_001_and_wire_md() { + let spec = socks5_open_spec(); + assert_eq!(spec.name, "channels/socks5/sub"); + assert_eq!(spec.op_type, OperationType::Sub); + assert_eq!(spec.visibility, Visibility::External); + // Trivial params: empty object, unknown fields rejected loudly + // (ADR-001 — empty params stay valid forever). + assert_eq!(spec.input_schema["type"], "object"); + assert_eq!(spec.input_schema["additionalProperties"], json!(false)); + assert!(spec.input_schema.get("required").is_none()); + assert_eq!( + spec.output_schema["properties"]["channel_id"], + json!({"type": "integer", "minimum": 1}) + ); + assert_eq!(spec.output_schema["required"], json!(["channel_id"])); + assert_eq!( + spec.access_control.required_scopes, + vec![SOCKS5_OPEN_SCOPE.to_string()] + ); + assert!(spec.access_control.required_scopes_any.is_none()); + let channel_open = spec.channel_open.as_ref().expect("channel-open marker"); + assert_eq!(channel_open.alpn, SOCKS5_ALPN); + assert!(spec.description.is_some()); + } + + #[test] + fn constants_match_adr_001_and_adr_006() { + assert_eq!(OP_SOCKS5_OPEN, "channels/socks5/sub"); + assert_eq!(SOCKS5_ALPN, "alk/socks5"); + assert_eq!(SOCKS5_OPEN_SCOPE, "socks5:open"); + assert_eq!(SOCKS5_BIND_SCOPE, "socks5:bind"); + } + + #[test] + fn trivial_params_serde_round_trip() { + let v = serde_json::to_value(Socks5Params::default()).unwrap(); + assert_eq!(v, json!({})); + assert_eq!(v, Socks5Params::as_json()); + let back: Socks5Params = serde_json::from_value(json!({})).unwrap(); + assert_eq!(back, Socks5Params::default()); + } + + #[test] + fn trivial_params_reject_unknown_fields() { + // The loud-rejection posture — the serde pin agrees with the + // input schema's additionalProperties: false (ADR-001). + let v = json!({"resource": "sneaky"}); + assert!(serde_json::from_value::(v).is_err()); + let v = json!({"substrate": "tcp"}); + assert!(serde_json::from_value::(v).is_err()); + } + + #[test] + fn establishment_reason_reads_the_typed_surface() { + let open_failed = ChannelOpenError::CallFailed { + error: CallError::new("channel:open_failed", "establishment failed", false) + .with_details(json!({"reason": "dial_failed"})), + }; + assert_eq!(establishment_reason(&open_failed), Some("dial_failed")); + let registry_gate = ChannelOpenError::CallFailed { + error: CallError::internal("forbidden"), + }; + assert_eq!(establishment_reason(®istry_gate), None); + let local_only = ChannelOpenError::MissingChannelId; + assert_eq!(establishment_reason(&local_only), None); + } +} diff --git a/tasks/implementation/core-params-errors.md b/tasks/implementation/core-params-errors.md index f3d8948..1f4a671 100644 --- a/tasks/implementation/core-params-errors.md +++ b/tasks/implementation/core-params-errors.md @@ -1,7 +1,7 @@ --- id: implementation/core-params-errors name: Core — params schema + scope constants + typed error surface -status: pending +status: completed depends_on: [implementation/core-crate-deps] scope: narrow risk: low @@ -81,8 +81,71 @@ error-reason mapping table. ## Notes -> To be filled by implementation agent +> Filled by the implementation agent (2026-09-23). + +- `src/params.rs`: the four wire-stable constants (`OP_SOCKS5_OPEN`, + `SOCKS5_ALPN`, `SOCKS5_OPEN_SCOPE`, `SOCKS5_BIND_SCOPE`), the + trivial-params struct `Socks5Params {}` (serde + `deny_unknown_fields`, + `Serialize` so tests round-trip it; + `as_json()` names the canonical wire form), `socks5_open_spec()` + (Sub/External/channel-open marker on `alk/socks5`; input schema + `{type: object, additionalProperties: false}`; reply schema + `channel_id` integer minimum 1 required; `required_scopes = + ["socks5:open"]`), and the `establishment_reason` helper re-export + (the alktunnels posture — delegates to + `ChannelOpenError::establishment_reason`). +- `src/error.rs`: `SocksOpenError` (single `Open` variant wrapping + `ChannelOpenError` — the same shape as alktunnels' + `TunnelOpenError`; the `From` impl covers the + `channel:open_failed`/`FORBIDDEN`/`INVALID_INPUT` classes), the + typed `SocksEstablishmentReason` vocabulary (ADR-049 §3's five + codes, `parse`/`as_str`; unknown codes are `None` — never silently + aliased, the vocabulary is one-way), `SocksOpenError::reason()` as + the typed view, and `SocksSessionError` (open errors vs in-stream + `Rfc(SocksError)` vs `Io`; the RFC reply-code refusals ride the + session type per consumer.md §Error surface). `SocksError`/ + `SocksServerError`/`ReplyError` re-exported from `rfc1928` + (`SocksServerError` lives in `rfc1928::server`, not the module + root). +- lib.rs re-exports both modules' public surface (convention 15). +- Deliberate deviations from the task text: the params struct + derives `Serialize` (test round-trip needs it; harmless on the + wire — the consumer sends `json!({})`); `SocksEstablishmentReason` + is a separate named type rather than an inline match so the + mapping table is testable in both directions; unknown reason codes + do NOT alias a variant (one-way vocabulary discipline). +- rustdoc links to `error::SocksOpenError` from `params` are + fully-qualified (`crate::error::…`) — plain `[SocksOpenError]` + does not resolve cross-module in this crate's layout. ## Summary -> To be filled on completion \ No newline at end of file +> Filled on completion (2026-09-23). + +Implemented the wire-stable open-op surface and the typed +open-error vocabulary: + +- `params`: constants + `socks5_open_spec()` per ADR-001/ADR-006 + exactly (op id `channels/socks5/sub`, ALPN `alk/socks5`, scopes + `socks5:open`/`socks5:bind`, trivial empty-object input schema with + loud `additionalProperties: false`, reply + `{channel_id: integer, minimum 1}` required). +- `Socks5Params {}` with `deny_unknown_fields` — empty object valid, + unknown fields rejected. +- `error`: `SocksOpenError` mapping the ADR-049 §4 reasons + (`dial_failed`/`unknown_resource`/`resource_shortage`/ + `handler_error`/`timeout`) through the typed + `SocksEstablishmentReason` (unknown codes never aliased); the + registry's `FORBIDDEN`/`INVALID_INPUT` classes correctly + non-establishment; `SocksSessionError` shaped open-vs-Rfc-vs-Io + per consumer.md §Error surface. +- 9 new tests (spec shape, constants, serde round-trip + unknown-field + rejection, the reason mapping table, non-establishment classes, + future-reason non-aliasing, the three-plane session surface). +- lib.rs re-exports the public surface. + +Verification: `cargo test` (104 green: 67 lib + 37 rfc1928 + 0 +doc), `cargo clippy --all-targets -- -D warnings` clean, +`cargo fmt --check` clean, `cargo doc --no-deps` zero warnings, both +wasm checks (`cargo check` + `cargo clippy` on +`wasm32-unknown-unknown`) clean. \ No newline at end of file