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.

fleetmesh

published
2026-09-26

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.

  1. 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.
  2. Announce. Join the libp2p gossipsub topic /meshstr/announce/1 and republish the descriptor there, signed under StrictSign by the transport key.
  3. Ask. Open peering/1.0/propose to any node whose descriptor declares the protocols you need. discover-features/2.0 confirms 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:

  1. Announce. A heartbeat carrying retiring, before anything is given away, so the silence that follows is not priced as stress by anyone listening.
  2. 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.
  3. 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.
  4. 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.
  5. 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. outcome is S1..S4. There is no positive attestation.
  • Evidence or nothing. A report MUST name, in its grant tag, a grant the reporter itself issued to the subject, and every evidence entry 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.

Cited links

Discussion

Connect a key to comment.