atproto relay conformance · invalid firehose data

Five relays, five definitions of “invalid”

The same malformed subscribeRepos frame, fed to each relay's real decoder and commit-verifier — Rust, Go, and Zig. One defect per frame, so every verdict is attributable — across #account events, real signed #commits carrying a CAR of blocks, and stateful #sync/sync-1.1 commit sequences.

49 cases5 relays#account · #commit · sync-1.12026-07-21
22 / 32
#account cases where at least two relays disagree
3 / 5
relays that forward a commit signed by the wrong key (atmoq, rsky, zlay) — only indigo & hydrant drop it
1 / 5
relays that enforce prevData correctness (hydrant) — sync-1.1's core guarantee, honored by one

Even the float64 “control” splits: zlay's DAG-CBOR decoder rejects all floats, so only the tag-42 control and the valid signed commit are accepted by all five.

What a “reject” actually does
atmoqRust · DRISL-strict

on reject drops the frame, connection stays up

Validates the whole frame against DRISL at ingest — encoding only. It treats the commit's CAR as opaque bytes, so it does no signature, CID, MST, or record checks (that's its unbuilt M2).

indigoGo · cbor-gen · production

on reject drops the whole PDS connection & reconnects

A frame CBOR-decode error unwinds the socket. Commit repo/MST/signature failures instead drop one event — and MST/record-presence checks are advisory (logged, not enforced).

rskyRust · serde_ipld_dagcbor

on reject drops the event, connection stays up

Lenient ciborium header, stricter dag-cbor body. CAR CID integrity & envelope checks are enforced; but default lenient mode publishes signature failures, and record-presence is a TODO.

hydrantRust · jacquard · strictest default

on reject CBOR error drops the connection; else drops the event

Always resolves the signing key, so signature verify and MST inversion run by default. Uniquely decodes record CBOR and enforces prevData correctness (inverts the MST and checks the root). Unknown types skip one frame, not the socket. verify_cids off by default.

zlayZig · zat SDK · fail-open

on reject drops the frame, connection stays up

zat.cbor is a strict whole-frame DAG-CBOR decoder, so it matches atmoq on encoding — and goes further, rejecting all floats incl. float64. But its commit/sync path is fail-open: a bad signature, bad CID, or cache miss forwards the frame unvalidated and re-resolves the key in the background — it never drops a commit on crypto.

Malformed #account frames

Signature- and CAR-free events: a reject can only mean the encoding or shape was rejected.

