Skip to content

endpoint-libs 2.0: transport seam, peer identity, hooks - #41

Merged
pathscale merged 6 commits into
mainfrom
feat/2.0-transport-seam
Jul 25, 2026
Merged

endpoint-libs 2.0: transport seam, peer identity, hooks#41
pathscale merged 6 commits into
mainfrom
feat/2.0-transport-seam

Conversation

@pathscale

@pathscale pathscale commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Makes the schema/handler/MCP machinery transport-agnostic, so the same server core runs over TCP+TLS+WebSocket (unchanged) and over local attested transports — Unix sockets, Windows named pipes, macOS XPC — implemented later in a sibling crate.

The wire protocols did not change. Legacy {method, seq, params} frames and MCP JSON-RPC are byte-identical to 1.9, so deployed frontends need no changes.

Paired with pathscale/EndpointGen#27 (lockstep, currently a draft). Merge this one first, publish 2.0, then unblock that one.

The proof

tests/transport_seam.rs is the definition of done. Over an in-memory duplex pipe with no TCP socket, it:

  • round-trips a legacy {method, seq, params} request through a real registered handler,
  • completes an MCP initializetools/listtools/call on the same connection,
  • drives the server through serve_connection and the client through WsClient::from_stream.

examples/uds_echo.rs does the same over a real Unix domain socket.

Commits

4a3f96a Phase 1WireMessage replaces the tungstenite re-export; backend meets the crate at two edges
5f1aad5 Phase 2PeerIdentity / Attestation / Extensions replace bare SocketAddr
82687dc Phase 2b — schema model future-proofed so OpenAPI lands as 2.1, not 3.0
6ef9df3 Phase 3 — the transport seam itself
e58ae1f Phase 4BeforeRequest / AfterRequest / OnConnect on both dispatch paths
8313e7c Phase 5 — 2.0.0-alpha.1, changelog, migration guide, README

Breaking changes (4)

Full per-symbol table in docs/2.0-migration.md. Most consumers touch two or three lines.

  1. WsMessage is no longer tungstenite::Message. The alias covers type positions only — not tungstenite's inherent methods (.into_text(), .into_data()). Replacements are documented per method.
  2. WsConnection.address: SocketAddr.peer: PeerIdentity, with a #[deprecated] address() returning a loopback placeholder.
  3. WsStream trait → MessageStream (alias retained).
  4. Schema-model types are now #[non_exhaustive] — out-of-crate matches need a wildcard arm, construction goes through ::new() + with_*.

Why Phase 2b is in a breaking release

OpenAPI/AsyncAPI emission is an endpointgen feature that touches no runtime code — but it only stays a minor release if the schema model can absorb new information without breaking. Type was a plain public enum and Field had nowhere to put per-field examples or constraints. ~20 lines of #[non_exhaustive] plus a reserved meta slot, free only while we were already breaking. Five tests pin the contract 2.1 depends on.

It paid off immediately: compiling endpointgen against this branch surfaced exactly the two breaks it predicted, plus a genuine gap in my own work (EndpointErrorSchema was made non-exhaustive with no constructor — added).

Notable findings

  • cargo test --all-features can never pass — and could not before this PR either. ws and ws-wtx are mutually exclusive via compile_error!. CI's cargo all-features test respects the denylist. The plan's verification loop was wrong and is corrected.
  • The plan's Phase 1 sweep list omitted client.rs, a live tungstenite leak site the alias hid from the compiler.
  • The feature matrix caught serve_connection importing ErrorCode/get_conn_id under #[cfg(feature = "ws")] — so it did not compile for ws-core, exactly the config a local transport uses.
  • The acceptance test silently ran zero tests until ws-client was added; caught by checking the count, not the exit code.
  • Extensions stores Box<dyn CloneAny> so RequestContext stays Clone. Box<dyn CloneAny> satisfies its own blanket impl, so boxed.as_any() never downcasts and self.clone_box() recurses into a stack overflow. Three explicit derefs, commented at each site, caught by tests.

Verification

  • 83 lib + 5 acceptance tests green
  • clippy clean (-D warnings)
  • types / ws-core / framed-transport / full all build; ws-core pulls zero tungstenite
  • uds_echo runs end to end

Known limitation (not a blocker)

WsClient::from_stream sits behind ws-client, which pulls tungstenite and rustls — so a sidecar speaking only XPC still compiles those in. Narrowing it is additive and non-breaking, deliberately deferred. Recorded in the migration guide.

Not in scope

No platform attestation code (sibling crate), no wire-protocol changes, no mission-token semantics, no OpenAPI emission (that is 2.1 — see PLAN-2.1.md).

🤖 Generated with Claude Code

