Metadata-Version: 2.5
Name: positronic-wire
Version: 0.12.0
Summary: The client side of the transports a Positronic inference session runs over
License-Expression: Apache-2.0
Requires-Python: >=3.11
Provides-Extra: grpc
Requires-Dist: grpcio; extra == 'grpc'
Provides-Extra: websocket
Requires-Dist: websockets>=15.0.1; extra == 'websocket'
Description-Content-Type: text/markdown

# positronic-wire

The client side of the transports a Positronic inference session runs over: websockets, gRPC, and a
partner's own protocol. It carries the facts both ends of a wire share. It is one distribution,
installable on its own. The core has no third-party dependencies; the `grpc` and `websocket` extras
install `grpcio` and `websockets` independently.

> **Alpha, under rapid development.** Names and behaviour change without notice, and nothing here is
> covered by a backwards-compatibility guarantee. Pin the exact version you tested against.

```bash
uv add "positronic-wire[websocket,grpc]==0.12.0"
uv add "positronic-wire[websocket,grpc] @ git+https://github.com/Positronic-Robotics/positronic@<tag or commit>#subdirectory=wire"
```

For an unpublished revision, use the Git installation pinned to a tag or commit.

`positronic_wire` never imports `positronic` or `positronic_model_server`. Both packages depend on
it: the server transports in `positronic_model_server` import the shared wire definitions, and
`positronic.offboard.client.InferenceClient` dials through the client wires.

## What a wire is

A wire is one transport that carries a protocol's frames as opaque bytes. It has two ends. The
client end dials a session and probes a server; the server end accepts sessions and refuses an
unauthorized peer. Everything specific to a transport — the library, its exception types, its
status codes, its metadata keys, the path a probe asks for, the URL scheme it writes — lives inside
that transport's wire classes. Code outside a wire speaks to every transport through one interface
and reads one answer. A wire over TLS is a member of its own, so no caller holds a `secure` flag,
and nothing anywhere reads a transport off a URL scheme: a caller names the wire.

WebSocket and gRPC carry the session frames used by `positronic_model_server` and the legacy
`positronic.offboard` server. Both servers use transports from `positronic_model_server`.
`roboarena` carries a partner's own protocol. The partner serves it, and this package holds the
client end alone.

## The package boundary

| Module | Holds |
|---|---|
| `positronic_wire.wire` | The routes (`API_PATH`, `SESSION_PATH`, `KEEPALIVE_PATH`), `ALIVE_SECONDS`, the key the keepalive answer carries, `MAX_MESSAGE_BYTES`, the addresses `HostPortAddress(host, port, path, query)` and `UnixSocketAddress(uds, path, query)` under the abstract `SessionAddress`, the type variable `AddressT` over them, `netloc`, `bracket_ipv6(host)`, `Refusal`, `ConnectRefused`, `PeerDisconnected`, `KeepaliveUnsupported`, and the abstract `ClientWire` and `ClientConnection` |
| `positronic_wire.websocket` | `WebsocketClientWire`, `WebsocketTlsClientWire`, `WebsocketUnixClientWire`, `WebsocketClientConnection`, `refusal_of(raised)`, which reads a failed handshake as a `Refusal`, and `connected_socket(host, port, timeout)`, a TCP connect whose timeout covers the name lookup and every address |
| `positronic_wire.grpc` | `GrpcClientWire`, `GrpcTlsClientWire`, `GrpcClientConnection`, `target(host, port)`, and the calls both ends agree on: `SERVICE`, `METHOD`, `METHOD_PATH`, `KEEPALIVE_METHOD`, `KEEPALIVE_METHOD_PATH`, `PROBE_PATH`, `SESSION_PATH_HEADER`, `SESSION_QUERY_HEADER`, `MESSAGE_SIZE_OPTIONS`, `PING_EVERY_MS` |
| `positronic_wire.roboarena` | `RoboarenaClientWire`, `RoboarenaClientConnection`, `RoboarenaAddress`, and `TextAnswer`, which a text frame raises. The handshake carries the headers the caller gives, and none where it gives none |
| `positronic_wire.registry` | `CLIENT_WIRES`, every installed member by its `NAME`, and `client_wire(name)` |