22 disagree8 all reject2 all accept32 cases
Malformed case atmoqindigorskyhydrantzlay
Frame framingthe two-object wire contract
header only, no payload object
single-object
REJECTREJECTREJECTREJECTREJECT
valid frame + one trailing byte
trailing-bytes
REJECTACCEPTREJECTACCEPTREJECT
zero-length binary message
empty-message
REJECTREJECTREJECTREJECTREJECT
CBOR well-formednesscan the bytes be decoded at all?
payload truncated mid-item
truncated-payload
REJECTREJECTREJECTREJECTREJECT
reserved additional-info 28 in payload
reserved-ai-payload
REJECTREJECTREJECTREJECTREJECT
bare break code (0xff) as payload
bare-break-payload
REJECTREJECTREJECTREJECTREJECT
random non-CBOR bytes as payload
garbage-payload
REJECTREJECTREJECTREJECTREJECT
DRISL determinismvalid CBOR, non-canonical encoding
payload map keys out of DRISL order
unordered-keys-payload
REJECTACCEPTACCEPTACCEPTREJECT
header keys out of DRISL order (op before t)
unordered-keys-header
REJECTACCEPTACCEPTACCEPTREJECT
duplicate map key in payload
duplicate-key-payload
REJECTACCEPTREJECTREJECTREJECT
non-minimal integer encoding in payload
nonminimal-int-payload
REJECTREJECTACCEPTACCEPTREJECT
non-minimal string length in payload
nonminimal-len-payload
REJECTREJECTACCEPTREJECTREJECT
indefinite-length map in payload
indefinite-map-payload
REJECTREJECTREJECTREJECTREJECT
indefinite-length text string in payload
indefinite-str-payload
REJECTREJECTACCEPTREJECTREJECT
Floats & simple valuesfloat width, NaN, undefined
float16 value in a (forward-compat) field
float16-payload † in unknown field
REJECTACCEPTACCEPTACCEPTREJECT
float32 value in a field
float32-payload † in unknown field
REJECTACCEPTACCEPTACCEPTREJECT
float64 value in a field (CONTROL: valid DRISL)
float64-ok-payload control · must accept
ACCEPTACCEPTACCEPTACCEPTREJECT
float64 NaN in a field
nan-payload † in unknown field
REJECTACCEPTACCEPTACCEPTREJECT
float64 +Infinity in a field
infinity-payload † in unknown field
REJECTACCEPTACCEPTACCEPTREJECT
CBOR undefined (0xf7) as a field value
undefined-payload † in unknown field
REJECTACCEPTACCEPTACCEPTREJECT
disallowed simple value simple(19) (0xf3)
simple-value-payload † in unknown field
REJECTACCEPTREJECTREJECTREJECT
Tags & keystag 42 only, text keys, UTF-8
CBOR tag 0 (datetime) in a field
tag-0-payload † in unknown field
REJECTACCEPTACCEPTACCEPTREJECT
CBOR tag 2 (bignum) in a field
tag-2-bignum-payload † in unknown field
REJECTACCEPTACCEPTACCEPTREJECT
CBOR tag 42 CID in a field (CONTROL: valid DRISL)
tag-42-ok-payload control · must accept
ACCEPTACCEPTACCEPTACCEPTACCEPT
tag 42 whose byte string lacks the 0x00 prefix
tag-42-no-prefix-payload † in unknown field
REJECTREJECTACCEPTACCEPTREJECT
integer map key in payload
int-map-key-payload
REJECTREJECTREJECTREJECTREJECT
invalid UTF-8 in the did text string
invalid-utf8-payload
REJECTACCEPTREJECTREJECTREJECT
at-sync semanticsvalid encoding, event shape
unknown message type #futurething
unknown-type forward-compat
ACCEPTACCEPTREJECTACCEPTACCEPT
known type with an unknown extra field
unknown-field forward-compat
ACCEPTACCEPTACCEPTACCEPTACCEPT
#account payload missing required seq
missing-seq
ACCEPTACCEPTREJECTREJECTACCEPT
seq encoded as a text string
wrong-type-seq
ACCEPTREJECTREJECTREJECTACCEPT
op:1 header with no t
op-1-no-t
REJECTACCEPTREJECTREJECTREJECT
REJECT event/frame dropped ACCEPT passed through relays disagree † defect sits in an unknown field the struct decoders skip hover any verdict for the exact reason

Malformed #commit frames

Real signed commits carrying a CAR of blocks — the validation that matters happens below frame-decode.

6 disagree0 all reject4 all accept10 cases
Malformed case atmoqindigorskyhydrantzlay
Valid baselinecorrect CAR, CIDs, signature
valid signed #commit (CONTROL)
valid control · must accept
ACCEPTACCEPTACCEPTACCEPTACCEPT
Envelope encodingDRISL on the outer payload map
#commit envelope map keys out of DRISL order
envelope-unordered
REJECTACCEPTACCEPTACCEPTREJECT
#commit envelope carries a float16 field
envelope-float16
REJECTACCEPTACCEPTACCEPTREJECT
Record contents$type & record shape — inside the CAR
create op whose record has no $type
record-no-type reported case
ACCEPTACCEPTACCEPTACCEPTACCEPT
create op whose record has an unknown $type / NSID
record-unknown-type forward-compat
ACCEPTACCEPTACCEPTACCEPTACCEPT
create op whose record is a CBOR array, not a map
record-not-map
ACCEPTACCEPTACCEPTACCEPTACCEPT
Repo & crypto integrityCAR CIDs, blocks, signature, flags
#commit with tooBig=true
too-big
ACCEPTREJECTREJECTACCEPTACCEPT
CAR block whose bytes no longer hash to its CID
cid-mismatch
ACCEPTREJECTREJECTREJECTACCEPT
create op references a record CID absent from the CAR
missing-block
ACCEPTACCEPTACCEPTREJECTACCEPT
#commit signed by the wrong key
bad-signature
ACCEPTREJECTACCEPTREJECTACCEPT
verdict = the relay-level decision (drop or publish), mirroring enforce-vs-advisory gating — not just whether a verify function errored

sync-1.1 compliance