meh and others added 6 commits July 25, 2026 17:11
Before this, `WsMessage` was a re-export of `tungstenite::Message` whenever
the `ws` feature was on, leaking a backend type through session, toolbox,
subs, push, conn, server and client. The `#[cfg(not(feature = "ws"))]` inner
enum was already the right shape; promote it to the canonical type.

- `WireMessage` is now unconditional, `#[non_exhaustive]`, and carries the
  same five variants. `pub type WsMessage = WireMessage` keeps type positions
  compiling; it does NOT preserve tungstenite's inherent methods.
- Conversions to/from tungstenite live only in the tungstenite backend module
  and are applied at two edges: the server upgrader's WsStream impl and the
  client's private stream helpers.
- Swept client.rs alongside the modules named in the plan — it was an
  unlisted leak site (stream_send took tungstenite's Message directly).
- `WireMessage::as_text` folds the old Text/Binary UTF-8 handling into one
  place; `is_close` replaces a common inherent-method call.
- tungstenite's `Frame` variant degrades to an empty Binary rather than
  panicking: this crate never uses the low-level frame API, and an unexpected
  raw frame is not worth killing a live session over.

The ws-wtx backend is deliberately untouched — it is dead code (compile_error
deprecation, denylisted, no longer implements WsUpgrader).

Verified: types / ws-core / full all build, ws-core pulls no tungstenite,
clippy clean, 67 tests green (65 baseline + 2 new round-trip tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A bare SocketAddr only describes a TCP/TLS peer. Local transports (Unix
sockets, named pipes, XPC) identify peers by process and by verified code
identity, and that has to reach the request context for authz and logging.

- New `libs::peer`: PeerIdentity (Network/Local/Unknown, #[non_exhaustive]),
  LocalPeer{pid,uid,attestation}, Attestation (None/Verified{mechanism,subject}).
- `WsConnection.address: SocketAddr` → `WsConnection.peer: PeerIdentity`, with
  a #[deprecated] `address()` accessor returning a loopback placeholder for
  non-network peers.
- `RequestContext` keeps `ip_addr` (populated via `peer.ip_addr()`, so logging
  consumers keep compiling) and gains `peer` + `extensions`.
- Both types gain `extensions: Extensions`, a type-keyed map implemented here
  rather than depending on `http`.

Extensions stores `Box<dyn CloneAny>` rather than `Box<dyn Any>` because
RequestContext derives Clone and consumers rely on it; values must therefore
be Clone, the same trade http::Extensions makes. Note the three explicit
derefs marked in that module: `Box<dyn CloneAny>` itself satisfies the blanket
impl's bounds, so `boxed.as_any()` / `self.clone_box()` silently resolve to
the box's own impl — the former never downcasts, the latter recurses into a
stack overflow. Both were caught by the tests in this commit.

Sites updated (the plan's grep inventory): server.rs construction + logging,
session.rs run/handle_message, headers.rs real-IP context, toolbox.rs
RequestContext::from_conn, and the ws-echo example (now the reference
migration for `conn.address` → `conn.peer`).

Verified: types / ws-core / full build, clippy clean, 72 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OpenAPI/AsyncAPI emission (PLAN-2.1.md) is an endpointgen feature that touches
no runtime code — but only stays a MINOR release if the schema model can
absorb new information without breaking. Today it cannot. This is the ~20-line
insurance premium, and it is only free while 2.0 is already breaking.

- #[non_exhaustive] on Type, Field, EnumVariant, EndpointSchema and
  EndpointErrorSchema. Type matters most: it is a plain public enum, so any
  future variant (a decimal with precision/scale, a constrained string, a
  format carrier) would otherwise be breaking.
- New `meta` slot on Field and EndpointSchema, plus with_meta setters. Empty
  in 2.0; the 2.1 emitters read examples, constraints, tags and deprecation
  from it.

`meta` is a MetaMap newtype rather than a bare BTreeMap<String, Value> because
Field derives Hash/Ord/Eq and serde_json::Value implements none of them. The
manual impls compare and hash by canonical JSON text, which is deterministic
given BTreeMap's key ordering.

Five tests pin the forward-compatibility contract 2.1 depends on: unknown
future fields deserialize, absent meta defaults empty, meta round-trips
including keys this version assigns no meaning to, empty meta never serializes
(so 2.0 artifacts stay byte-identical and endpointgen --check sees no spurious
drift), and Field's Hash/Ord derives still work with meta populated.

Note for the lockstep release: #[non_exhaustive] breaks endpointgen's struct
literal at definitions.rs:430 — fixed in that repo in this same session.

Verified: types / ws-core / full build, clippy clean, 77 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Sockets

The point of 2.0: the session/dispatch/MCP machinery no longer assumes a
WebSocket underneath it.

- `libs::ws::transport`: `Transport` (STOLEN SHAPE from tarpc — a blanket alias
  over Sink + Stream, so implementors never name it) and `TransportStream`,
  which adapts any such transport into the object-safe `MessageStream`.
- `WsStream` trait renamed to `MessageStream` (alias kept) — it was never
  WebSocket-specific, only named that way.
- `transport::framed` behind the new `framed-transport` feature: length-
  delimited WireMessage framing over any byte stream. Format is documented in
  the module and pinned by a test, because non-Rust peers must implement it.
  Uses tokio-util's LengthDelimitedCodec but NOT tokio-serde: the kind byte
  means the payload is not a bare serde value, so that layer buys nothing.
- Server: `serve_connection` (transport-agnostic entry) + `serve_with` +
  `SessionListener`. `post_upgrade_connection` is now a thin WS-specific
  wrapper over `serve_connection`; the upgrader/TLS/shard machinery is
  untouched and feeds the same entry.
- Client: `WsClient::from_stream`, the mirror of `serve_connection`. The
  private WsStream enum gains a Message variant — both additive.

The acceptance test (tests/transport_seam.rs) is the definition of done: over
an in-memory duplex pipe with no TCP socket, it round-trips a legacy
{method,seq,params} request through a real registered handler AND completes an
MCP initialize -> tools/list -> tools/call, driving the server through
serve_connection and the client through from_stream. examples/uds_echo.rs
proves the same on a real OS transport.

Two things the feature matrix caught that a `full`-only build would not:
ErrorCode/get_conn_id/get_log_id were imported under #[cfg(feature = "ws")],
so serve_connection did not compile for ws-core — exactly the configuration a
local transport uses. And the acceptance test silently ran zero tests until
ws-client was added to the feature set.

Known follow-up (non-breaking, deliberately not in scope): `from_stream` lives
behind `ws-client`, which pulls tungstenite. Splitting the client so it is
available from ws-core alone is additive and can come with the sibling crate.

Verified: types / ws-core / framed-transport / full all build, ws-core pulls no
tungstenite, clippy clean, 83 lib + 2 acceptance tests green, uds_echo runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
STOLEN SHAPE (tarpc request_hook): a Before/After pair around dispatch. This
is where policy that is not endpoint-specific plugs in — mission-token
verification above all — without the transport layer or the handlers knowing.

- `libs::ws::hooks`: BeforeRequest (may reject, may attach claims to
  ctx.extensions), AfterRequest (observes RequestOutcome), OnConnect (refuses a
  peer once, rather than re-checking attestation on every request).
- Registered via add_before_hook / add_after_hook / add_on_connect_hook;
  snapshotted into each spawned task so a slow hook cannot stall the session
  loop. Registration order, first error short-circuits.
- Placement is after check_roles on both paths, so hooks only see calls already
  allowed to reach the endpoint.

The subtle part is that the two paths need different error envelopes: the
legacy path emits a WsResponseError carrying the hook's code and params, the
MCP path emits jsonrpc_result(encode_tool_error(..)) so the caller sees a tool
error with isError: true. Both are asserted.

OnConnect runs before the connection is registered in `states`, so a refused
peer never gets a slot and cannot be sent to.

Five acceptance tests now cover: legacy round trip, MCP initialize/list/call,
hook rejection on the legacy path (exact frame, code and params), hook
rejection on the MCP path (tool error payload), claims flowing hook -> handler
via extensions, AfterRequest observing both outcomes, and OnConnect admitting
an attested peer.

Note for anyone adding handlers: `check_handler` requires the struct to be
named `Method<SchemaName>`, so the claims test needed its own Claims endpoint
rather than a second handler on Echo.

Verified: ws-core builds, clippy clean, 83 lib + 5 acceptance tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ansports

- Version 2.0.0-alpha.1.
- CHANGELOG entry listing the four breaking items and the additive surface,
  with the "wire protocols are unchanged" statement up front — that is the
  thing consumers most need to know.
- docs/2.0-migration.md: exhaustive per-symbol table, including the
  WsMessage-alias caveat (type positions only, not tungstenite's inherent
  methods, with a replacement for each), the LocalSet requirement, and the
  known ws-client/from_stream limitation.
- README "Transports (2.0)" section: the three entry points, the framed_json
  wire format for non-Rust peers, peer identity/attestation, and hooks.

Also adds EndpointErrorSchema::new + with_message/with_fields — a gap in
Phase 2b: the type was made #[non_exhaustive] without giving out-of-crate
callers any way to build one. Found by compiling endpointgen against this
release rather than by inspection.

Verified: 83 lib + 5 acceptance tests green, clippy clean, and all four
feature configurations build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@pathscale
pathscale merged commit b5ca6de into main Jul 25, 2026
1 of 4 checks passed
@pathscale
pathscale deleted the feat/2.0-transport-seam branch July 25, 2026 11:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant