Skip to content
Portal Control Protocol

Transports

PCP traffic flows over three transport modes: the daemon socket (clients connecting to the daemon), per-app Tier 1 sockets (the daemon connecting to application servers), and in-process dispatch (for hosts that own their servers). All three use the same framing format and the same message types. The difference is which encoding carries the payload and whether a socket is involved at all.

Every frame on the wire follows the same structure:

+---------------+-------------------------+--------------------+
| u32 LE | postcard StreamHeader | payload bytes |
| total_len | (varint fields) | (see encoding) |
+---------------+-------------------------+--------------------+
total_len = len(header) + len(payload); MAX_FRAME_SIZE = 16 MiB

The frame begins with a four-byte little-endian length prefix that covers both the header and the payload. A reader first reads four bytes to learn the total frame size, then reads that many more bytes for the header and payload combined. The maximum frame size is 16 MiB.

The header is a postcard-encoded StreamHeader struct:

pub const STREAM_MAGIC: u32 = 0x5043_5000; // "PCP0"
pub const STREAM_VERSION: u16 = 1;
pub struct StreamHeader {
/// Must equal STREAM_MAGIC ("PCP0").
pub magic: u32,
/// Must equal STREAM_VERSION (1).
pub version: u16,
/// Message type discriminant (u8).
pub message_type: StreamMessageType,
/// Length of the payload section in bytes.
pub payload_len: u32,
/// CRC-32 checksum over the payload bytes.
pub checksum: u32,
}

Validation proceeds in a fixed order. The reader checks magic bytes first, then version, then payload length, then CRC-32. A mismatch at any step produces a defined protocol error:

Check Failure Error
magic == STREAM_MAGIC Magic bytes don’t match BadMagic
version == STREAM_VERSION Version mismatch BadVersion
payload_len within remaining bytes Frame truncated Truncated
CRC-32 matches payload Checksum failure ChecksumMismatch
message_type is known Unrecognized discriminant UnknownMessageType

The payload section of a frame uses one of two encodings, depending on which path the frame travels:

Path Payload encoding Rationale
daemon to/from clients (SI, CLI tools, scripts) JSON (serde_json) Client payloads embed JSON-typed params and timestamp fields. The encode cost is well under 0.1 ms against a 200 ms IPC budget, and JSON interoperability makes client implementations straightforward.
daemon to/from Tier 1 apps Postcard envelope with embedded JSON bytes The outer envelope is compact postcard, but semantic fields like params_json, data_json, manifest_json, and state_json are carried as Vec<u8> JSON inside that envelope. This gives a small outer frame with a schema-flexible interior.

Both encodings carry the same logical message types. The difference is purely mechanical: how the bytes are arranged inside the payload section. A client sending InvokeCapability to the daemon uses JSON. The daemon forwarding that same invocation to a Tier 1 app re-encodes it as a postcard envelope with the JSON params embedded inside.

The encode_payload and decode_payload helpers in the stream crate are reserved for future compact encodings. Production paths use exactly the two encodings described above.

The primary transport is a Unix-domain socket bound by the daemon. Clients connect to this socket, authenticate via peer-credential checks, and exchange framed messages in both directions.

The daemon binds the socket with restrictive filesystem permissions (owner read/write only). On accept, it reads the connecting process’s UID through SO_PEERCRED and verifies it against an allowed user. Connections from unrecognized UIDs are rejected silently.

The daemon socket carries JSON-encoded payloads. It is the transport that SI, CLI tools, and scripts use. Event subscriptions, capability invocations, state queries, and audit lookups all flow through this socket.

Tier 1 applications bind their own Unix-domain sockets and serve the PcpServer contract. The daemon connects to these sockets as a client through its AppClientManager, maintaining a connection pool keyed by application ID.

The app socket carries postcard-envelope payloads with embedded JSON. The daemon sends messages like GetManifest, InvokeCapability, ValidateParams, QueryState, and Ping. The app responds with the corresponding result messages. A Pong with zeroed fields acknowledges a Ping. Anything the app doesn’t recognize produces an error response.

When a Tier 1 app crashes or closes its socket, the daemon’s connection manager detects the failure and surfaces an AppNotReachable error. The recovery runtime degrades repeated offenders over time.

The protocol defines an in-process transport for hosts that own their servers. An InProcessDispatcher holds a map of PcpServer trait objects and dispatches invocations without any socket crossing.

This is the embedding path. The daemon itself uses it for its runtime services: learning, recovery, inspection, and coordination are registered as in-process servers, and the daemon invokes them directly. A future host that embeds the entire daemon role in-process would use the same dispatcher.

The in-process path does not change any message type or encoding. The same StreamMessageType discriminants, the same payload structures. The transport layer is simply bypassed. A host MAY embed the daemon’s entire role in-process without altering any message definition in the protocol specification.

PCP authentication is built on Unix-domain socket permissions and peer-credential checks, not on passwords, tokens, or certificates.

On bind, the socket is created with restrictive filesystem permissions. Only the owning user can connect.

On accept, the daemon reads the connecting process’s UID through SO_PEERCRED. It resolves the expected allowed user (by username lookup, cached) and rejects connections from any other UID. The rejection is silent: the connection closes without an error frame.

This model assumes single-user, single-host operation. There is no multi-tenant isolation, no role-based access control, no network-level authentication. The protocol does not define these mechanisms. A future extension might, but the base protocol is local-only.

Each accepted connection is split into independent read and write halves. A single writer task exclusively owns the write half. Multiple producers (the read loop, per-subscription event relays) push write commands through a bounded channel. This single-writer pattern ensures that frames on the wire never interleave, even when multiple concurrent operations produce responses at the same time.

Backpressure is the channel bound itself. When the channel fills, producers wait. The channel size is chosen to accommodate the expected concurrent response load without unbounded memory growth.

Client-side connection uses automatic retry with exponential backoff. On connect failure, the client retries at increasing intervals (1 s, 2 s, 4 s) up to a maximum. This handles transient socket unavailability during daemon startup or restart.

Last updated: