ADR 0014: A stream declares its subscription reply schema separately from its push payload
- Status: accepted
- Date: 2026-09-08
- Carried over: 2026-09-14, from the system Truewire was extracted from (its ADR 0022)
Context
A kind: 'stream' endpoint in the request/response shape (design §8) carried two schemas:
parameters for the subscribe call and payload for each pushed message. The legacy
openapi shape had a third slot, responses.reply, and the migration dropped it: no
mechanized client's subscribe() did anything with the acknowledgement beyond handing it
back, so nothing seemed lost. truewire_core.util.Stream kept an Any-defaulted
SubscriptionReply type argument that no generated method ever filled.
dydx showed what was lost. Its Indexer acks every channel with a real value — v4_orderbook
with the full book as {price, size} objects, v4_markets with every market, the two
subaccount channels with the whole subaccount — and pushes a different shape afterwards
([price, size] tuples, deltas). Its hand-written StreamsMixin.subscribe had one
response_type and applied it to both frames, so entering an order-book stream raised
ValidationError before the first push (SDK PoC report, 2026-09-06, live on testnet), and
three channels validated their ack silently to {}. The interim fix stopped validating the
ack at all, returning it raw into the Any slot. The seven *ReplyContents types sat
generated in indexer/schemas.py, unreferenced, because the spec had nowhere to name them.
The alternatives were a per-client meta convention (a core-private fact codegen would
have to be taught per venue) or a union payload covering both frames (which types every
push as possibly-a-snapshot and every ack as possibly-a-delta — wrong on both sides).
Decision
StreamEndpointSpec gains an optional fourth new-shape field, reply: a JSON Schema for the
value the core hands back as Stream.reply, after envelope.reply_payload (or payload)
extraction, exactly as payload describes a pushed message after envelope.payload
extraction. It is the new-shape counterpart of the legacy openapi.responses.reply.
truewire checkvalidates every recorded<id>.reply.jsonagainst it, extracting through the envelope first. The authoring audit sees it as thereplyresponse.- The Python generator renders it as the second type argument of the generated method's
StreamManager[Payload, Reply, Any]and passes it to the resolved core asreply_type=, besideresponse_type=. A bare$refimports the shared type, aspayloaddoes. - A stream that declares no
replygenerates byte-for-byte what it did before: noreply_type=,Anyin the reply slot. Verified by regenerating every stream-carrying client and diffing nothing. - A core is expected to validate the ack through
reply_typeand never throughresponse_type, and vice versa. dydx'sStreamsMixin.subscribedoes; its seven channels declarereplypointing at their*ReplyContentsschemas.
Consequences
Stream.replyis a real type on any channel that declares one, so an SDK consumer can seed a local book from the ack without a cast. The generatedvalidateflag now governs the ack as well as the pushes on such a channel.- Declaring
replyon a stream whose core'ssubscribe()has noreply_typeparameter fails pyright at generation time. That is deliberate: the core is the one place that knows how the ack is unwrapped, so adopting the field is a per-core step, not something codegen can do for a venue on its own. - Left open: every other mechanized client's cores still take no
reply_type, and their streams declare noreply. Where a venue's ack carries a value worth typing (deribit'sresultlist of subscribed channels, say), adopting it is one spec field plus one parameter on that core'ssubscribe(); the audit does not require it.