Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Wire

The opt-in serialization layer.

Serialization is deliberately out of the always-on prelude: a program that never persists a value imports none of this. Everything here is pure total functions over Bytes; writing those bytes to a file, a socket, or a replay log is a capability routed through the effect system, never performed here.

The surface has three layers. The Serialize class and its instances are the codec: a value’s compact positional body, derived structurally by deriving (Serialize) and bottoming out in the primitive instances below. The envelope (wire_encode_stable/wire_decode_stable, or the explicit-digest *_with_digest escape hatches) wraps a body in the frame [scheme tag][kind][contract digest][body?], checked before the body so a stale layout is rejected up front. The version ladder (compose_upgrade/ compose_downgrade) spans frozen versions with O(n) adjacent converters.

The frame

Every serialized thing is one envelope, read left to right, each header part checked before the next is touched:

   +------------+------+-----------------+ - - - - - - +
   | scheme tag | kind | contract digest |    body?    |
   +------------+------+-----------------+ - - - - - - +

   scheme tag       length-prefixed string, always "prism-core-hash-v1";
                    a foreign scheme is rejected before anything else
   kind             uvarint naming what the digest identifies:
                    value 0, def 1, protocol 2, kont 3, cert 4
   contract digest  length-prefixed hex shape digest of the expected
                    layout, checked before the body is decoded
   body?            the compact positional encoding below; an absent
                    body is a pure reference, named by its digest

   body encoding    Int and I64 as zigzag LEB128 varints, U64 and
                    constructor tags as plain LEB128, strings and
                    lists length-prefixed with the length bounded on
                    both encode and decode, a sum as its constructor
                    tag followed by its arguments, a product as its
                    fields in declaration order, no padding

The Bytes representation

Bytes is an unboxed byte buffer (Buf, runtime/prism_buffer.c) plus a read cursor: Bytes(buf, off) is the bytes buf[off ..]. The buffer holds raw u8 with no UTF-8 interpretation, so it threads byte-for-byte identically on both backends (the parity contract a String-of-bytes would break, since reconstructing a String from raw bytes repairs invalid UTF-8 lossily). The cursor makes decode cheap: peeling a byte off the front advances the offset in O(1) with no copy, so a whole decode is linear. Encode accumulates into a growable buffer builder (buf_push, amortized O(1)), so building a container or a string body stays linear rather than paying the quadratic cost a right-nested immutable wire_cat would incur. The concrete representation is owned by the codec; a derived instance only ever threads a Bytes through the builders below, never inspects it. bytes_of_list/bytes_to_list bridge to the older List(Int) view for callers that still want it.

Totality

decode is total. Every read consumes at least one byte from a finite, strictly shrinking list, so decode always terminates; a truncated or hostile input runs out of bytes and fails through Fail rather than looping or panicking. Varints are length-capped and container and string lengths are bounded on both encode and decode, so a hostile length prefix cannot force unbounded work. The frame’s total decoder checks the scheme, kind, and digest before touching the body and rejects trailing bytes after the value.

Deriving

deriving (Serialize) writes the codec: a product encodes its fields in declaration order, a sum prefixes a constructor tag, and each field defers to its own instance. deriving (Stable) succeeds only when every component is itself Stable, so the derivability is the proof that a frozen format contains no unserializable value.

Types

Bytes

type Bytes = Bytes(Buf, Int)

A compact, positional byte body: an unboxed byte buffer and a read cursor, Bytes(buf, off) denoting the bytes buf[off ..]. The concrete representation is owned by the codec; a derived instance only ever threads a Bytes through the builders below, never inspects it.

Loss

type Loss = Loss(List(String))

What a downgrade could not carry down: the names of the fields dropped when lowering a value to an older frozen version. A downgrade never silently discards, it reports every field it had to drop.

Policy

type Policy = Reject | LargestSafeSubset

How to reconcile a higher-version value that a reader cannot represent at its own version: Reject refuses it, LargestSafeSubset downgrades it and keeps the Loss. A version mismatch always has one of these defined outcomes, never undefined behavior.

Type Classes

Serialize

class Serialize(a)
  encode : (a) -> Bytes
  decode : (Bytes) -> (a, Bytes) ! {Fail | e}

