Repository navigation
Open
Labels
Description
Spec
- Spec doc:
docs/specs/2026-04-24-error-prefixes.md - Merged via PR spec: machine-readable rejection reasons on wire protocol #216
Summary
Introduce a typed WireRejectReason enum carried in a new WireMessage::Reject(RejectPayload) variant in willow-common, so peers can react programmatically to rejections — retry on rate-limit, re-auth on AuthRequired, drop silently on Duplicate, surface a permission prompt on PermissionDenied — instead of matching on free-form error strings that are only fit for logs. This brings Nostr-style machine-readable rejection signaling to Willow's binary bincode wire format, with the added benefit of compile-time exhaustive matching and structured payloads (retry_after_ms, violated Permission, expected/actual hashes, etc.).
Build phases
- Phase 1 — Wire types in
willow-common- Add
WireRejectReasonenum (#[non_exhaustive]) tocrates/common/src/wire.rswith all variants from the spec (Duplicate,Invalid,RateLimited,PermissionDenied,ParentHashMismatch,SeqGap,SignatureInvalid,PayloadTooLarge,UnsupportedVersion,AuthRequired,Restricted,NotSyncProvider,UnknownTopic,TopicInvalid,Capacity,ServerError). - Add
RejectPayload { target_peer, reason, context, human }andRejectContext { Event, Topic([u8; 32]), Envelope }. - Add
WireMessage::Reject(RejectPayload)variant. - Round-trip tests in the existing
pack_unpack_*_round_tripstyle for every variant.
- Add
- Phase 2 — Upstream typing for
PermissionDenied- Thread typed
Permissionout ofcheck_permission(returnResult<(), CheckPermissionError>). - Change
InsertError::PermissionDenied(String)→InsertError::PermissionDenied { author, missing: Permission }(or similar) and updatemanaged.rs.map_errsite. - Optionally rename existing
gotfields onInsertError::SeqGap/PrevMismatchandTransportError::UnsupportedVersiontoactualfor consistency, or translate at the boundary.
- Thread typed
- Phase 3 — Producer wiring
- Map every row from the spec's mapping table from
InsertError/IdentityError/TransportError/ relay sites toWireRejectReasonand emitWireMessage::Rejectviapack_wire. - Relay (
crates/relay/src/lib.rs): replace logged-onlytopic_str_is_validfailures andMAX_TOPICScap hits with on-wire rejects targeting the offending peer; leave connection-cap saturation logged-only (documented exception). - Replay (
crates/replay/src/role.rs): emit rejects from DAG-insert failure paths during sync streaming. - Storage (
crates/storage/src/role.rs): emit rejects from apply / archival-write failure paths.
- Map every row from the spec's mapping table from
- Phase 4 — Receiver wiring
- Add
ClientEvent::Rejected { from: EndpointId, payload: RejectPayload }tocrates/client/src/events.rs. - Add the
WireMessage::Rejectarm in each crate's gossip listener that already matches onWireMessage, dropping rejects whosetarget_peer≠ localEndpointId. - Forward decoded rejects to the client's event stream.
- Add
- Phase 5 — Client consumption
- Implement the dispatch shown in the spec's "Client consumption pattern" section (backoff, re-auth prompt, permission UI, history re-request, upgrade prompt, payload chunking, etc.).
- Phase 6 — Tests
- Round-trip in
crates/common/src/wire.rs(one value per variant). - Exhaustive
InsertError→WireRejectReasonmapping incrates/state/src/tests.rs. -
check_permissionper-Permissionrejection assertsPermissionDenied(p). - Transport: oversized payload →
PayloadTooLarge; v0 / v999 →UnsupportedVersion. - Relay: invalid topic →
TopicInvalid; topic cap full →Capacity. - Forward-compat: synthetic unknown discriminant → log + outbox preserved + no panic.
- Browser test (
crates/web/tests/browser.rs):PermissionDenied(SendMessages)flips a sent bubble to "cannot-send" within onetick().await.
- Round-trip in
- Phase 7 — Logging & metrics
- Structured
tracing::warn!(reason = ?r, context = ?c, human = %h)at every emit site. - Metric counter keyed on
discriminant()for a flat rejection-cause histogram.
- Structured
- Phase 8 — Docs
- Update
crates/commonandcrates/clientmodule-level docs with the new variant and the receiver-side filtering rule. - Note in the relay docs which paths now emit rejects vs. stay logged-only.
- Update
Acceptance criteria
WireMessage::Reject(RejectPayload)exists inwillow-commonand round-trips throughpack_wire/unpack_wirefor everyWireRejectReasonvariant.check_permissionandInsertError::PermissionDeniedcarry a typedPermissionend-to-end; noformat!-based extraction anywhere in the production path.- Every rejection site listed in the spec's mapping table either (a) emits a
WireMessage::Rejectwith the variant from the table, or (b) is documented as local-only with a logged justification (currently: iroh gossip receive error, relay connection-cap saturation). - Receivers drop
Rejectpayloads whosetarget_peerdoesn't match their ownEndpointIdbefore any application-layer processing. - Client surfaces the new
ClientEvent::Rejectedand dispatches each variant per the "Client consumption pattern" section (backoff, re-auth prompt, permission UI, history re-request, upgrade prompt, payload chunking). - Forward-compat: a
WireMessage::Rejectcarrying an unknown discriminant failsunpack_wirecleanly, the client logs and preserves the outbox, no panic. - All required tests (state, client, transport, relay, browser round-trip, forward-compat) are added and pass under
just check. - Existing free-form rejection strings remain available only as the optional
humanfield onRejectPayload(logs/UI), never matched on by code. just check(fmt + clippy + test + WASM) passes with zero warnings.
Out of scope
- Bumping
PROTOCOL_VERSION— adding aWireMessagevariant is not a breaking wire change (spec, "Extensibility & versioning"). - Adding a positive-acknowledgement
MessageType::Ack(deferred to open question 4 — revisit afterRejectships). - Recording rejections as a new
EventKindfor audit (open question 5 — contradicts the "rejected events never enter the DAG" rule). - The
relay/src/lib.rs:156connection-cap-saturation path: connection is already dropped before any topic subscription, so a same-topic reject can't reach the peer; stays logged-only by design. - Encrypting
PermissionDenied's payload to the recipient — only required if a malicious-relay threat materializes (open question 2).
Open questions
(All from the spec; resolve during build.)
- Envelope correlation. How do we correlate
RejectContext::Envelopewith the offending send when the envelope never carried a hash? Proposal: stamp an outboundsend_id(u64) in everyEnvelopeand echo it in the reject. PermissionDeniedleakage. Is telling a third party whichPermissiona peer lacks a role-enumeration risk?target_peerfiltering helps, but a malicious relay reading the gossip stream still observes it. Encrypt the payload to the recipient if this becomes a threat.RateLimited.retry_after_mssemantics. Advisory (client may ignore) or enforced (peer drops earlier retries)? Nostr leaves this implementation-defined.- Need for a positive
Ack? Currently we rely on gossip delivery as implicit ACK; revisit afterRejectships. - Audit trail via new
EventKind? Tempting, but contradicts the "rejected events never enter the DAG" rule from2026-04-12-state-authority-and-mutations.md.
Activity