docs(spec,security,adr): specify the gateway contract - #377
Merged
Conversation
Layer 2 of the program architecture existed only as a concept: "gateway" and "backbone" appeared in this repository solely as Reticulum integration prose, and docs/reticulum.md documented a daemon protocol as though a counterpart shipped, when none has ever existed anywhere. Specifies what a gateway is: five verbs (Attach, Submit, Verdict, Presence, Capabilities), four invariants, the newline-JSON wire protocol a device speaks to a gateway daemon, and how Reticulum carries traffic between zones. The framing is a reframing, not an invention. The internet relay already implements all five verbs, so the SDK's existing machinery around its answers is gateway-verdict machinery that predates the name, and a second kind of gateway plugs into it rather than needing its own. The daemon protocol likewise promotes the one both mobile bridges already speak, extended with the verbs that make a gateway a gateway: a version field, an address declaration under a new offline-gateway-addr-v1 domain, per-recipient verdicts, presence and capabilities. Verdict is load-bearing and the spec is explicit that verdicts are unauthenticated claims: they may open a path, must never close one, and settle nothing, because only the recipient's acknowledgement settles a message. That single rule is the whole answer to a hostile gateway. Two decisions recorded. ADR 0016: gateways are provisioned, never emergent, because the in-zone forwarding-bias shape does not transfer to bridging (most devices structurally cannot bridge, and no in-zone redundancy covers a bridge that leaves), so self-promotion produces silent partitions. ADR 0017: Nostr is a carrier and never a gateway, because a broadcast relay reports no per-recipient delivery; the gap is recorded as permanent so nobody closes it by inferring verdicts from evidence that is really about the relay. Threat model gains adversary A7 and residual R10. Reticulum docs corrected: the phantom daemon, "fourth transport" predating Nostr, the reconnection table presenting inert Rust config as live behaviour, and a max-payload constant documented as a limit that nothing reads.
The contract declares itself the document to implement against and then leaves out the one message every attached device depends on. Both shipped Reticulum managers handle MessageReceived, and that is the whole inbound path for the transport, but contract v1 never mentioned it. A daemon built strictly to the five verbs would attach devices, take their frames, answer their verdicts, and never deliver a thing to any of them. It is specified now, and deliberately not as a sixth verb: delivery is what any carrier does, not what makes a carrier a gateway. Attach had the same hole from the other side. Every MUST in it pointed at the device: check the address you were bound to. Nothing obliged the *gateway* to verify the signature, to verify that the address is the one derived from the declared key, or to insist on its own single-use challenge. So a conforming daemon could attach any device under anybody's address, which is a lovely way to hand a hostile device someone else's inbound traffic and let it poison the presence answers given about them. Both halves are spelled out, and neither substitutes for the other. R10 listed mitigations "in place" that are nothing of the sort: token buckets at a gateway that does not exist, attach under a domain that is reserved and unemitted, and a decay policy that lives in another branch. A threat model that reads as protection when the protection is still prose is precisely the failure this branch set out to fix in docs/reticulum.md, so the list is split into what holds today and what is merely written down. While at it: the reserved signing domain is pinned by the guard that owns non-prefixing, because that property belongs to the whole set, and a guard watching only live domains would happily accept a future live domain that prefixes this one. The MDU was 464 bytes in one document and 465 in another; 465 is the one that matches MTU 500 minus the header. And the daemon section now records that today's bridges confirm a send on the socket write and ignore MessageSent and DeliveryError entirely, which is the sort of silence you only discover after writing a daemon that answers correctly.
Rebasing onto main pulled #376 in, and #376 is the branch that shipped the per-recipient reachability table. So R10's "specified but not yet implemented" list, which named the recipient-aware decay, became wrong the moment this branch moved: the decay is in the tree with a ten-minute TTL for a verdict and five for a presence answer. That is a little funny, given the entire point of splitting that list was to stop a threat model from reading as protection when the protection is only prose. A list like that is a claim about the world, and the world moved. Moved it into the mitigations that hold today, where it belongs. While at it: the Presence section said presence answers "decay the same way" as verdicts, which is now checkable and not quite true. They decay on a shorter TTL. Say that instead, with the reason, because presence is a statement about a moment and a verdict is a statement about an attempt.
bahdotsh
force-pushed
the
docs/d1.2-gateway-contract-spec
branch
from
August 18, 2026 17:06
66c413a to
aa1eb27
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Workstream D1.2 (spec half), D1.4 and D1.6 from the gateway design record. Documents plus one test guard, independent of #375 and #376, mergeable in any order.
Why
Layer 2 of the program architecture existed only as a concept. "Gateway" and "backbone" appeared in this repo solely as Reticulum integration prose, and
docs/reticulum.mddocumented a daemon TCP protocol as though a counterpart shipped — it never has, in this or any companion repository, andrnsd's actual IPC is a different protocol entirely (which the same document describes three sections earlier, contradicting itself).What is specified
docs/spec/gateway-contract.md— four invariants, five verbs, the daemon wire protocol, the backbone.The key move is that this is a reframing, not an invention: the internet relay already implements all five verbs. So the SDK's existing machinery around relay answers (parking, escalating probes, mesh offers, presence-driven flush) turns out to be gateway-verdict machinery that predates the name, and a Reticulum gateway plugs into it rather than needing its own path. Likewise the daemon protocol promotes the one both mobile bridges already speak, rather than designing a third — the client side is implemented twice already, and since no daemon exists, extending it breaks nobody.
Verdict is the load-bearing verb, and the spec is explicit that verdicts are unauthenticated claims: they MAY open a path, MUST NOT close one, and settle nothing. That single rule is the entire answer to a hostile gateway, and it is why the rest of the contract can be as permissive as it is.
New signing domain
offline-gateway-addr-v1, registered in the spec's signing-domain table as reserved (specified, not yet emitted). It is registered in advance because mutual non-prefixing is a property of the whole set, so a domain cannot be chosen in isolation — and it must stay distinct fromoffline-relay-addr-v1so a proof harvested by one gateway cannot be replayed against the other.Decisions recorded
OKrejection or a send timeout — both are evidence about the relay, and inferring recipient state from them yields confident wrong claims, which is worse than honest silence.Threat model
Adversary A7 (hostile gateway) and residual R10: blackholing, verdicts lying in both directions, zone-membership exposure to the operator, backbone exhaustion — each with why it is bounded to latency rather than loss, and an honest "nothing closes the lying-gateway case, because the lie is about someone else's state."
Corrections (D1.4)
docs/reticulum.md: the phantom daemon section now points at the contract and says plainly that no counterpart exists; "fourth transport" predated Nostr; the reconnection table presented inert RustReticulumConfigfields as live behaviour when reconnection is entirely owned by the native managers (1s doubling to 30s, not configurable);RETICULUM_MAX_PAYLOAD_SIZEis documented as a limit but has no reader anywhere.docs/transport-architecture.mdaligned.Review round 1
Five gaps found in review, all fixed in the follow-up commits on this branch:
MessageReceivedand it is the transport's whole inbound path, but the contract, which now calls itself the document to implement against, never mentioned it: a daemon built to the five verbs alone would attach devices, accept their frames, answer their verdicts and deliver nothing to any of them. Added asDeliver, deliberately not as a sixth verb (delivery is what any carrier does, not what makes one a gateway), withsendermarked as a gateway claim rather than authentication.addressis the address derived frompublic_key, and that the challenge is its own and single-use. Without those a conforming daemon attaches any device under anybody's address, which draws a victim's inbound traffic to a hostile device and poisons the presence answers about them.offline-gateway-addr-v1now sits in the non-prefixing and distinctness guards inprotocol::types::signing_domain_tests, since non-prefixing is a property of the whole set and a guard watching only live domains would accept a future live domain that prefixes this one. Negative-controlled: pointing the constant at a prefixing value fails the guard.docs/reticulum.md(464) and the new spec (465). 465 is correct (MTU 500 minus 35 header).Also recorded, because it is a silence rather than a message and a daemon author will otherwise miss it: today's bridges confirm a send on the socket write and ignore
MessageSentandDeliveryErrorentirely, so a daemon that answers contract v1 correctly gets no verdict handling until the transport work lands.Rebased onto main after #375 and #376 merged, resolving one
CHANGELOG.mdconflict (both sides opened an[Unreleased]section; the gateway entries now sit inside the existing### Documentationsubsection, per the house ordering that puts it last).The rebase invalidated one of the fixes above, which is worth stating rather than quietly correcting: R10's "specified but not yet implemented" list named the recipient-aware decay, and #376 is the branch that shipped it. The decay is now in the tree (ten-minute TTL for a verdict, five for a presence answer) and has moved into the mitigations that hold today. The Presence section's claim that presence answers "decay the same way" as verdicts is likewise now checkable and was not quite right; they decay on a shorter TTL, and the spec says so with the reason.
Verified locally on the rebased tree:
cargo fmt --all --check, CI-paritycargo clippy --workspace -D warnings,cargo test --workspace --lib(2,226 pass),RUSTDOCFLAGS=-D warnings cargo doc, and a scripted relative-link and anchor check across every touched document (0 broken).Verification
All relative links resolve (scripted check across every touched file). No em dashes in the new documents, per house style.