The content model of a curated registry of gno.land networks: Zone,
Endpoint, their validation, and the curation state machine, held by a
Registry.
1import"gno.land/p/moul/zones/v0" 2 3r:=zones.NewRegistry() 4must(r.Propose(proposer,height,"onyx",zones.Info{ChainID:"onyx-1",Title:"Onyx", 5Kind:zones.Testnet,RPCURL:"https://rpc.onyx.testnets.gno.land"})) 6z,_:=r.Zone("onyx") 7must(r.ReviewZone("onyx",zones.Approved,z.Revision,curator,height,"")) 8z,_=r.Zone("onyx")// the approval bumped the revision 9id,err:=r.Register(proposer,height,"onyx",zones.Peer,10"g1x5mlj5ava0dw9vkf4j6admjlzswm6f06p44krn@seed-1.onyx.testnets.gno.land:26656","gno core")11must(err)12e,_:=r.Endpoint(id)// a verification names the zone's revision and the endpoint's13must(r.ReviewEndpoint(id,zones.Verified,z.Revision,e.Revision,curator,height,"answers onyx-1"))14r.Endpoints(zones.EndpointFilter{Zone:"onyx",Kind:zones.Peer,Status:zones.Verified})// that one peer
A zone is one network: chain id, title, description, kind (mainnet,
testnet, devnet, local), its main RPC, gnoweb and genesis URLs. An
endpoint is one way into a zone: rpc, gnoweb, seed, peer, indexer,
faucet or explorer, unverified until a curator says otherwise.
The live registry is r/moul/zones, which holds one
Registry and decides who may write to it.
It decides nothing about who may act, deliberately. Every write that
records a decision takes the acting address and the height as arguments (the
two removals take neither: who may remove is the holder's call). Whether that
address is a curator, the proposer, or nobody is the holding realm's call, so
the same model can move under a DAO or a system realm later without a line
changing here.
The curation policy
decision
from
reason
also
approve
pending, rejected, retired; approved, to restate the reason
optional (required to restate)
reject
pending; rejected, to restate the reason
required
verified endpoints go back to unverified (a restatement resets nothing)
retire
approved; retired, to restate the reason
required
verified endpoints go back to unverified (a restatement resets nothing)
edit
pending, approved
none before review, required after
a new chain id un-verifies endpoints; leaving local drops the private ones, at most MaxDropPerEdit (64) in one edit, refused while one carries a curator's ruling
remove
pending, rejected
endpoints go with it
verify an endpoint
any, the same one only with a new reason
optional
zone pending or approved
unverify an endpoint
any, the same one only with a new reason
optional
flag an endpoint
any, the same one only with a new reason
required
Every endpoint verdict, and every removal of one endpoint, names the endpoint's own
Revision, bumped on registration, on every verdict and on every reset, from
the same never-repeating counter zones use: so it fails if another curator
ruled on the endpoint since it was read (a verdict landing unseen would reverse
theirs). A verification also names the zone's Revision, and fails if the zone
changed: what it checks is that the endpoint answers for this zone's chain id.
A flag or an unverify does not, so editing a zone cannot hold off a warning.
The two are named apart, never combined, because a reader who takes them from
two reads at two heights could otherwise combine stale ones into a valid one.
Every decision on a zone binds to the zone as read: its fields and its
status. Every edit bumps the
zone's Revision, registry-wide and never reused (not even by a zone removed
and proposed again under the same slug). Approving, rejecting, retiring,
editing and removing all name it, so an edit that lands between the reading
and the decision makes the decision fail, instead of attaching a name to text
nobody read. Every status change bumps it too, so an approval opened before a
colleague's rejection fails rather than reversing it. Verifying an endpoint
binds the same way, to the revision it was checked against: what was checked
is that it answers for this zone's chain id. An edit that changes nothing is
refused, so a revision only moves when something did. An endpoint's verdict
is not part of the zone and does not move it: a flag on the zone's own main
RPC shows on the zone, it does not invalidate an approval in flight.
Only a pending or approved zone is editable. An edit before review takes
no reason. An edit to an approved zone is a curator decision: the reason is
required and replaces the review on record. A changed chain id, on any zone,
sends every verified endpoint back to unverified, because what was verified
was that it answered for the old one. A reset caused by an edit to a pending
zone by its own proposer records nobody, because that is not a review:
ReviewedBy is empty and Reason says why. Every other reset records the
editor or the reviewer who caused it.
Nothing goes back to pending. A rejected zone is approved after all,
removed, or pushed out by 64 newer rejections.
A zone that was ever official is never removed by a caller. An approved
one is retired, and a retired one is kept until 128 newer retirements push it
out, so whoever still holds its chain id can find out what happened to it.
What a field accepts
Validated at write time, not escaped at render time, for everything that is
also an index key, a URL segment or a config-file line:
Slug: 2 to 32 of [a-z0-9-], alphanumeric at both ends.
Chain id: 1 to 50 of [A-Za-z0-9._-].
URL: visible ASCII with none of <>"'`()[]{}|\^#, no credentials,
no % without two hex digits after it, no &name; shape, a host that is a
DNS name or an IPv4 address (anything a browser would read as IPv4, like
0x7f.1, must be a valid dotted quad), an optional port of 1 to 65535 with no leading zero, in
digits, and a scheme from a short list. A zone's main RPC is http, https
or tcp with no path, query or trailing slash, which is what gnokey -remote
dials; an rpc endpoint also takes ws, wss and a path (a tcp:// one
is host and port only, since gnokey dials it as such), and an indexer
takes ws and wss for its subscriptions. A % never
escapes a character that needs no escaping, and a path has no . or ..
segment, so a URL has one spelling. A host that names a different machine
for every reader is refused except on a local zone: a name with no dot,
.localhost, .local, .internal, .localdomain, the RFC 6761 names
.test, .example and .invalid, the never-delegated .lan, .home,
.corp, .mail, .intranet, .private, .onion, .alt, the
service-discovery, container and overlay names .consul, .lxd, .incus,
.docker, .podman, .localnet, .svc and .i2p, every .arpa
name (infrastructure, never a public service), and IPv4 "this network",
loopback, private, link-local, CGNAT, IETF-protocol, documentation,
benchmarking, 6to4 relay anycast, multicast and reserved ranges.
IsPrivateHost fails closed: anything but a bare host is private to it. A
zone that leaves the local kind drops every endpoint on one, in the same
edit, and the edit is refused while one of them carries a curator's ruling.
An IPv4 host has no terminal dot.
Peer: <node id>@<host>:<port>, the shape p2p.persistent_peers takes,
the node id a lowercase g1 address (tm2 compares node ids byte for byte), and
no terminal dot on an IPv4 host (Go's dialer cannot use one, so URLs refuse
it too).
Address (proposer, registrant, reviewer): a valid g1 address in lowercase.
bech32 also decodes the uppercase form, and here it would be a second identity.
Trimming: tabs and every Unicode space separator (Zs: U+0020, U+00A0,
U+3000 and the like) are trimmed from what a caller types. Anything else at
an edge (a line separator, U+0085) reaches the validator and is refused, not
cut. A zone's gnoweb and genesis URLs, like an endpoint's, have an empty
query's ? and a bare / dropped, as a browser's address bar writes them;
the zone's main RPC, which gnokey dials, is refused with them. An endpoint is
validated as it will be stored.
Free text (title, description, label, reason): one line, bounded in
characters (so at most four times as many bytes), valid UTF-8, with no control
character, no invisible, format, private-use or unassigned character, no
variation selector except right after a character it modifies (FE0E or FE0F
after a symbol or one of the five emoji whose base is punctuation or a letter
by category (‼ ⁉ ℹ 〰 〽), as a phone
writes ❤️ or ‼️; a Mongolian free variant; an ideographic variation sequence), no enclosing mark (it draws a badge's frame around any
character), no run of more than four nonspacing marks, none of the
status glyphs a Render draws nor their look-alikes, and, unless it is empty,
something visible. Refused rather than stripped, so what is stored is what is
shown. It must still be escaped by whoever renders it. Two consequences:
the zero-width joiner and non-joiner are refused, so the Persian and Sinhala
spellings and the emoji sequences that need them cannot be written; and
"assigned" means in the chain's own Unicode tables, 15.0, so a character
assigned since is refused on chain though gno test (which uses the host's
tables) accepts it.
An endpoint, like a zone's URLs, is stored with its scheme and host lowercased
(a peer lowercased whole, without its host's terminal dot; a URL without an
empty query's ? or a bare /, which gnokey would dial), so every later
comparison finds nothing to change, and deduplicated on
Canonical: scheme and host lowercased, a terminal dot, a default port, an
empty path before a query, an empty query's ? and a bare / dropped, %XX
hex uppercased, an rpc tcp:// read as the http:// gnokey dials, anything
meaningful after the host kept as typed. The same address under two kinds is two
endpoints.
Bounds
The live registry, pending and approved zones together, holds at most 256.
Rejected and retired zones are kept for the record but do not count against it:
each state keeps at most 64 and 128, and the next one in drops the zone that has
been in that state longest, endpoints and all. So no flood, no curator and no
amount of time fills the registry for good.
The last ReservedForReviewers (16) places of each hard cap, the live registry
and a zone's endpoints, take only the exempt calls: strangers who keep a cap
full would otherwise lock out the curators who act under it.
Per-address caps make one address cheap to ignore: 4 pending proposals, 16
endpoints on a zone. Neither stops a flood from many addresses, so the review
queue has an admission gate of its own: 64 pending zones in all, 64 endpoints
per zone waiting for a verdict, under a hard 128 per zone. A flagged endpoint
has its verdict and leaves the queue, so curators keep warnings instead of
deleting them to make room. The gate is checked where something enters, so a
review or a reset can push a count past it. A flood fills the queue and never
crowds out an approved zone or a verified endpoint, and ProposeExempt and
RegisterExempt skip the gate and the per-address caps (not the hard caps) for
the reviewers the holding realm trusts, so a full queue never locks out the
people who clear it. An endpoint registered that way is marked Exempt.
Clearable is an endpoint nobody ruled on that is not Exempt, what a bulk
clear of a flood may remove, and Registry.Clearable counts them without
reading one; since the gate holds the queue, there are never more than 64. Each entry costs its sender a storage deposit, refunded to
whoever signs the transaction that frees it (on a chain with transfers locked,
to the storage fee collector instead), so a flooder who withdraws first gets it
back: a bond, not a fee.
Storage
Records live in a B+ tree, the keyed, ordered container EFFECTIVE_GNO recommends
for iteration and pagination (671 B per entry at fanout 32), with ids that are
never reused. Every tree here, kit/index's included, is at fanout 32, not 128:
a removal shifts every later value in its leaf, and each shifted value is
rewritten, about 90k gas apiece for a number and 230k for a pointer on a real
node, so a smaller leaf bounds what one removal costs. kit/store has that
shape on an avl tree (2,029 B), and on an immutable path the choice is
permanent. Three B+ trees hold an id per key: slug, the endpoint dedup key (a
128-bit hash of the canonical address rather than a second copy of it), and the
order zones entered their state. Four hold a count per key: pending proposals
per proposer, endpoints per registrant on a zone, flagged and clearable
endpoints per zone. The other six are kit/index: status,
approved-by-chain-id, and for endpoints zone, zone-and-kind, not-verified and
on-a-private-host (so leaving local reads only those). Every one is written in
the same method as its record; counts come from the indexes without reading a
record, and a page reads the records on it only (the bucket's id list, at most
256 or 128 ids, is read whole). Measured on a node at the caps, with every text
field at full length in the costliest characters and 240-character hosts: the
heaviest page is a zone page of 25 flagged rows with full reasons, about 1.5B of
the 3B query cap. A removal shifts the later records in its B+ tree leaf, and
borrows from a neighbour when the leaf underflows; endpoint ids are handed out
registry-wide, so what a removal of a zone's endpoints costs depends on how they
interleave with other zones'. With them registered together, a rejection or
retirement that evicts a full zone costs about 0.39B; in the worst layout a
registrant can arrange, with the shifted neighbours at full size and flagged and
the zone's verified endpoints spread across leaves, 2.35B (78% of a 3B block),
the heaviest write (an earlier 2.20B layout was sent to a node capped at 3B and
landed). That is also why an edit leaving local drops at most 64 (2.02B worst,
with a chain-id change and both own URLs moved in the same edit).
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:
⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.
Overview
Package zones is the content model of a curated registry of gno.land networks: what a zone is, what an endpoint on one is, which strings each field accepts, and the curation states both move through.
A zone is one network a person can point a node or a wallet at: mainnet, a testnet, a staging chain, somebody's gnodev. An endpoint is one way in: an RPC, a gnoweb, a seed or persistent peer, an indexer, a faucet, an explorer. Anybody may propose a zone, and register endpoints as the holding realm allows; a curator decides which zones are official and which endpoints are verified. Every decision is recorded with who made it and when; a rejection, a retirement, a flag and an edit to an approved zone also require a reason, which the public reads.
The package decides nothing about WHO may act. Every write that records a decision takes the acting address and the height as arguments (the two removals take neither), and the realm holding the Registry decides whether that address is a curator, the proposer, or nobody. That is the split that lets the same model move under a different authority later (a DAO, a system realm) without a line changing here.
1const( 2MaxZones=256// pending and approved zones together 3MaxPending=64// zones awaiting review, all proposers together 4MaxPendingPerProposer=4// open proposals one address may have at once 5MaxRejected=64// rejected zones kept for the record 6MaxRetired=128// retired zones kept for the record 7MaxEndpointsPerZone=128 8MaxUnverifiedPerZone=64// endpoints on one zone still waiting for a verdict (flagged ones have theirs) 9MaxEndpointsPerAddress=16// endpoints one address may register on one zone10// MaxDropPerEdit bounds how many endpoints one edit off the local kind11// drops, and so its gas: a removal rewrites up to about 45 later values in12// two B+ tree leaves, and a caller can lay ids out so every removal does,13// which at 128 measured 2.6B, close to a 3B block, and at 64 measures14// 2.02B even with a chain-id reset spread across leaves and both own URLs15// moved in the same edit. More waits for removals.16MaxDropPerEdit=6417// ReservedForReviewers is how much of each hard cap (MaxZones live,18// MaxEndpointsPerZone) only the exempt calls may use: strangers who keep19// a cap full refill it the block after a curator clears it, and a cap a20// curator cannot reach is one they cannot act under.21ReservedForReviewers=1622)
The LIVE registry, pending and approved zones, has a hard cap. Rejected and retired zones are kept for the record but do not count against it: each has a cap of its own, and when it is reached the zone that has been in that state longest is dropped to make room (by when it entered the state, not when it was proposed, so a long-lived network retired today is not the next to go). So nothing a proposer or a curator does, and no amount of time, can fill the registry for good: a cap that only ever fills is a lifetime cap, and on an immutable path that is a brick.
The per-address caps make one address cheap to ignore. Neither stops a flood from many addresses, because an address is not an identity, so admission to the review queue has a gate of its own (MaxPending zones, MaxUnverifiedPerZone endpoints awaiting a verdict), well under the hard caps: a flood fills the queue and never crowds out an approved zone or a verified endpoint. A flagged endpoint has its verdict and leaves the queue, so curators keep a warning instead of having to delete it to make room. The gate is checked where something enters (Propose, Register); a review or a reset can push a count past it, and never fails for it. ProposeExempt and RegisterExempt skip it, and the per-address caps, for the reviewers a holder trusts: a full queue must not lock out the people who clear it. The queue clears through approval, verification, a flag, rejection, RemoveZone, RemoveEndpoint, an edit leaving the local kind, and eviction. Flagged endpoints still count toward MaxEndpointsPerZone: a flood a curator flags rather than removes can fill the places a gated registration may use (all but the last ReservedForReviewers), and removing it is the answer. Each entry costs its sender a storage deposit, refunded to whoever signs the transaction that frees it (on a chain where ugnot is not transfer-locked; on one that is, the refund goes to the storage fee collector), so a flooder who withdraws first gets it back: a bond, not a fee.
1const(2ChainIDChanged="the zone's chain id changed; verify again"3ZoneRetired="the zone was retired; verify again if it returns"4ZoneRejected="the zone was rejected; verify again if it is approved"5)
The reasons an endpoint carries when its zone changed under it and sent it back to unverified. A reset caused by an edit to a pending zone by its own proposer records nobody, because that is not a review: ReviewedBy is empty and Reason says why. Every other reset (a rejection, a retirement, anybody else's edit, any edit to an approved zone) records whoever caused it.
1const( 2MinSlugLen=2 3MaxSlugLen=32 4MaxChainIDLen=50// tm2's own limit on a chain id 5MaxTitleLen=64 6MaxDescriptionLen=512 7MaxURLLen=256 8MaxLabelLen=64 9MaxReasonLen=28010)
Bounds on every caller-supplied string. A bound rather than none: every stored byte locks a storage deposit, and an unbounded field is a bill a stranger chooses the size of.
1const(2RPCEndpointKind="rpc"// a tm2 JSON-RPC: http(s) and tcp for gnokey, ws(s) for a subscriber3GnowebEndpointKind="gnoweb"// a gnoweb frontend4SeedEndpointKind="seed"// a p2p seed, for p2p.seeds5PeerEndpointKind="peer"// a p2p node, for p2p.persistent_peers6IndexerEndpointKind="indexer"// a tx-indexer GraphQL endpoint7FaucetEndpointKind="faucet"// a faucet page or API8ExplorerEndpointKind="explorer"// a block explorer9)
1const( 2// Pending is a proposal nobody has reviewed yet. Every zone starts here. 3PendingStatus="pending" 4// Approved is an official zone: the one state a reader should trust. 5ApprovedStatus="approved" 6// Rejected is a proposal a curator turned down, with the reason kept. 7RejectedStatus="rejected" 8// Retired is a zone that was official and no longer runs. It stays 9// listed, because a node operator holding its chain id deserves to find10// out why nothing answers, until MaxRetired newer retirements push it out.11RetiredStatus="retired"12)
1const(2// Unverified is every endpoint until a curator looks at it. Listed, and3// labelled as such, never hidden: an unverified RPC is still an RPC.4UnverifiedVerification="unverified"5// Verified means a curator checked it answers for this zone.6VerifiedVerification="verified"7// Flagged means a curator says do not use it, and says why.8FlaggedVerification="flagged"9)
Canonical is the form two addresses are compared in, so one endpoint cannot be listed twice under two spellings: the scheme and the host lowercased (both are case-insensitive), the host's terminal dot dropped, the scheme's default port dropped (:443 for https and wss, :80 for http and ws), an empty path before a query dropped, an empty query's "?" and a bare "/" dropped, the hex digits of every %XX escape uppercased, and for an rpc endpoint tcp:// read as http://, which is how gnokey dials it. A path or a query that says something is kept as typed: those are case-sensitive and name different resources. A peer is lowercased whole and loses its host's terminal dot: its node id is lowercase bech32 and its host is a name.
HasVisible reports whether s has at least one character a reader can see: not a space, not a combining mark, not an invisible format character, not a blank filler. A required title or reason must, or it passes the "needs a reason" check and renders blank.
HostOf returns the host of an endpoint address: the part between :// and the port or path of a URL, or between @ and the port of a peer. It assumes an address that already passed validation.
IsPrivateHost reports whether host names a machine that is different for every reader, or no machine at all:
a name with no dot (resolved through the reader's own search domain), or one under a suffix reserved for local or private use, or that public DNS does not delegate: .localhost, .local (mDNS), .internal, .localdomain, .test, .example, .invalid, .lan, .home, .corp, .mail, .intranet, .private, .alt, .onion and .i2p, the service-discovery and container names .consul, .lxd, .incus, .docker, .podman, .localnet and .svc, and every .arpa name, which is infrastructure and never a public service (.home.arpa, ipv4only.arpa, default.service.arpa). Hosts files' localhost4, localhost6, ip6-localhost and ip6-loopback have no dot and fall under the first rule;
an IPv4 address in a loopback, private, link-local, carrier-grade NAT, "this network", IETF-protocol, documentation, benchmarking, 6to4 relay anycast, multicast or reserved range;
anything else shaped like a number but not a valid dotted quad, so an outside caller is not told 127.1 is public (validation refuses it anyway).
It fails closed: anything but a bare host ([A-Za-z0-9.-] only) is private to it. It looks at the string only; a public name that resolves to a private address is beyond what a registry can know.
IsReset reports whether an unverified endpoint is unverified only because its zone changed under it: its reason is one of the three above. A reset reaches only a verified endpoint, so it says nothing against the endpoint itself.
TrimSpaces trims spaces (Unicode Zs: U+0020, no-break space, U+3000 and the like) and tabs from both ends, and nothing else. strings.TrimSpace also strips line breaks and separators (CR, LF, U+0085, U+2028), which free text refuses: trimmed first, they would be accepted silently, changed. A line break a caller types reaches the validator and is refused there.
ValidAddress reports whether a is a valid g1 address in the one spelling the chain uses for it: lowercase. bech32 also decodes an all-uppercase string, so IsValid accepts G1ABC…, but every comparison here (curators, proposers, registrants, node ids) is on the string, and an uppercase spelling of a real address would be a second identity for it.
ValidateSlug accepts 2 to 32 characters of [a-z0-9-], starting and ending with a letter or a digit.
Checked at write time rather than escaped at render time, deliberately: the slug is also an index key and a URL path segment, so one carrying a slash or a pipe would break the link and the table as well as the page.
1typeEndpointstruct{ 2IDint64 3Zonestring// the zone's slug 4KindEndpointKind 5Addressstring// a URL, or id@host:port for a seed or a peer 6Labelstring// who runs it, or what it is, in the registrant's words 7Registrantaddress 8RegisteredAtint64 910StatusVerification11ReviewedByaddress12ReviewedAtint6413Reasonstring14// Revision is bumped, from the registry-wide counter zones use, when the15// endpoint is registered, on every verdict and on every reset. A verdict16// and a removal name it, so either fails on an endpoint that changed17// after it was read.18Revisionint6419// Exempt marks an endpoint registered through RegisterExempt, by a20// reviewer the holder trusts: never clearable as a never-reviewed one,21// whoever is a reviewer later.22Exemptbool23}
Clearable reports whether nobody has ruled on the endpoint and it was not a reviewer's own registration: no verdict (every verdict names its reviewer), no reset (every reset leaves a reason), not Exempt. It is what a bulk clear of a flood may remove.
1typeInfostruct{2ChainIDstring// what a node's genesis and a signer's -chainid say3Titlestring4Descriptionstring5KindKind6GnowebURLstring// optional: a local chain may not run one7RPCURLstring// required: the one endpoint every tool needs8GenesisURLstring// optional: where to download genesis.json9}
1typeRegistrystruct{ 2// The two sequences live behind a pointer like every container here, so a 3// copied Registry value shares them: with them inline, r2 := *r1 would 4// share every zone but count revisions on its own, and hand out a revision 5// r1 had already used, which a stale decision would then pass. 6ids*sequences 7 8zones*table 9bySlug*unique// slug -> zone id10byStatus*index.Index// status -> zone ids, so a page reads only its rows11entered*unique// status|entry sequence -> zone id: who has been in a state longest12byProposer*counter// proposer -> how many PENDING zones they have13approved*index.Index// chain id -> ids of APPROVED zones naming it1415endpoints*table16byAddress*unique// slug|kind|hash(canonical address) -> endpoint id17byZone*index.Index// slug -> endpoint ids18byKind*index.Index// slug|kind -> endpoint ids, so a page reads only its rows19byOwner*counter// slug|registrant -> how many endpoints they registered20unchecked*index.Index// slug -> ids of endpoints that are not Verified21flagged*counter// slug -> how many of those are Flagged22fresh*counter// slug -> how many endpoints are Clearable23private*index.Index// slug -> ids of endpoints on a private host (a local zone's only)24}
Registry holds the zones and their endpoints. The zero value is not usable: call NewRegistry. All its state is behind pointers, so a copy of the value is the same registry, not a second one sharing half of it.
Every write touches its record and all of that record's indexes in one method. The index writes cannot fail as written: every key is validated non-empty and every unique key is checked free before the first write. If a later change made one fail, the method returns the error and the holding realm's abort undoes the half-applied write; that abort, not this method, is the atomicity guarantee.
ApprovedByChainID returns the approved zones naming that chain id, in proposal order. Usually one, but chain ids are not unique across networks (every gnodev is "dev"), so it is a list. Only approved zones are indexed, so proposals naming a real chain id cost a reader of this nothing.
Edit replaces a zone's Info: every field but the slug and the status.
Only a pending or an approved zone can be edited. A rejected one is removed and proposed again, or approved first; a retired one is a record, and its retirement reason is the thing it is kept for.
Every edit bumps the zone's Revision and records who made it and when. On a pending zone that is all, and a reason is refused rather than silently dropped: nobody has reviewed anything yet. On an approved zone an edit is a curator decision of its own: the reviewed values changed, so the review on record is replaced by this one, with a reason required.
When the chain id changes, whatever the status, every endpoint verified against the old one goes back to unverified: what was verified was that it answered for a chain this zone no longer names. A reset caused by the proposer's own edit to a pending zone records no reviewer; any other records the editor. An edit that changes nothing is refused, and leaving the local kind removes every endpoint on a private or special-use host (IsPrivateHost), at most MaxDropPerEdit in one edit, and is refused while one of them carries a curator's ruling.
An edit names the revision it was written against, like an approval does, so two editors working from the same reading cannot silently undo each other.
EndpointPage returns page (1-based) of a zone's endpoints of that kind, "" for every kind, size per page, oldest first. Like Registry.ZonePage it reads the records on the page only, and the bucket's id list (at most MaxEndpointsPerZone ids) whole.
Endpoints returns copies of the endpoints passing f, oldest first. With a zone set it reads that zone's ids only (that kind's, with a kind set), so it costs at most MaxEndpointsPerZone records. Without one it reads every endpoint in the registry: bound it yourself.
ProposeExempt is Propose without the admission gates (MaxPending and MaxPendingPerProposer), for the reviewers a holding realm trusts: a flood that fills the queue would otherwise lock out the people who clear it. It also takes the last ReservedForReviewers places of the live registry, which Propose may not; MaxZones itself still holds.
Register adds an endpoint to a zone, Unverified, and returns its id.
A zone takes endpoints while it is pending or approved, and a rejected or retired zone takes none. Who may register on a pending zone is the holding realm's call; a URL is stored with its scheme and host lowercased and without an empty tail (trimEmptyTail), a peer lowercased whole without its host's terminal dot, so what is listed is what dials (Canonical, which also drops default ports and reads tcp as http, is what they are compared in, not what is stored).
RegisterExempt is Register without the admission gates (MaxUnverifiedPerZone and MaxEndpointsPerAddress), for the reviewers a holding realm trusts, as ProposeExempt is, and also takes a zone's last ReservedForReviewers places; the hard cap, MaxEndpointsPerZone, still holds.
RemoveEndpoint deletes an endpoint, freeing its storage deposit to whoever signs the removal. revision is the endpoint's Revision as read: a removal fails if a verdict landed since, rather than delete one nobody saw. Who may is the holding realm's call.
RemoveZone deletes a pending or rejected zone and every endpoint registered on it, freeing their storage deposit to whoever signs the removal. It names the revision it was decided on, so a removal meant for one proposal cannot land on another proposed again under the same slug. A zone that has ever been official cannot be removed this way: an approved one is retired, and a retired one is kept until MaxRetired newer retirements push it out, so whoever still holds its chain id can find out what happened to it.
ReviewEndpoint records a curator's verdict on an endpoint. Any verdict may follow any other, and the same one again with a new reason (so a typo is corrected without passing through a state its registrant may remove it from), with these rules:
every verdict names the endpoint's Revision as read, and fails if the endpoint changed since: a verdict landing after another curator's, unseen, would silently reverse it;
flagging needs a reason, because "do not use this" with no why is not something an operator can act on;
a verification also names the zone's Revision it was checked against, and fails if the zone changed since: what is verified is that the endpoint answers for THIS zone's chain id, and a proposer could otherwise switch it while the verdict is in flight. A flag or an unverify does not, so an edit to the zone cannot hold off a warning;
only an endpoint of a pending or approved zone can be verified.
ReviewZone records a curator's decision on a zone.
The transitions are the curation policy, and they are deliberately few:
Example
1approve from pending, rejected or retired reason optional
2reject from pending reason REQUIRED
3retire from approved reason REQUIRED
and a decision restated: the zone's present status again, with a new visible reason, which records the new reviewer and bumps the revision and does nothing else (no new entry in the state, no eviction, no reset). It is the only way to correct a reason.
Every decision names the zone's Revision the curator read, and fails if the zone changed since: otherwise a proposer's edit landing just before an approval would become official under the curator's name, and a rejection's public reason would describe text the curator never saw.
Rejecting or retiring sends every verified endpoint back to unverified: a rejected zone was never vouched for, and a retired network's peers may be somebody else's hosts by the time anyone reads them. Each state keeps at most MaxRejected or MaxRetired zones; the next one in drops the zone that entered that state first, with its endpoints. Every transition bumps the zone's Revision, so a decision prepared against the old status fails.
Nothing goes back to pending: a rejected zone is approved after all, removed, or pushed out by newer rejections. An official zone is never rejected after the fact, it is retired, so the record of it having been official survives.
SoleApproved returns the approved zone naming that chain id when there is exactly one, which is the only case a reader can be pointed at it without a guess. It counts first and reads one record at most.
Zones returns copies of the zones passing f, in the order they were proposed. With a status set it reads that status's zones only.
The result, like Endpoints', is capped at its length. A slice handed to another realm is readonly there: an append that fits in spare capacity writes into it and aborts, one that does not copies and succeeds. Uncapped, an importer's append would pass or abort by how many rows the filter dropped, which strangers decide by registering.
1typeZonestruct{ 2Slugstring// the key: [a-z0-9-], stable, and what a URL carries 3ChainIDstring 4Titlestring 5Descriptionstring 6KindKind 7GnowebURLstring 8RPCURLstring 9GenesisURLstring1011StatusStatus12Proposeraddress13ProposedAtint641415// Revision changes on every edit of the zone's Info, on every status16// change and on every restated decision, and is never reused,17// not even by a zone removed and proposed again under the same slug. A18// curator acting on a zone names the revision they read, so an edit that19// lands between their reading and their decision makes the decision fail20// instead of attaching their name to text they never saw.21Revisionint6422EditedByaddress// who last edited the Info; empty if nobody has23EditedAtint6424Enteredint64// when it entered its current status, in registry order: eviction goes oldest first2526// The latest curator decision, empty until there is one. A curator's edit27// to an approved zone is a decision too, and replaces these.28ReviewedByaddress29ReviewedAtint6430Reasonstring31}
Flat on purpose: every field is a scalar. A nested struct inside a persisted object is stored as an object of its own, and `gnokey query vm/qeval` prints it as an opaque ref(...) instead of its fields, so a reader asking a node for a zone would get the slug and nothing they came for. Zone.Info gives the editable part back as one value.