Order-entry protocol

September 1, 2026 · View on GitHub

The binary order-entry protocol spoken by cmd/obgw, the reference gateway.

It exists to demonstrate that the library's pieces compose into a working venue edge. It is not a standard, and it is not FIX, OUCH or SBE. If you need one of those, write an adapter — the seam this protocol sits on (pkg/orderentry) is the supported surface, and the codec is deliberately unexported.


What this is and is not

FramingSoupBinTCP 3.00, taken from the published spec
PayloadsThis repository's own, fixed-width big-endian
DependenciesNone. A 2-byte length and a 1-byte type.
Transport securityNone. Assumes a trusted network or a TLS wrapper below.
CredentialsA shared secret, over TLS when the venue is given a certificate and in the clear when it is not. The wire carries the secret either way; what protects it is the transport.
InstrumentsA set per gateway (-symbols); one book per instrument, fanned out per book
StabilityFrozen by internal/wire/testdata/*.hex; changing a layout means bumping Version
Current version4

Framing is borrowed rather than invented so the session rules — heartbeats, sequenced replay, login and logout — are somebody else's well-tested design. Payloads are ours because no standard order-entry payload matches this engine's order surface.


What is deliberately absent from the wire

A client never names an account and never sees an engine order id. Orders are referenced only by the client's own ClOrdID, scoped to the authenticated session.

That is a security boundary, not a simplification. The engine cancels by (orderID, userID), and self-trade prevention lets one account observe another's resting orders. A wire that carried either field would let a client name an order it does not own; there is simply no field in which to express that. Two accounts using the identical ClOrdID string cannot reach each other's orders, and there is a test for exactly that.

Also absent, deliberately:

  • STPMode — self-trade-prevention policy belongs to the venue, not the client.
  • The privileged flag — it is a liquidation capability. Client-settable, it would bypass every pre-trade cap.
  • Symbol on a cancel — a ClOrdID is unique venue-wide, across every book, so it already names exactly one order and a symbol would be redundant at best and contradictory at worst. (This used to say "the gateway serves one instrument". The conclusion survived multi-symbol; the reason did not. See MULTI-SYMBOL.md §"Client order ids are venue-wide".)

Session

client                          server
  |  LoginRequest ('L')            |
  |------------------------------->|
  |                                |  LoginAccepted ('A')  -> session id, sequence
  |<-------------------------------|  or LoginRejected ('J') -> one reason byte
  |                                |
  |  Unsequenced ('U') Enter       |
  |------------------------------->|
  |                                |  Sequenced ('S') Accepted / Executed / ...
  |<-------------------------------|
  |  ClientHeartbeat ('R')         |
  |------------------------------->|
  |  LogoutRequest ('O')           |
  |------------------------------->|

Login must be the first packet. Anything else drops the connection with no reply: an unauthenticated peer learns nothing about the venue.

Authentication defaults to deny. A gateway with no accounts configured rejects every login. An empty configuration must not produce an open venue.

Login rejection codes

ByteMeaning
ANot authorised — unknown user or wrong password
SUnknown session — the cursor belongs to a different venue incarnation
QBad sequence — the requested point is no longer retained

Resume, and why a session id is not decoration

Reconnect with the session id you were given and the last sequence you received, and the server replays everything since — including executions that landed while you were disconnected.

The guarantee, stated so it can be falsified: for one venue incarnation, an account's outbound sequence is dense and gap-free from 1, and any suffix still within the retention ring can be replayed exactly once, in order.

Three ways that can fail, and what happens instead of failing silently:

  • The venue restarted. Sequence numbers only mean anything within one run. A restart mints a new incarnation id, so a stale cursor is refused with S rather than served different content under numbers you believe you already have.
  • You are too far behind. The per-account ring is bounded, because an unbounded one turns a client that never reconnects into a venue-wide memory leak. A cursor older than what is retained is refused with Q. You must reconcile out of band; you are not told you are up to date.
  • You are ahead of the venue. Claiming messages that were never sent means you are out of step, and is refused rather than ignored.

The reason resume works at all is that an account's outbound stream outlives any connection. A Session is a socket; a Stream is the account's sequence. If outbound events belonged to the connection, a maker whose resting order filled while its TCP connection was down would never learn about the fill — the worst failure an order-entry system can have, because the client's position is now wrong and it cannot tell.


Messages

All payloads are big-endian, fixed-width, and begin with two bytes: the message type, then the protocol version. Both are checked on decode — a payload that would decode cleanly as the wrong message is precisely what this header prevents.

Fixed-width string fields are NUL-padded; an over-long value is a hard error rather than a truncation, because a truncated ClOrdID would collide with another of your own orders.

v1 → v2. Version 1 had no type byte and distinguished messages by payload length. Any future message sharing a length with an existing one would have been silently misread as it. The type byte is why the version freeze exists, and this is what spending it looks like.

v3 → v4. MDSubscribe named an incarnation and a sequence but no symbol, so a market-data connection could only ever mean "the one book this venue serves" — not a protocol a multi-symbol venue can speak (MULTI-SYMBOL.md §4.5). It gains Symbol, and a subscription now selects exactly one instrument: every message on that connection belongs to it, so no other market-data payload changed. A subscriber watching two symbols opens two connections, which is also the shape it wants — it can drop one without disturbing the other. Sequences are per symbol and are not comparable across them. A subscription for an instrument the venue does not serve is refused with MDRejectUnknownSymbol rather than quietly served the wrong book, which a subscriber has no way to detect for itself.

v2 → v3. A trade had no name. Executed and MDTrade reported price, quantity and aggressor, so no message could ever refer back to one specific print — and when the engine gained trade bust (TRADE-BUST.md) the venue could annul a fill it had never named, with no way to tell the client which one. Both payloads gained TradeID (+8 bytes each) and two messages now use it: Busted on order entry and MDBust on market data. Every other payload is byte-identical to v2 apart from the version field itself, which is the discipline a bump is supposed to carry — licence to move what had to move, not an amnesty for the rest.

Inbound

Enter — a new order.

FieldBytesNotes
MsgType1E
Version1
ClOrdID20your identifier, unique within your session
Symbol16must name one of the gateway's instruments (-symbols)
Side1B buy, S sell
Type1L limit, M market
TIF1G GTC, I IOC, F FOK, D DAY
PostOnly1
Price8ticks; 0 for market
Quantity8lots

ReplaceOrder — MsgType Z (1) + Version (1) + OrigClOrdID (20) + base order (56). Cancels a resting order and enters another in one command.

Without it a reprice is two messages, Cancel then Enter, and between them you are naked: if the connection dies in the gap you do not know whether you hold zero orders or one, and another participant can take the price meanwhile.

Priority is forfeited. The replacement goes to the back of its price level, which is correct — an order that could reprice or grow in place would let a participant reserve a place in the queue. For a same-price size reduction use Reduce, which keeps priority.

There is no new outbound message. A successful replace is a Canceled for the old ClOrdID followed by an Accepted for the new one, which already describes it exactly.

The atomicity is precise, and narrower than the word suggests:

  • No other command interleaves — the cancel and the entry happen back to back on the matching goroutine.
  • If the original cannot be cancelled (already filled, not yours, or inside the minimum resting time) the replacement is not entered, and the refusal names the original ClOrdID. A client replacing an order it no longer holds did not ask to open a new position, and entering one would double its exposure.
  • If the original is cancelled and the replacement is then refused — a price band, a post-only cross — you hold neither, and are told by a Canceled followed by a Rejected. That is reported inside the same command rather than left to be discovered, which is the part the two-message sequence cannot offer.

A replace is subject to the minimum resting time, like a cancel and a reduce: it withdraws displayed size, and a verb that escaped the floor would leave the anti-spoofing control guarding two routes out of three.

Conditional entry — five messages, each carrying the same 56-byte base-order block as Enter's body plus its own parameters:

MessageTypeExtra fieldsWidth
EnterStopSStopPrice (8)66
EnterIcebergIDisplayQty (8)66
EnterTrailingWTrail (8)66
EnterPeggedYRef (1), Offset (8)67
EnterOCONStopClOrdID (20), StopPrice (8), StopLimitPrice (8)94

Base-order block: ClOrdID (20) + Symbol (16) + Side (1) + Type (1) + TIF (1) + PostOnly (1) + Price (8) + Quantity (8).

The engine has supported all five since v0.5.0 and the wire could express none of them: a client could place a limit or a market order and nothing else. Four of the six order types the engine implements were reachable only by an embedder calling it in-process.

Each type has its own message rather than one message with a union of fields. A single conditional message carrying StopPrice, DisplayQty, PegOffset and Trail would mean four fields of which three are meaningless on any given message, and a field that exists but is never checked is what the v0.11.0 audit spent its time removing.

Notes that are load-bearing rather than decorative:

  • A stop needs a positive trigger. Zero would mean "fire on arrival", which is a market order, and a client should have to say so.
  • An OCO's stop leg inherits symbol, side, quantity and time-in-force from the primary; only its own ClOrdID and prices come off the wire. Legs of differing size would leave a residual position behind whichever one fired, so the protocol cannot express the mistake. StopLimitPrice 0 makes the leg a stop-market.
  • A pegged order must send Price 0. The peg computes the price, and a client-supplied one is refused rather than silently overwritten — otherwise you believe you set a price the venue replaced.
  • An iceberg has no jitter field. Reload-size jitter is venue policy, set from the engine's own configuration, so a client value would be decoded and overwritten: precisely the Symbol bug from v0.10.0.
  • All five are rate-limited and journalled exactly like a plain Enter. A conditional path that skipped the admission gate would be a way around the venue's throttle, and one that skipped the log would not survive a restart.

EnterStop, EnterIceberg and EnterTrailing encode to the same 66 bytes and are separated by nothing but the type byte.

DAY (D) rests until the venue's session close and then expires. It needs no extra field — the venue's close is the deadline — so it rides the existing Enter as a new value for a byte that already exists, moving nothing and invalidating no vector. A venue with no session configured refuses a DAY order rather than treating it as GTC: silently making an order immortal is the opposite of what you asked for.

EnterDated — MsgType J (1) + Version (1) + base order (56) + ExpiresAt (8). Good-till-date: the order carries its own deadline, in Unix nanoseconds UTC.

It is a separate message because Enter has nowhere to put a timestamp, and adding one would move every byte after it and invalidate a vector deployed clients already parse. Sending TIF T on a plain Enter is refused, not quietly downgraded to GTC, which would leave an order you believe is dated resting forever.

A deadline already in the past is refused rather than accepted and expired on the next command — an accept-then-cancel for something that was never viable is just confusing.

When an order expires, the Canceled you receive is the venue's, not yours. Expiry also ignores the minimum resting time: an anti-spoofing floor that could hold an order past its own stated lifetime would be the venue inventing liquidity you never offered.

Cancel — MsgType C (1) + Version (1) + ClOrdID (20).

Reduce — MsgType M (1) + Version (1) + ClOrdID (20) + Quantity (8). Shrinks a resting order in place, keeping its queue position, and is answered by a Replaced.

This is the one order-entry operation a client provably cannot build for itself. Cancel-then-new is the obvious substitute and it is wrong: it sends the order to the back of its price level, which for a maker managing size is a material loss.

Three properties are load-bearing:

  • Quantity is the new total, not a delta. A delta cannot be made safe against a concurrent fill — the venue and the client would be subtracting from different numbers, and the resulting size would depend on which of the two the venue believed. A total is unambiguous whatever arrived in between.
  • It is a reduction only. An increase, or a price change, forfeits priority; a resting order that could grow ahead of the queue would let a participant reserve a place in line. Those remain cancel-then-new and are refused here rather than silently reinterpreted.
  • Zero is not a cancel. A client that means to cancel must send a Cancel. Reinterpreting a reduce-to-zero would give one message two meanings.

Unlike a cancel, a refused reduce is always reported, because it fails for reasons the client caused and can correct: asking to grow (14 invalid quantity), asking to shrink below what is already filled (also 14), or naming an order that is not yours or no longer live (2 unknown order). A client that heard nothing could not distinguish a refusal from a reduce still in flight.

A reduce is subject to the venue's minimum resting time, exactly as a cancel is, and is refused with 17 until the order has met it. That control targets the spoofing pattern of posting size and pulling it before it can fill; a reduce from 1000 lots to 1 withdraws 999 of them, so exempting it would have left the pattern available behind a different verb. Retry once the floor has elapsed. The floor is off unless the venue configures one.

Reduce is durable: the command is written to the WAL before it is applied, so the size a client was told is the size the venue holds after a restart. This was not true when Engine.Reduce was first added — the log recorded submits and cancels only, so recovery silently restored the pre-reduce size.

MassCancel — MsgType F (1) + Version (1). Cancels every order the account has resting, and is answered by a MassCancelAck G (Count, Seq).

This is the control a market maker reaches for when its own state is wrong or it needs out of the market now, and it is the difference between a venue you can test against and one you would quote on. Each removed order still produces its own Canceled on the stream; the ack follows them all and says how many there were, so a completed sweep of zero orders is distinguishable from a connection that died mid-sweep.

The ack is written only after every Canceled it accounts for has been queued for your connection. An ack that overtook them would have you briefly believing you hold a book the venue has already emptied.

CancelOnDisconnect — MsgType B (1) + Version (1) + Enabled (1), answered by CODAck V. Asks the venue to pull your book if this session drops. Idempotent, so re-assert it freely.

It is a message rather than a LoginRequest field because adding a field there would move every byte after it and invalidate a committed golden vector — which is what the type byte exists to avoid.

Two caveats worth knowing before you enable it:

  • The sweep is account-wide. Orders are not tagged with the session that entered them, so an account holding two connections — one with this enabled — loses its whole book when that one drops, including orders entered on the other.
  • A venue shutdown does not trigger it. A graceful shutdown drops every connection at once, and firing the sweep there would permanently destroy books that are meant to come back after the restart. The control means "if I lose my session", not "if the venue closes".

Query — MsgType Q (1) + Version (1). Carries nothing: the account is the session's, and the reply covers every book the venue serves in one pass rather than one instrument at a time. A client reconciling after a reconnect asks once and gets its whole open state.

Outbound

MessagePayloadCarries
Accepted AClOrdID, Price, Quantity, Sidethe order is live
Rejected RClOrdID, Reasonthe engine looked and declined
Executed XClOrdID, Price, Quantity, LeavesQty, Aggressor, TradeIDa fill
Canceled DClOrdID, Reasonthe order left the book
Replaced PClOrdID, LeavesQtysize changed in place, queue kept
CmdReject KClOrdID, Reasonthe venue would not look at the command
OpenOrder OClOrdID, Price, LeavesQty, Sideone live order, in reply to a Query
QueryEnd TCount, Seqthe Query reply is complete
MassCancelAck GCount, Seqthe mass cancel is complete
CODAck VEnabledthe cancel-on-disconnect setting in force
Busted UClOrdID, TradeIDa fill of yours has been annulled

Each is preceded by its type byte and the version, as above.

Rejected and CmdReject are distinct on purpose: one means the engine evaluated your order and refused it, the other means the command never reached the engine — malformed, throttled, or the matcher was saturated. A client that conflates them retries the wrong things.

Canceled arrives whether you asked or not. Self-trade prevention, an OCO twin filling, an IOC remainder, and an operator kill switch all remove orders you did not cancel.

Replaced likewise has two causes: a Reduce you sent, and self-trade-prevention DECREMENT shrinking a maker you did not touch. A client that assumes it only ever follows its own Reduce will drift on the second.

LeavesQty is trustworthy because the engine's event stream is proven to reconstruct per-order remaining quantity. The proof is no longer the scenario list in TestEventStreamReconstructsBook alone: a hand-written list proves only what someone thought to write down, and the combination it did not contain — a fill-or-kill that cannot fill crossed with self-trade prevention — was where the stream fell silent about a maker it had removed. The claim now rests on the per-command mirror check in runDiff, which reconstructs the book from the stream after every one of 2,240 generated commands and compares it against the engine's own. Without that proof this field would have been a guess, and once the golden vectors were committed it could never have been added.

One rule a reconstructing consumer must follow: ignore an Accepted whose quantity is zero. Self-trade prevention under DECREMENT can empty an order inside the command that created it — both sides lose their whole overlap — and the venue still announces it, because it was accepted before it was emptied. An order with nothing left cannot rest, so treating that announcement as a resting order leaves a zero-lot phantom in the reconstruction forever. Nothing further is published about it; there is no later cancellation to wait for. This is the only such rule, and it is stated here rather than left in the test that found it (fifo seed 5 command 133, capped-shard3 seed 7 command 118).

What 2 (unknown order) does and does not mean

A message that names an order — Cancel, Reduce, ReplaceOrder — is resolved from the client's own identifier to the engine's order id when the command reaches the front of the matching queue, not when the gateway receives it. Every command the client sent earlier has been applied by then, including the Enter that created the order.

So 2 means the order genuinely is not live for this account: never accepted, already filled, already cancelled, or never the client's to begin with. It does not mean "not yet" — a client may send Enter and Cancel back to back without waiting for the acknowledgement, and the cancel will find its order.

This was not true in v0.16.0 and earlier. Resolution happened on the gateway's read loop, ahead of the queue, so under load a cancel could be refused for an order whose Enter was still queued in front of it. A client is right not to retry a definitive 2, so those orders stayed in the book, addressable by nobody — measured at 12,843 of them in thirty seconds at 10,000 messages a second. See SOAK.md.

Reason codes

CodeMeaningCodeMeaning
0None8Post-only would cross
1Other9FOK cannot fill
2Unknown order10Halted
3Duplicate ClOrdID11Throttled
4Too small12Overloaded
5Too large13Not authorised
6Price band14Malformed
7Self-trade15Shutting down
16Invalid quantity
17Too soon

Code 16 is distinct from 14: malformed means the venue would not look at the message, invalid quantity means it looked at a real order of yours and the size you asked for is not one it can take.

Code 17 is the only refusal here worth simply retrying. It means the venue runs a minimum resting time and the order has not met it yet — see below.

The vocabulary is deliberately narrow and lossy. Mirroring the engine's internal error set would mean that adding a sentinel — an ordinary, non-breaking change — silently became a protocol change. Anything unrecognised maps to Other, which a client must already handle.


Reconciliation, when resume is not available

Resume can legitimately fail: an evicted cursor (Q) or a restarted venue (S). Send a Query and the server replies with one OpenOrder per live order, then a QueryEnd.

The report is the venue's authoritative view, read from the book on the matching goroutine — not from any consumer's shadow copy, which is the point, since you are asking precisely because you no longer trust your own picture.

Two details that make it usable rather than merely present:

  • The terminator is not optional. QueryEnd.Count lets you verify you received the whole report. Without it you cannot distinguish "you have nothing open" from "the connection died mid-report" — opposite conclusions.
  • QueryEnd.Seq names the point in your own stream the report is consistent with. The server reads the book, drains its publisher, and only then writes the report, so every event up to that instant has already reached you. Everything after Seq is a change to apply on top. Reading the book without draining first would let an execution from before the read arrive after the report, and you would apply it twice.

Pending stops and trailing stops are not included. They are not resting orders and a client reconciling its book should not treat them as such.

Session liveness

The server sends a heartbeat every 5s on an idle outbound path, so a client can tell a quiet venue from a dead one. An unauthenticated peer has 10s to send its login; an authenticated session has 30s of read idle before it is dropped. Any inbound packet, including a client heartbeat, refreshes it.

Connect-and-say-nothing is the cheapest resource-exhaustion attack there is, so the unauthenticated timeout is the tightest of the three.

Market data

A second listener, on its own port (-mdaddr), serving the venue's public feed from the same engine. One process, two edges, which is how a venue is actually shaped: order entry is authenticated and per-account, market data is anonymous and identical for everyone. Sharing one port would put an unauthenticated subscriber on the same code path as order entry, which is the wrong default however carefully it is written.

Subscribe — MsgType b (1) + Version (1) + Incarnation (10) + Seq (8) + Symbol (16).

  • Seq 0 means "I have nothing": you get a snapshot, then the live stream.
  • A non-zero Seq is a resume: you get everything after it and no snapshot.
MessageTypeCarries
MDLevellSide, Price, Qty — one level of a snapshot
MDSnapshotEndeCount, Seq, LastTradePrice — the snapshot is complete
MDDeltadSeq, Side, Price, Qty — one aggregated level change
MDTradetSeq, Price, Qty, Aggressor, TradeID — a print
MDStatussSeq, State (Open / Halted / Cancel-only)
MDIndicativeiSeq, Price, Volume, Imbalance — what an auction would clear at
MDBustuSeq, TradeID — an earlier print is annulled

MDBust says a print will not settle. It changes no book state and implies none: a subscriber that rewinds its own depth on one diverges from the venue, because the engine does not rewind either — not the orders, not the stops the print fired, not the last trade price. Adjust your tape, not your book; the reasoning is TRADE-BUST.md §2. The private counterpart on order entry is Busted, and both name the trade with the same TradeID, so a drop copy and a feed can be reconciled against each other.

Neither carries a reason. Bust reasons are operator free text on a fixed-width wire, so carrying one would mean inventing a code vocabulary nobody has asked for; Nasdaq's ITCH "Broken Trade" carries the match number and nothing else, for the same reason.

A subscription naming an instrument the venue does not serve is refused with MDRejectUnknownSymbol (S).

MDIndicative is published during pre-open and the closing auction, on the venue's own cadence rather than per order: during an auction the indicative price moves on nearly every message, and broadcasting that would be several times the traffic of the order flow for information nobody can act on at that granularity. Imbalance is buy minus sell interest at the indicative price, in lots — positive means more to buy than to sell. The price says where the auction is; the imbalance says which way it moves if nobody responds.

Pre-open and post-close are reported as cancel-only on the wire: a subscriber needs to know it cannot trade, and the exact phase is a venue concept the feed does not attempt to enumerate. | MDReject | r | one reason byte |

Market data numbers its types separately from order entry. The two are separate conversations on separate connections and nothing decodes both, so sharing one space would make every future order-entry message avoid every market-data one for no benefit. A decoder handed the wrong family still refuses it, because every decoder checks for exactly the type it wants.

The contract, stated so it can be falsified: for one incarnation the sequence is dense and gap-free, and everything after MDSnapshotEnd.Seq applies on top of the snapshot while nothing at or below it does. A subscriber can join at any instant and be exactly right. Feed.Snapshot takes the book and its sequence under one lock, which is what makes that true — reading them separately is the bug the order-entry side shipped in v0.12.0.

A snapshot is a run of MDLevel followed by MDSnapshotEnd, not one variable-length message. Every payload in this protocol stays fixed-width and bounds-checkable by inspection, and the terminator carries a count so a truncated snapshot cannot look like a complete one — the same shape as the order-entry Query reply, for the same reasons.

Two ways to be refused, both explicit rather than silently papered over:

  • I — your cursor belongs to another run of the venue. Sequence numbers mean nothing across a restart.
  • E — you are further behind than the feed retains. Resubscribe with Seq 0. You are told rather than quietly resynchronised, so you know your picture had a hole.

Backpressure. A subscriber that stops reading is disconnected, as on the order-entry side. Market data admits a better answer — conflate, and hand it a fresh snapshot when it catches up, since nothing is owed to it personally — and that is deliberately not implemented rather than half implemented.

Durability

obgw -wal path turns on the write-ahead log: every command is written before it is applied, group-committed every 20ms, and replayed on start. With -snapshot and -checkpoint it also snapshots on a cadence, so a restart re-applies only the tail after the last checkpoint rather than all history.

Records are CRC-32C-checksummed behind a magic header. A crash mid-write leaves a torn tail and recovery stops at it cleanly; a complete record whose checksum disagrees is media corruption and recovery refuses to start rather than serving a book that does not match its log. The record length is bounded, so a corrupted prefix cannot turn a restart into a multi-gigabyte allocation.

Every command that mutates the book is logged: Enter, Cancel, Reduce, and the operator's account-wide cancel. That list is the whole contract — a mutating command missing from it is not "not yet logged", it is a book the log cannot reproduce, which is exactly how a reduced order used to come back at its original size and a pulled account used to get its book handed back.

The log is a set of files, and it is bounded only if you bound it. -wal /var/lib/obgw/BTC-USD.wal names a stem; segments are its siblings, BTC-USD.wal.0000000000610422, the sixteen digits being the first sequence that file holds. -wal-segment-bytes sets when it rotates (128 MiB), -wal-retain is a byte budget for the whole set, and -wal-retain-segments a floor under it. Once a snapshot that has been read back and verified covers a segment entirely, that segment is copied to -wal-archive if one is set and then deleted, oldest first, never the one being written. -wal-retain defaults to zero, meaning keep everything, so an upgrade changes where bytes live and not how many there are; the venue says so on startup. Restart cost is O(retained log), which is O(all history) until you set a budget.

Retention has a price and it is not hidden: once it has fired, the log below the retention floor is gone, so a venue running it without -wal-archive has a recovery point objective equal to its newest snapshot. RUNBOOKS.md §"A corrupt snapshot" carries the procedure that replaces "delete the snapshot and replay from the beginning", which stops working the moment the beginning is not there.

A full disk is defined behaviour rather than undefined. Below -wal-min-free (2 GiB) the venue warns and runs retention immediately; below -wal-min-free-stop (256 MiB) every book goes cancel-only, so new orders are refused with ReasonHalted and cancels still work — participants can get flat while the largest source of log growth stops. A sync that actually fails halts the book, fails /readyz, and latches until a restart. No new wire message and no new reject reason: cancel-only is a state clients already see, and orderbook_phase already reports it.

Without -wal the gateway runs with no durability at all and says so on startup. That is a legitimate configuration for a test harness and an indefensible one for anything else.

Recovery restores the session layer's index too, not just the book. On start the gateway seeds its ClOrdID → order-id map from the recovered book, so an order that outlived a restart can still be named in a Cancel or a Reduce, and a fill against it still produces an execution report.

That last one is why this matters most. Without the index the publisher held no record of a recovered order, so a trade against it was dropped rather than reported: a maker whose resting order filled while the venue was down would never have been told, and its position would have been wrong with no way to notice. It is the same failure the stream-outliving-the-connection design exists to prevent, and recovery had been reintroducing it.

Adoption restores the index, not the conversation. Nothing is re-announced on any stream: those orders were acknowledged in a previous incarnation, and replaying them into a fresh sequence space would be inventing history. A client that wants the current picture asks for it with a Query.

Backpressure

Three bounded queues, each of which drops or refuses rather than blocking, because the alternative is one participant stalling the venue:

  1. Inbound. The matcher's command queue is bounded. A full queue yields CmdReject with Overloaded; the client sheds.
  2. Publisher. Bounded between the matching goroutine and the fan-out pump. Overflow drops the oldest and increments a counter. Blocking here would stop the venue; growing without limit would end it differently.
  3. Per connection. A bounded send queue. A client that stops reading is disconnected rather than allowed to back up into the venue.

A client that misses messages discovers it through a sequence gap and can resume. It is never told it is up to date when it is not.


Running it

go run ./cmd/obgw -addr 127.0.0.1:9000 -symbol BTC-USD -accounts-file ./accounts

The file holds user:password or user:sha256:<64 hex> lines, # comments allowed; -hash-secret reads a secret on stdin and prints its hashed form. -accounts alice:s3cret,bob:hunter2 works too and is what the tests use, but argv is visible in ps to every account on the host — a development convenience, not a deployment form.

With durability:

go run ./cmd/obgw -addr 127.0.0.1:9000 -symbol BTC-USD \
  -accounts-file ./accounts -wal obgw.wal -snapshot obgw.snap -checkpoint 30s

cmd/obgw/server_test.go is a working client: login, enter, cancel, resume, and the failure paths. It is the most useful reference for writing another one.

On the golden vectors

internal/wire/testdata/*.hex were generated by running the encoder. They prove the layout has not changed accidentally — which is their job, and a real one. They do not prove the layout is correct; nothing here does that except reading it. Treat them as a ratchet, not as a specification.