[`positronic_model_server`](../model_server/README.md) owns the server transports:
`server_wire.Wire`, `server_wire.ServerConnection`, `websocket_wire.WebsocketWire` and
`grpc_wire.GrpcWire`. It also owns `ModelServer`, shared message definitions and value serialization.
The WebSocket server uses Uvicorn directly; the gRPC server uses `grpcio`'s `grpc.aio` API.
Each server transport has its own optional installation extra.

`positronic.offboard` owns `InferenceClient`, the legacy `PolicyServer`, and v1/v2 robot-command
encoding. `positronic-wire` does not depend on either server package.

## The client interface

A caller selects a wire by `NAME`: `websocket`, `websocket_tls`, `websocket_unix`, `grpc`, `grpc_tls`,
`roboarena`. A wire dials an address of its `ADDRESS` type, and `DEFAULT_PORT` names the port a URL
leaves out on the members that carry one.

- `session_url(address)` — the session as this wire names it, for a log and for an error. The
  websocket members write `ws://` or `wss://`, `websocket_unix` writes `ws+unix://`, and the gRPC
  members write `host:port`, and `roboarena` writes the root it dials. What a wire dials is its own:
  `websocket`, `websocket_tls` and `roboarena` dial this spelling, and the others dial a socket or a
  target instead.
- `dial(address, headers, open_timeout)` — a client's end of one session. It raises
  `ConnectRefused` when the session does not open, whatever refused it. The `refusal` on the
  exception says what the caller does next: `COLD` (a backend still starting) and `SILENT`
  (nothing answered) retry, `FORBIDDEN` retries a few times, `FINAL` surfaces at once.
- `probe(address, headers, open_timeout)` — whether a server answers at the address, without opening
  a session. It carries the same `headers` as `dial`, so an edge that authenticates on them lets the
  probe through to the server behind it: the probe wakes what a session would reach. `None` when a
  server answers; a `Refusal` says why none did, in the terms `dial` uses, `FORBIDDEN` among them for
  a credential the edge refused. The websocket wire asks the host's root for an upgrade, which the
  server refuses with 403 and nothing else answers 403 there. The gRPC wire calls `PROBE_PATH`,
  which a server that is up answers `UNIMPLEMENTED`. The roboarena wire opens the root and reads the
  frame the server announces itself with.
- `keepalive(address, headers, timeout)` — resets the server's idle timer outside a session, and
  returns the seconds the server stays alive after the call, or `None` for a server with no idle
  timeout. `timeout` bounds the whole call. The websocket members send `POST` to `KEEPALIVE_PATH`, and
  the gRPC members call `KEEPALIVE_METHOD_PATH`. `keepalive` raises
  `KeepaliveUnsupported` where the server serves sessions but not the call, and `ConnectRefused`
  otherwise, in the terms `probe` uses: a 200 that does not carry the keepalive answer is `FINAL`.
  `roboarena` always raises `KeepaliveUnsupported`, and dials nothing.

`registry.client_wire(name)` selects an installed transport. `CLIENT_WIRES` includes only families
whose libraries are installed; requesting an unavailable transport raises with installation guidance.
Use `[websocket]` for WebSocket and Roboarena, `[grpc]` for gRPC, or `[websocket,grpc]` for all wires.
Importing the core registry without either extra produces an empty registry. Broken imports from an
installed transport surface as errors.

**Each wire declares the address it dials, and takes no other.** `ClientWire.ADDRESS` names that
type, and every verb above takes it. `websocket`, `websocket_tls`, `grpc` and `grpc_tls` take a
`HostPortAddress` — `host`, `port`, `path` (`SESSION_PATH`), `query`, as written.
`websocket_unix` takes a `UnixSocketAddress` — `uds`, `path`, `query`. `roboarena` takes a
`RoboarenaAddress` — `host` and `port`. A record that names an endpoint carries the wire's name and
that wire's fields, never a URL, and no address carries a field a wire ignores.

