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
| Framing | SoupBinTCP 3.00, taken from the published spec |
| Payloads | This repository's own, fixed-width big-endian |
| Dependencies | None. A 2-byte length and a 1-byte type. |
| Transport security | None. Assumes a trusted network or a TLS wrapper below. |
| Credentials | A 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. |
| Instruments | A set per gateway (-symbols); one book per instrument, fanned out per book |
| Stability | Frozen by internal/wire/testdata/*.hex; changing a layout means bumping Version |
| Current version | 4 |
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
ClOrdIDis 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
| Byte | Meaning |
|---|---|
A | Not authorised — unknown user or wrong password |
S | Unknown session — the cursor belongs to a different venue incarnation |
Q | Bad 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
Srather 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.
MDSubscribenamed 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 gainsSymbol, 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 withMDRejectUnknownSymbolrather than quietly served the wrong book, which a subscriber has no way to detect for itself.
v2 → v3. A trade had no name.
ExecutedandMDTradereported 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 gainedTradeID(+8 bytes each) and two messages now use it:Bustedon order entry andMDBuston 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.
| Field | Bytes | Notes |
|---|---|---|
| MsgType | 1 | E |
| Version | 1 | |
| ClOrdID | 20 | your identifier, unique within your session |
| Symbol | 16 | must name one of the gateway's instruments (-symbols) |
| Side | 1 | B buy, S sell |
| Type | 1 | L limit, M market |
| TIF | 1 | G GTC, I IOC, F FOK, D DAY |
| PostOnly | 1 | |
| Price | 8 | ticks; 0 for market |
| Quantity | 8 | lots |
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
Canceledfollowed by aRejected. 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:
| Message | Type | Extra fields | Width |
|---|---|---|---|
| EnterStop | S | StopPrice (8) | 66 |
| EnterIceberg | I | DisplayQty (8) | 66 |
| EnterTrailing | W | Trail (8) | 66 |
| EnterPegged | Y | Ref (1), Offset (8) | 67 |
| EnterOCO | N | StopClOrdID (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.
StopLimitPrice0 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
Symbolbug 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:
Quantityis 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
| Message | Payload | Carries |
|---|---|---|
Accepted A | ClOrdID, Price, Quantity, Side | the order is live |
Rejected R | ClOrdID, Reason | the engine looked and declined |
Executed X | ClOrdID, Price, Quantity, LeavesQty, Aggressor, TradeID | a fill |
Canceled D | ClOrdID, Reason | the order left the book |
Replaced P | ClOrdID, LeavesQty | size changed in place, queue kept |
CmdReject K | ClOrdID, Reason | the venue would not look at the command |
OpenOrder O | ClOrdID, Price, LeavesQty, Side | one live order, in reply to a Query |
QueryEnd T | Count, Seq | the Query reply is complete |
MassCancelAck G | Count, Seq | the mass cancel is complete |
CODAck V | Enabled | the cancel-on-disconnect setting in force |
Busted U | ClOrdID, TradeID | a 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
| Code | Meaning | Code | Meaning | |
|---|---|---|---|---|
| 0 | None | 8 | Post-only would cross | |
| 1 | Other | 9 | FOK cannot fill | |
| 2 | Unknown order | 10 | Halted | |
| 3 | Duplicate ClOrdID | 11 | Throttled | |
| 4 | Too small | 12 | Overloaded | |
| 5 | Too large | 13 | Not authorised | |
| 6 | Price band | 14 | Malformed | |
| 7 | Self-trade | 15 | Shutting down | |
| 16 | Invalid quantity | |||
| 17 | Too 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.Countlets 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.Seqnames 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 afterSeqis 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).
Seq0 means "I have nothing": you get a snapshot, then the live stream.- A non-zero
Seqis a resume: you get everything after it and no snapshot.
| Message | Type | Carries |
|---|---|---|
| MDLevel | l | Side, Price, Qty — one level of a snapshot |
| MDSnapshotEnd | e | Count, Seq, LastTradePrice — the snapshot is complete |
| MDDelta | d | Seq, Side, Price, Qty — one aggregated level change |
| MDTrade | t | Seq, Price, Qty, Aggressor, TradeID — a print |
| MDStatus | s | Seq, State (Open / Halted / Cancel-only) |
| MDIndicative | i | Seq, Price, Volume, Imbalance — what an auction would clear at |
| MDBust | u | Seq, 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 withSeq0. 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:
- Inbound. The matcher's command queue is bounded. A full queue yields
CmdRejectwithOverloaded; the client sheds. - 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.
- 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.