The binary wire format: frame types, CBOR encoding, field semantics.
Wire format
Every CapDAG message is a length-prefixed CBOR frame:
┌──────────────────────────────────────────┐
│ 4 bytes: u32 big-endian payload length │
├──────────────────────────────────────────┤
│ N bytes: CBOR-encoded map (integer keys)│
└──────────────────────────────────────────┘
The 4-byte prefix gives the exact byte count of the CBOR payload that follows. The receiver reads exactly that many bytes, then decodes the CBOR map.
There are two size limits:
- Negotiated max_frame (default 3.5 MB / 3,670,016 bytes): Set during handshake. Frames larger than this are rejected by both reader and writer.
- Hard limit (16 MB / 16,777,216 bytes): An absolute ceiling to prevent memory exhaustion. Cannot be negotiated upward.
Payloads that exceed max_frame must be split into chunks (see Streaming).
Source: capdag-rs/src/bifaci/io.rs (encode_frame, decode_frame, write_frame, read_frame).
Frame structure
The CBOR payload is a map with integer keys. Every frame has three required keys (version, frame_type, id). All other keys are optional and their presence depends on the frame type.
Key reference table
| Key | Name | Type | Description |
|---|---|---|---|
| 0 | version | u8 | Protocol version. Always 6. Any other value is rejected. |
| 1 | frame_type | u8 | Discriminant identifying the frame type (see below). |
| 2 | id | bytes[16] or uint | Request ID. UUID for requests, uint for control frames. |
| 3 | seq | u64 | Sequence number within a flow. Assigned by SeqAssigner at output. |
| 4 | content_type | text | MIME-like content type of the payload. |
| 5 | meta | map | String-keyed metadata map. Contents depend on frame type. |
| 6 | payload | bytes | Binary payload data. |
| 7 | len | u64 | Total byte count for a chunked transfer. Set on the first chunk only. |
| 8 | offset | u64 | Byte offset of this chunk within the stream. |
| 9 | eof | bool | True on the final chunk of a stream or on END frames. |
| 10 | cap | text | Cap URN for REQ frames. Identifies which capability to invoke. |
| 11 | stream_id | text | UUID identifying a stream within a request. |
| 12 | media_urn | text | Media URN identifying the data type of a stream. |
| 13 | routing_id | bytes[16] or uint | XID assigned by RelaySwitch for routing. |
| 14 | chunk_index | u64 | Zero-based index of this chunk within its stream. |
| 15 | chunk_count | u64 | Total number of chunks in a stream. Set on STREAM_END. |
| 16 | checksum | u64 | FNV-1a 64-bit hash of the chunk payload. |
| 17 | is_sequence | bool | Whether the producer used emit_list_item (true) or write (false). STREAM_START only. |
| 18 | force_kill | bool | Whether Cancel force-kills the cartridge process. Cancel only. |
| 19 | credit | u64 | Flow-control grant in CHUNK units. Credit frames only. |
| 20 | unbounded | bool | Stream makes no length promise (L16). STREAM_START only. |
| 21 | output | text | The output a response stream is: that output’s media URN from the cap definition. Response STREAM_START only; required there. |
Source: capdag-rs/src/bifaci/frame.rs (keys module; Frame struct).
Frame types
The following diagram shows how frame types relate within a request lifecycle:
stateDiagram-v2
[*] --> Hello : connection setup
Hello --> Req : connection live
state "Request Lifecycle" as RL {
Req --> StreamStart : open stream
StreamStart --> Chunk : send data
Chunk --> Chunk : more data
Chunk --> StreamEnd : stream complete
StreamEnd --> StreamStart : open another stream
StreamEnd --> End : all streams done
Req --> End : empty response
Req --> Err : request failed
StreamStart --> Err : request failed
Chunk --> Err : request failed
}
state "Anytime" as AT {
Log : Log (progress, info)
Heartbeat : Heartbeat (health probe)
}
Hello (0)
Sent once by each side during connection setup. The host sends Hello first (no manifest); the cartridge responds with Hello including a JSON-encoded manifest in meta["manifest"]. The cartridge manifest has the structure:
{
"name": "mycartridge",
"version": "1.0.0",
"description": "...",
"cap_groups": [
{
"name": "default",
"caps": [{"urn": "cap:effect=none", "title": "Identity", ...}, ...],
"adapter_urns": []
}
]
}
The meta map also carries limit proposals: max_frame, max_chunk, and max_reorder_buffer. Both sides negotiate limits by taking the minimum of the two proposals. A cartridge’s HELLO additionally carries the mandatory pools entry: JSON bytes encoding its full concurrency-pool state map (one singleton pool per registered cap, every manifest-declared shared pool, and the reserved all pool), each with declared/configured/optional available/active/queued; see 13.1). A missing or malformed map is a protocol violation, not an invitation to infer a default. The heartbeat is the capacity CONFIG channel: a probe may carry desired_capacities (JSON bytes: pool name → operator configured value), and every heartbeat reply carries the mandatory refreshed pools map. An unknown pool name refuses the whole desired batch with an UNKNOWN_POOL ERR in place of the reply. See Handshake for the full handshake sequence.
Hello uses id = Uint(0) and is not associated with any request.
Source: frame.rs (Frame::hello, Frame::hello_with_manifest).
Req (1)
Initiates a capability invocation. The cap field (key 10) carries the cap URN to invoke. The request may include a payload and content_type for inline data, though most arguments arrive as separate streams (STREAM_START/CHUNK/STREAM_END).
The routing_id (key 13) is assigned by the RelaySwitch at routing boundaries. Cartridges sending peer invocations do not set routing_id; it is added by the infrastructure.
The cap URN is validated at construction time: Frame::req() panics if the URN is malformed, because an invalid URN is a programming error, not a runtime condition.
Source: frame.rs (Frame::req).
Chunk (3)
Carries a piece of streaming data within a named stream. Required fields:
stream_id(key 11): Which stream this chunk belongs to.chunk_index(key 14): Zero-based position of this chunk within the stream. Monotonically increasing.checksum(key 16): FNV-1a 64-bit hash of the payload bytes. Mandatory for corruption detection.
Optional fields set by the chunking layer:
offset(key 8): Byte offset of this chunk’s data within the total stream.len(key 7): Total byte count. Set on the first chunk only (chunk_index = 0).eof(key 9): True on the last chunk.content_type(key 4): Set on the first chunk only.
Source: frame.rs (Frame::chunk, Frame::chunk_with_offset).
End (4)
Terminal frame that signals all streams for a request are complete. No flow frame may follow an END for the same request ID (L4); runtimes drop and count post-terminal frames, never write them. May carry an optional final payload.
The eof field is set to true. A successful END carries exit_code = 0 in meta and MAY carry terminal metadata: progress (f64) and message. The terminal progress is the authoritative final progress event (L5). It rides in the terminal frame itself and therefore cannot race it. A successful END without an explicit progress reads as 1.0; a failed END never synthesizes one.
Source: frame.rs (Frame::end).
Log (5)
Carries source diagnostics and progress updates. The meta map (key 5) contains:
level(non-empty text): One of"info","warn","error","progress", or a source-defined level.message(non-empty text): Human-readable message.-
attribution_class(text, mandatory for non-progress LOG): One ofinputresourceenvironmentinternal. arg_urn(non-empty text, optional for non-progress LOG): The one argument the source attributes the diagnostic to.progress(float, optional): A value between 0.0 and 1.0. Present only whenlevelis"progress".
Progress frames are LOG frames with level = "progress". They are constructed only through the progress API and do not carry diagnostic attribution. A non-progress LOG must carry a valid attribution_class; missing or unknown values are protocol violations. arg_urn is present only when the emit source knows that the message concerns one argument. Receivers never infer either field.
LOG frames can appear at any point during a request, interleaved with CHUNK frames. They do not affect the data stream.
Source: frame.rs (Frame::log, Frame::progress, log_level, log_message, log_progress, attribution_class, attribution_arg_urn).
Err (6)
Terminal frame that signals failure. Like END, no further frames may follow for this request. The meta map carries:
code(non-empty text): Machine-readable error code.-
attribution_class(text): Whose domain the failure belongs to, declared at the definition or emit site. One ofinputresourceenvironmentinternal. message(non-empty text): Human-readable error description.arg_urn(non-empty text, optional): The media URN of the one argument the source attributes the failure to.
All three required fields are strict. A missing or unknown attribution_class is a protocol violation; receivers do not manufacture internal. When the source cannot attribute the failure to one argument, arg_urn is absent, never empty. See Error handling.
ERR and END are mutually exclusive terminals. A request ends with exactly one of them.
Source: frame.rs (Frame::err, error_code, attribution_class, attribution_arg_urn, error_message).
Heartbeat (7)
Health monitoring frame. Either side can send a Heartbeat; the receiver must respond with a Heartbeat carrying the same id. The CartridgeHostRuntime sends heartbeats every 30 seconds and expects a response within 10 seconds. A cartridge that fails to respond is marked unhealthy.
Heartbeat frames bypass sequence numbering because they are not flow frames and do not participate in ordering.
Source: frame.rs (Frame::heartbeat); see also host_runtime.rs (HEARTBEAT_INTERVAL, HEARTBEAT_TIMEOUT).
StreamStart (8)
Announces a new named stream within a request. Carries:
stream_id(key 11): A UUID uniquely identifying this stream within the request.-
media_urn(key 12): The media URN (see Media URNs) that identifies the data type of the stream. output(key 21): on a response stream that is not the cap’s main output, the media URN of the output this stream is. The stream of the main output carries nooutput, and neither does a request stream.
A request carries one STREAM_START per argument it is given, each labelled with the URN the cap’s definition delivers that argument under (CapArg::stream_urn()), so an op finds each argument by a URN it declares. A request stream that carries output is refused by the runtime that reads it: it is not an argument.
A response carries at most one STREAM_START per declared output. The one that names no output is the cap’s main output, the output the cap URN’s out= states: whoever asked for the cap reads its answer without holding the cap’s definition, which is what a cartridge calling a peer and a command line printing a result both do. Every other stream names the output it is. media_urn is always the type of the data; output says which of the cap’s other outputs a stream is, which the type alone cannot when two outputs carry values of one type. A stream that names the main output, a stream that names an output the cap does not declare, and a second stream for one output are each a contract violation. An optional output that does not fire has no stream. See Input and output.
Source: frame.rs (Frame::stream_start).
StreamEnd (9)
Marks the end of a specific stream. After this frame, any CHUNK for the same stream_id is a protocol error. Carries:
stream_id(key 11): The stream being ended.chunk_count(key 15): The total number of CHUNK frames sent in this stream, by the sender’s count. The receiver can verify it received all chunks. A stream announcedunboundedmakes no length promise and its STREAM_END omitschunk_count(L16).
Source: frame.rs (Frame::stream_end).
RelayNotify (10)
Sent by a RelaySlave to its paired RelayMaster to advertise the capabilities available on that host. The meta map carries:
manifest(bytes): JSON-encoded object with the structure:{ "caps": ["cap:effect=none", "cap:in=...;out=...;generate", ...], "installed_cartridges": [] }capsis a flat list of capability URN strings from all healthy cartridges on the host.installed_cartridgescarries cartridge process metadata. CAP_IDENTITY (cap:effect=none) must be present; the RelaySwitch rejects the connection otherwise.max_frame,max_chunk,max_reorder_buffer: Protocol limits for this relay connection.
RelayNotify frames are intercepted by the relay and never pass through to the other side. The master stores the manifest and limits and makes them available to the RelaySwitch for routing decisions.
Source: frame.rs (Frame::relay_notify); see also Relay topology.
Credit (13)
Grants per-stream flow-control credit to the sender of a stream (see
Flow control). Carries the target request’s
RID in key 2, an optional stream_id (key 11; absent means the request’s
sole stream), and the grant count in credit (key 19, CHUNK units).
Intermediaries forward Credit end-to-end like Cancel; they never originate or
absorb it (L11). A CREDIT frame without a credit count is rejected at decode.
Cancel (12)
Cancels one in-flight request, identified by its RID in key 2 (and its XID
in key 13 when routed). A Cancel always acts: the receiving runtime
cancels the request cooperatively (closes its input, releases its credit
waiters, cancels its peer calls, and answers with a terminal ERR once the
handler has exited), or, with force_kill (key 18), the host kills the
cartridge process.
A Cancel is attributed exactly like an ERR frame, through meta (key 5),
in the failure taxonomy’s vocabulary (see Error handling). Every entry
is optional, and an unattributed Cancel still cancels:
| meta key | meaning | what the terminal ERR gets |
|---|---|---|
code |
the terminal code the request will end with: CANCELLED (an operator’s cancel), ABORTED_COLLATERAL (another step of the same run failed), ABORTED (the host decided on its own) |
code; absent → CANCELLED |
attribution_class |
whose decision/fault the cancel is: user for an operator’s cancel; the originating failure’s class for a collateral teardown; the host’s class for a host abort (environment for a stale run, resource for memory pressure) |
attribution_class; absent → internal (unclassified means “ours”) |
message |
the reason: the failed step for collateral, the host’s reason | message; absent → “Request cancelled” |
CancelReason (frame.rs) is the typed form: user(force_kill),
collateral(class, detail), host(class, detail, force_kill),
unattributed(force_kill); Frame::cancel_reason() reads it back from
any Cancel, never failing: an unknown class token decodes as “no class”, a
missing meta as unattributed. Cascades carry the reason unchanged (a peer
call of an aborted request is collateral of the same failure), and a
force-kill death answers every pending request in the reason’s own code and
class. “Cancelled” therefore always means a human cancelled; a teardown
because something else failed says so and is attributed to that failure.
A request ends once. Its handler’s END (or ERR) and a cancel’s ERR are alternatives, never both: whichever claims the request first is its terminal. A cancel that arrives after the handler has ended the request adds nothing, and once a cancel has claimed it the handler’s own terminal is not sent. Any frame for the request after its terminal is a post-terminal straggler the writer suppresses (L4). An in-process host sends the cancel’s ERR before it closes the handler’s input, so a handler that notices the cancel first cannot end the request in its own terms.
Input cut short is not complete input. When a cancel (or the connection’s
end) closes a request’s input, every stream still open, and the input
package itself, fails with an abandoned error rather than ending; a peer
response cut short fails the same way. A handler can never take part of its
input for all of it. Accumulating helpers (accumulate_input) refuse input
that did not reach its END or that carried an ERR.
Source: frame.rs (CancelReason, Frame::cancel, Frame::cancel_reason),
cartridge_runtime.rs (the Cancel arm, TerminalClaim, the demuxes),
in_process_host.rs, relay_switch.rs (cancel_request).
CloseStream (14)
Closes one request’s live input stream(s), the tap-off (15.2 §Runs
stop). Carries the target request’s RID in key 2 and, optionally, the
stream_id (key 11) of the one input stream whose feed to close; absent
means every live feed the request holds. The receiving runtime closes the
matching feed tap(s): capture ends, the handler drains what was already
captured, and the request ends naturally with END and complete outputs.
Nothing is cancelled: host-side request state is untouched, no terminal is
synthesised, and the frame never cascades to peer calls (the tap is on the
feed-bearing request alone; its peers drain naturally). A CloseStream for a
request holding no matching open feed is logged and ignored; it never
becomes an abort.
CloseStream is a non-flow control frame like Cancel and Credit (no seq,
no reorder buffering), routed by RID→XID to the request’s destination, and
forwarded by intermediaries without interpretation.
Source: frame.rs (Frame::close_stream), cartridge_runtime.rs (the
CloseStream arm), relay_switch.rs (stop_request_feeds).
RelayState (11)
Sent by a RelayMaster to its paired RelaySlave to provide host resource information. The payload (key 6) carries an opaque blob (CBOR or JSON encoded by the engine) containing resource state such as model paths, GPU availability, or capability demands.
Like RelayNotify, RelayState frames are intercepted by the relay and never pass through.
Source: frame.rs (Frame::relay_state).
Policy (15)
What the host allows the cartridges it serves to do, as it stands now. It travels one way, from the engine toward cartridges, and is never sent by a cartridge. Today it says one thing, meta["network"]:
network |
A cartridge may reach |
|---|---|
offline |
nothing beyond this machine |
no_telemetry |
third-party services and MachineFabric’s own, never analytics |
full |
the same, and analytics |
A Policy carries id = Uint(0), no seq, and is not reorder-buffered. A Policy without network, or with any other value, is rejected at decode.
Who sends it, and when. The engine gives the RelaySwitch its live policy. The switch sends it to each master as the first frame after that master’s RelayNotify, before it routes anything there, and to every master each time it changes. Relays pass it through. A CartridgeHostRuntime keeps the latest one it was given and sends it to each cartridge it has connected, before the first request it routes to that cartridge, and to every cartridge each time it changes. A host that runs without a switch (the command line, an in-process host) is given its policy by whoever builds it. A host is never without one when it routes a request: one that has not been given a policy when a request reaches it refuses that request as a protocol violation.
What a cartridge does with it. The runtime keeps the latest Policy and a handler reads it live. The request’s own value is not a snapshot. Code that reaches the network asks first, before each call, so a change to offline stops a running download or quote stream at its next call. A call the policy does not allow is not made: it fails with code NETWORK_POLICY, class user, and a message naming the policy. A REQ that reaches a cartridge before any Policy is a protocol violation, except the host’s identity check (CAP_IDENTITY), which is part of the handshake and can precede it: a host connects its cartridges before it has a policy to give them. A cartridge run from a command line, with no host, is under full, because the person running it decides for themselves.
A cartridge that never reaches the network has nothing to ask. It still receives Policy frames and keeps the latest one.
Source: frame.rs (Frame::policy, HostPolicy), cartridge_runtime.rs (the Policy arm, Request::network), host_runtime.rs (set_policy), relay_switch.rs (set_policy).
MessageId
Frame IDs come in two variants:
- Uuid: 16 random bytes (v4 UUID). Used for request IDs and stream IDs, and anything else that needs to be globally unique.
- Uint: A 64-bit integer. Used for control frames (Hello uses
id = 0, Heartbeat can use any integer).
UUIDs are encoded as CBOR byte strings (major type 2, 16 bytes). Integers are encoded as CBOR unsigned integers (major type 0).
Source: frame.rs (MessageId enum).
Flow frames vs control frames
Frames are classified as flow or non-flow. The distinction matters for sequence numbering and reordering:
- Flow frames: Req, Chunk, End, Log, Err, StreamStart, StreamEnd. These participate in sequencing: the
SeqAssignerassigns monotonically increasingseqvalues per flow, and theReorderBufferat relay boundaries reorders them if they arrive out of sequence. - Non-flow frames: Hello, Heartbeat, RelayNotify, RelayState, Cancel, Credit, CloseStream, Policy. These bypass sequence assignment entirely (their
seqstays at 0) and are not buffered by the reorder buffer. Credit in particular must never queue behind the data it is flow-controlling.
The is_flow_frame() method on Frame returns this classification, and FrameType::is_terminal() says which flow frames end a flow (End, Err).
Both are the proved model’s decisions (formal/CapDAG/Bifaci/Flow.lean, FrameType.isFlow / isTerminal), generated into every mirror: no runtime keeps its own list.
Source: frame.rs (FrameType::is_flow, FrameType::is_terminal, Frame::is_flow_frame).
Checksum
Every CHUNK frame carries a checksum in key 16: an FNV-1a 64-bit hash of the payload bytes. The sender computes it via Frame::compute_checksum():
const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325;
const FNV_PRIME: u64 = 0x100000001b3;
let mut hash = FNV_OFFSET_BASIS;
for &byte in data {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
The receiver verifies the checksum after decoding. A mismatch is a protocol error, and the frame is rejected.
FNV-1a is fast and simple, not cryptographically strong. It detects accidental corruption (bit flips, truncation) but is not designed to resist intentional tampering.
Source: frame.rs (compute_checksum).