`uds` is an absolute path to a Unix socket a server on the same machine bound, dialled instead of
the network. The address refuses a relative path when it is built, because a relative one names a
different socket to each caller. It names no host and no port, because a socket has neither: the
handshake carries `localhost` as a stand-in the server never resolves. A socket is same-machine by
construction, so no TLS member sits beside it. An absent path is `SILENT`: the client cannot tell a
misspelt path from a socket nobody has bound yet, so it retries either to its deadline. A refusal
from a socket a server is restarting on is `SILENT` too. A path holding something that is not a
socket, and a refused permission, are `FINAL`, because no retry reaches them. A handshake that timed
out or was reset reached the socket, so the server rather than the path was not ready, and it reads
`SILENT` as it does on a port.

`roboarena` is a partner's own protocol: msgpack frames on a websocket at the bare root of a port the
partner publishes. The server closes any other path, and it routes on a key inside each frame, so the
address names no route and no query. It publishes no default port either, so every address states one.
The server announces its configuration as the first frame of every connection: `dial` leaves that frame
for the caller's codec, and `probe` reads it and closes. A server that upgrades the connection and
announces nothing is a backend still starting, so it reads `COLD`. The server reports a failure in a
text frame and serves nothing more on that connection, so `recv` and `probe` raise `TextAnswer`, which
carries the text: a retry does not change it. The protocol names no URL scheme and the port is plain,
so no TLS member sits beside this wire; a partner who terminates TLS in front of it refuses a handshake
in the terms the websocket members already read.

Typing carries the split: a wire handed the other wire's address is a type error at the call site.
`registry.client_wire(name)` answers by name and cannot, so `InferenceClient` checks `ADDRESS` once,
before it dials, and names both in the refusal.

## Whether a policy server is up

`keepalive` answers it, and `probe` answers it where a server serves no keepalive call. Each one's
timeout is one wall-clock deadline over the name lookup, every address the name resolves to, and every
read, so a server the caller did not write cannot hold the call past it. Both open their own socket, so
they ignore a proxy the environment names, where `dial` goes through it.

| What came back | Means |
|---|---|
| `keepalive` returns | a policy server admitted the headers |
| `KeepaliveUnsupported`, then `probe` returns `None` | a server answers and serves no keepalive call |
| `FORBIDDEN` | an answer that refused the credential |
| `COLD` | an answer that asks for a retry: a 5xx, a 429 |
| `FINAL` | an answer no retry changes: any other status, a 200 without the keepalive answer, a host with no address |
| `SILENT` | no answer: no connection, or none before the timeout or the close |

`COLD` and `SILENT` mean the server is not up yet. `roboarena` raises `KeepaliveUnsupported` without a
dial, and its `probe` raises `TextAnswer` where the server announces a failure. The gRPC members read
`SILENT` off the details gRPC writes for a connect nothing answered, and off `DEADLINE_EXCEEDED`.

The libraries cap what a call reads: `websockets` reads at most 128 headers of 8 KiB and 1 MiB of a
refused upgrade's body, and `http.client` at most 100 headers of 64 KiB. The websocket members read at
most 16 KiB of a keepalive body, and the roboarena member one frame of at most `MAX_MESSAGE_BYTES`.

## What each consumer pays

| Consumer | Needs | Installs |
|---|---|---|
| A rig client, and `positronic` itself | Every verb, the session protocol, the policy stack | `positronic`, which pins `positronic-wire` exactly |
| A coordinator that probes an endpoint and warms it | `registry.client_wire`, `probe`, `PROBE_PATH`, the routes | `positronic-wire[websocket,grpc]`, or just the required transport extra |
| A service that validates an endpoint record | `registry.CLIENT_WIRES` | `positronic-wire` with the transport extras it accepts |
| A host that runs a policy server and waits for it before it sends sessions | `keepalive`, `probe` | `positronic-wire[websocket,grpc]`, or just the required transport extra |
| A native model server | The shared server and its transports | `positronic-model-server[websocket,grpc]`, or just the required transport extra |
| A legacy policy server | `positronic.offboard.server.PolicyServer` | `positronic` |

