Skip to content

docs(spec,security,adr): specify the gateway contract - #377

Merged
bahdotsh merged 3 commits into
mainfrom
docs/d1.2-gateway-contract-spec
Aug 18, 2026
Merged

docs(spec,security,adr): specify the gateway contract#377
bahdotsh merged 3 commits into
mainfrom
docs/d1.2-gateway-contract-spec

Conversation

@bahdotsh

@bahdotsh bahdotsh commented Aug 18, 2026

Copy link
Copy Markdown
Member

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.md documented a daemon TCP protocol as though a counterpart shipped — it never has, in this or any companion repository, and rnsd'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 from offline-relay-addr-v1 so a proof harvested by one gateway cannot be replayed against the other.

Decisions recorded

  • ADR 0016 — gateways are provisioned, never emergent. Self-promotion is the obvious design and this codebase already does something that looks like it (role-blind forwarding with a capability bias). That shape does not transfer: it works in-zone because every device can forward and suppression makes redundancy cheap. Most devices structurally cannot bridge, and no in-zone redundancy covers a bridge that leaves, so an emergent gateway produces a silent partition — from inside the zone, an absent gateway and one that decided it wasn't needed look identical.
  • ADR 0017 — Nostr is a carrier, never a gateway. A broadcast relay reports no per-recipient delivery, so there is no verdict to be had. Recorded as permanent so it stops being rediscovered as a bug, and specifically so nobody "fixes" it by inferring verdicts from a relay OK rejection 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 Rust ReticulumConfig fields as live behaviour when reconnection is entirely owned by the native managers (1s doubling to 30s, not configurable); RETICULUM_MAX_PAYLOAD_SIZE is documented as a limit but has no reader anywhere.
  • docs/transport-architecture.md aligned.

Review round 1

Five gaps found in review, all fixed in the follow-up commits on this branch:

  • Contract v1 specified no inbound delivery. Both shipped Reticulum managers handle MessageReceived and 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 as Deliver, deliberately not as a sixth verb (delivery is what any carrier does, not what makes one a gateway), with sender marked as a gateway claim rather than authentication.
  • Attach had verification obligations on one side only. Every MUST pointed at the device. The gateway is now required to verify the signature, that address is the address derived from public_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.
  • R10 listed unimplemented mechanisms as "in place": gateway token buckets, attach under a domain that is reserved and unemitted, and the recipient-aware decay that lives in feat(protocol): make transport choice ask where the recipient is #376. Split into what holds today and what is only specified.
  • The reserved signing domain was prose only. offline-gateway-addr-v1 now sits in the non-prefixing and distinctness guards in protocol::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.
  • The MDU contradicted itself across 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 MessageSent and DeliveryError entirely, 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.md conflict (both sides opened an [Unreleased] section; the gateway entries now sit inside the existing ### Documentation subsection, 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-parity cargo 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.

@bahdotsh bahdotsh closed this Aug 18, 2026
@bahdotsh bahdotsh reopened this Aug 18, 2026
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 18, 2026
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
bahdotsh force-pushed the docs/d1.2-gateway-contract-spec branch from 66c413a to aa1eb27 Compare August 18, 2026 17:06
@bahdotsh
bahdotsh merged commit f3dd75c into main Aug 18, 2026
23 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant