Rust gives you two ways to report failure. The common one is a heap box: Box<dyn Error>, anyhow::Error, a string. It is convenient, it composes with everything, and it tells the caller almost nothing: the set of things that can go wrong is open, and the only way to find out what happened is to read the message. The other way is a closed enum: a finite, exhaustive, Copy-able list of every failure the function can produce, each variant carrying exactly the data needed to react.
Valhalla’s trust boundaries use the second form everywhere, and the reason is structural: a foreign value is hostile until parsed, and a hostile input is entitled to a precise refusal: not a stack trace, not a formatted string, a named refusal the caller can match on.
unknown is a type-level fact
The pattern starts at vhalla-core. parse_untrusted(raw: &[u8]) -> Result<UntrustedEnvelope, ParseError> takes a byte slice and returns either an UntrustedEnvelope, a bounded bag of bytes that grants no meaning, or ParseError::TooLarge { actual, limit }. That is the entire error type. One variant. The function is #[must_use], the envelope’s only method is as_bytes(), and the byte bound (MAX_ENVELOPE_BYTES = 64 * 1024) is checked before anything is allocated from the input.
This is “parse from unknown“ made literal. The function signature refuses to accept a String, a serde_json::Value, or anything that has already been interpreted. It wants bytes. Interpretation is the next layer’s problem, and the next layer has its own closed error set to prove it happened.
The type that comes back is the second half of the pattern. UntrustedEnvelope wraps Vec<u8> and exposes only as_bytes(). It is a bounded value that has crossed exactly one boundary (size) and claims nothing else. vhalla-core also defines UntrustedContent for the transport adapter’s view: an optional author handle plus opaque bytes, with the doc comment “It is deliberately not a command or policy.” The types make the trust level explicit in the signature: you cannot accidentally treat raw bytes as if they had already been checked, because the type that carries them is named Untrusted.
a closed error set
vhalla-wire is where the pattern gets serious. Envelope::decode returns Result<Envelope, DecodeError>, and DecodeError is the complete list of ways a byte slice can fail to become an envelope:
pub enum DecodeError {
TooLarge { actual: usize, limit: usize },
Truncated { field: DecodeField },
UnsupportedVersion { found: u8 },
InvalidKind,
BodyTooLarge { actual: usize, limit: usize },
TrailingBytes { count: usize },
}
Six variants. DecodeField is itself a closed enum (Version, Kind, Author, Realm, Room, Event, Sequence, BodyLength, Body), so Truncated tells you not only that the input ended early but where. Every variant carries the numbers needed to respond: TooLarge gives the actual size and the limit; TrailingBytes gives the count of extra bytes. There is no Other(String), no Io(io::Error), no catchall.
The same discipline runs through the whole pipeline. vhalla-crypto has SignError (2 variants), DecodeSignedError (5), and VerifyError (13, one per check: InvalidBound, TooLarge, WeakKey, AuthorMismatch, AudienceMismatch, RealmMismatch, RoomMismatch, EpochMismatch, SessionMismatch, Expired, InvalidSignature, Replay, Capacity). vhalla-session’s Reject is 12 variants. vhalla-identity’s IdentityError is 6. vhalla-policy’s Denied is 7. Not one of them contains a string.
Notice what the variants are. They are not severity levels or categories; they are named checks in the order the verifier performs them. VerifyError::AudienceMismatch exists because the audience check is a distinct step, and it failing means something different than EpochMismatch failing. The enum is the verification procedure written as a type: read the variants top to bottom and you have the checklist.
why closed
Three properties fall out of keeping error sets closed and Copy.
Exhaustiveness is enforced. When vhalla-rooms-consensus handles a vhalla_journal::JournalError, the compiler requires every variant to be addressed. Add a variant upstream and every match downstream fails to compile. An open error type cannot do that: Box<dyn Error> silently accepts anything, including failures nobody planned for.
Errors compose without losing fidelity. vhalla-steel-thread’s SteelError has six variants, one per stage: Sign(SignError), SignedDecode(DecodeSignedError), Verify(VerifyError), Transport(TransportError), Denied(Denied), Host(HostError). Each wraps the inner closed enum, so a failure deep in signature verification surfaces with its variant intact (SteelError::Verify(VerifyError::Replay)) rather than flattened into a message. The caller can react to Replay specifically; a string would make that a substring search.
Tests can pin the contract. Because errors are values, a test asserts the exact variant: Envelope::decode(&[2, 1]) must be Err(DecodeError::UnsupportedVersion { found: 2 }), not “an error,” not “an error containing the word version.” That assertion is in crates/vhalla-wire/src/lib.rs’s own test module, which runs through the malformed inputs one by one: empty input is Truncated { field: Version }, a lone version byte is Truncated { field: Kind }, a zero kind is InvalidKind. The error set is as much a public contract as the success path, and the tests treat it that way.
The protocol error enums all derive Clone, Copy, Debug, Eq, PartialEq. Copy matters more than it looks: a Copy error carries no heap data, so it cannot hide an allocated string, a backtrace, or a wrapped error with its own behavior. What you matched is all there is. IdentityError is the deliberate exception: it is only Debug, because its Io variant has to carry std::io::Error so the caller can see the OS’s actual reason. The custody boundary trades Copy for fidelity: when the filesystem is the thing failing, “an error happened” is not enough. Even there, the failure set is closed: six variants, and Io is the only one that can hold anything.
the errors are the contract
There is a subtler benefit at the trust boundary: a closed error set is also a closed behavior set. DecodeError::TrailingBytes { count } exists because the canonical encoding forbids trailing bytes. The decoder does not ignore them, warn about them, or log them. It refuses, and names the refusal. vhalla-native’s BoundedCodec does the same on a live socket: read the 4-byte big-endian length, reject if it exceeds MAX_FRAME, read exactly that many bytes, then read one more byte and reject if anything is there. “Oversized frame” and “trailing bytes” are io::ErrorKind::InvalidData with fixed strings. The adapter maps the pure layer’s discipline into io::Error because the Codec trait requires it, but the closed set is still enforced at the boundary where the bytes come in.
This is the difference between an error and an excuse. An open error type says “something went wrong.” A closed set says “here are all the ways this can go wrong, choose one.” At a trust boundary (bytes from a peer, a file from disk, a claimed identity), the second is the only honest answer. The parser’s job is to refuse with precision, and a variant you can match is precision you can act on.
what it costs
The tradeoff is real. Closed error sets multiply types: every stage has its own enum, and the conversions (From<ParseError> for DecodeError, From<Denied> for HostError) are boilerplate you write by hand. They also couple the caller to the contract: a new variant is a breaking change for every exhaustive match, which is the point but also the friction. And they demand discipline about what goes in a variant: DecodeField exists because “truncated” without “where” was not good enough.
The payoff is that failure becomes data. An Err you can enumerate is an Err you can test, log without formatting, translate across a wire, and prove you handled. It is also an Err a peer cannot weaponize: a closed Copy variant carries exactly the information the designer chose to reveal, and no more. The workspace bet on enumeration over convenience, and the result is that every boundary in the system fails closed with a name, not a message.
sources
- vhalla
crates/vhalla-wire/src/lib.rs:DecodeErrorandDecodeFieldin fullcrates/vhalla-core/src/lib.rs:parse_untrustedandParseErrorcrates/vhalla-crypto/src/lib.rs:VerifyError’s thirteen named refusalscrates/vhalla-native/src/codec.rs: length-before-allocation on a live socketcrates/vhalla-steel-thread/src/lib.rs:SteelErrorcomposing six closed sets