A consumer whose lockfile already carries `grpcio` (through a cloud SDK) and `websockets` (through
`uvicorn`) adds no third-party package when it adds this one.

## What the wire keeps out of its consumers

A transport-specific dial disappears from a consumer once it speaks to the wire. A consumer that
opens a `websockets.connect` of its own to wake an endpoint has to skip every gRPC endpoint, because
the same call cannot reach one; `probe` reaches both. A consumer that classifies a failed dial by
matching library exception names over the raised type's bases has to list `grpc.RpcError`,
`websockets.*` and `httpx.*`; `dial` raises one `ConnectRefused` for all of them, so `isinstance`
answers.

A copied fact disappears too. The gRPC probe path, the session route and the keepalive route are each
one symbol here, and the set of wires is `registry.CLIENT_WIRES`. A literal spelled a second time
in another repository drifts the day either side edits it, and nothing reports the drift; an
imported symbol cannot.

A scheme table disappears with them. A record that names an endpoint carries the wire's name and the
fields that wire's address declares, so no reader of the record derives a transport, a TLS setting or
a default port from the spelling of a URL.

The port a server serves a wire on, and the server flag that names it, stay literals where they are
spelled: they are a deployment's configuration, not wire facts. A test in the consumer that installs
`positronic` pins the flag name against the server's own signature.

## Versioning and skew

`positronic` pins `positronic-wire==<version>` exactly, as it pins `positronic-platform-client`.
`utilities/check_workspace_version_bump.py` refuses a change under `wire/` that reuses the version,
and one that leaves the root's pin behind. The release workflow publishes `wire/` to the index
before `positronic`, with `skip-existing`, so an unchanged wire is a no-op and a changed one is on
the index before the release that depends on it.

A consumer outside this repository pins the wire the way it pins the client: by the index once the
wire is published there, and until then by git subdirectory at a commit
(`positronic-wire @ git+…positronic@<sha>#subdirectory=wire`). A consumer that also installs
`positronic` at a commit names the same commit for both, so the wire the git install provides is
the version `positronic` requires.

Two deployments of one consumer can hold different wire versions: a rig runs a pinned console on
its own schedule while a coordinator runs its checkout. The wire keeps that skew readable instead
of guarding it with a version floor. Each side reports the names its registry lists, and the side
that hands an endpoint record across refuses a wire name the other side's registry lacks. A
transport added to the wire is then one entry in `registry.CLIENT_WIRES`, and no consumer has to
raise a constant naming the version that first dialled it.

## Adopting the wire in a consumer

A consumer moves onto the wire in this order, each step green on its own:

1. Depend on `positronic-wire` with the required transport extras. Import the routes, the probe path and the registry; delete the
   local copies, the tables keyed by scheme, and every read of a URL scheme. **An endpoint record
   names its wire, then that wire's address** — the fields `ClientWire.ADDRESS` declares, which differ
   per wire. Report the registry's names in the deploy handshake and retire any per-transport version
   floor.
2. Replace the transport-specific dial with `probe`, and the exception-name match with
   `isinstance(raised, (wire.ConnectRefused, wire.PeerDisconnected, TimeoutError))`.
3. Move the consumer's own transports into their own `ClientWire` subclasses, each declaring the
   address it dials, registered by name beside the wire's own.
4. Deploy the pinned side at the commit that carries the wire, then the checkout side.

## What is not shared

- Server transports live in `positronic-model-server` and use Uvicorn or `grpc.aio`.
- Model message definitions and value serialization live in `positronic-model-server`. The policy
  stack and robotics codecs live in `positronic`. Neither is a dependency of `positronic-wire`.
- A transport this package does not implement. A consumer that owns one writes it as a `ClientWire`
  of its own.
