30817:meshstr-relay-mesh-protocol
meshstr: a permissionless relay mesh with bilateral rate accounting
Relay operators peer directly and publish, signed, the budget they have granted each peer; peers account for their own consumption in signed receipts. Adverse findings are admissible only from a node that issued the subject a grant. Ten event kinds, six DIDComm v2 protocols, and a deterministic WASI compute market.
meshstr
Permissionless relay mesh, bilateral rate accounting, and deterministic compute
draft optional
This document specifies meshstr: a permissionless mesh that nostr relay operators can join from a VPS, a Raspberry Pi, a laptop or a phone, in order to have what a solitary relay cannot — spam control derived from what other operators actually observed, rather than from a blocklist somebody else maintains.
It is a proposal. One alpha implementation exists, and the numbers below are alpha-mutable. It is published here to fix the event kinds against collision and to let the design be argued with in public.
The full specification, with the reasoning behind every choice, is at https://www.meshstr.org/spec.html. This document is the normative core of it: what an implementation must put on the wire, without the arguments.
Motivation
A relay that wants to reject spam has three options today. It can write its own heuristics, which every relay then rewrites. It can import a blocklist, which means adopting somebody else's moderation and their mistakes. Or it can require payment, which prices out exactly the users least able to pay.
None of those is the obvious fourth option: ask the operators who already saw the traffic. That is not possible today because relays have no way to say anything to each other, and no way to hold each other to what they said.
meshstr is that channel, built so that saying something costs the speaker something. Every node publishes, signed, the budget it has granted each peer. Every peer accounts for its own consumption in signed receipts. A finding against a peer is admissible only if the reporter itself issued that peer a grant — so an accusation is always backed by a relationship the accuser publicly entered into, and nobody can report on a node they never carried traffic for.
Three properties hold throughout:
- Joining costs a node no data it already had. The mesh replicates additively. Leaving is a flag.
- No private data crosses. No IP addresses, no subscription contents, no user identifiers. What crosses is events, budgets, receipts and adverse findings.
- There is no authority. No registry, no bootstrap host anyone must reach, no release manager, and no key that can revoke a node it does not peer with.
Terminology
The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT and MAY are to be interpreted as described in RFC 2119.
A node is a relay that speaks this protocol. A peering is a pair of grants, one per direction. A window is the accounting period a grant names. An issuer grants capacity; a subject consumes it and accounts for it.
Nodes take one of three classes, declared in the descriptor and not negotiable per peer:
| Class | Runs on | Obligations |
|---|---|---|
leaf |
a phone or a laptop | one WebSocket, a bounded store, no p2p stack; MAY hold no coverage at all |
edge |
a VPS or a home server | full transport ladder, holds coverage, serves range reconciliation |
anchor |
a well-connected server | everything an edge does, plus mediation for leaves and long-lived descriptors |
Node identity
A node is identified by a did:nostr DID whose subject is its nostr pubkey.
Keys
DIDComm v2 requires X25519 for envelope encryption, and iroh and libp2p both identify hosts by Ed25519. No single key does all three, so a node holds three and binds the other two by listing them in a descriptor signed by the first.
| Key | Curve / multicodec | Purpose | Rotation |
|---|---|---|---|
| node | secp256k1 x-only, 0xe7 |
the DID subject; signs the descriptor, grants, reports, receipts and every event the node authors | never — rotating it creates a new node |
| agreement | X25519, 0xec |
keyAgreement; DIDComm v2 ECDH-ES and ECDH-1PU |
SHOULD rotate every 90 days, overlapping one window |
| transport | Ed25519, 0xed |
iroh NodeId and libp2p PeerId; signs gossipsub under StrictSign |
MAY rotate freely; a new descriptor is the only requirement |
Multikey values use the multibase f (base16-lower) prefix throughout, so one
document never mixes bases.
A verifier MUST treat transport keys as bound to a DID only through a currently valid descriptor. An inbound connection from an Ed25519 key not named in an active descriptor originates from an unknown node.
The node descriptor
Kind 11801, replaceable. This is the only binding artefact: it carries the node's class, the protocol version it speaks, its other two keys, its endpoints, and its expiry.
{
"kind": 11801,
"content": "Community relay for the Bristol nostr meetup.",
"tags": [
["class", "edge"],
["protocol", "meshstr/0-draft", "<digest>"],
["software", "https://meshstr.org", "0.2.0"],
["key", "agreement", "fec01<x25519>", "1795000000"],
["key", "transport", "fed01<ed25519>"],
["endpoint", "iroh", "<ticket>", "10"],
["endpoint", "libp2p", "/dnsaddr/relay.example.org/p2p/<peerid>", "20"],
["endpoint", "wss", "wss://relay.example.org", "40"],
["mediator", "did:nostr:<anchor>"],
["c", "https://meshstr.org/peering/1.0"], ["c", "https://meshstr.org/budget/1.0"],
["c", "https://meshstr.org/sync/1.0"],
["c", "relay"], ["c", "blossom"], ["c", "mediator"], ["c", "negentropy"],
["coverage", "<digest of this node's coverage claim set>"],
["policy", "<sha256 of the policy doc>", "https://relay.example.org/policy"],
["operator", "<operator pubkey>"],
["nonce", "<nip13 nonce>", "20"],
["expiration", "1789600000"]
]
}
endpoint's fourth element is a preference value, low first. expiration is
NIP-40 and is REQUIRED: a descriptor that outlives its operator's interest is
how stale federations accumulate dead peers. Maximum lifetime is 30 days for
anchors and edges, 24 hours for leaves.
The protocol tag names the version and the digest of its machine-readable
schema. Two nodes declaring different digests MUST decline to peer. There is no
release authority; a version exists when nodes declare it and dies when they
stop.
A shared digest says two nodes read the same schema. It does not say either
built all of it. So a node also declares which protocols it implements, as c
tags whose value is the protocol identifier, one per tag. Every node MUST
declare peering/1.0 and budget/1.0: a node that cannot be proposed to or
cannot account for what it sends is not a node. The other four are optional.
These declarations are binding, unlike the plain capability words beside them,
which are hints. A discover-features/2.0 answer MUST agree with the
descriptor, and a peering/1.0/propose repeats the list in its protocols
field; a disagreement between any two of the three is BAD_DESCRIPTOR. A peer
that shares the digest but lacks a protocol the peering needs is declined with
UNSUPPORTED_PROTOCOL. Declining is not a finding.
The resolved DID document
Given a DID whose subject has a live kind 11801, a did:nostr resolver emits a
node profile: the minimal document plus #agree and #wire verification
methods, a DIDCommMessaging service, and whatever NostrRelay,
BlossomServer and IrohNode endpoints the descriptor declared. Resolution of
ordinary user identities is unaffected, so a resolver can add this without
changing any answer it already gives.
Revocation
Kind 11802, replaceable. Peers MUST zero active grants on a valid revocation,
and MUST NOT transfer accumulated standing to a successor. The successor tag
is advisory: a successor node starts on probation and earns standing through
clean windows.
A revocation proves only that the key holder asked to retire. A legitimate operator and a compromise adversary can both publish one.
Discovery
A new node needs somewhere to publish its descriptor, a way to hear about others, and a first peer willing to grant it anything. None of the three may require a centralised service.
- Publish. Write the kind 11801 descriptor to every relay in the node's own kind 10002 relay list. Any nostr relay will do; it is an ordinary replaceable event and needs no special support.
- Announce. Join the libp2p gossipsub topic
/meshstr/announce/1and republish the descriptor there, signed under StrictSign by the transport key. - Ask. Open
peering/1.0/proposeto any node whose descriptor declares the protocols you need.discover-features/2.0confirms the declaration once a channel exists; it does not replace it.
Enrolment can be open because the opening grant is worth almost nothing: a new
peer starts at the probation tier, whose ceilings are listed below. NIP-13
proof of work on the descriptor prices bulk identity creation.
Coverage
Kind 30803, addressable. A node declares which shards it holds, as a filter
plus a completeness claim, and a negentropy fingerprint over the set. A node
MUST NOT publish completeness: asserted for shards it holds only through mesh
replication — that is how third-party replication launders unverified peer data
into authoritative records.
There is one door through that rule, and it is signed by the node the rule
protects against. A node that asserts a shard first-hand MAY hand it over: it
signs a statement naming one replica, the shard, the range and the set, and
sends it as sync/1.0/handover after a done that found the two sets equal.
The replica then MAY publish asserted over exactly that set, carrying the
handover inline under a handover tag. The content is
{"handover": [artefact, ...]} — a chain, ordered from the first-hand
root to this claim — and the tag is SHA-256 of the last artefact's canonical
form with sig excluded, the same construction a receipt is cited by. A
reader hashes it, checks the signatures and resolves nothing.
// sync/1.0/handover — body, and the artefact a kind 30803 carries inline
{
"from": "<pubkey of the node that holds the shard first-hand>",
"to": "<pubkey of the one replica this is to>",
"shard": "bristol-2026H2",
"claim": "<event id of the signer's own kind 30803>",
"filter": "{\"#t\":[\"bristol\"],\"kinds\":[1]}",
"since": 1751328000,
"until": 1767225600,
"fingerprint": "<NIP-77 fingerprint over the set>",
"count": 5,
"at": 1787100000,
"sig": "<BIP-340 by from over SHA-256 of this body, sig excluded>"
}
Three rules make the door narrow enough to leave open.
Exactly the handed set. The replica's claim MUST carry the handover's
filter, since, until, fingerprint and count, and until MUST NOT be
later than at. Fingerprint equality with one upstream proves the replica
holds what the upstream holds and nothing about the world, so the replica may
assert only what somebody first-hand already asserted, over a range that had
closed when they said so. What arrives after until the replica holds
best-effort or claims first-hand under a claim of its own.
The signer is liable too. A handover is the signer's own asserted claim
over the set it names, and it is reportable against the signer the way a kind
30803 is: a report cites it as evidence of kind handover. An event inside the
range that the signer does not hold is a hole in the handover exactly as it is
a hole in the replica's claim. Two nodes signed for one set and both answer
for it.
No signer may report the hole. A hole a signer files in a claim resting on
its own handover is an event it held and did not pass on, or one it never held
and vouched for anyway. Either way the fault is its own and the report would be
the mechanism for laundering it onto the replica. A reader that holds the claim
can decide this alone: a kind 30802 whose reporter is the from of any link
in the chain its cited claim carries is discarded unread, like a report without
a grant. Anyone else with a grant to the replica may file it.
Withdrawing a claim, and leaving in order
A kind 30803 is addressable, so a node takes a claim back by republishing it at
the same d as best-effort with a NIP-40 expiration. The assertion it
replaces is dropped by relays, best-effort promises nothing and is not
reportable, and the replacement lapses rather than outliving the node that made
it. This is what the capacity ladder's narrow_scope rung means by a claim
lapsing, and it is the last step of a retirement.
A node that intends to stop SHOULD retire rather than go quiet, in this order:
- Announce. A heartbeat carrying
retiring, before anything is given away, so the silence that follows is not priced as stress by anyone listening. - Close. Every shard it asserts gets a range ending now. A handover signs for a closed set, so an open range cannot be passed on — and the shards worth passing on are exactly the ones nobody ever bounded.
- Hand over. A handover for each closed shard to each peer holding a live grant, sent rather than waited for. A retiring node cannot wait out its peers' pull intervals, and the peer this matters most to is the one that is offline while its only first-hand holder leaves, so the message is store-and-forward. It is sent and not reconciled: the receiver keeps a handover only where it fingerprints to what the receiver holds, so a statement to a peer without the data is refused and costs nothing.
- Withdraw. Every claim republished as above. After the handovers and never before, because a withdrawal replaces the very claim a handover copies its terms from.
- Farewell. A receipt for every open window, which is what a node already owes its issuers on the way out.
A revocation (kind 11802) MAY follow, and only where the key itself is retiring: it is the one step nothing undoes. Everything before it is reversible by starting the node again. A restart MUST NOT run this sequence — a fleet upgrade that closed every range and gave away every shard's authority would pay exactly the cost the sequence exists to avoid.
A handover MAY be over a claim that itself carries one, and then the replica MUST carry every link. Each link hands to the node that signs the next, every link names the same set, and no key appears twice; a chain is at most eight links. This is not bookkeeping. Every signer in the chain is barred from reporting a hole in the set, and carrying only the last link would leave the node that withheld an event able to report that hole against the node two links down — liable for it, unbarred, and filing the report the bar exists to stop, laundered through one intermediary. A reader checks the whole chain or it has checked nothing.
A node that hands on a set it was handed MUST pass on the chain it holds with its own link appended. One that omits a link is declaring itself the first-hand holder of a set it did not receive first-hand, and takes the whole liability for it alone, which is the only way that statement can be priced.
A handover moves no standing and touches no tier: the replica's claim is a new liability the replica chose to take, and each signer's remains its own.
Liveness
Kind 21801, ephemeral. Carries the node's class, a digest of its current
endpoint set (so a stale descriptor is detectable) and an advisory load figure.
It names no peer and carries nothing about accounting. Anchors MUST publish one
at least hourly. The endpoints digest is SHA-256 over the canonical JSON of
the sorted [scheme, address] pairs the node is serving; the preference value
is excluded. A heartbeat whose class contradicts the live descriptor is a
misreport about itself, S2. Leaves MUST NOT publish one.
A heartbeat MAY carry retiring, which says the publisher is leaving on
purpose and now. It is the only thing in this protocol that tells a departure
from a silence, and it exists because nothing else can: under FLP a node that
went away and one that went quiet are indistinguishable, so a peer holding a
schedule against a node that has retired records a partition it was told about
in advance. A reader SHOULD NOT count the partition that follows a retiring
heartbeat as stress from that direction. It is advisory, and a node cannot
gain by publishing it falsely — the whole of what it asks for is that its
peers stop expecting it. Being ephemeral, only a peer listening at the time
hears one; a peer that was itself down records the partition, and that
partition is true.
Control plane
Everything two nodes say privately is DIDComm v2. Messages are DIDComm
plaintext encrypted ECDH-1PU to the peer's #agree key, except first contact,
which is anonymous ECDH-ES.
meshstr reuses out-of-band/2.0, discover-features/2.0, trust-ping/2.0,
routing/2.0, report-problem/2.0 and empty/1.0 unmodified, and defines six
protocols of its own. Every message sent store-and-forward carries
please_ack; the answer is an ack header, on the empty message when nothing
else is due.
| PIURI | Messages | Purpose |
|---|---|---|
peering/1.0 |
propose, offer, accept, decline, amend, terminate | Establish and revise a peering. Terminates in a pair of grants, one per direction. |
sync/1.0 |
open, range, have, need, deliver, done, handover | Set reconciliation over a shard, carrying NIP-77 negentropy payloads. |
budget/1.0 |
receipt, receipt-ack, close, dispute | The self-accounting channel. Carries receipts and window closes. |
complaint/1.0 |
notice, evidence, remedy, appeal, withdraw | Present a finding to its subject before publishing it, and let the subject answer. |
compute/1.0 |
quote, accept, sealed, settled, refute | Negotiate and deliver one compute job. Quotes stay off the wire, so a price to one buyer is not a public commitment to every buyer. |
standing/1.0 |
ask, tell, decline | Ask a peer what it makes of a third node. Answering is discretionary; refusing is normal. |
A DIDComm PIURI is an identifier, never fetched, so this namespace creates no runtime dependency on a domain anyone controls. Implementations MUST treat the string as opaque and compare it byte for byte.
complaint/1.0 is mandatory-first for S1 and S2: an issuer MUST deliver a
notice and wait one window before publishing a kind 30802 report against a
reachable peer. Most conformance failures are clock skew, a bug, or a misread
limit, and a protocol that publishes before it asks produces a permanent public
record of transient faults. S3 and S4 MAY be published immediately.
Transport
Messages ride iroh (ALPN meshstr/didcomm/1), libp2p
(/meshstr/didcomm/1.0.0), HTTPS (POST /didcomm), or nostr gift wrap — kind
21059 by default, kind 1059 when store-and-forward is required. The nostr
binding always works, which is what makes the ladder safe to attempt in order.
For data transport, both peers rank the schemes their descriptors share by the sum of the two advertised preference values, low first; equal sums rank by scheme name, ascending bytewise, so both sides pick the same rung. A peer MUST NOT be reported unreachable until every shared rung has failed.
Replication
A node MUST support NIP-77 to peer. Reconciliation is negentropy over the shard
a grant scopes, and the range fingerprint is NIP-77's own — meshstr defines no
second one. Events cross between nodes inside a delivery envelope, and every
node that forwards one appends a hop {node, at, sig}:
msg = SHA256("meshstr/hop/1" || "|" || event_id || "|" || prev_hop || "|" || at)
sig = BIP-340(node_key, msg)
event_id is the wrapped event's id as 64 lowercase hex; prev_hop is the
previous hop's node string exactly as written, empty for the first hop; at
is that hop's own unix seconds in decimal. A field a signature does not cover
is a field anyone may rewrite, so at is inside it: a hop is a statement that
this node sent this event at this moment, which is what lets it stand as
evidence. It is also the only artefact a forwarder signs for an event it did
not write. A
receiver charges every node in the path it holds a grant with, which is what
closes grant-evasion routing. A path MUST NOT exceed three hops.
A range fingerprint is taken over a set, and implementations MUST agree on
which events are in it or they will compute NIP-77 correctly and still never
converge. Four rules decide membership. Ephemeral kinds
(20000 <= n < 30000) are never stored. Replaceable and addressable
kinds retain only the winner: later created_at, and on a tie the
lexicographically lower id. A NIP-09 deletion removes only events its own
author signed, permanently, and binds even when it arrives before its target,
so a tombstone MUST record which key asked and the authority check happens on
arrival. An event whose NIP-40 expiration has passed is not in the set. The
since/until arguments and a filter's own are intersected, never widened.
Enumeration is ordered by created_at ascending, then id ascending.
Two kinds never cross. Kind 24133 (NIP-46 signer traffic) MUST NOT be replicated on any transport under any grant. Kind 1059 replicates only to nodes named in the recipient's own kind 10050 relay list, never in bulk and never on a filter a third party supplied.
Grants and receipts
A node does not rate-limit its peers privately. It publishes the budget and makes the peer account for its own consumption.
The grant
Kind 30801, addressable. The grant itself is NIP-44 ciphertext addressed to the subject; what is public is a commitment to it.
The d tag is a blinded pair identifier derived with HKDF-SHA256 over the
issuer/subject ECDH x-coordinate, so only the two parties can derive it. Third
parties see that a grant exists and cannot see between whom, or for how much.
Grants are never published in the clear: a public grant table exposes the
complete peering graph with standing scores attached.
Third parties ask about a node over standing/1.0 instead. Answering is
discretionary and declining is not adverse.
The receipt
The subject maintains a token bucket mirroring each limit and, at the cadence
the grant names, sends a signed receipt over budget/1.0. Receipts within a
window form a hash chain — prev is the SHA-256 of the canonical form of the
previous receipt — so a subject cannot rewrite an earlier claim after learning
what the issuer saw. The issuer keeps only the head.
The canonical form is the one every digest in this document uses (canonical
in constants.json): the body as JSON, UTF-8, object keys sorted ascending by
code point, no whitespace between tokens. prev for seq 0 is thirty-two zero
bytes. Counters are integers, so no number-formatting question arises.
// budget/1.0/receipt — body
{
"grant": "<event id of the kind 30801 in force>",
"window": 496389,
"seq": 7,
"prev": "<sha256 of the canonical form of receipt seq 6>",
"counters": {
"conn_open": 2, "req": 141, "events_in": 802,
"bytes_in": 2216041, "bytes_out": 19883, "blob_bytes": 0,
"errors": 1, "dups_sent": 44, "sync_ranges": 61
},
"forwarded_for": ["did:nostr:<origin>"],
"at": 1787003600,
"sig": "<BIP-340 by the subject node key over this receipt's chain link>"
}
sig is the subject's signature over the chain link, which is SHA-256 of the
canonical body with sig itself excluded. Excluding it means the link is the
same value before and after signing, so signing disturbs no chain and the head
the issuer acknowledges is exactly the message that was signed. A receipt
without a valid sig is not a claim anyone can be held to, and an issuer
answers one with divergent.
A window prices each limit against the counter of the same name. Where the
names differ, counter_of in constants.json names the counter: conn_rate
is priced against conn_open, the sessions the subject opened in the window,
on whatever transport carried them. conn_max and sub_max are advisory and
not in the ceiling table, because a cumulative receipt cannot carry a gauge and
peers never open a subscription on a relay face.
at is the subject's own clock. Both ends are expected to be
NTP-disciplined: the window's grace period absorbs ordinary skew, and drift
beyond max_clock_skew_seconds produces divergence findings nobody earned. An
issuer SHOULD record a receipt timed further than that from its own clock as a
diagnosis rather than a finding, because the remedy is a clock and not the
ladder.
Counters are cumulative within the window and strictly monotonic. The window
is evaluated against the most recent receipt the issuer received for it: that
receipt's counters are the subject's claim, and traffic measured after it is
compared against them and priced by divergence. Nothing moving after the last
receipt is a clean window, which is how a leaf that syncs and then sleeps
produces no finding. Measured traffic with no receipt at all is active silence,
S2 — and that is a floor, not a ceiling: the measured traffic is still evaluated
against the limits and against the empty claim, and the window takes the worse
of the two. A peer that overran and said nothing must not be cheaper than one
reporting it honestly. budget/1.0/close is optional: a final receipt, accepted as final when it
arrives within the grace period of one tenth of the window. The issuer answers
every receipt privately with receipt-ack carrying the head it holds and one of
accepted, divergent or final.
Clean windows publish no public events. Positive reports are arithmetically inert under the trust weighting below, so broadcasting them would leak bilateral topology and add nothing. Only adverse findings are published.
Window arithmetic
Evaluated by the issuer at window close, per limit ℓ:
overrun_ℓ = max(0, measured_ℓ / limit_ℓ - 1)
divergence_ℓ = |measured_ℓ - claimed_ℓ| / max(measured_ℓ, floor_ℓ)
severity = max over all ℓ of class(overrun_ℓ, divergence_ℓ)
clean : strain_ℓ = min(1, measured_ℓ / limit_ℓ)
if measured and strain_ℓ = 0 and limit_ℓ > open_ℓ
limit_ℓ ← max(open_ℓ, limit_ℓ × 0.90) # decay by disuse
else
limit_ℓ ← limit_ℓ + tier_base_ℓ × max(0.02, 0.10 × strain_ℓ)
S1 : limit_ℓ ← max(probation_ℓ, limit_ℓ × 0.50)
S2 : limit_ℓ ← max(probation_ℓ, limit_ℓ × 0.25) ; tier ← probation
S3 : limit_ℓ ← 0 for 24h, then resume at probation
S4 : tier ← revoked ; grant deleted ; report published
then, always : limit_ℓ ← min(tier_max_ℓ, limit_ℓ) at the tier the branch returns
A grant opens BELOW the ceiling of its tier: volume at opening_limit_fraction
of it, the structural limits (conn_rate, req, sync_ranges, errors) at the
ceiling, because a peer needs enough plumbing to demonstrate anything at all. A
grant that opened at the ceiling made the additive increase a no-op, so nothing
grew and every capacity could only be eroded. The clean step is then scaled by
what the window carried, with a floor, so growth is priced in work rather than in
windows. An evaluation offering no measurement gets the floor step, never the
full one.
A limit is also taken back where it went unused, floored at the tier's opening
limits: what a peer has not used it gives back, what every peering is given it
keeps. A peering whose windows measure nothing at all for decay_idle_windows
in a row has its whole grant decayed. No measurement is not a measurement of
zero — an evaluation offering no counters gets the floor step and no decay, in
either direction. A decay is amended with cause: disuse.
A grant MUST NOT carry a limit above the ceiling of the tier it names. That holds for a remedy as much as for a first issue, and it binds hardest at S2, the one branch that lowers the tier: a quarter of any higher tier's limit is still far above what probation allows, so the ceiling decides and the quartering never gets to. S2 does not leave a peer a quarter of what it had. It makes it a stranger again.
The node's own ceiling
A grant bounds one peer and nothing bounds the node: the meter is per peering, so twenty anchor grants each inside its own limit is 960,000 events an hour with nobody at fault. A node declares its own total and sheds against it.
The total is the operator's number and this NIP states no default. A class declares connectivity and not capability, so a Raspberry Pi and a forty-core gateway both say anchor. A node with no declared total measures its pressure, reports it and sheds nothing. What is normative is the ladder.
pressure = max over counters of (window aggregate over all peers / declared total)
0.75 refuse_peerings a new proposal is declined CAPACITY_EXCEEDED;
peerings already held are untouched
0.85 narrow_scope shards held only through the mesh stop being claimed
and the claims already published are withdrawn
0.95 amend_down every live grant is reduced, cause: capacity
1.00 leaf_behaviour no pull is opened; receipts still flow
release: every rung together, below 0.60
A shed is announced, it is not adverse, and it makes a peer smaller and never a
stranger. peering/1.0/amend carries cause, one of remedy, capacity or
operator; only remedy is a finding. The shed floor is a fraction of the
probation ceiling and not the ceiling itself, because a peering opens at that
ceiling and a floor there would make the rung do nothing for exactly the peers a
busy node has most of. It is not zero: zero for 24h is what S3 does.
Stress a node survived
A restart, a peer that went quiet, a relay that dropped, a clock that drifted: each is correctly refused as a finding about a peer and then discarded, so the information goes out with the accusation. Kept, it sets the node's own reserves.
Two rules. Local and defensive only — a strain record never becomes a finding and never crosses the wire, and a peer's own account of what it has weathered is a claim, not evidence. If a node's whole response to stress is to be readier for that stress, an attacker who causes it gains nothing. Directions, never magnitude — a reserve grows with the number of independent directions (one peer, one shard, one transport), each capped, each weighted by the tier this node granted that peer. Probation weighs 0.0, so free identities buy no directions and a tier is only raised by a person. One peer moves a node by at most one direction's worth however hard it pushes.
A node whose strain is nearly all one direction is a flag tree: structurally dependent on that peer, and it will snap when the peer leaves. Reported to the operator; never a finding.
class(overrun, divergence) -> severity, evaluated per limit
by_overrun = S3 if overrun >= 4.0 else
S2 if overrun >= 1.0 else
S1 if overrun >= 0.1 else S0
if divergence > 0.1 -> worse(by_overrun, S2) // a floor, never a ceiling
otherwise -> by_overrun
dup_ratio is evaluated in the same window and is not a counter: it has no
line in a grant, so nothing scales it, and its ceiling is 0.15 at every tier.
The receiver alone knows what it already held, so there is no claim to
compare and no divergence term:
class(dups_sent / events_in / 0.15 - 1, 0)
and it is not evaluated when events_in is under the divergence floor,
where a ratio is noise.
A window's severity is the most severe across all its limits. S4 is never reached by arithmetic: it is forgery or impersonation, found structurally.
Mind the units. A schedule tag is a ratio of the limit — 1.1 means 110% —
while overrun is measured/limit - 1. A schedule ratio r is an overrun
threshold of r − 1. Reading one as the other is the likeliest implementation
bug in this section.
Tier ceilings
A grant MAY set any limit lower than its tier ceiling; it MUST NOT set one higher. AIMD grows a limit toward the ceiling and reductions floor at the probation ceiling.
| Ceiling | probation | member | trusted | anchor |
|---|---|---|---|---|
conn_rate |
4 | 12 | 48 | 192 |
req |
60 | 600 | 2400 | 9600 |
events_in |
300 | 3000 | 12000 | 48000 |
bytes_in |
3355443 | 33554432 | 134217728 | 536870912 |
bytes_out |
26843545 | 268435456 | 1073741824 | 4294967296 |
blob_bytes |
1677721 | 16777216 | 67108864 | 268435456 |
sync_ranges |
100 | 400 | 1600 | 6400 |
errors |
4 | 40 | 160 | 640 |
Tier weight in trust aggregation: probation 0.0, member 0.25, trusted 0.6, anchor 1.0. A reader that holds no grant to a reporter weights it at zero.
dup_ratio is a quality signal rather than a capacity, and is 0.15 at every
tier. It is evaluated in the window as dups_sent / events_in against that
ceiling through the same class(overrun, divergence) as every limit, with no
divergence term: the receiver alone knows what it already held, so there is no
claim to compare. It has no line in the grant and is never scaled. A ratio over
fewer events than the divergence floor is not evaluated.
The severity ladder
The remedy for a given breach is deterministic and stated in the grant before the breach happens. An operator who can choose the punishment after seeing the offender is doing moderation, and moderation does not federate.
| Name | What it is | Remedy | |
|---|---|---|---|
| S0 | drift | within 110% of every limit, receipts agree with measurement | none; counts toward the clean-window streak |
| S1 | overrun | sustained traffic over a limit, accurately reported — honest but unshaped | limit × 0.5, notice first, no publication on first occurrence |
| S2 | misreport | receipts diverge beyond tolerance, fail to arrive during active egress, or declared metadata contradicts observation | tier → probation, limits to the probation ceiling, report published after notice |
| S3 | abuse | traffic outside granted scopes, malformed floods, excessive duplicates, repeated hop-limit violations | limits → 0 for 24h, report published immediately with evidence |
| S4 | malice | forged signatures, corrupted receipt chains, transport key impersonation, shard poisoning, grant-evasion routing | revoked, grant deleted, report published, peers re-evaluate |
The ordering is the argument. S1 overruns rank below S2 misreports. A peer that exceeds its capacity but reports accurately is treated more leniently than one that underreports. Capacity overruns are operational. Falsified reporting destroys the accounting everything else rests on.
The report
Kind 30802, addressable, d = subject pubkey, one per (reporter, subject),
replaced as the relationship evolves. It carries the reporter's current verdict,
not a log.
Two rules make it admissible:
- Adverse only.
outcomeis S1..S4. There is no positive attestation. - Evidence or nothing. A report MUST name, in its
granttag, a grant the reporter itself issued to the subject, and everyevidenceentry MUST be something the subject itself signed. A report without a grant is discarded unread.
Evidence travels inline. The report's content is
{"note": <string>, "evidence": {<id>: <artefact>}}, where <id> is SHA-256 of
the artefact's canonical form and is what the evidence tag carries. A reader
hashes the artefact, compares it to the tag, and checks the subject's signature
on it, resolving nothing. This is not decoration: a receipt and a delivery
envelope are private DIDComm bodies that no relay serves, so a reference to one
resolves to nothing anywhere, and a report about divergence could otherwise be
checked by no one but its author. An event entry MAY instead be a bare
reference, because a nostr event is public already.
Evidence pins the subject's half of a disagreement and no more. Nobody but the reporter observed the traffic, so a reader takes the measurement on the reporter's standing and checks only that the subject really said what it is quoted as saying. Two nodes that measured differently disagree without either being refuted.
Active silence cannot be reported, and that is the answer rather than a gap.
A window with measured traffic and no receipt is S2 at least, but the subject
signed nothing that window, so there is nothing of its own to quote and no
reader could check either half of the claim. The remedy is the local one: the
grant goes to zero, and standing/1.0 carries the streak to anyone who asks.
Nobody imports anybody's bans. A report is an input to the reader's own trust weighting, never an instruction. Reports may only lower a view, are weighted by the reporter's tier from the reader's own perspective, and a reader that holds no grant to the reporter weights it at zero.
Compute
Nodes MAY sell deterministic WASI compute to each other. The market has no global state and nowhere to put a rake: asks and receipts are ordinary events, and settlement is peer to peer over Lightning.
A compute offer (kind 11803) advertises executors and prices. Prices are
quoted per engine: instruction fuel is an artifact of the runtime, so the order
book is segmented by the runtime tag — engine, version, and the digest of its
capability profile — and quotes across engines are never compared.
The ask prices the work without describing it
An ask has to reach sellers the buyer has not chosen yet. It does not have to say what the job is, and those are separable.
Kind 30810 carries only what a seller needs to quote: executor, engine, fuel
ceiling, input length, deadline, bid, and the verification mode. The module and
input digests are carried in a commit tag and travel in
compute/1.0/accept, encrypted to the one seller that wins.
This is not a new construction — it is the kind 30801 grant envelope reused. A public ask and a public receipt together would form a permanent, signed log of who computed what over whose data for how much, beside a grant system that goes to real trouble to hide exactly that. A module digest alone identifies the computation.
Kind 30811 follows for the same reason. A receipt is a record of a job that went right, and this protocol does not publish positive events: the envelope carries a blinded job identifier and a commitment, and everything the receipt is about sits inside NIP-44 ciphertext addressed to the buyer. A seller MAY publish a cleartext receipt where the buyer consents.
Verification is a declared mode
Re-execution is a fine mechanism and a poor requirement: it obliges whoever adjudicates to hold the plaintext. So the mode is named in the ask, before the work, the same way a grant names its remedy before the breach. A refutation citing evidence the declared mode does not admit is not a finding.
| Mode | Evidence a dispute admits | Needs plaintext? |
|---|---|---|
sample |
an independent re-run contradicting the seller-signed hash | yes |
dual |
two executors returning different hashes for one job | yes |
proof |
the proof fails against the module and input commitments | no |
none |
delivery only — the sealed commitment against the preimage | no |
Under proof the seller returns evidence that the committed module, run on the
committed input, produced the committed result. Nobody re-runs. That is what
makes confidentiality affordable: a proof-mode receipt can be adjudicated by a
third party who never sees the job.
Confidentiality and verifiable work units are one property, not two. Confidentiality is only affordable if correctness does not require re-execution, and correctness only stops requiring re-execution if the unit carries its own proof.
proof ships as an expressible mode with no reference implementation.
Proving costs orders of magnitude more than executing and breaks the price
denominator, since a prover's bill is not proportional to the fuel it proves
about. It is a named direction, on the same footing as contingent payment. Proof
generation is itself a good job for this market to sell.
Delivery
Accountable rather than atomic: the seller signs compute/1.0/sealed before
payment, so a key that does not open the ciphertext is provable. A buyer can
still be defrauded once per seller and recovers nothing; the remedy is
reputational, which is the same bargain the rest of this document makes.
Hardware-attested execution (TEE) is refused, not deferred. It is the only practical way to hide a job from the node running it, and every form of it terminates in a remote attestation signed by a hardware vendor's root key. This protocol has no authorities by construction.
Event kinds
| Kind | Class per NIP-01 | Name | Content |
|---|---|---|---|
11801 |
replaceable | node descriptor | free |
11802 |
replaceable | node revocation | free |
11803 |
replaceable | compute offer | empty |
21801 |
ephemeral | node heartbeat | empty |
21802 |
ephemeral | diagnostic telemetry | empty |
30801 |
addressable | peer grant envelope | nip44 ciphertext of the grant |
30802 |
addressable | conformance report | free |
30803 |
addressable | coverage claim | empty |
30810 |
addressable | compute ask | empty |
30811 |
addressable | job receipt | nip44 ciphertext of the job receipt |
Ranges are NIP-01's: replaceable is 10000 ≤ n < 20000, ephemeral is 20000 ≤ n < 30000, addressable is 30000 ≤ n < 40000.
Every kind carries the tags below. Formal JSON Schema (Draft 2020-12) for all of them, plus the DIDComm bodies, decrypted payloads and DID documents, is at https://www.meshstr.org/schemas.html.
11801 — node descriptor
| Tags | |
|---|---|
| REQUIRED | class, protocol, c, expiration |
| OPTIONAL | software, key, endpoint, mediator, nips, coverage, policy, operator, nonce |
C is one capability per tag, repeated. NIP-01 indexes only single-letter tags, so a multi-letter capability tag cannot be queried and a leaf could not find a mediator without downloading every descriptor. A meshstr protocol identifier as the value declares that the node implements that protocol, and that declaration is binding. Every node declares peering/1.0 and budget/1.0, which is why c is required.
11802 — node revocation
| Tags | |
|---|---|
| REQUIRED | reason |
| OPTIONAL | successor |
11803 — compute offer
| Tags | |
|---|---|
| REQUIRED | executor, expiration |
| OPTIONAL | limit, pay, runtime |
21801 — node heartbeat
| Tags | |
|---|---|
| REQUIRED | class |
| OPTIONAL | endpoints, load, retiring |
Liveness only, named after no peer: an anchor publishes one at least hourly. It carries nothing about accounting, because a node holds one receipt chain per issuer and a single "latest head" has no referent. retiring says the publisher is leaving on purpose and now. Advisory: a peer that reads one SHOULD NOT count the partition that follows as stress from that direction, because it was told. Under FLP nothing else can tell a node that went away from one that went quiet, and a node cannot gain by publishing it: it asks its peers to stop expecting it.
21802 — diagnostic telemetry
| Tags | |
|---|---|
| REQUIRED | sample_window |
| OPTIONAL | metric, expiration |
Opt-in. Counters are coarse-binned and aggregated over the whole node for the sample window, with no per-peer attribution. Alpha measurement only; see the alpha contract.
30801 — peer grant envelope
| Tags | |
|---|---|
| REQUIRED | d, commit, scheme, window, epoch, expiration |
| OPTIONAL | — |
D is HKDF-SHA256 over the issuer/subject ECDH x-coordinate under blinding.grant's salt and info; commit is SHA256(blinding || canonical grant) per commitment.scheme; blinding is fresh random per commitment (commitment.blinding_factor); the grant itself is never public
30802 — conformance report
| Tags | |
|---|---|
| REQUIRED | d, outcome, grant, windows, evidence, expiration |
| OPTIONAL | observed, claimed, notice |
Adverse only: outcome is S1..S4, never a positive attestation. grant MUST reference a grant the reporter itself issued to the subject; a report without one is discarded unread. Evidence travels inline in the content, as {note, evidence: {id: artefact}}, because receipts and delivery envelopes are private bodies no relay serves; an evidence entry of kind event may instead be a bare reference. See grant.evidence. The report carries the kind 30801 the grant tag names in that same inline map, keyed by its id: a grant is addressable under a blinded d, so the one that governed a past window has been replaced at that coordinate and no relay serves it, and a report that did not carry its own stopped being admissible to anyone about an hour after it was published. A reader takes the carried copy over the relays and holds it to more: it must be the id it is filed under, signed, kind 30801, and by the reporter.
30803 — coverage claim
| Tags | |
|---|---|
| REQUIRED | d, filter, completeness |
| OPTIONAL | since, until, fingerprint, count, handover, expiration |
A node MUST NOT publish completeness asserted for shards it holds only through mesh replication, with one door: a handover. The node that holds the shard first-hand signs {from, to, shard, claim, filter, since, until, fingerprint, count, at} naming the replica, and the replica carries it inline under the handover tag as a chain: content is {handover: [artefact, ...]} ordered from the first-hand root, the tag being SHA-256 of the last artefact canonical form with sig excluded. Every signer in the chain is carried so that every signer is barred from reporting a hole in the handed set; a single link would let the node that withheld an event report the hole against a node two links down. Each link hands to the node that signs the next, every link names the same set, and no key appears twice. The replica may then assert exactly the handed set and no more: same filter, same range, same fingerprint and count, and until no later than at. A handover is reportable against its signer as an asserted claim is, because it signed for the same set. The signer MUST NOT be admitted as the reporter of a hole in the claim it handed over: a hole in the handed set is its own. A handover travels over sync/1.0/handover after a done that reports the sets equal. A claim is withdrawn by republishing it at the same coordinate as best-effort with a NIP-40 expiration: addressable, so the asserted one it replaces is dropped by relays, and the replacement lapses rather than outliving the node that made it. The capacity ladder’s narrow_scope rung and a retiring node both withdraw this way.
30810 — compute ask
| Tags | |
|---|---|
| REQUIRED | d, commit, executor, bid, deadline, fuel_max, verify, expiration |
| OPTIONAL | runtime, input_size |
An order book entry, not a job specification. module and input travel encrypted over compute/1.0 to the seller that wins the quote; commit binds them in advance so the work cannot be swapped after quoting. verify declares which evidence a later dispute will admit.
30811 — job receipt
| Tags | |
|---|---|
| REQUIRED | d, commit, scheme, expiration |
| OPTIONAL | — |
D is HKDF-SHA256 over the buyer/seller ECDH x-coordinate under blinding.job's salt and info; commit is SHA256(blinding || canonical receipt); blinding is fresh random per commitment (commitment.blinding_factor). The ask, module, input, result, fuel_used, paid_msat, payment_hash and executor are all inside the ciphertext: a receipt is a positive event, and this document already declines to publish those. A seller MAY publish a cleartext receipt where the buyer has consented.
Kinds reused rather than reinvented
meshstr defines new kinds only where nothing existing fits. Everything else is an existing NIP, used unmodified:
| Kind | Used for |
|---|---|
1059 |
NIP-59 gift wrap - store-and-forward control transport |
1984 |
NIP-56 reporting - user content, feeds author grants |
1985 |
NIP-32 labeling - subjective moderation classification bridge |
5000-5999 |
NIP-90 DVM compute requests - ingress gateway to meshstr WASI |
6000-6999 |
NIP-90 DVM compute responses - egress results from meshstr WASI |
7000 |
NIP-90 DVM compute feedback / job status |
9321 |
NIP-61 nutzap proofs - asymmetric rate budget settlement |
10002 |
NIP-65 relay list |
10040 |
NIP-85 trusted provider list - how a client chooses whose kind 30385 to read, under 30385:rank |
10050 |
NIP-17 DM relay list - gates kind 1059 replication |
10063 |
Blossom server list |
10166 |
NIP-66 relay monitor announcement |
21059 |
NIP-59 ephemeral gift wrap - live control transport, default |
30166 |
NIP-66 relay discovery - what a node's relay face is and accepts, one per ws or wss endpoint; see discovery in constants.json. Liveness is not in it: that is a measurement for a NIP-66 monitor to take |
30385 |
NIP-85 trusted assertions - the relay score a node projects from its own kind 30802 reports: d is the subject's relay URL in canonical form, k is web (NIP-73), a cites the report; see projection in constants.json. PR #2418 proposes a differently shaped relay assertion under the same number and is unmerged |
30817 |
NostrHub decentralized NIP specification |
It also projects its internal state outward, so clients that know nothing about
meshstr still benefit: a node MAY publish a NIP-66 kind 30166 about its own
relay face, one per endpoint its descriptor declares, carrying what the face
accepts and never a measurement of itself (discovery in constants.json),
and a NIP-85 kind 30385 about the subject's relay URL derived
from its own kind 30802 reports and from nothing else. That projection is
adverse only; its shape is projection in constants.json.
Protocol version
A node declares a version as a name plus the SHA-256 of its machine-readable
schema — kinds.json, messages.json and constants.json, each serialised
with sorted keys and no whitespace, concatenated in that order.
meshstr/0-draft@04cf011f80b295a1f49d6c69e081b8158d603cfa5172163c4400e282fa53e563
Two nodes declaring different digests decline to peer rather than half-speak. During alpha the digest may change on any day and nobody is owed a migration.
Each protocol identifier carries a version of its own, as in sync/1.0. Those
versions are informative labels. The digest is normative: it changes whenever
any message set or body field changes, and a node negotiates nothing below it.
An identifier's version is bumped when its messages or fields change, so a
reader can see which protocol moved without diffing the schema.
Status
Alpha draft. One implementation exists and it is alpha: a node that runs the whole of this document over a wire, in the repository this NIP is published from. It is what found the defects the design notes record, which is the only claim being made for it.
Every grant, receipt and report from the alpha period is discarded at 1.0. Standing accumulated against constants that changed underneath it is not standing. Nobody should join the alpha for a head start; they should join it to find out whether the thing works.
This is deliberately not submitted as a NIP. The NIPs repository requires two
independent implementations and meshstr has one, written by the same hand as
the specification, which is not independence. The kinds
are being registered in
nostr-protocol/registry-of-kinds
instead, which is collision avoidance and nothing more.
- Specification — https://www.meshstr.org/spec.html
- Schemas — https://www.meshstr.org/schemas.html
- Source — https://gitlab.pocketlabs.dev/meshstr/meshstr
- Licence — CC0 1.0 Universal
Cited links
meshstr.org
The meshstr Protocol
An alpha, permissionless relay network for nostr: the specification, in ten parts.
meshstr.org
meshstr.org — JSON Schema Specifications (Draft 2020-12)
github.com
GitHub - nostr-protocol/registry-of-kinds
Contribute to nostr-protocol/registry-of-kinds development by creating an account on GitHub.
Discussion
Connect a key to comment.