The codec, derived structurally by deriving (Serialize). encode writes a value’s compact positional body; decode reads one back, returning the value alongside the bytes that follow it so a reader can thread a frame’s fields in declaration order. Decode is total: it fails through Fail on a truncated or malformed body rather than panicking, so hostile input is one ordinary failure channel.

Stable

class Stable(a)
  shape_digest_of : (a) -> String

The structural witness that a type’s format is frozen-serializable: a record is Stable when all its fields are, a sum when all its variants’ arguments are. The derivability is the whole content of the proof: persisting a type with a non-stable component (a function, an effectful thunk) fails at the deriving (Stable) site, at compile time, naming the offending field.

The one method, shape_digest_of, is the type’s contract digest: the shape digest of its frozen layout. A derived instance’s body is that digest injected by the compiler from its single shape-digest computation, so shape_digest_of is a per-type constant no source can forge (Stable is derive-only; a hand-written instance is rejected). The argument is used only to resolve the instance by value; the digest does not depend on it. wire_encode_stable/wire_decode_stable read it, so ordinary code never hand-threads a digest string into the envelope.

Instances

serializeInt

instance serializeInt : Serialize(Int)

serializeI64

instance serializeI64 : Serialize(I64)

serializeU64

instance serializeU64 : Serialize(U64)

serializeBool

instance serializeBool : Serialize(Bool)

serializeUnit

instance serializeUnit : Serialize(Unit)

serializeChar

instance serializeChar : Serialize(Char)

serializeString

instance serializeString : Serialize(String)

serializeList

instance serializeList : Serialize(List(a))

serializeBytes

instance serializeBytes : Serialize(Bytes)

serializeOption

instance serializeOption : Serialize(Option(a))

serializePair

instance serializePair : Serialize((a, b))

serializeTriple

instance serializeTriple : Serialize((a, b, c))

serializeMap

instance serializeMap : Serialize(Map(k, v, ord))

Functions and Values

bytes_of_buf

bytes_of_buf : (Buf) -> Wire.Bytes

bytes_buf

bytes_buf : (Wire.Bytes) -> Buf

bytes_at

bytes_at : (Wire.Bytes, Int) -> Int

wire_empty

wire_empty : Wire.Bytes

wire_cat

wire_cat : (Wire.Bytes, Wire.Bytes) -> Wire.Bytes

Concatenate two byte bodies into a fresh buffer.

wire_is_empty

wire_is_empty : (Wire.Bytes) -> Bool

True when a body has no bytes left. The reader uses it to reject trailing bytes after a value, and a peer uses it to tell a reference frame (no body) from an inline one.

wire_len

wire_len : (Wire.Bytes) -> Int

The number of bytes in a body.

bytes_of_list

bytes_of_list : (List(Int)) -> Wire.Bytes

Build a body from a List(Int) of byte values (each masked into 0..255), and read a body back out as that list. These bridge the buffer representation to the older list view for callers that thread bytes as ordinary data.

bytes_to_list

bytes_to_list : (Wire.Bytes) -> List(Int)

wire_tag

wire_tag : (Int) -> Wire.Bytes

Encode a constructor tag (a small non-negative integer) as a varint. The derived Serialize for a sum prefixes its body with this.

wire_get_tag

wire_get_tag : (Wire.Bytes) -> (Int, Wire.Bytes) ! {Fail}

Peel a constructor tag off the front of a body, returning it and the rest. The derived Serialize for a sum reads this before dispatching on the tag.

wire_scheme_tag

wire_scheme_tag : String

wire_kind_value

wire_kind_value : Int

wire_kind_def

wire_kind_def : Int

wire_kind_protocol

wire_kind_protocol : Int

wire_kind_kont

wire_kind_kont : Int

wire_kind_cert

wire_kind_cert : Int

wire_frame

wire_frame : (Int, String, Wire.Bytes) -> Wire.Bytes

Build a frame around a body. wire_ref builds the bodyless reference form.

wire_ref

wire_ref : (Int, String) -> Wire.Bytes

A pure reference: a frame that carries its contract digest and no body. Its identity is the digest; a peer resolves the body from its store or requests it.

wire_open

