Status: implemented. This document specifies the on-wire encoding the craft_client transport
(craft_wire + net/) is built against -- the concrete encoding of the client-facing verbs and the
transport requirements enumerated in client-internals.md ("What the transport must
provide"). The reference model (craft_reference) can also drive the same client in-process over MemTransport.
The first real binding is TCP over io_uring: kernel TCP for reliability and congestion control, io_uring for submission (socket ops are SQEs on the ublk queue's own ring, so a reply is a CQE the queue already reaps -- requirement 7). Homa is the back-pocket transport if the quorum-ack tail under incast is ever measured as the bottleneck and an out-of-tree kernel module is acceptable; RDMA (RoCEv2 / iWARP) is the option for a site willing to spend on RDMA NICs and a lossless fabric. The message encoding below is transport-agnostic so those bindings do not force a re-encode.
- The CRAFT message (this document, most of it) is transport-agnostic: a fixed message header, a per-operation header, and an opaque bulk payload. Every transport carries these bytes unchanged.
- The transport framing (the "TCP binding" section) owns what is transport-specific: stream delimiting, the connection-init handshake, and integrity digests. Digests live here, not in the message: over TCP they are CRC32C (TCP's own checksum is too weak for storage); over RDMA the NIC's ICRC already covers the wire, so no application digest is added.
- All multi-byte integers are little-endian and naturally aligned within their header. Headers are packed (no implicit padding); every reserved byte MUST be written 0 and ignored on read.
- A field marked
i64is signed (an_lsnor dLSN, where -1 means "none/unknown");u64/u32/u16/u8are unsigned.uuid[16]is a 16-byte RFC-4122 identifier in network byte order (asboost::uuids::uuidserializes it). - Offsets in the per-operation tables are relative to the start of that operation header (which itself begins immediately after the 8-byte message header).
- Little-endian is deliberate: every field is fixed-offset, so a receiver overlays the header struct on the buffer and reads in place -- no parse, no copy. The bulk payload is never interpreted by the wire layer.
Every request and every response begins with this 8-byte header.
7 6 5 4 3 2 1 0
+------+------+------+------+------+------+------+------+
0 | op (u8) | see the op table; request and response
+------+------+------+------+------+------+------+------+ are distinct op codes
1 | status (u8) | response ops only; 0 = OK (see below)
+------+------+------+------+------+------+------+------+
2-3 | request_id (u16) | per-connection correlator; response echoes it
+------+------+------+------+------+------+------+------+
4-7 | body_len (u32) | bytes of payload after the operation header
+------+------+------+------+------+------+------+------+
There is no op_hdr_len and no per-message version. The operation-header size is fixed per
(op, version), and both are known -- op is in the header and the wire version is a connection-lifetime
constant negotiated once at connection-init -- so op alone implies the operation-header layout. Only the
variable payload length (body_len) has to be carried. The 8-byte header keeps the operation header
8-aligned, so it can be overlaid in place (its first fields are _lsn / token u64s). A receiver frames a
message as 8 + opHdrSize(op) + body_len (+ digests); a message whose op it does not recognize (a version
bug, or corruption the digest already flags) is unframeable and resets the connection, which is the correct
response anyway.
op carries the operation and the direction: request and response are distinct codes (there is no
response flag bit), so a server only ever receives request ops and a client only ever receives _RSP ops --
the value alone is a direction check. The op set is far under 64, so a single byte is ample.
| op | value | op | value | |
|---|---|---|---|---|
LOGIN |
1 | LOGIN_RSP |
2 | |
HELO |
3 | HELO_RSP |
4 | |
WRITE |
5 | WRITE_RSP |
6 | |
READ |
7 | READ_RSP |
8 | |
KEEPALIVE |
9 | KEEPALIVE_RSP |
10 | |
LOGOUT |
11 | LOGOUT_RSP |
12 | |
RESOLVE |
13 | RESOLVE_RSP |
14 | |
CREATE_VOLUME |
15 | CREATE_VOLUME_RSP |
16 | |
GET_RS_COMMIT_LSN |
17 | GET_RS_COMMIT_LSN_RSP |
18 | |
FETCH_DATA |
19 | FETCH_DATA_RSP |
20 | |
AUTH_START |
21 | AUTH_CHALLENGE |
22 | |
AUTH_PROOF |
23 | AUTH_RESULT |
24 |
1-14 are the client plane. 15-20 are the peer plane (replica to replica, plus bootstrap volume creation),
specified by wire.hpp and not described further here. 21-24 are the connection-phase AUTH exchange (see
Authentication).
request_id correlates a response to its request. It is a per-connection correlator allocated per request --
not the ublk tag (a split read issues several requests against one tag) -- and is not reused on a connection
until its reply arrives or the connection resets, so a reply the client abandoned on a timeout can never be
miscorrelated with a later request (NVMe/TCP's "CID not reused until the command completes"; see
transport.md). A u16 is ample: the in-flight window on one connection is one queue's depth times its split
factor, and connections do not share an id space, so neither nr_hw_queues nor the replica count multiplies
it. A request op sets status to 0; a response op (the _RSP codes) carries the result in status.
The session is connection-bound. A client holds one connection per (queue, replica); each is bound once,
at open, and every IO on it reuses that binding. There is no per-IO or per-op handshake -- that would be a
latency killer -- so the only constants carried per IO are the ones that actually change (the commit
watermarks).
- AUTH (ops 21-24) runs first on every connection and admits it for one volume. It answers "may this connection exist"; it carries no session state. See Authentication.
LOGINestablishes a new session on the leader (the fullGetRSCommitLSN->SyncRSCommitLSN->InternalLoginorchestration) and binds the connection it arrives on. It returns the sessionterm, the startingdLSN, and the volume geometry.HELObinds an additional connection (another queue, or a follower) to the already-established session. The replica knows the sessiontermfrom the replicatedInternalLogin, so this is an attach, not a re-login.- Fencing is by the connection's bound term, not a per-IO wire field: the replica compares the socket's
bound
termto its current sessiontermon each IO. A takeover bumps the session term; the old connection's bound term then trails it and its IOs failSTALE_TERM. This is why the per-IO header does not carryterm.
WRITE, READ, KEEPALIVE, LOGOUT, and RESOLVE requests begin their operation header with this 16-byte
block -- CRAFT's commit, piggybacked on the IO the client is already sending (there is no standalone commit
verb). LOGIN, HELO, and the AUTH ops omit it (pre-session).
| offset | size | field | notes |
|---|---|---|---|
| 0 | 8 | commit_lsn (i64) |
advance the contiguous frontier toward this, in dLSN order; -1 = do not advance |
| 8 | 8 | all_committed_lsn (i64) |
client-computed set-wide min commit_lsn; floors journal reclaim; -1 = unknown |
Establish a session; leader-only orchestration. A follower does not fail -- it returns term == 0 with
leader_hint set, and the client retries at that replica.
Request operation header (24 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 16 | volume_id (uuid) |
which volume to establish the session on (the design's login(client_token, vol_id)); a multi-volume server routes by it, and it is the same id HELO presents |
| 16 | 8 | client_token (u64) |
ownership token from the caller |
No request body.
Response operation header (56 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 8 | term (u64) |
0 = NOT_LEADER redirect; > 0 = session term |
| 8 | 8 | dLSN (i64) |
starting per-partition LSN for new IO |
| 16 | 8 | capacity (u64) |
volume size in bytes -- the device size presented to ublk |
| 24 | 4 | lba_size (u32) |
block size in bytes; alignment unit for addr/len |
| 28 | 4 | max_tx (u32) |
largest single IO payload (data) in bytes; caps ublk max_sectors. A READ_RSP frames its extent table on top of this, so it does not bound body_len directly -- see Sizing and limits |
| 32 | 4 | member_count (u32) |
number of member descriptors in the body |
| 36 | 4 | reserved (0) | |
| 40 | 16 | leader_hint (uuid) |
non-nil iff redirect (term == 0); nil on success |
Response body: member_count member descriptors, packed:
| offset | size | field | notes |
|---|---|---|---|
| 0 | 16 | id (uuid) |
replica id |
| 16 | 2 | addr_len (u16) |
length of the address string |
| 18 | addr_len |
addr (bytes) |
"host:port", not NUL-terminated |
This member list is the only variable-length, non-overlayable structure in the protocol; it is parsed once, at login, off the hot path.
Bind this connection to an existing session. Sent on every connection after the first LOGIN. Any replica
answers (not leader-only). OK binds; otherwise WRONG_TOKEN / STALE_TERM / NOT_LEADER.
Request operation header (32 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 16 | volume_id (uuid) |
which volume this connection serves |
| 16 | 8 | client_token (u64) |
session owner |
| 24 | 8 | term (u64) |
the session term the client believes is current |
No request body. Response carries status only (no operation header, no body).
Append the client-assigned write at dlsn. An empty body (body_len == 0) is a zero write (WRITE_ZEROES;
len is the range to unmap; reads back as a hole); a non-empty body is a data write of exactly len bytes.
Request operation header (40 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 16 | common request header | commit_lsn, all_committed_lsn |
| 16 | 8 | dlsn (i64) |
client-assigned slot |
| 24 | 8 | addr (u64) |
byte offset, block-aligned |
| 32 | 8 | len (u64) |
byte length, block-aligned; the write size and (for a zero write) the unmap range |
Request body: len bytes of block data, or none for a zero write.
Response operation header (16 bytes) -- the achieved LSNs, piggybacked so the client refreshes the reclaim floor and recovery on every write without a separate keep_alive:
| offset | size | field | notes |
|---|---|---|---|
| 0 | 8 | commit_lsn (i64) |
replica's achieved commit_lsn |
| 8 | 8 | last_append_lsn (i64) |
replica's highest appended dLSN |
No response body. ACK fires on journal-append completion.
Return the latest version <= the read horizon H for [addr, addr+len), filling the caller's buffer in
place and describing the sparse layout. Entries above H are never served even if the replica holds them.
Request operation header (40 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 16 | common request header | commit_lsn, all_committed_lsn |
| 16 | 8 | read_lsn (i64) |
the read horizon H |
| 24 | 8 | addr (u64) |
byte offset, block-aligned |
| 32 | 8 | len (u64) |
byte length, block-aligned |
No request body.
Response operation header (24 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 8 | commit_lsn (i64) |
replica's achieved commit_lsn (piggybacked, as on WRITE_RSP) |
| 8 | 8 | last_append_lsn (i64) |
replica's highest appended dLSN |
| 16 | 4 | extent_count (u32) |
number of extent descriptors in the body |
| 20 | 4 | reserved (0) |
Every IO response the client gets back -- WRITE_RSP, READ_RSP, KEEPALIVE_RSP -- leads with the replica's
achieved {commit_lsn, last_append_lsn}, so any round-trip to a replica refreshes its watermark in the
client's per-member model (its synced, the reclaim floor), not just writes and keep_alives. This is the
response-side mirror of the client watermarks (commit_lsn, all_committed_lsn) every IO request carries.
Response body: extent_count extent descriptors (ascending by addr), then the concatenated data bytes
for the non-hole extents in the same order. The descriptors come first on purpose: the receiver builds an
iovec per data extent pointing at that extent's sub-range of dest and does one scatter recv, so the
packed wire bytes land at their dest offsets in a single copy with no separate reassembly; the hole ranges
are the gaps the iovecs do not cover and are zero-filled locally, so no zero bytes cross the wire. (Over
kernel TCP that recv copy is unavoidable, so a read is single-copy, not zero-copy; only RDMA, writing each
extent straight into dest, makes a read zero-copy too -- see transport.md.)
Extent descriptor (24 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 8 | addr (u64) |
byte offset into the volume |
| 8 | 8 | len (u64) |
byte length |
| 16 | 1 | hole (u8) |
1 = hole (reads as zero, no bytes in the body); 0 = data |
| 17 | 7 | reserved (0) |
A hole is a resolved "reads as zero" and is NOT the same as Missing.
The dedicated commit carrier: advance the frontier toward commit_lsn, reset the session watchdog, and
return the member's achieved LSNs. It is also how the client drives liveness without a timer (see the client
README).
Request operation header (16 bytes): the common request header. No body.
Response operation header (16 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 8 | commit_lsn (i64) |
|
| 8 | 8 | last_append_lsn (i64) |
Explicit, term-fenced session teardown. Leader-only. The leader commits an InternalLogout that clears the
session on every replica; later IO from this client is fenced STALE_TERM.
Request operation header (16 bytes): the common request header. No body. Response carries status only.
The client-requested resolution round (the design's client-request SyncRSCommitLSN trigger), fired after a
failed (sub-quorum) write instead of waiting for the watchdog / periodic cadence. The round is LEADER work,
but the client cannot know who leads mid-session (leadership may have moved since login), so it broadcasts
the request to every member -- at most one outstanding per connection, like its keep_alive drive -- and
whichever member is the current leader runs it; a non-leader answers NOT_LEADER (a server may equally
forward the request to its leader). Term-fenced. The leader resolves every unresolved slot <= upto: it
fetches each from a holder (the failed write completes late), or, on quorum-lacks evidence, verdicts it Empty.
A slot <= upto that had not landed anywhere when the leader resolved is verdicted Empty, and a replica
rejects a late arrival into an Empty slot (reconciliation: Empty beats data) -- so that write's own ack
path sees deterministic rejects and fails, per the undefined-outcome contract for un-acked IO.
Request operation header (24 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 16 | common request header | commit_lsn, all_committed_lsn |
| 16 | 8 | upto (i64) |
resolve every unresolved slot at or below this dLSN |
No request body.
Response operation header (16 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 8 | resolved_upto (i64) |
everything at or below this is now resolved set-wide |
| 8 | 4 | empty_count (u32) |
number of Empty-verdict dLSNs in the body |
| 12 | 4 | reserved (0) |
Response body: empty_count packed i64 dLSNs (ascending) -- the slots <= resolved_upto verdicted Empty,
including verdicts from earlier rounds, so a client whose previous reply was lost still learns them. Every
other formerly-unresolved slot <= resolved_upto was filled and is durable.
Mutual CHAP over HMAC-SHA-256, as two request/response pairs at the start of a connection, before LOGIN /
HELO. Each end proves it holds the replica's per-attach secret; the secret itself never goes on the wire.
Like NVMe-oF in-band authentication (Authentication Send/Receive over ordinary command capsules), it uses the
normal message framing rather than a separate transport PDU type. Admission and the credential lifecycle are
in transport.md.
client replica
AUTH_START {volume_id, username, Mc} ->
<- AUTH_CHALLENGE {Ms} status != OK: closes
AUTH_PROOF {Rc} ->
<- AUTH_RESULT {Rs} status != OK: closes
A credential is {username, password}, provisioned per (attach, replica) by the control plane. Both are
opaque byte strings, used exactly as provisioned (no decoding). username is 1-64 bytes and is not secret: it
identifies the credential generation, so a replica holding a rotated credential rejects a stale one by name.
password is the HMAC key and MUST carry at least 256 bits from a CSPRNG -- the scheme has no defense against
offline guessing of a weaker one. With 32 random bytes delivered hex-encoded, the 64 ASCII characters
themselves are the key, not the 32 bytes they encode.
Rc = HMAC-SHA-256(password, "craft-auth-v1 client" | volume_id | u8 len(username) | username | Mc | Ms)
Rs = HMAC-SHA-256(password, "craft-auth-v1 server" | volume_id | u8 len(username) | username | Mc | Ms)
The labels are 20 ASCII bytes, no terminator. The role label stops a proof being reflected back as the
other side's; covering both nonces stops a proof being replayed on another connection. Mc and Ms are 32
bytes each from a CSPRNG, fresh per connection. Proofs are compared in constant time. craft/auth.hpp
implements both proofs; client and server MUST hash through it so they hash identical bytes.
Request operation header (56 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 2 | version (u16) |
auth scheme version; 1 = the HMAC-SHA-256 exchange above |
| 2 | 2 | reserved (0) | |
| 4 | 4 | reserved (0) | |
| 8 | 16 | volume_id (uuid) |
which volume's credential to check; the same id LOGIN / HELO must present |
| 24 | 32 | client_nonce (u8[32]) |
Mc |
Request body: username, 1-64 bytes.
Response (AUTH_CHALLENGE) operation header (32 bytes):
| offset | size | field | notes |
|---|---|---|---|
| 0 | 32 | server_nonce (u8[32]) |
Ms; zero-filled on a non-OK status |
No response body. Status: OK, NOT_EXPOSED (no credential for volume_id here), STALE_CREDENTIAL
(username is not the current credential), or INVALID_ARGUMENT (unsupported version, or username
length out of range).
Request operation header (32 bytes): client_proof (u8[32]) = Rc. No body.
Response (AUTH_RESULT) operation header (32 bytes): server_proof (u8[32]) = Rs, zero-filled on a non-OK
status. No body. Status: OK or AUTH_FAILED (Rc did not verify).
- Order. A replica that enforces auth accepts only
AUTH_STARTas a connection's first message, then onlyAUTH_PROOF. Any other op before success, or any AUTH op after it, closes the connection without a reply. - One attempt. A non-OK
AUTH_CHALLENGE/AUTH_RESULTis sent and the replica closes. There is no retry on the same connection. - Mutual, fail-closed. A client given a credential completes the exchange and verifies
Rsbefore sendingLOGIN/HELO, on every connection including reconnects. A missing or wrongRs, or any non-OK status, fails the connection; the client never falls back to an unauthenticated connection. A client with no credential skips AUTH; only a replica that does not enforce auth (the reference server, tests) admits it. - Volume binding.
LOGIN/HELOon an authenticated connection must name the authenticatedvolume_id; a mismatch isINVALID_ARGUMENT. - Refusals are final for the credential.
NOT_EXPOSED,STALE_CREDENTIAL,AUTH_FAILED,INVALID_ARGUMENT, or a badRsmean the credential cannot succeed against this replica again: a client stops connecting with it and fails fast until it is given new attach info. The client reportsINVALID_ARGUMENT(the replica cannot accept thisversionorusername) asAUTH_FAILED. To keepNOT_EXPOSEDmeaning "not exposed", a replica MUST restore its exposed credentials before it accepts CRAFT connections. Any other non-OK AUTH status (e.g.INTERNAL) fails only that attempt. - AUTH statuses on AUTH replies only. Statuses 8-10 are valid only on
AUTH_CHALLENGE/AUTH_RESULT; a client treats one on any other reply as a protocol violation and classifies the op like a lost reply (it may have applied). - Stalls. The replica's accept-to-bound deadline covers a stalled exchange; AUTH adds no timer of its own.
- Stable layout. The AUTH op layouts do not change with the wire
version, so they can run independently of connection-init. A new scheme is a newAUTH_START.version, not a new layout.
Carried in the message header status byte on responses. Values 1-6 are exactly craft_error
(craft/types.hpp), so client-side mapping is a cast.
| value | name | meaning |
|---|---|---|
| 0 | OK | success |
| 1 | STALE_TERM | IO term != session term (the protocol's ETERM) |
| 2 | NOT_LEADER | leader-only op sent to a follower (LOGIN returns a redirect instead) |
| 3 | NO_QUORUM | could not reach a quorum of live replicas |
| 4 | WRONG_TOKEN | client_token is not the current owner |
| 5 | NOT_ELIGIBLE | replica cannot serve this read (Missing overlap / below login dLSN) |
| 6 | REPLICA_DOWN | addressed replica is down / unreachable |
| 7 | INVALID_ARGUMENT | misaligned addr/len, or body_len over max_tx |
| 8 | NOT_EXPOSED | AUTH: the volume is not exposed on this replica; get fresh attach info from the control plane |
| 9 | STALE_CREDENTIAL | AUTH: the credential was rotated; get fresh attach info from the control plane |
| 10 | AUTH_FAILED | AUTH: the client's proof did not verify |
| 255 | INTERNAL | unexpected server-side failure |
Before any CRAFT message, the two ends exchange a transport init (the analogue of NVMe/TCP ICReq/ICResp):
the client offers its maximum wire version and its digest capabilities; the replica replies with the
version to use and which digests are enabled. Because the client is always >= the replica in version,
the chosen version is the replica's, and the client speaks its backward-compatible codec for that version.
Digest negotiation is per-connection and fixed for its lifetime. Connection-init precedes AUTH.
Not implemented yet: there is no init exchange today, and both ends run wire version 1 with both digests off.
A CRAFT message frames from its 8-byte header: op implies the operation-header size for the connected
version and body_len gives the payload, so the total is 8 + opHdrSize(op) + body_len (+ digests) -- no
separate length prefix is required. The binding MAY prepend a 4-byte sync word for stream resynchronization;
it is a framing aid, not part of the message. On a message transport (Homa) a message is one datagram and
framing is free.
CRC32C (Castagnoli -- the SSE4.2 crc32 instruction), matching iSCSI and NVMe/TCP practice:
- Header digest (HDGST), negotiated, to default on once connection-init exists: a
u32over the message header plus the operation header, placed immediately after the operation header and before the body -- so a corrupt header is rejected before the receiver commits to reading (or allocating for) the payload. - Data digest (DDGST), negotiated: a
u32over the body, placed after it. Enable it for wire-to-disk end-to-end integrity; it may be left off where the storage layer already carries a client-computed at-rest block checksum that covers the same path.
On the wire a framed message is therefore:
[message header][op header][HDGST?][body][DDGST?], with the two digests present per the connection's
negotiation.
- WRITE request: build
[message header][op header]in a small scratch and gather-send it with the data iovec pointing straight at the ublk buffer (SEND_ZC), so the block payload leaves zero-copy; it is consumed at submit, so a straggler send outliving the quorum ack is safe (requirement 6). - READ response: build an iovec per data extent (into the matching
destsub-ranges) and do one scatter recv, so the packed wire bytes are placed at theirdestoffsets in a single copy, with no reassembly; hole ranges are zero-filled locally. A kernel-TCP recv always copies socket buffer to userspace, so a read is single-copy, not zero-copy -- RDMA (writing each extent straight into the registereddest) is what makes a read zero-copy too.
max_tx is the volume's largest single IO payload -- the data of one transfer, the number LOGIN_RSP
carries and a driver caps max_sectors to (iSCSI's 512 KiB payload with the header excluded is the analogue).
It is not a bound on body_len. A WRITE request body is pure payload, so its body_len <= max_tx; but a
READ_RSP body is the extent table then the data, so a full-payload read frames a body of max_tx data
plus one extent_desc per block (worst case, a fully fragmented read). The transport therefore bounds a body at
framed_body_max(max_tx, lba_size) = max_tx + ceil(max_tx / lba_size) * sizeof(extent_desc)
-- the payload plus the largest extent table a read of it can carry (e.g. 512 KiB + 3 KiB = 515 KiB at a 4 KiB
block). Both peers derive that bound from the payload agreed at LOGIN, so nothing extra is negotiated. A
receiver MUST reject a body_len over the bound it applies -- a server bounds a request at max_tx (requests
carry no extents), a client bounds a reply at framed_body_max -- to cap its buffer. There is no
application-level fragmentation or reassembly.
- Peer-plane op layouts (15-20): allocated and defined in
wire.hpp, not yet described here. Peer-plane connections are not authenticated yet; a replica serving them on its CRAFT port must not apply the AUTH ordering rule to them until they have a credential model. - Encryption: out of scope for an intra-DC v1. If needed it is TLS under the TCP framing, below and independent of the AUTH exchange.
- Multi-partition: each partition is its own client with its own session and connections; this document describes a single partition's wire.