params/error: the wire-stable open-op surface + the typed open-error vocabulary

- 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.
This commit is contained in:
glm-5.3-flash committed 2026-09-23 15:17:20 +00:00
1 parent d530eae5c4
commit 1ccd5bbe0c
4 files changed
+522 -8

No files matched your search

+229 -2
View File
@@ -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> {
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<Self> {
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(_)));
}
}
+8
View File
@@ -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};
+219 -3
View File
@@ -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
/// `<alpn-family>:<capability>`. 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": <u32>, 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::<Socks5Params>(v).is_err());
let v = json!({"substrate": "tcp"});
assert!(serde_json::from_value::<Socks5Params>(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(&registry_gate), None);
let local_only = ChannelOpenError::MissingChannelId;
assert_eq!(establishment_reason(&local_only), None);
}
}
+66 -3
View File
@@ -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
> 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.