wire_open : (Wire.Bytes, Int, String) -> Wire.Bytes ! {Fail}

Open a frame, checking the scheme, kind, and digest before the body and returning the body bytes that follow. Any header mismatch fails through Fail, so a stale layout is rejected on a cheap comparison, never by a corrupt field.

wire_is_reference

wire_is_reference : (Wire.Bytes, Int, String) -> Bool ! {Fail}

True when a well-formed frame for kind/digest carries no body (a pure reference). Fails if the header itself does not match.

wire_open_value_any

wire_open_value_any : (Wire.Bytes) -> (String, Wire.Bytes) ! {Fail}

Open a value-kind frame without knowing its contract digest up front, returning that digest alongside the body bytes. It checks the scheme and the value kind before the digest. An empty body is legal and returned as-is: a record with no fields encodes to zero bytes, so emptiness cannot be read as “reference” here without an explicit marker in the frame. Trailing-byte discipline is left to the body decoder, exactly as wire_open leaves it. A version-dispatched reader uses the returned digest to pick which frozen rung the body decodes as.

wire_encode_value_with_digest

wire_encode_value_with_digest : forall a. (String, a) -> Wire.Bytes

Encode a value as a value-kind frame carrying an explicitly supplied contract digest. This is the escape hatch: it trusts the caller’s digest verbatim, so it is for code that already holds a compiler-computed digest (a stable block’s generated frame helpers) or is exercising a hand-built frame in a test. Ordinary Stable code uses wire_encode_stable, which supplies the digest from the type.

wire_decode_value_with_digest

wire_decode_value_with_digest : forall a. (Wire.Bytes, String) -> a ! {Fail}

Decode a value-kind frame against an explicitly supplied contract digest: check the header, decode the body, and reject trailing bytes. A bodyless reference frame fails here, because a value cannot be materialized without its bytes. The digest-supplying counterpart to wire_encode_value_with_digest; ordinary Stable code uses wire_decode_stable.

wire_encode_stable

wire_encode_stable : forall a. (a) -> Wire.Bytes

Encode a Stable value as a value frame under its own contract digest. The digest is shape_digest_of, a per-type constant the deriving (Stable) instance carries, so no digest string is hand-threaded. This is the ordinary encode for a frozen-serializable value.

wire_decode_stable

wire_decode_stable : forall a. (Wire.Bytes) -> a ! {Fail}

Decode a Stable value from a value frame, checking the frame’s digest against the type’s own shape_digest_of and rejecting trailing bytes. The result type comes from the use site; an unannotated call is ambiguous and asks for an annotation. A wrong digest, a wrong kind, a truncated body, and trailing bytes are all decode failures through Fail. The frame is opened without assuming its digest, the body decoded as the annotated type, and only then is the frame’s digest required to equal that type’s own, so a frame minted for another shape is refused.

no_loss

no_loss : Wire.Loss

The empty Loss: a downgrade that dropped nothing.

dropped

dropped : (List(String)) -> Wire.Loss

A Loss naming the fields a downgrade dropped.

loss_names

loss_names : (Wire.Loss) -> List(String)

The field names inside a Loss.

lossless

lossless : (Wire.Loss) -> Bool

True when a Loss dropped nothing, so the downgrade was lossless. The safe subset of a version step is exactly the values whose downgrade is lossless.

loss_union

loss_union : (Wire.Loss, Wire.Loss) -> Wire.Loss

Merge two losses along a composed downgrade.

compose_upgrade

compose_upgrade : forall a b c. ((a) -> b, (b) -> c) -> (a) -> c

Compose two adjacent upgrades into one spanning upgrade.

compose_downgrade

compose_downgrade : forall a b c. ((a) -> (b, Wire.Loss), (b) -> (c, Wire.Loss)) -> (a) -> (c, Wire.Loss)

Compose two adjacent downgrades into one spanning downgrade, unioning the losses each step reported.

reconcile

reconcile : forall a b. (Wire.Policy, (a) -> (b, Wire.Loss), a) -> (b, Wire.Loss) ! {Fail}

Apply a mismatch policy to a downgrade. Reject fails through Fail; LargestSafeSubset runs the downgrade and returns the lowered value with the fields it had to drop.