The at-synchronization rules: the #sync event, and the prevData / rev-ordering checks that only fire on a second commit once the relay holds prior repo state.

5 disagree0 all reject2 all accept7 cases
Malformed case atmoqindigorskyhydrantzlay
#sync eventsync-1.1's compact repo-state message
valid #sync event (CONTROL)
sync-event-valid control · must accept
ACCEPTACCEPTACCEPTACCEPTACCEPT
#sync event signed by the wrong key
sync-event-bad-sig
ACCEPTREJECTACCEPTREJECTACCEPT
Stateful commit checksprevData, rev order, rebase — need prior state
valid second commit with correct prevData (CONTROL)
commit2-valid control · must accept
ACCEPTACCEPTACCEPTACCEPTACCEPT
second commit omits prevData
commit2-missing-prevdata
ACCEPTREJECTACCEPTACCEPTACCEPT
second commit with a mismatched prevData CID
commit2-wrong-prevdata
ACCEPTACCEPTACCEPTREJECTACCEPT
second commit whose rev is not greater than the last seen
commit2-rev-rollback
ACCEPTREJECTACCEPTSKIPSKIP
#commit with the deprecated rebase flag set
commit-rebase-flag
ACCEPTACCEPTREJECTACCEPTACCEPT
stateful: a valid setup commit runs first, then the frame under test — so prevData / rev checks actually fire rsky verdicts are its default lenient mode; strict mode is stricter (see tooltip) zlay is fail-open: an ACCEPT on a bad sig / prevData means forwarded unvalidated, not verified
What to look at

Fail-open vs fail-closed: the same bad commit, opposite fates

Give five relays a commit signed by the wrong key: hydrant and indigo drop it; zlay forwards it unvalidated (and re-resolves the key in the background), rsky publishes it with a warning, atmoq has no crypto. zlay is fail-open by design — it never drops a commit on bad signature, CID, or MST. hydrant is its mirror image. The spec never says which is conformant.

sync-1.1's prevData guarantee is enforced by exactly one relay

A commit whose prevData is present but wrong (the MST inversion fails) is dropped only by hydrant — it always holds the signing key, so it always inverts the MST. The other four accept it. The property that lets a consumer verify an op without fetching the repo is honored by one relay of five. MUST, or hint?

Even the float64 control isn't universal

The float64 case is a control — valid DRISL, meant to be accepted everywhere. It is, by four relays. zlay's zat DAG-CBOR decoder rejects all floats, float64 included — atproto records carry no floats, so it forbids them outright. Two whole-frame validators (atmoq, zlay) enforce DRISL; they still disagree on whether float64 is even legal.

No relay validates record contents

A #commit whose record omits $type — or is a list, not a map — passes all five. Only hydrant decodes record bodies at all (as generic CBOR), so it alone would reject a malformed-CBOR record; none require a lexicon $type. Record semantics are left to PDSes and AppViews.

The blast radius differs, not just the verdict

A bad frame costs atmoq/rsky/zlay one event; it costs indigo and hydrant the whole connection to that PDS until reconnect — a mild DoS foothold. And rsky is still the one relay whose parser errors on an unknown message type (#futurething); the other four tolerate it. Specify the consequence, not only reject-or-not.

Strictness is a per-relay default, not a spec

Two relays validate the whole frame's encoding (atmoq, zlay); three decode into structs and never re-check it. rsky's default publishes bad sigs and rev rollbacks; hydrant enforces them; zlay forwards them. indigo drops deprecated tooBig/rebase flags that others ignore. Five codebases, five postures — a spec's MUST list should say which is conformant, and match what ships enabled by default.

Method. Each verdict runs the relay's real code: atmoq Frame::parse; indigo cbor-gen decode + VerifyRepoCommit gating; rsky SubscribeReposEvent parse + commit validation; hydrant decode_frame + validate_commit/validate_sync at its default posture; zlay zat.cbor decode + Validator.validateCommit/validateSync with the signing key pre-seeded (fail-open forward on any failure). Commits are genuine signed repos built with @atproto/repo. Corpus & harness: tests/relay-conformance/ in the atmoq repo.
Fidelity notes. atmoq does no repo/crypto validation yet (its M2), so it accepts any commit with a valid envelope. The reject-consequence row (connection vs event) is from ingest source, not a live capture; an end-to-end injection harness would measure it directly.