EXTENSION-REGISTRY

Version: 1.21 Status: Active Depends: ENTITY-CORE-PROTOCOL.md (v7.40+); EXTENSION-ATTESTATION.md (v1.3+) — the supersedes-chain discipline that binding revocation and superseded-binding retention are defined against (§3, §6.5, §7) Related: EXTENSION-RELAY.md (Mode S can host a registry peer's tree; Mode A gates cross-registry federation, deferred from v1 — §8.2); EXTENSION-CONTENT.md (binding entities live in the content tree); EXTENSION-DISCOVERY.md (the sibling mechanism — peer-finding, not name lookup); EXTENSION-NETWORK.md (bootstrap endpoints) Tier: Operational — Tier 2b (network), per SYSTEM-ARCHITECTURE.md §13.1. Authors: Architecture team.

⚠ COMPLETENESS — this extension is v1, NOT finished

The substrate + the two concrete v1 backends are landed and implemented. Several pieces are specified-and-deferred or not-yet-designed. Do not read "Landed" as "complete."

✅ Landed + implemented (v1): resolver substrate (§2–§5); local-name backend (§6); peer-issued resolve + curated registration (§6a.1–§6a.8).

🟡 Design folded, implementation deferred or partial: service advertisement (§3b, folded v1.5) — the system/registry/service-advertisement entity and the §3b.1 services field are specified; §3b.2/§3b.3's rendezvous-hash selection function over a caller-supplied pool is a separable half and is the simpler build. Peer-issued live registration open/allowlist/manual (§6a.9); the manual-approval path (§6a.9.3, ruled v1.3, corrected v1.4) together with the four gaps v1.4 rules — the denied status enumeration, the un-typed decision input, the superseded-head code, and supersession observability. Signed binding-manifest (§6a.7) — format locked, implementation deferred.

Which peers have built which of these is deliberately not recorded here. A spec that carries build state goes stale silently and gets cited as authority while wrong; per-peer status lives in ROADMAP-EXTENSIONS.md and the cohort's own reports.

🔴 NOT yet designed — outstanding work before this extension is "done":

Tracking: the deferred items are named in §6a.9.1, §8.2, and §12. The PROPOSAL-PEER-ISSUED-REGISTRY-BACKEND that produced §6a is closed/ratified — the domain-control loose end is a forward dependency of the web-native backend work, not an open question of that proposal.


§1 Concept

A registry is a function: name → (peer_id, bootstrap_endpoints, attestations, trust_anchor, ttl). It indirects from human-shareable names to cryptographic peer identities + dial-able endpoints.

This extension specifies the registry substrate (§2 resolver-handler contract; §3 binding entity; §4 resolver-config; §5 capability model) and ships the local-name backend (§6) as the v1 concrete backend that exercises the substrate end-to-end. Additional backends (peer-issued, DID:web, DNS-TXT, DHT, consensus-anchored, aggregator) compose on the substrate and ship in their own proposals; this spec defines the contract they bind to.

Five positions are load-bearing:

  1. Backends are parallel. A deployment installs whichever backends fit its trust model + use cases. No hierarchy is implied by the substrate.
  2. The substrate gates no name claims. Anyone can publish a binding entity claiming any name. Whether a receiver TRUSTS that binding is the receiver's policy.
  3. Mechanism is the mechanism. The substrate exposes the resolver contract + binding shape + trust-evidence machinery + cryptographic verification + composition with RELAY and CONTENT. Deployments configure which backends, what trust anchors, what name formats. Substrate provides knobs.
  4. Registry is just a peer. A registry peer is a peer publishing system/registry/binding/... entities. Mode S relay can host its tree. No special infrastructure role.
  5. Bootstrap-with-precedes. Distributions ship with pre-configured resolver-config + pre-cached precedes; users inherit by accepting the build; override is structural.

Discovery (peer-finding) is a distinct concern — finding peers you don't know exists. That's a separate extension (DISCOVERY, currently DRAFT proposal). Registry resolves names you already know to peers.


§2 The resolver-handler contract

§2.1 The handler operations

The substrate exposes two handler operations across all backends:

system/registry:resolve(name, [hints]) → ResolutionResult
system/registry:invalidate-cache(name | null) → ()    ; null = flush all

ResolutionResult is:

{
  status:        "resolved" | "not_found" | "chain_exhausted",
  binding:       <hash of system/registry/binding entity>,
  peer_id:       <Base58 peer-id per V7 §1.5>,
  transports:    [<system/hash, BARE>],                 ; hashes of system/peer/transport/* profiles,
                                                        ;   ordered; passed through from the binding (§3)
  attestations:  [<hash of supporting attestation entities>],
  trust_anchor:  <variant identifying which backend resolved>,
  ttl:           <ms duration | null>,                  ; positive-result cache hint (§3 is the canonical declaration)
  neg_ttl:       <ms duration | null>,                       ; OPTIONAL negative-cache hint on not_found / chain_exhausted; SHOULD per backend
  backend_id:    <peer_id_hash | identifier>,           ; which backend produced this
  services:      <system/registry/service-advertisement | absent>
                                                        ; OPTIONAL — the deployment's shared infrastructure
                                                        ; set (§3b). Absent is a valid floor (§3b.1).
}

All durations in ResolutionResult (and timestamps throughout this spec) are milliseconds since Unix epoch (UTC, signed int64) unless otherwise stated — aligned with V7 cap-expiry convention.

Wire encoding (MUST; erratum). On the wire, ResolutionResult is the entity type system/registry/resolution-result with the data fields above carried flat under data. Implementations MUST NOT wrap under another envelope type (e.g. system/protocol/status with a {result: {...}} payload). The prose name "ResolutionResult" throughout this spec refers to this on-wire shape. Caught when three impls picked three encodings in the cross-impl run; pinned in place in the registry/discovery cross-impl-run absorption (Ruling-3).

hints is an optional opaque payload for the backend (e.g., DNS resolver override; DHT bootstrap node list). Backends MAY ignore.

§2.2 The meta-resolver pattern

Multiple backends installed simultaneously; the meta-resolver dispatches by name format and/or consults the configured resolver chain:

meta_resolve(name, hints) → ResolutionResult
  for each backend in resolver_chain (per resolver-config):
    if backend.matches(name):
      r = backend.resolve(name, hints)
      if r.status == "resolved":
        validate(r)          ; signature + trust-anchor + receiver policy
        if validated:
          return r
        else:
          continue          ; failed validation; try next
      ; otherwise advance
  return { status: "chain_exhausted" }

The meta-resolver is convention; each backend is independent and registers via the standard handler-registration mechanism.

§2.3 :resolve in the transport-fallback path

REGISTRY isn't just first-contact name resolution. :resolve is part of the transport-fallback loop. When a cached transport endpoint for a peer fails, the dispatcher re-resolves the original name (stored in session state per NETWORK §6.6) to refresh endpoints, then retries the transport:

try(transport T1) → fail → try(T2) → fail
  → registry:resolve(original_name_from_session)   ; name-keyed, not peer-id-keyed
  → refresh transports
  → try(T3 from refreshed set) → ...

:resolve is name-keyed by contract. Reverse peer_id → binding lookup is address-discovery and is scoped to EXTENSION-IDENTITY per §12. NETWORK §6.6's session entity holds the original name that produced the current peer-id binding; the transport-fallback loop re-resolves THAT name. Re-resolves from the fallback loop SHOULD be tagged is_fallback_reresolve: true when logged (see §11.1) — they are NOT counted toward the resolution-log's per-call sampling budget.

The ResolutionResult shape (§2.1) returns transports + ttl — the right output for this loop. Consumers MUST understand that :resolve is invoked on transport failure, not only on cold-start. TTL-bounded caching of resolutions interacts with this: a resolution MAY be re-fetched before its TTL expires if the cached endpoints stop working.

The full loop mechanics (when to re-resolve, how many retries, demotion of failed endpoints) are operational integration; the substrate's contract is the ResolutionResult shape + the "invoked on failure" semantic + the name-keyed re-resolve discipline.

§2.4 Trust-anchor variants

Each backend's trust_anchor identifies what authority chain the receiver must validate against. Built-in variants:

VariantMeaning
self_certifyingname IS peer-id; trivial; no resolution authority needed
local_nameuser's own local assignment; no global meaning
dns_txt:{zone}DNS TXT record at zone; unauthenticated unless qualified (raw DNS TXT carries no integrity; DNSSEC-signed / DoH / DoT qualifications can be expressed as dns_txt:{zone}:dnssec, etc.; receiver policy distinguishes)
well_known_url:{domain}well-known URL at domain; HTTPS PKI authority
did_web:{domain}W3C did:web; same authority as well-known-url
peer_issued:{registry_peer_id}a registry peer signed the binding; trust the peer
consensus_anchored:{chain}:{block}blockchain consensus; trust the chain
out_of_bandexplicit user input; also used for pinned bindings (§5.5)

Backends MAY define additional variants. Receiver policy decides which variants are acceptable for which operations.

§2.4.1 Vocabulary mapping table (cohort-convergence pin)

Three concept axes touch the same backend identity; the canonical mapping (all hyphen-spelled):

binding.kindresolver-config.backend_kindtrust_anchor variant
self-certifyingself-certifyingself_certifying
local-namelocal-namelocal_name
dns-txtdns-txtdns_txt:{zone}
well-known-urlwell-known-urlwell_known_url:{domain}
did-webdid-webdid_web:{domain}
peer-issuedpeer-issuedpeer_issued:{registry_peer_id}
consensus-anchoredconsensus-anchoredconsensus_anchored:{chain}:{block}
out-of-bandout-of-bandout_of_band

Hyphenation is normative. trust_anchor variants use underscores per V7 enum convention because they're encoded as discriminator strings, not type names; binding.kind and backend_kind are field-type enums with hyphenated values matching the spec convention.


§3 The binding entity type

type: "system/registry/binding"
data: {
  name:               <string>,                ; the user-facing name
  kind:               "self-certifying"        ; binding mechanism (see §2.4.1 vocab table)
                    | "local-name"
                    | "dns-txt"
                    | "well-known-url"
                    | "did-web"
                    | "peer-issued"
                    | "out-of-band"
                    | "consensus-anchored",
  target_peer_id:     <Base58 peer-id per V7 §1.5>,  ; the identity, NOT a content-hash
  transports:         [<system/hash, BARE>],   ; hashes of system/peer/transport/* profile
                                               ;   entities (NETWORK §6.5.1), preferred order
  issued_at:          <ms-since-epoch>,
  ttl:                <ms duration | null>,    ; null = sticky until revoked
  supersedes:         <system/hash, BARE | null>,  ; per ATTESTATION supersedes chain
  issuer_attestation: <system/hash, BARE | null>,  ; for peer-issued: the registry's authority cert
  metadata:           <opaque object | null>   ; backend-specific
}

Stored at system/registry/binding/{binding_hash} (universal — every kind, including local-name bodies, lives here). Aggregators MAY re-publish at their own path (see §8). Local-name bindings ADDITIONALLY have a tree pointer at a name-keyed path (see §6.3).

transports carries hashes, not endpoint objects [MUST, v1.21]. Each element is a bare system/hash naming a system/peer/transport/* profile entity (NETWORK §6.5.1). An implementation MUST NOT inline an endpoint object, a profile body, or any other map in this field, and MUST reject a non-byte-string element.

Why the reference and not the object. REGISTRY does not define transport shapes — NETWORK does — and an inlined profile body makes this extension the de-facto definer of a structure it does not own. It also discards the field NETWORK §6.5.1a D5 makes authoritative: D5 binds transport_type to the entity-type suffix and requires decoders to fail closed on a mismatch, so a type-stripped inline map deletes the authoritative source and leaves only the field D5 demotes. And transports is a cached hint, not the binding's substance (§6.3) — §6a.1 puts choosing and performing transports in the transport layer — so a reference is the right encoding for a pointer into another layer's namespace. Content-addressing then stores one profile for the N names that share a target, instead of N copies.

Hash-field shape. All bare-hash fields in this entity — transports' elements, supersedes, issuer_attestation, and system/registry/revocation.revokes — are bare system/hash values (format byte + digest — 33 bytes under 0x00/SHA-256, 49 under 0x01/SHA-384; the length follows the format byte and MUST NOT be fixed, SPECIFICATION-FORMAT.md §8.4.5), NOT wrapped in any envelope or object. Conformance: impls MUST NOT wrap or double-encode them. The conformance claim here is the shape — bare and unwrapped — never the width; a CBOR bstr is self-delimiting, so nothing downstream needs the length to parse the field.

target_peer_id is an identity, not a content-hash. It is the Base58-encoded peer-id per V7 §1.5 multikey form (key_type ‖ hash_type ‖ digest, encoded). Self-certifying naming uses this string directly (name == target_peer_id), NOT hex() of a hash. This is the V7 §1.5 alignment pin.

Self-certifying bindings have no issuer signature; name == target_peer_id; trivially verified by checking that name Base58-decodes to a valid V7 §1.5 peer-id structure.

Local-name bindings have no issuer signature; the user is the trust source (see §6).

All other kinds MUST carry an issuer_signature system/signature entity per V7 §5.2 / §975, carried in the envelope's included map with:

Per V7 §989 invariant-pointer carriage, the signature MUST also be reachable via tree:get system/signature/{hex(binding.content_hash)} so cross-peer fetches (e.g., the §7.4 http-poll ESR flow) can verify without round-tripping to the issuer. This is the V7 §5.2 / §833 refless target-matching contract — NOT a refs: block.

Receiver verifies by:

  1. Locating the system/signature entity via target-matching (data.target == binding.content_hash) in included, OR by invariant-pointer fetch at system/signature/{hex(binding.content_hash)} if not inlined.

  2. Verifying signature cryptographically against the issuer's published key (varies by kind: DNS resolver result; HTTPS fetch; cached registry peer's identity). 2a. Checking binding.name against the name the binding was located under (MUST). A signature proves who issued a binding, never what it was issued for. Any binding located through an index the receiver did not itself author — a by-name pointer, a served listing, a manifest entry — was located at a position the party serving the bytes chose, while the signature covers only the body. A receiver that skips this accepts a validly-signed binding for name X in answer to a query for name Y.

    This step is here, at §3, rather than in a backend section, because it is a property of the body and not of any backend. Every kind carrying an issuer_signature over a body containing namepeer-issued today, and dns-txt / well-known-url / did-web / consensus-anchored when their backends ship — inherits the identical substitution. Scoping the rule to §6a would make the next backend's author re-derive it, which is exactly the separability that the defect consists of.

  3. Applying receiver policy to the trust_anchor variant returned by the backend.

§3.0a Unknown binding kind (forward-compat, mirrors §4.2)

An unknown kind value on a received binding MUST cause the binding to be ignored during meta_resolve with a warning; the binding entity itself remains valid on the wire (forward-compat). Known kinds proceed as normal. This mirrors the §4.2 backend_kind forward-compat rule.

§3.0 One name, many target peers (multiplexing pattern)

A binding carries a single target_peer_id. The "many peers behind one logical name" case — backend pool, multi-region failover, load-balanced front — is handled at the transport layer, not the registry layer: the same target_peer_id may publish multiple transports (per NETWORK §6.5 priority-selection) covering distinct addresses, and a binding's transports field MAY enumerate several. Genuinely multiple distinct peers fronting one name is the aggregator/federation case (§8.2), v1-deferred. v1 registry returns a single binding per resolution (§4.1.1).

target_peer_id rotation under DNS-backed bindings (the DNS record rotates faster than the cached binding's ttl) is mitigated by the §2.3 re-resolve-on-failure loop — a stale binding whose endpoints stop working triggers :resolve re-fetch before TTL expires.

§3.1 Revocations

Revocations travel as supersedes-chain entries (per ATTESTATION extension) — new binding with kind unchanged + a marker that the previous is revoked — OR as a separate system/registry/revocation entity referencing the binding hash:

type: "system/registry/revocation"
data: {
  revokes:    <binding_hash, BARE>,
  revoked_at: <ms-since-epoch>,
  reason:     <string | null>
}

The revocation's authenticating system/signature entity is carried per the same target-matching + invariant-pointer contract as bindings (§3): data.target == revocation.content_hash, data.signer == authority (same authority as the revoked binding), and reachable at system/signature/{hex(revocation.content_hash)}.

:resolve MUST check for a system/registry/revocation targeting a candidate binding before returning status: "resolved". If a revocation is found and verifies against the same authority as the binding, the binding is excluded and meta_resolve advances to the next chain entry. Subscription-driven cache invalidation is the MAY path on top.


§3b The service-advertisement entity

A binding answers who a name is and how to reach them. It does not answer what shared infrastructure a deployment offers — the STUN reflector and signaling/rendezvous carrier a peer needs to establish a direct connection, and the optional relay fallbacks. Before this section that infrastructure was out-of-band configuration, and the consequence was concrete: all three implementations built §3b.2's pool-selection rule and had no protocol way to learn a pool to select from. The browser-leg face of it is a peer negotiating with host candidates only, because nothing fills its ICE server list.

A deployment publishes one signed, static-served system/registry/service-advertisement in its registry zone, alongside its bindings. It is the entity-native SRV-analog to §3's A-record and EXTENSION-RELAY.md §3.5's MX — the same one-signed-zone pattern, one more record type.

system/registry/service-advertisement := {
  deployment: <peer_id>,        ; the deployment/registry identity this set belongs to
  services: {
    reflector:    [+ {endpoint: primitive/string, priority: uint}]   ; STUN URI (§3b.0) — core path
    signaling:    [+ {endpoint: primitive/string, priority: uint}]   ; carrier URL (§3b.0) — core path
    ? data_relay: [+ {endpoint: primitive/string, priority: uint,
                      policy: "open" / "members" / "metered"}]       ; TURN URI (§3b.0) — optional
    ? inbox_relay:[+ {peer_id: <peer_id>, priority: uint}]           ; RELAY Mode-S — optional
  },
  ttl: uint                     ; ms DURATION, per §3 (NOT since-epoch)
}

§3b.0 endpoint is a URI string, and a reflector is not a peer [cross-peer seam — MUST; corrected 2026-08-14]

endpoint here is a primitive/string carrying a URI — it is NOT an EXTENSION-NETWORK.md §6.5 endpoint object. Per service type:

Serviceendpoint formReference
reflectorstun:<host>[:<port>] or stuns:RFC 7064. Non-hierarchical: there is no //.RFC 7064 §3
data_relayturn:<host>[:<port>][?transport=udp|tcp] or turns:RFC 7065. Also non-hierarchical.RFC 7065 §3
signalinga scheme-prefixed carrier URL — ws://, wss://, tcp://host:portmatches EXTENSION-NETWORK.md §6.5.1a D4's inner url value, as a bare string
inbox_relay(no endpoint — carries peer_id)§3b

Why not §6.5, stated as a category rather than a type mismatch. A §6.5 transport profile describes how to reach an entity peer: it carries supported_ops, freshness, nonce_required, and cap_flow, none of which mean anything for a STUN reflector. A reflector is not a peer. It speaks RFC 5389 over UDP (EXTENSION-SIGNALING.md §9.3), holds no identity, completes no handshake, and answers no entity operation — EXTENSION-NETWORK.md §6.7.1 already refuses to conflate the two in the other direction. A TURN relay is the same. Pointing this field at §6.5 was a category error, and the type mismatch below was its symptom.

The mismatch it caused [the reason this is a MUST]. §6.5.1a D4 pins the live form to the object {url: "<scheme>://…"}, while §3b.3 hashes "the advertised endpoint string's bytes exactly as published." An object has no string's bytes, so two implementers could each be textbook-correct and diverge — one hashing the CBOR encoding of the {url: …} map, the other the UTF-8 of the inner url value. Different bytes → different SHA-256 → different argmaxthe two peers select different signaling members and never meet, which is the exact silent failure §3b.2 makes MUST and §3b.3 pins to the byte to foreclose, reintroduced one level down in what identifies a member — a free variable §3b.3's own preamble names.

A second consequence, and the reason the form is pinned and not merely the type. "<scheme>://…" cannot express a valid ICE URL at all. A browser hands these to RTCIceServer.urls, which requires the RFC 7064/7065 non-hierarchical form, and a malformed entry does not degrade — it throws at RTCPeerConnection construction, taking out the establisher rather than falling back to host-only. Publishing the URI in its final form means a consumer hands the published bytes to the ICE agent verbatim, with no transform — which is also the ENTITY-CORE-PROTOCOL.md §1.8 byte-preservation posture, since a re-encode on a boundary is the interop hazard.

It MUST carry a system/signature at the invariant-pointer path (V7 §3.5), verified against the pinned deployment identity exactly as a binding is (§6a.4). A tampered or expired advertisement fails closed — it is discarded entire, never partially honored and never silently downgraded to "no services." Absence and rejection are distinguishable to the peer; only absence is a valid floor.

Service semantics.

Why deployment-scoped and not per-peer. Reflector and signaling are shared infrastructure, not attributes of any one peer; advertising them once per deployment rather than once per peer matches both reality and cost. A peer MAY still override per-peer where it genuinely differs — and the connector-entered-by-URL path, which never performs a resolve at all, is served by EXTENSION-SIGNALING.md §4.5 rather than by this entity.

§3b.0a data_relay carries no credential, and that is a named gap — not a floor

This section specifies where a data relay is, and whether a peer may use it. It does not specify how a peer authenticates to it, and nothing else in this corpus does either. The data_relay member carries endpoint, priority, and policy — there is no username, no credential, and no mechanism that mints one.

What that costs, per policy:

policyUsable todayWhy
openYesan unauthenticated relay needs nothing this field does not carry
membersNoadmission is asserted but not provable — there is no credential to present
meteredNothe same, and this is the policy whose whole purpose is attributing billed bytes to a payer

So two of the three policies are declared and unreachable. A consumer handed a turn: endpoint under members or metered gathers no relay candidates, and — because ICE reports that as an ordinary failure to find a path — the result is indistinguishable from a NAT that could not be punched. An implementation SHOULD refuse a data_relay endpoint it has no credential for, rather than install it and fail silently; a refusal at configuration time is diagnosable and a silent empty candidate set is not.

This is a deferral, stated so the absence is explicit rather than discovered. Specifying a credential channel means choosing an issuer, a rotation model, and a delivery route for a secret rather than an endpoint — none of which the endpoint-shaped machinery of this section extends to. Until it exists, policy: open is the only interoperable data-relay deployment, and the members/metered values are reserved shape, not usable capability. The credential channel is upstream of any question about how relay provisioning reaches a peer or how often it is re-read — there is no rotating secret to route or refresh until something issues one.

§3b.1 The services field on ResolutionResult

When the resolved zone carries a service-advertisement, :resolve returns it in the OPTIONAL services field of ResolutionResult (§2.1). The whole cheap core path is therefore learned in the resolve a peer already performs — no extra round-trip and no second lookup surface.

The field is OPTIONAL and MUST-ignore when absent: a Tier-0 static registry that advertises no live services resolves normally with services absent, and that is a valid floor, not a degraded result.

§3b.2 Intra-pool selection is per-service-type [cross-peer seam — MUST]

Each service entry is a pool, so a deployment can scale a service horizontally. How a peer selects within a pool differs by service type, and getting signaling wrong is a silent cross-peer bug — so the rule is pinned per type, never left to "lowest priority wins."

ServiceIntra-pool selectionWhy
reflectorany member; SHOULD consult several and require agreement (EXTENSION-NETWORK.md §6.7.1; EXTENSION-SIGNALING.md §9.3 states the same MUST). priority is a preference/failover hint only.Every reflector independently yields the same fact. No pair-convergence needed.
signalingclient-side rendezvous-hash (MUST) — both peers compute the §3b.3 weight over the pool and independently select the same member. NOT lowest-priority, and never a round-robin load balancer.Two peers must meet at the same carrier for the seconds of the handshake. Priority-order or an LB splits the pair across servers and the punch never completes — a silent never-meet, exactly like a key-derivation mismatch.
data_relay, inbox_relaypriority-order failover (MX semantics) — lowest priority first, next on failure.Any relay carries the data; the sender alone picks. For a mailbox the recipient's MX is the shard map.

Signaling scales with zero shared state: pool capacity is the sum of its members, each owning a shard of the key space; a hot key is one handshake rather than a hot shard, and no member needs any other's state.

§3b.3 The weight function, pinned to the byte [cross-peer seam — MUST]

"Highest-random-weight" is a family, not a function — operand order, the digest, what identifies a member, and how weights compare are all free variables, and two implementations can each write textbook-correct HRW and split every pair.

For a pool member advertised at endpoint:

weight(k, endpoint) = SHA-256( k ‖ endpoint_bytes )
member              = argmax over the pool, weights compared lexicographically

priority partitions; it does not weight. Select the lowest priority tier present in the pool, then rendezvous-hash within that tier. "Weight the hash" is explicitly not the rule: a weighting function is itself unpinned bytes, and stacking a second invented rule on the first is worse than not tiering at all. Both peers read the same advertisement, so both land in the same tier before the hash runs.

Stale-pool skew (SHOULD). Two peers holding slightly different advertisements may hash to different members. Each peer SHOULD try its top-2 choices, which covers a single-member pool delta cheaply. A "looking-for-you" beacon forwarded within the pool is the heavier mitigation and is not v1 — add it only under measured skew.

§3b.3.1 What the selection vector MUST discriminate [MUST]

A two-member pool is necessary and not sufficient. It catches an implementation that computes the weight wrongly. It cannot catch several implementations that each compute it correctly over different operands — each is internally self-consistent, and a fixture authored by one of them ratifies whichever operand its author chose. That is the cohort-consistency trap ([ADR-0012]: a cohort all passing one author's vectors is cohort-consistent, not independent convergence), sitting one level inside the very rule §3b.3 exists to pin. Two members are not enough to discriminate, which is why the properties below are stated rather than a member count.

This section states the properties the vector must have. The vector's own bytes are not authored here — they are generated and pinned by the conformance oracle, and cited N·0F @ <oracle-commit> like every other published conformance number ([ADR-0012]). A digest hand-written into prose cannot be checked by reading it, which is precisely the failure mode this section exists to prevent.

The selection vector MUST:

  1. Use a pool of at least two members in the same priority tier. argmax over one member returns that member whatever the weight computes, so every construction agrees and a green gate says nothing.
  2. Discriminate the operand (§3b.0/§3b.3). The member endpoints MUST be chosen so that hashing the enclosing map's CBOR encoding, or the inner value of a {url: …} object, or a canonicalized form of the endpoint, yields a different selected member than hashing the endpoint string's published UTF-8 bytes. A vector every candidate operand passes tests nothing.
  3. Separate weight order from endpoint order. The selected member MUST NOT be the lexicographically-first endpoint in the pool, so that an implementation sorting by endpoint rather than by weight fails.
  4. Exercise the tier rule (§3b.3) with at least one member outside the lowest priority tier present, so that hashing before partitioning fails.
  5. Use a real rendezvous key. k MUST be well-formed per EXTENSION-SIGNALING.md §3.1 — varint(format) ‖ digest, 33 bytes, with the format at the SHA-256 floor 0x00 and never the deriving peer's home format — and the vector MUST publish the §3.1 derivation inputs (mode, mode_input) alongside the key bytes, so k is auditable rather than asserted. A leading tag no conformant derivation emits yields a key an implementation may reject before it hashes anything, and a fixture that will not load discriminates nothing; opaque bytes that merely start at the floor parse cleanly and leave a reviewer nothing to check. An implementation consumes the published bytes — the inputs are there to be recomputed, not to make §3.1 a prerequisite for a selection test.

Properties 2–4 constrain which member wins and 5 removes k from the free variables, so the search that satisfies all four runs over the endpoint strings, the priority assignment and the mode_input. That search is the oracle's work, and it is why the bytes are not authored here.

[§11.5-class] — single-impl-invisible. This is the same invisibility as EXTENSION-SIGNALING.md §7.2 fire_at, and it is why the pin went unnoticed while three implementations built the selection function against pools they configured themselves.

§3b.4 Prefer the cheap path [SHOULD]

A peer establishing a connection SHOULD attempt, in order: direct dial → reflector + signaling punch → data_relay → store-and-forward via an inbox_relay. This is simultaneously the correct latency order and the budget-preserving one — a metered relay is touched only after the free path fails.

Stated SHOULD, not MUST, and the reason is the MAY/SHOULD test. Two peers ordering their paths differently still connect: the divergence costs the operator money, it does not split a pair or corrupt a seam, so it is not the latent interop bug a MUST exists to foreclose (contrast §3b.2, where a wrong choice silently never-meets and is therefore MUST). No conformance test can fail a peer for dialing a relay first without knowing the deployment's intent. Its source proposal wrote MUST; the fold downgraded it deliberately and this note records that, so the change is not read as a transcription slip.

§3b.5 Trust of advertised infrastructure

A deployment vouches for the infrastructure it advertises — that is what signing the set means. A peer MAY additionally pin or allow-list specific reflector / relay identities on top. Cross-deployment shared community relays compose with the aggregator federation pattern (§8.2) and are deferred with it, not resolved here.


§4 The resolver-config entity

Deployment-side configuration of which backends + order + trust acceptance:

type: "system/registry/resolver-config"
data: {
  resolver_chain: [
    {
      backend_kind:           <"local-name"|"did-web"|"dns-txt"|"peer-issued"|...>,
      backend_id:             <peer_id_hash | identifier>,    ; e.g., the registry peer-id
      priority:               u32,                            ; ascending; lower = consulted first
      accepted_trust_anchors: [<variant filter>],             ; receiver policy
      hints:                  <opaque object | null>          ; backend-specific config
                                                              ; PINNED KEYS: max_ttl (§6a.9.1 resolver ceiling, ms), neg_ttl
    }
  ],
  pinned_bindings: [
    {
      name:           <string>,
      target_peer_id: <peer_id_hash>,
      reason:         <string | null>                         ; documentation
    }
  ],
  name_format_dispatch: [                                     ; meta-resolver routing
    {
      pattern:        <closed wildcard pattern — `*` is the ONLY metacharacter; see "pattern grammar" below>,
      backend_kinds:  [<kind>]                                ; which backends to consult for this format
    }
  ]
}

Stored at system/registry/resolver-config (peer-local; not synced).

name_format_dispatch is a filter, not a routing table, and it expresses no precedence. Two entries in any realistic configuration match the same name — a catch-all matches everything, and a domain-shaped pattern and a bare authority pattern overlap on every dotted authority. A name matching several entries is eligible at the union of their backend_kinds; evaluation does not stop at the first matching entry. Precedence is resolver_chain[].priority — the filtered backends are consulted in ascending priority order (§4.1 step 3) and the first validated hit wins (§4.1.1). What bounds a broad pattern is therefore not its position in the list but what it is permitted to name — §4.1 step 2's configuration MUST.

A name matching no entry yields the empty set, and the chain reports chain_exhausted (§4.1 step 4, fail-closed) [MUST, v1.14]. This is the same disposition §4.1a already gives the mirror case — a dispatch entry narrowing to a backend absent from the chain — reached from the other side. It is not "no filtering": an unmatched name resolving through the whole chain would consult every name-transmitting backend in it, which is a strictly larger disclosure than the one this section's MUST forbids, arriving by fallthrough. A name that resolves nowhere is a visible, recoverable misconfiguration; a name that resolves everywhere is an irreversible disclosure.

Earlier text here read "a name matching no entry is treated as matching the catch-all", and that sentence could never execute. The catch-all is *, which matches every name — so if a catch-all row is configured, no name fails to match one, and if none is configured, the sentence names a row with no referent. Two implementations read it as "no filtering", which is the one meaning it cannot carry: the catch-all is the most restrictive row in the recommended list.

pattern grammar. The name_format_dispatch[].pattern field matches against the user-facing name string — not against a tree path — and it is therefore a registry-local matcher.

This is the registry's name matcher, and it is the ONLY one [MUST, v1.15]. Every field in this specification that globs a user-facing name uses the grammar below: name_format_dispatch[].pattern (§4) and the issuer policy's name_constraints (§6a.9.1). There is one matcher per registry. Two matchers over one domain diverge silently — the fields sit two subsections apart, both match the same flat name string in the same handler, and the one example the spec gives for each (*.eth, *.lab) is grammar-identical under every candidate reading, so nothing in the document discriminates them. (Earlier text scoped this grammar "to this field." That sentence was written to fence the matcher off from ENTITY-CORE-PROTOCOL §5.4 — which it still does, below — and it fenced off name_constraints as collateral, leaving an admission gate with an undefined grammar.) * matches any run of characters within the name, including none. Examples: *@*.* → DNS-style handles; did:web:* → did:web; *.eth → ENS; * → catch-all (typically local-name). Deployments needing richer matching layer it in the backend, not the dispatch config.

The grammar is CLOSED, and every character that is not * is a LITERAL [MUST, v1.13]. The matcher is a pure wildcard match over the whole name string, anchored at both ends:

dispatch_match(pattern, name):
  ; `*`  — matches any run of characters, including none.
  ; ANY other byte, including `?` `[` `]` `\` `.` `:` `@` `/`, matches only itself.
  ; Any NUMBER of `*` is permitted — `*@*.*` is three, and it is in the table below.
  ; `/` is NOT a separator here: a name is a flat string with no segment structure.
  ; The match spans the WHOLE name; there is no unanchored/substring form.

Implementations MUST NOT delegate this to a path-glob or shell-glob library. Go's path.Match, POSIX fnmatch, and their equivalents all give ? and […] character-class meaning this grammar does not grant, and most of them stop * at a /. A matcher that merely omits those features and one that treats them as literals are indistinguishable until a name or a pattern carries one — the same reason ** had to be rejected rather than left unmentioned in EXTENSION-REVISION §2.4.

No pattern is invalid, so there is no write-time rejection here — every string is a well-formed pattern, because every non-* byte is a literal. That is a deliberate difference from EXTENSION-REVISION's four closed forms, which need a 400 because that grammar can be violated. State it rather than infer it: a registry MUST NOT reject a dispatch pattern for containing ?, [, or \.

Conformance REG-DISPATCH-GRAMMAR-1 (REQUIRED, cross-impl-observable). Four rows: pattern a?c matches the literal name a?c and not abc; pattern a[bc]d matches a[bc]d and not abd; pattern *@*.* matches alice@example.com; pattern x*z matches x/y/z* crosses /, which is the row that fails against every path-glob implementation.

This is not ENTITY-CORE-PROTOCOL §5.4 and MUST NOT be read as it. §5.4 governs paths, where pattern/* is a subtree prefix match; a name is a flat string with no segment structure and no peer-id head, so the path matcher's forms (/*/ peer strip, trailing /* subtree) have nothing to bind to here. The two are separate matchers over separate domains, and neither confers a reading on the other. No ** token exists in either.

§4.1 Precedence order on resolution

When meta_resolve(name) is called:

  1. Pinned bindings override everything. If name matches a pinned entry, return the synthesized result (§4.1.2) immediately. 1a. Authority-part peer-id decode precedes glob dispatch (MUST). For a name of the form name@X, attempt to decode X as a V7 §1.5 Base58 peer-id before applying step 2. If it decodes, X is a verification pin, not a targeting instruction: resolve name through the ordinary chain and require the result's peer_id to equal X, refusing fail-closed and advancing the chain on mismatch (§6a.4's disposition — a pin that resolves elsewhere is the §6a.1a substitution case caught one layer up). This ordering is normative because a broad *@* dispatch entry would otherwise capture alice@z6Mk… and send a private name to a remote registry to answer a question the consumer can answer locally.

  2. name_format_dispatch filter — narrow the resolver-chain to backends whose backend_kind is eligible for the queried name, using the registry-local name matcher defined above (not ENTITY-CORE-PROTOCOL §5.4).

Eligibility is a pure function of the name [MUST, v1.14]:

eligible_kinds(config, name):
  rules := config.name_format_dispatch
  if rules is absent or empty:
      return ALL                                          ; the filter is disabled — see below
  matched := [ r for r in rules if dispatch_match(r.pattern, name) ]
  return union( r.backend_kinds for r in matched )        ; the EMPTY SET if nothing matched

; a resolver_chain entry is consulted IFF entry.backend_kind ∈ eligible_kinds(config, name)

A kind reaches eligibility only by being named. There is no per-backend default and no "match all" for a kind that appears in no rule: matched is a set union over the rules, so a kind named nowhere is eligible nowhere. Row order is irrelevant — the union is order-free, which is why this list carries no precedence (§4) and resolver_chain[].priority carries all of it.

An absent or empty name_format_dispatch disables the filter entirely, and that is the only place "no filtering" is correct. It is a real discontinuity — zero rules admit every kind, one non-matching rule admits none — and it is deliberate: it is the ordinary filter-absent versus filter-present-and-excluding distinction, and a peer whose chain holds only name-blind backends transmits nothing either way. What makes it safe is the MUST below, which reaches it.

This paragraph previously carried a second, per-backend sentence"backends without a name_format_dispatch entry default to match all; backends with one are consulted ONLY when the pattern matches"and it contradicted the union rule above. It was a category error rather than a wording problem: rules name backend_kinds, not backends, so "a backend without an entry" has no referent. Three implementations reached three behaviours from one paragraph. The mechanism is now stated once, as a function.

This is the primary privacy mechanism — without it, the queried name leaks to broad-matching backends earlier in priority.

Consequently: a distribution's shipped system/registry/resolver-config MUST NOT make a name-transmitting backend eligible for an unscoped name [MUST, v1.14]. The name-transmitting kinds are dns-txt, well-known-url, did-web and consensus-anchored (the table below). The rule binds the configuration as a whole, not one row, and it has two doors:

A third door — leaving a name-transmitting kind out of every rule so it "defaults to match all" — is closed by construction by the union rule above, and needs no clause.

An unscoped name discloses every bare name a user types — including a private handle or a typo — silently, on the happy path, in a configuration the user did not choose, and irreversibly. An operator MAY override this on their own peer; a distribution MUST NOT ship it.

Stated at the width of the invariant, not of the instance. An earlier form bound only the catch-all row, which made it evadable by not writing that row.

Eligibility here is kind-scoped, not chain-scoped [MUST, v1.17]. The rule is violated by naming a name-transmitting kind in a rule that matches unscoped names — independent of whether resolver_chain currently carries an entry of that kind.

The reason is that this MUST binds a distribution, and a distribution's artifact is extended by parties it will never see [v1.18]. Under a chain-scoped reading, "this shipped resolver-config is safe" is not a property of the shipped artifact — it is a property of the artifact paired with whatever the downstream operator later adds to resolver_chain. The distribution cannot evaluate that pairing, cannot re-review after it changes, and by §1's bootstrap model the user has already inherited the config by accepting the build. A safety property that does not survive extension is not one a shipper can be held to, and holding shippers to it is the entire purpose of this sentence. Kind-scoped makes validity a function of name_format_dispatch alone, so a reviewed artifact stays reviewed.

The failure costs are asymmetric, and that settles the residual doubt. A kind-scoped false positive refuses a configuration that is presently harmless: the operator deletes a kind from one row, sees the diagnostic immediately, and loses nothing. A chain-scoped false negative admits a configuration that discloses every bare name a user types the moment an unrelated chain entry appears — silently, on the happy path, and by this section's own words irreversibly. Where one direction is a visible edit and the other is silent irreversible disclosure, the conservative reading is the correct one even at some cost in refused-but-harmless configurations.

What this rule is not justified by, recorded because it was published as the reason and it is weaker than it looked [v1.18]. The first statement of this ruling argued that a chain-scoped config arms silently when an operator later adds the backend. It does not arm silently — the paragraph above binds the configuration as a whole, so adding that chain entry is itself a write the §4.3 check evaluates against the whole config, and a chain-scoped implementation would refuse it at that moment. The hole the "silent arming" argument describes is closed by whole-config validation under either reading. What survives is the monotonicity argument, which is about who can evaluate the property and when, not about detectability.

The MUST binds the write, not the load, and those are different acts with different actors [MUST, v1.17]. A resolver reading a stored config sees only bytes: §6a.9.2's store-first rule puts an operator's edit and a distribution's seed in the same entity at the same path, so at load the two acts are indistinguishable by construction. Enforcement therefore lives at the two points where an actor is present:

Provenance is a property of the write, not of the bytes — which is why it is expressible here and nowhere else [v1.18]. "Did a distribution ship this, or did the operator choose it?" cannot be answered by inspecting a stored entity, and it must not be answered by a field inside one: a field is written by whoever writes the bytes, so a distribution could simply set it, and it would move a content-addressed type's hash to carry a claim it cannot secure. The distinction is an act, so it is carried by the operation that performs the act — an acknowledgement parameter on set-resolver-config, gated by the operator's own capability. This is what lets §4.1's operator MAY be honored without either a forgeable field or a load-time guess.

At load: surface it, never normalize it, never refuse to start [MUST, v1.17]. A resolver that loads a violating config MUST surface the condition as a diagnostic; it MUST NOT silently alter its own behaviour, and it MUST NOT decline to run. Silent normalization makes the operator's stored bytes lie — the config says one thing and the peer does another with no diagnostic — and refusing to start would delete the MAY above, since a peer that will not boot on a config the operator deliberately wrote has revoked the override it was granted.

A resolver MUST NOT rewrite stored configuration as a side effect of reading it [MUST, v1.17]. Whatever a peer decides about a config it disagrees with is an in-memory decision. Rewriting the stored entity moves its content hash and republishes the operator's intent as the peer's — the same distinction §6a.9.1 draws for the resolver ceiling (a use bound, not a re-issue), and it is general: reading is not writing, at any configuration surface.

The banned property is name transmission, not remoteness, and the two are not the same thing:

Backend kindCatch-allWhy
local-name, self-certifying, out-of-bandMAYNo network consultation at all. (A pinned binding never reaches this table — §4.1 step 1 returns it before dispatch runs. out-of-band is the kind a pin's synthesized binding carries (§4.1.2), and per §6a.4 it matches only when explicitly configured as its own chain entry — so it is dispatchable where pinned is not.)
peer-issued resolved per §6a.4 through the signed rootMAYEvery request is content-addressed. The queried name is matched inside a node already fetched and never appears in a request.
dns-txt, well-known-url, did-web, consensus-anchoredMUST NOTConsultation is disclosure — the name goes to a third party as a query, a path segment, or a document name.

§6a.4 is what makes the peer-issued row safe, and it is already mandatory. A resolver MUST verify signature, name-association and revocation inside the signed tree; §6a.3a states that the host-served listing MUST NOT be presented as authoritative. A conformant peer-issued resolution therefore has no host-trusted-pointer path to fall back to — the mechanism is fixed by the kind, in the direction that makes it safe. A non-conformant resolver that trusts a host-served by-name pointer does put the name in a URL, and is excluded here for the same reason it is excluded there.

What this does not claim is zero disclosure, and the difference is worth stating precisely. The walk descends by the name's own hash, so an origin observes which interior nodes were fetched — a hash-prefix oracle over the queried name, shared by every name in that bucket. On a hit it additionally observes a fetch of the binding blob, whose hash identifies a name the registry has published, and therefore already public. Neither discloses the queried string, and neither reaches a name the registry does not carry. That is categorically weaker than handing a private name to a third-party resolver, which is the harm this rule exists to prevent.

A name-transmitting backend stays fully reachable through an explicit scoped form (alice@example.org), which is the user stating which authority they are willing to tell. An operator MAY override this on their own peer; a distribution MUST NOT ship it as the default. 3. Filtered resolver-chain backends in priority order — try each, returning the first validated result. 4. If all backends miss / fail validation: return chain_exhausted (fail-closed; no silent fallback).

The local-name backend (§6) participates as a resolver-chain entry like any other backend. Local-name-first ordering is a deployment convention realized by setting the local-name entry's priority to 0 (or another low value); the substrate stays uniform.

§4.1a The recommended default dispatch list

A distribution SHOULD ship the list below; the catch-all rule inside it is a MUST (§4.1 step 2). It realizes the four name shapes of guides/GUIDE-RESOLUTION.md §6.1 as eligibility. The # column numbers the rows for reference; it is not an evaluation order (§4).

#patternbackend_kindsShape
1did:web:*["did-web"]scheme-typed
2did:key:*["self-certifying"]scheme-typed, self-certifying
3*.eth["consensus-anchored"]scheme-typed by suffix
4*@*.*["dns-txt", "well-known-url"]domain-scoped — dotted authority
5*@*["peer-issued"]registry-scoped — undotted handle
6*["local-name", "self-certifying", "out-of-band", "peer-issued"]catch-all — no name-transmitting backend (MUST)

Two tokens were corrected here [v1.12], and both were dead config in every conformant peer. §2.4.1 is the canonical backend_kind vocabulary and neither appeared in it, so §4.2's forward-compat rule — an unknown backend_kind MUST cause the entry to be skipped with a warningdiscarded rows this spec recommends shipping.

Rules 4 and 5 overlap, and priority resolves it — not the row order. The dispatch matcher cannot express "undotted" — it has one metacharacter — so *@* necessarily also matches a dotted authority: alice@example.org is eligible at dns-txt, well-known-url and peer-issued. The dispatch list does not choose between them; resolver_chain[].priority orders them and §4.1.1 returns the first validated hit. A deployment that does not want a dotted name reaching its peer-issued registry expresses that by priority, or by narrowing rule 5 to its own registry handle (*@entity-church) — never by relying on the order of the rows above.

Entries naming a backend that is not in the resolver-chain are inert, not harmful. Rules 1–4 name backends that are not yet built; a dispatch entry narrowing to an absent backend yields the empty set and the chain reports chain_exhausted (§4.1 step 4, fail-closed). Reserving the routing now is deliberate: without these entries a web-native name shape falls through to the catch-all, which is the disclosure §4.1 step 2 forbids, reached by a different door.

Deployments MAY override. This is the interoperable default, not a wire format: two peers shipping it interoperate; one that does not simply routes its own way. The catch-all MUST is the exception, and it binds what a distribution ships rather than what an operator may configure.

§4.1b Which patterns are "broad" — the classifier [MUST, v1.19]

§4.1 step 2's MUST turns on "a pattern that matches unscoped names," and until v1.19 that predicate had no grammar — so two conformant implementations split on *.*, a.b, alice.eth and *.e*, and a third classified an exact literal name as broad. A privacy MUST whose central predicate is undefined is not enforceable; this closes it.

Scoped and unscoped are already defined upstream, in guides/GUIDE-RESOLUTION.md §6.2 — "Presence of @ ⇒ scoped; absence ⇒ default chain; leading scheme: ⇒ typed system" — over §6.1's name shapes, which §4.1a's default list realizes row for row. A scoped name carries one of three markers: an @authority, a scheme: prefix, or an enumerated typed suffix. A name carrying none is bare, and a bare name is what the user typed with no authority named.

A pattern is NARROW if and only if at least one of these holds:

#ConditionWhy it is safe
athe pattern contains no *it matches exactly one name — an explicit routing decision the operator wrote out
bthe pattern contains a literal @every matching name carries an @authority; the user named who they are willing to tell
cthe pattern's literal head, before its first *, ends in :every matching name carries a scheme: prefix
dthe pattern ends in an enumerated typed suffix (§4.1b.1) with no * after itevery matching name is in a naming system the user opted into by typing it

Otherwise the pattern is BROAD. Equivalently: broad means the pattern can match at least one bare name.

Worked against §4.1a's own rows, which is the check any implementation should run first: did:web:* (c) · did:key:* (c) · *.eth (d) · *@*.* (b) · *@* (b) · * broad.

And against the four that independent readings diverged on: *.* is BROAD. is not a typed suffix, so it matches bare dotted names like billslab.com, which §6a explicitly admits as legal local names. *.e* is BROAD — the trailing * means it does not end in a fixed suffix. a.b is NARROW by (a). alice.eth is NARROW by (a), and also by (d).

Why "any literal at all" is not the line, though it is the tempting one. A pattern requiring some literal is qualitatively different from * — only names the user deliberately typed that way go out, and a typo of a private handle is not a typo into a suffix. But . alone narrows almost nothing: dotted bare names are ordinary here. The line is not "does a literal exist" but "does the literal identify an authority or a naming system," which is exactly the distinction §6.2 draws and the reason the marker set is enumerated rather than inferred.

§4.1b.1 Enumerated typed suffixes [v1.19]

SuffixNaming systemBackend kind
.ethENSconsensus-anchored

This list is deliberately short and grows only by spec revision [MUST]. Admitting a suffix is a privacy decision — it declares that every name a user types ending in it may be disclosed to a third party — so it is not implementation-defined, not operator-extensible, and not inferable from the pattern's shape. An unrecognized suffix makes the pattern broad, which is the fail-safe direction: the cost is a refused rule the operator rewrites, against the cost of silently disclosing a namespace nobody reviewed.

§4.1.2 Synthesized result for pinned bindings

When a pinned_bindings entry matches, meta_resolve returns a system/registry/resolution-result entity (§2.1 wire encoding) with flat data fields:

ResolutionResult {
  status:       "resolved",
  binding:      <hash of a synthetic system/registry/binding entity constructed from {name, target_peer_id, transports: []}, kind: "out-of-band"; deterministic per-pin>,
  peer_id:      <pin.target_peer_id>,
  transports:   [],                  ; empty — transport-layer resolution per NETWORK §6.5 follows
  attestations: [],
  trust_anchor: "out_of_band",
  ttl:          null,                ; pins are sticky until removed
  neg_ttl:      null,
  backend_id:   "pinned"
}

Empty transports on a pin is acceptable; pins assert binding authority. Transport resolution per NETWORK §6.5 finds reachable endpoints.

§4.1.1 Single binding per name per resolution

meta_resolve returns the first hit that passes validation. If two backends would resolve the same name to different target_peer_id values, the higher-priority backend wins; the lower-priority backend's binding is never surfaced for that resolution. This is by design for v1 — the alternative (surface all hits, let the caller choose) is the aggregator/federation case, deferred per §8.2. Caller-side multi-hit awareness lives at the aggregator layer when Mode A ships.

§4.2 Schema versioning

New backend kinds will be added over time. Resolver-config is forward-compatible: an unknown backend_kind MUST cause the entry to be skipped with a warning, NOT cause the whole config to be rejected.

An unknown kind is not a name-transmitting kind, and MUST NOT be treated as one [MUST, v1.14]. The §4.1 step-2 refusal is scoped to the four kinds this spec declares name-transmitting (dns-txt, well-known-url, did-web, consensus-anchored); an undeclared kind falls to the rule above and is skipped, so it consults nothing and discloses nothing. Refusing a whole configuration because a broad pattern names a kind this build does not recognize rejects a deployment authored against a newer vocabulary, which is the case this section exists to permit.

The forward risk this raises is real and is discharged by when the check runs, not by refusing early. A kind that is unknown today may be declared name-transmitting tomorrow, and a config validated once at write time would then carry a violation nobody re-examined. §4.1 step 2 therefore requires the classification to run at load as well as at write — so the peer that upgrades its vocabulary re-evaluates the same stored config on its next load, and the entry that was inert surfaces as a diagnostic at the moment it stops being inert. A write-time-only check is the variant that fails here, which is why the enforcement points are two and not one; and the load-side check does not need to be conservative about kinds it cannot classify, because it reports rather than refuses [v1.18].


§4.3 Managing the resolver-config — set-resolver-config / get-resolver-config [v1.18]

system/capability/registry-configure named an act the corpus never defined, exactly as registry-manage-issuer-policy did before §6a.9.2: the capability was declared as a bare tree-write against system/registry/resolver-config, so there was no operation for a peer to validate at, no place to carry an operator's override, and no defined shape for a client to call. A raw tree write cannot refuse selectively and cannot carry an acknowledgement — so the §4.1 step 2 write-time MUST had no surface to bind to. Defined here on §6a.9.2's pattern, for its reasons.

OperationInputOutputGate
set-resolver-config{config: system/registry/resolver-config, acknowledge_name_disclosure?: bool}the stored system/registry/resolver-config, as writtensystem/capability/registry-configure
get-resolver-confignone — the §3.2 empty-params shapesystem/registry/resolver-config, or 404 not_found when unsetsystem/capability/registry-configure

set-resolver-config MUST validate the whole config before storing [MUST] — §4.1 step 2's eligibility rule against every rule and every chain entry, not the delta. Partial application is forbidden: on refusal nothing is written, and a subsequent get-resolver-config MUST return the previous bytes unchanged.

acknowledge_name_disclosure is the operator MAY, made expressible [MUST]. Absent or false, a config violating §4.1 step 2 is refused 403 policy_rejected with the full violation list — every violation, not the first, because an operator repairing a chain wants the whole list. Set true, the config is stored and the peer surfaces the disclosure at every load.

It is a parameter of the operation and MUST NOT become a field of the entity [MUST]. A field would be written by whoever writes the bytes — so a distribution could set it and defeat the rule it is meant to bound — and it would move a content-addressed type's hash to carry an unsecurable claim. The acknowledgement is meaningful only because it is bound to a capability-gated call by an identified actor, which is precisely the thing a stored byte cannot be.

A write that changes pinned_bindings additionally requires system/capability/registry-pin [MUST, v1.19]. §5 declares registry-pin as a capability of its own — "who may add or remove pins" — but pins live inside resolver-config, so any surface that writes the whole entity writes the pins too, and registry-pin had no place to be checked. A capability whose operations column is a bare tree-write is a name with no enforcement point: a raw write cannot refuse selectively, cannot carry a qualifier, and cannot be distinguished from any other write to the same entity. This clause is that enforcement point.

This matters because a pin is the most privileged row in the file. §4.1 step 1 returns a pinned match immediately — before the step-2 disclosure filter and before the §6a.9.1 resolver ceiling — so a pin is the one entry that answers a name while bypassing both of this extension's privacy and freshness controls. A capability split that lets the less specific grant write the more privileged row inverts the model.

set-resolver-config MUST therefore diff pinned_bindings against the stored config and, when they differ, require both registry-configure and registry-pin; absent the latter, refuse 403 not_entitled and write nothing. A write that leaves the pin list byte-identical needs only registry-configure. (This is what makes the §5 split real: an operator may now be granted pin authority without whole-config authority, or the reverse, and each grant means what §5 says it means.)

The diff is over raw bytes [MUST]. "Differ" means the submitted pinned_bindings bytes are not byte-identical to the stored config's. A comparison over a decoded-and-re-encoded form is fail-open: any §4.2 forward-compatible key a pin carries that the comparing type does not model is dropped before the compare, so adding or stripping such a key reads as "no change" and rewrites the most privileged row in the file under registry-configure alone. This is ENTITY-CORE-PROTOCOL.md §1.8's byte-preservation discipline applied to a byte-identity decision, and it is the same rule that forbids re-encoding on receive.

§4.3.1 Pin authority is the operation pin-bindings [MUST, v1.20]

system/capability/registry-pin is authority over the operation pin-bindings on the registry handler. That name is what a grant carries and what a conformance client mints.

Why an operation name and not a resource path. A system/capability/grant-entry scopes on exactly two axes, path-scope resources and id-scope operations. Per ENTITY-CORE-PROTOCOL.md, path locations are not structurally fixed — they are communicated through the grant, and peers that diverge remain conformant — so a resource-path discriminator cannot be relied on by a party that did not receive the grant, which is precisely the position of a conformance client minting a capability for a foreign peer. The operation axis is the portable one. (A path split is also unavailable in fact: pinned_bindings is a field of system/registry/resolver-config, not a path beneath it.)

pin-bindings MUST NOT be dispatchable. Nothing routes to it, it appears in no operation table, and a peer MUST reject it as an EXECUTE operation. It exists solely as the capability-check discriminator. (Stated because a checkable-but-not-callable operation name is unusual enough that an implementation may reasonably expose it, and doing so adds an undeclared operation to this handler's wire surface.)

Capability checks precede config validation [MUST]. A set-resolver-config that both changes pinned_bindings and violates §4.1 step 2, submitted with registry-configure alone and no acknowledge_name_disclosure, MUST return 403 not_entitled — never 403 policy_rejected. Two reasons: the order is otherwise a cross-peer-observable divergence between conformant peers; and this operation's violation response is deliberately exhaustive (every violation, above), so validating first discloses a config-shaped violation list to a caller holding no authority to change it. Authorize, then validate.

Out-of-band seeding still works and is still not an override [MUST]. §6a.9.2's store-first rule applies unchanged: a config written directly to the tree is the stored config. Such a write bypasses this operation and therefore carries no acknowledgement, so a violating seeded config is surfaced at every load and never silently honored. This is the seam that keeps the operation from being security theatre — it is a control on the documented path, and the undocumented path is loud rather than blocked.


§5 Capability model

The substrate gates no name claims. Anyone can publish a binding entity claiming any name. The substrate enforces:

Trust is receiver-side. Whether a binding gets used depends on:

The substrate's cap surface:

CapPurposeOperation(s)
system/capability/registry-resolvewho may invoke :resolve against the registry handler:resolve
system/capability/registry-configurewho may edit the resolver-configthe two operations defined in §4.3 (set-resolver-config / get-resolver-config); tree-write system/registry/resolver-config remains the out-of-band seed path
system/capability/registry-pinwho may add or remove pinspin-bindings — a non-dispatchable operation name ([MUST, v1.20], §4.3), checked when a set-resolver-config write changes pinned_bindings; the out-of-band tree-write of resolver-config remains the seed path and carries no check, per §6a.9.2
system/capability/registry-cache-controlwho may invalidate cached resolutions`:invalidate-cache(name
system/capability/registry-local-name-bindwho may create or update local-names (§6):bind, :update-transports
system/capability/registry-local-name-unbindwho may remove local-names (§6):unbind
system/capability/registry-local-name-listwho may enumerate local-names (§6):list

Every row's operations column names an operation a grant can carry, and that is the property this table is maintained for [MUST, v1.20]. Two ways a row fails it, and the second is subtler than the first:

A new row is not landable without naming an operation name that id-scope can literally match. A runtime data condition does not satisfy this.

Per-backend caps for backends shipped in their own proposals (e.g., "may publish a binding to the peer-issued registry") live in those backend extension specs.

No system/capability/registry-publish-binding-for-name-X cap exists. Anyone can publish a binding claiming any name. Receiver policy decides.

§5.1 Connection-authority invariant

Resolution never confers connection authority. A binding from an untrusted or low-trust backend can be safely consumed for its transports field because dialing those transports does NOT admit the peer — IDENTIFY (per NETWORK / EXTENSION-IDENTITY) is the gate. The dispatcher (NETWORK §10) is responsible for cap-verifying the dialed peer post-IDENTIFY. Symmetric to DISCOVERY §2.2's pin: the registry surfaces candidates; trust is established at IDENTIFY.

§5.2 Default grants on first install

The REGISTRY seed-policy bootstrap (per V7 §6.9a) grants the local peer all seven caps above. Otherwise the user cannot use their own local-name store or run resolutions, and §4.4 advertised-handler discipline is violated. Distributions MAY tighten via --default-grants flags, but the v1 floor grants the local peer full self-access.


§6 Local-name backend (v1)

The local-name backend is the v1 concrete backend that exercises the substrate's resolver-handler contract end-to-end. It's the simplest possible backend (no network dependencies, no signature ceremony, no authority semantics) and useful immediately for organizing known contacts.

§6.1 Concept

A local-name is a user-assigned local name for a peer identity. Local-names have no global meaning; they exist only in the assigning user's local configuration. Trust source is the user themselves — the user is asserting "this name means this peer-id; I take responsibility for the binding."

§6.2 Backend identity

The local-name backend identifies as backend_kind: "local-name" in resolver-config. There is exactly one local-name store per peer; backend_id for local-name entries is the local peer's identity.

A local-name backend MAY register with a custom backend_id distinguishing multiple local-name namespaces (e.g., personal vs work local-names). v1 is single-store.

§6.3 Local-name binding (specialization of §3)

type: "system/registry/binding"
data: {
  name:           <string>,                ; the local-name (user-chosen)
  kind:           "local-name",
  target_peer_id: <Base58 peer-id per V7 §1.5>,
  transports:     [<system/hash, BARE>],   ; optional; cached from last contact (§3 shape)
  issued_at:      <ms-since-epoch>,
  ttl:            null,                    ; local-names are sticky until user removes
  supersedes:     <system/hash, BARE | null>,  ; previous local-name for same name (rebound)
  metadata: {
    notes:        <string | null>,         ; user-facing note
    pinned:       bool                     ; whether user has pinned (default true)
  }
}

No system/signature — the user IS the trust source; the binding lives in the user's local store; signing is meaningless (the user trusts themselves by definition). This is the local-name carve-out from the §3 universal signature requirement.

Two-layer storage (universal entity-system pattern):

Name-path safety (normative). Because the storage path embeds {name} as a path segment, local-name names MUST satisfy:

bind rejects names violating these rules with bind_invalid_name and does not write to storage. Already-stored bindings that violate (legacy / migration) MUST be either rejected at load with a warning or normalized + re-bound; impls MUST NOT silently treat ambiguous-path bindings as valid.

§6.4 Local-name-store config

type: "system/registry/local-name-config"
data: {
  default_pinned:     bool,                 ; new entries default to pinned (recommended true)
  allow_supersede:    bool,                 ; allow rebinding existing names (default true)
  case_normalization: "none" | "lower"      ; local-name case handling (default "none")
}

Stored at system/registry/local-name-config.

§6.5 Local-name handler operations

The local-name backend implements system/registry:resolve per §2.1 + four backend-specific operations:

system/registry:resolve (substrate-required):

resolve(name, hints) → ResolutionResult
  normalized = nfc_normalize(name)
  if local-name-config.case_normalization == "lower":
      normalized = lowercase(normalized)
  pointer = lookup_tree_pointer("system/registry/binding/local-name/" + normalized)
  if pointer is null:
    return { status: "not_found" }
  entry = fetch_binding_body(pointer)        ; from the content-tree at system/registry/binding/{hash}
  return {
    status:        "resolved",
    binding:       entry.hash,
    peer_id:       entry.target_peer_id,
    transports:    entry.transports,         ; MAY be empty
    attestations:  [],                       ; local-name carries no attestations
    trust_anchor:  "local_name",
    ttl:           null,
    neg_ttl:       null,
    backend_id:    <local_peer_id>
  }

Normalization symmetry:resolve applies the same NFC normalization + optional case fold (per local-name-config.case_normalization) BEFORE lookup that :bind (§6.5.1) applies before storage. The normalized form is the storage key.

An empty entry.transports (the user bound a local-name before ever observing a reachable endpoint) still returns status: "resolved" — the binding IS authoritative for the name-to-peer mapping; transports are a cached hint, not the binding's substance. The downstream Layer-B logic (transport-profile resolution, transport-fallback per §2.3) handles "no reachable transport" separately.

system/registry/local-name:bind — create a new local-name binding:

bind(name, target_peer_id, transports, notes) → binding_hash
  validate name (per §6.3 name-path safety + per local-name-config)
  normalized = nfc_normalize(name)
  if local-name-config.case_normalization == "lower":
      normalized = lowercase(normalized)
  existing_pointer = lookup_tree_pointer("system/registry/binding/local-name/" + normalized)
  if existing_pointer and not allow_supersede:
    return error("bind_already_exists", 409)
  if existing_pointer and allow_supersede:
    new_body = construct_binding(supersedes = existing_pointer.hash, …)
  else:
    new_body = construct_binding(supersedes = null, …)
  store_body("system/registry/binding/" + new_body.hash, new_body)        ; universal §3 location
  update_tree_pointer("system/registry/binding/local-name/" + normalized, new_body.hash)  ; live index
  return new_body.hash

Error codes (REGISTRY's code domain per V7 §3.3):

CodeStatusWhen
bind_invalid_name400name violates §6.3 path safety (contains /, control chars, or fails NFC)
bind_already_exists409normalized name already bound and allow_supersede=false

system/registry/local-name:unbind — remove a local-name binding:

unbind(name) → ()
  remove from local_name_store
  (binding entity remains in CONTENT tree; supersedes-chain preserved per ATTESTATION discipline)

system/registry/local-name:list — list all current local-name bindings:

list([filter]) → [LocalNameEntry]
  enumerate tree-pointer prefix "system/registry/binding/local-name/*" via LocationIndex.ListPrefix
  ; this IS the live index — tree pointers are the live name→hash mapping
  ; supersedes-chain walking is the audit log, accessed by hash lookup when history is needed
  return [{name, hash, target_peer_id, notes, pinned} per tree pointer]

:list reads the index, not the audit log. Each tree pointer holds the live head binding hash; supersedes-chain history is accessed via tree:get system/registry/binding/{hash} when needed (auditable, but not on the hot path of listing).

system/registry/local-name:update-transports — update cached transports for an existing local-name (e.g., after a successful contact reveals new endpoints):

update-transports(name, transports) → new_binding_hash
  issue new binding with supersedes = existing.hash
  same target_peer_id; only transports updated
  return new binding.hash

§6.6 Local-name composition with EXTENSION-IDENTITY

Local-name target_peer_id IS an EXTENSION-IDENTITY peer-id (V7 §1.5 multikey). A local-name can point at:

When the target identity rotates per EXTENSION-IDENTITY §4.3/4.4, the local-name remains valid (it points at the stable Public_X identifier; runtime-peer-set walks find current runtime peers).

§6.7 What the local-name backend does NOT do


§6a Peer-issued backend (v1)

The peer-issued backend is the second v1 concrete backend. Where local-name (§6) trusts the user themselves, peer-issued trusts a remote registry peer whose key the resolver has pinned — turning a name into a verified binding ("the registry signed this, and I checked it against the key shipped with my build") rather than a pinned one ("the distro hard-asserts it"). It is the sibling of §6: same reads, different trust source. Full design rationale + the cohort spec-doubt rulings (P1–P7) live in docs/proposals/implemented/extensions/PROPOSAL-PEER-ISSUED-REGISTRY-BACKEND.md; the normative contract is here.

§6a.1 Concept — trust logic over transport-agnostic reads

A registry peer is just a peer (§1 position 4); its bindings are ordinary entities in its tree. The backend reads them with the normal tree:get / content:get machinery against the registry peer and does not know or care whether that peer is reached over http-poll (a static coral-reef, the demo case) or a live socket — how the registry is reached is the transport layer's job (NETWORK §6.5; http-poll = SUBSTITUTE §7 Mode S). The backend's only registry-specific substance is trust verification (§6a.4 step 3). The backend MUST NOT perform or select a transport itself.

§6a.1a The fourth actor — the party that serves the bytes

Because the backend's reads are transport-agnostic, the party serving the bytes is not necessarily the registry. For a static registry — the coral-reef deployment this section is written for — the registry signs and an origin (a bucket, a CDN, a mirror) serves. When this extension's threat model was written those two were one party, and every actor in it is a requester, the registry itself, or the consumer's own fallback chain. There is no actor for the byte-server, and no review of the existing rows finds a missing row.

The static deployment splits them, and the split hands the origin two powers that no signature revokes:

What this actor cannot do, and the limits are what make the two rules sufficient rather than merely helpful: it cannot forge a signature, alter a body, or move the consumer's clock. So every remaining defense is one the consumer computes locally over bytes it has verified — which is exactly the shape of the two requires added at §6a.4.

Consumers pinning a registry SHOULD understand that pinning the registry's key does not pin its host, and that the honest revocation bound against a hostile origin is the binding's TTL, not the revocation's publication.

§6a.2 Backend identity

The peer-issued backend identifies as backend_kind: "peer-issued" in resolver-config. Its backend_id is the registry peer-id, which doubles as the pinned trust root: a resolver-chain entry for peer-issued names carries that peer-id and accepts a binding only if signed by it.

§6a.3 The peer-issued binding + by-name index

The binding body is a standard §3 system/registry/binding with kind: "peer-issued", ttl set (issued bindings expire, unlike sticky local-names), and — unlike §6.3 — it carries a system/signature (the registry is a remote authority; the signature is the whole point). Signature is reachable at the invariant-pointer system/signature/{hex(binding_hash)}, target-matching the binding's content_hash (V7 §5.2).

Two-layer storage, the direct analog of §6.3:

Name-path safety (normative): identical to §6.3 — no /, no C0/DEL control chars, NFC at issue time. Domain-shaped names (billslab.com) are fine (dots allowed).

Units — ttl is a duration, issued_at is an instant (pointer, not a redefinition). Both are declared canonically at §3: issued_at: <ms-since-epoch>, ttl: <ms duration | null>. Stated here because a reader working from §6a never reaches §3 and has twice implemented ttl as an absolute timestamp. §3 remains the only home; this is a cross-reference.

A kind: "peer-issued" binding MUST carry a non-null ttl [MUST]. §6a.4's expiry check is the only check on this path that a hostile byte-server cannot influence — it cannot forge a signature, alter a body, or move the consumer's clock, but it can withhold a revocation indefinitely. The stated bound on a withheld revocation is "ttl + revocation"; with ttl: null that bound is not weak, it is absent, and the binding is permanently unrevokable. The (or ttl null) allowance is scoped to the local-trust kinds (local-name, pinned), where stickiness is the user's own assertion and the user is the trust root. It has no place on an issued binding, whose trust root is the registry.

A kind: "peer-issued" binding MUST carry a non-empty transports [MUST]. §4.1.2's "transport resolution per NETWORK §6.5 finds reachable endpoints" is sound for a pin and for a live target, and has no static counterpart: NETWORK §6.5.4 makes profile discovery out-of-band in v1, so for a statically published peer there is nothing to find. A consumer resolves a peer-id and stops. The §4.1.2 pin carve-out is unchanged, and the contrast is the reason: a pin is the user's assertion, so reaching the peer is the user's problem; an issued binding is the registry's assertion and is worth nothing operationally without a way to reach the target.

A publishing registry MUST serve what its bindings reference [MUST, v1.21]. A registry that publishes bindings for consumption MUST make reachable, under the same endpoint that serves the bindings:

This is what makes transports a reference rather than a dead end. The MUST above requires a non-empty transports, on the reasoning that profile discovery is out-of-band in v1 and "for a statically published peer there is nothing to find" — and a hash the consumer cannot resolve reinstates exactly that gap one indirection later, satisfying the rule in letter while delivering nothing. The consumer already reaches this endpoint by hash for the binding body itself; the obligation is that the referents are there too.

EXTENSION-NETWORK.md §6.5.2 states the publisher-side form of the same rule for a signed root — "MUST upload … the transitive hash-linked closure … plus the published-root entity itself and its system/signature entity." The failure mode is identical and is the reason both are MUSTs: a missing referent and a withheld one are byte-identical at the consumer, so a publishing mistake is diagnosed as the origin acting in bad faith.

§6a.3a Enumerating a registry — the walk is the authority, the listing is a menu

The authenticated form of "what names does this registry carry" is a walk of the published trie from published-root.root_hash (EXTENSION-TREE.md §3.1, §3.5). Trie leaves are [key, value_hash]the key is in the node — so walking every node from the signed root yields the complete key set the signature commits to. No new mechanism is required and none is added.

A registry intending to be browsable SHOULD publish a prefix under which system/registry/binding/by-name/ is enumerable, so the trie's key set is the name set. This is the same per-purpose tracked-prefix ruling EXTENSION-TREE.md §3.3a already gives for any other published extent.

The published prefix MUST also cover the signature location [MUST, v1.21]. A binding's signature lives at system/signature/{hex(binding_hash)} — V7 §5.2's invariant pointer, required by §3 and fetched by §6a.4 step 3 — which is outside every system/registry/… prefix. A registry publishing system/registry/binding/by-name/ alone enumerates every name correctly and then fails to resolve any of them, at step 3, with a 404 the consumer cannot distinguish from a withholding origin. Publishing system/ satisfies both; so does any prefix set covering system/registry/binding/by-name/ and system/signature/. (Earlier revisions recommended the narrow prefix. That recommendation was unimplementable and is corrected, not deprecated.)

A served listing artifact ({path}{tree_listing_suffix}) is a transport-trusted convenience and MUST NOT be presented as the registry's authoritative contents. Keep it — it is one fetch instead of O(N) and it is the right first-paint artifact — but resolve every listed name through the signed root before presenting it as a binding. The asymmetry is the point: a hostile origin can omit an entry from a served listing undetectably, and cannot omit a node from the walk without the walk failing. Silently hidden becomes visibly incomplete, which is the strongest completeness property a static origin admits of.

(Note: EXTENSION-TREE.md §3.4.2's "consumers use LocationIndex rather than walking trie subtree structure" is about efficient prefix scan under hash-keyed routing, which scatters related keys. It is not a claim that the key set is unrecoverable, and for a registry the distinction dissolves, because the registry chooses its own published prefix.)

§6a.4 Resolve algorithm (normative)

peer_issued.resolve(name, config):           ; config = the resolver_chain entry (§4)
  registry = config.backend_id                ; the registry peer-id = PINNED trust root
  norm     = nfc_normalize(name)              ; §6.3 name-path safety

  ; 1-2. transport-agnostic reads against the registry peer (transport layer maps to http-poll
  ;       GETs for a static coral-reef, or a live socket if the registry runs one):
  binding_hash = tree:get(registry, "system/registry/binding/by-name/" + norm)
  if binding_hash is null: return { status: "not_found", neg_ttl: config.neg_ttl }
  binding      = content:get(registry, binding_hash)        ; bytes hash-verified == binding_hash

  ; 3. VERIFY (§3 steps 1-3, §5) — the ONLY registry-specific logic; this IS the backend:
  sig = read(registry, "system/signature/" + hex(binding_hash))   ; invariant-pointer (V7 §5.2)
  require sig.target == binding_hash
  require sig.signer == content_hash(canonical(registry.system_peer))   ; signer is a hash (V7 §1.5/§5.2)
  require verify_crypto(sig, pinned_key_of(registry))                   ; §6a.5 trust-anchor floor
  require ("peer_issued:" + registry) in config.accepted_trust_anchors  ; empty set ⇒ fail-closed
  require binding.name == norm            ; ← THE ASSOCIATION CHECK. See below.
  require binding.ttl != null             ; §6a.3: peer-issued MUST carry a finite ttl
  require not revoked(registry, binding_hash)                           ; §6a.6
  require binding.issued_at + binding.ttl > now()

  ; 4. surface
  return ResolutionResult {
    status: "resolved", binding: binding_hash,
    peer_id: binding.target_peer_id, transports: binding.transports,
    trust_anchor: "peer_issued:" + registry, ttl: binding.ttl, backend_id: registry }

The association check (binding.name == norm) is normative, and it closes a live substitution [MUST]. A signature proves who issued a binding, never what it was issued for. The by-name pointer at system/registry/binding/by-name/{norm} is transport-supplied — for a static registry it is a file on an origin — so the party serving the bytes chooses which signed binding answers which name. Without this comparison, an origin repoints one pointer file and foundation.example resolves to the binding the registry legitimately issued for protocol.example: valid signature, correct signer, unexpired, unrevoked, wrong name. Every other require above passes.

The fix costs nothing because the association was already committed: the signature covers a body that contains name, so sig(R, {name, target_peer_id, …}) is the registry's assertion of the pairing. The defect is not a missing commitment, it is a discarded one — the resolver decodes the body (it reads issued_at, ttl, target_peer_id from it) and never compares the one field that binds the result to the question asked. Zero extra fetches, zero new artifacts, no publishing change.

Framing to carry, because the wrong one was in circulation: this is not "per-binding signatures give authenticity but not association." They give both. It is "the verifier throws the association away."

Fail-closed (normative): any verify / association / revocation / expiry failure returns None/dead-end at this rung; the meta-resolver (§4.1) advances the chain. It MUST NOT silently downgrade to an out_of_band pin — a pin matches only if explicitly configured as its own chain entry.

Local diagnostics SHOULD distinguish the failures; the chain value MUST NOT. The rule above collapses signature failure, name mismatch, expiry, revocation and unsupported-kind into one undifferentiated dead end, which surfaces to an operator as "the registry is broken" — and a unit error, a clock skew and an active substitution attempt are then indistinguishable during exactly the incident where telling them apart matters. A resolver SHOULD surface which require failed to its own operator, and MUST NOT let that distinction change what the chain sees or what crosses a peer boundary. There is no confidentiality argument against this: V7 §5.5a's Denied-vs-NotFound discipline governs what a peer tells a remote requester, and this is a local resolver reporting to the operator running it.

Levels (P2/P3): the backend returns not_found + a first-class neg_ttl slot on the negative result (backend-scoped); the meta-resolver collapses a whole-chain miss to chain_exhausted (§4.1) carrying the aggregated neg_ttl. neg_ttl is a defined optional top-level field, not an opaque hint-bag entry.

Offline / precedes path: when the binding is pre-cached as a precede (§7), steps 1–2 read the local store instead of the wire; step 3 verify is identical. Precedes are just a warm cache.

§6a.5 Trust-anchor floor (v1)

The v1 trust anchor is an Ed25519 identity-multihash registry peer-id: the pubkey is the peer-id digest (V7 §1.5 canonical form), so pinned_key_of(registry) is derived from backend_id and config carries only the peer-id. For a non-self-describing peer-id form (SHA-256-form / Ed448), the resolver-chain entry MUST carry the pubkey explicitly (or a locally-resolvable system/peer); this config-carried-key path is deferred (no v1 demo needs it). Consistent with the core §9.1 floor.

§6a.6 Revocation (by-target index, normative)

revoked(registry, binding_hash) is an O(1) index lookup, not a scan: system/registry/revocation/by-target/{hex(binding_hash)} → the revocation entity (presence = revoked, if it verifies against registry per §3.1). This is the revocation analog of the §6a.3 by-name index. A live registry MAY layer subscription-driven invalidation on top (§3.1).

Scope — this binds the remote-registry path only, and §3.1 is a different sentence [v1.17]. revoked() takes a registry as its parameter and its caller is §6a.4, the peer-issued algorithm; the whole argument below is about "the party §6a.1a names as the fourth actor" — a byte-server answering for a registry that is not this peer. §3.1's rule — ":resolve MUST check for a system/registry/revocation targeting a candidate binding" — names no index and constrains no storage path, and it governs the local peer's own revocations at the generic §3 layer, where a scan is a conformant implementation. (Recorded because the two were conflated once and the keyed form was routed to §3.1 as a divergence: an index-only reader at that layer makes REG-*-driven local-revocation checks unpassable, since a revocation written directly to the tree at its own-hash path is invisible to a by-target index. The distinction is the caller, not the word "revocation.")

The index is a performance structure and carries no integrity (MUST NOT be read as evidence). A resolver MUST NOT treat a missing by-target/{hex(binding_hash)} key as proof that the binding is not revoked. The key is served by the party §6a.1a names as the fourth actor, and an absent key and a withheld key are byte-identical at the consumer — so presence proves revocation, absence proves nothing. The bound on a withheld revocation remains the binding's ttl (§6a.3), exactly as §6a.1a states, and moving from a scan to a keyed lookup does not change that bound.

What the keyed form does change is the cost of a targeted withholding, and that is worth stating plainly. Under a prefix scan, suppressing one revocation means manipulating a listing; under a keyed lookup it means answering 404 to one URL. A resolver that wants better than the TTL bound does not get it from this index — it walks the published trie from the signed root over the revocation prefix (§6a.3a), where withholding a node makes the walk fail visibly instead of returning a short answer. (Both shapes ask the host the same question. The index makes the question cheap, not trustworthy.)

§6a.7 Signed binding-manifest (OPTIONAL — format pinned, v1 = per-name index)

The by-name index (§6a.3) is the floor and the v1 implementation: one tree pointer per name, one round-trip per name; a conformant resolver MUST support it. Single-name resolution is internet-scale at the floor (fetch only by-name/{name} — the DNS model; no zone download).

A registry MAY additionally publish a single signed manifest (system/registry/binding-manifest at {tree_url_prefix}/registry/manifest/current) listing the whole name→hash index, for one-round-trip bulk fetch — the direct analog of SUBSTITUTE §2.4/§7.2 snapshot-manifest with registry-domain fields (registry_id, snapshot_at, seq, coverage, bindings, optional predecessor). Its format is pinned here so no registry invents its own (anti-fracturing); its implementation is deferred (optional optimization, the way SUBSTITUTE Mechanism B sits on Mechanism A). Normative rules when present: signature is MUST (verified against the pinned key; seq freshness per SUBSTITUTE §7.2; no operator-trust override); a missing/stale/signature-invalid manifest MUST fall through to the §6a.3 per-name pointer; absence governed by coveragepartial (default) ⇒ a name not in bindings MUST fall through to the per-name pointer (no negative claim); complete ⇒ absence is authoritative not_found (DNSSEC-NSEC / TUF model). Past the V7 §4.10 max-payload ceiling, the sanctioned scaling direction is a delegated/sharded manifest (DNS-zone / TUF-delegated-targets; an entry value is a system/hash, a sub-manifest is another binding-manifest) — direction pinned, detailed format deferred. Reuses SUBSTITUTE's manifest_signature_invalid / manifest_stale_seq codes.

§6a.8 Curated registration (v1)

A curated/static registry's operator decides what it signs; registration is operator tooling, no live protocol (this is how the release registry, e.g. the Entity Church Registry, ships):

registry-issue-binding(name, target_peer_id, transports, ttl) :    ; operator tool, holds K_registry
  body = system/registry/binding { name, kind:"peer-issued", target_peer_id, transports, issued_at:now, ttl }
  sig  = sign(K_registry, body.content_hash)
  publish body at system/registry/binding/{body.content_hash}
  publish sig  at system/signature/{hex(body.content_hash)}
  set pointer system/registry/binding/by-name/{nfc(name)} → body.content_hash

registry-revoke-binding(binding_hash, reason) :
  rev = system/registry/revocation { target: binding_hash, reason, issued_at:now }
  sig = sign(K_registry, rev.content_hash)
  publish rev + sig at the invariant-pointer
  set pointer system/registry/revocation/by-target/{hex(binding_hash)} → rev.content_hash

§6a.9 Live registration — register-request (open/allowlist/manual)

Curated registration (§6a.8) is operator-signs-by-hand. Live registration lets a publisher self-register against a registry that runs the handler. A registry is just a peer (§1 position 4) and operates in one of two modes — curated/static (no live protocol) or live (runs the register-request handler). The request:

type: "system/registry/register-request"
data: {
  name:           <string>,                  ; name-path safety per §6.3
  target_peer_id: <Base58 peer-id, V7 §1.5>, ; what the name resolves to
  transports:     [<system/hash, BARE>],      ; §3 shape — profile hashes, never inline objects
  requested_ttl:  <ms | null>,
  nonce:          <bytes>,                    ; anti-replay
  issued_at:      <ms-since-epoch>
}

The request MUST carry a system/signature by target_peer_id (target-matching, V7 §5.2; invariant-pointer at system/signature/{hex(request.content_hash)}). This is ownership-proof layer 1 and is always required: it proves the requester holds the key they are binding the name to, so no one can register someone else's peer-id under a name. Handler op:

system/registry/peer-issued:register-request(request)
    → system/registry/register-result  (200 approve | 202 queue)
    | system/protocol/error            (4xx reject)
  0. check name-path safety (§6a: no '/', no C0/DEL, NFC)  ; else 400 bind_invalid_name
  1. verify request signature by target_peer_id          ; layer-1 (always)
     on failure: 401 signature_invalid                   ; layer-1 domain — see the status table below
  2. apply issuer-policy admission (§6a.9.1)              ; layer-2 → approve | reject | queue
  3. on approve: registry-issue-binding(...) (§6a.8)      ; signs with K_registry, publishes, sets by-name pointer
     return 200 register-result { status: "bound", binding_hash }
  4. on reject:  error (name_taken | not_entitled | policy_rejected)   ; layer-2 reject domain ONLY
  5. on queue:   store the pending request (§6a.9.3)      ; manual mode
     return 202 register-result { status: "pending_review", pending_hash }

Result type [MUST] [RULED 2026-08-12]system/registry/register-result.

type: "system/registry/register-result"
data: {
  status:        "bound" | "pending_review" | "denied",  ; which outcome; snake per STYLE (status code)
  binding_hash?: <system/hash>,                ; REQUIRED on "bound",         absent otherwise
  pending_hash?: <system/hash>                 ; REQUIRED on "pending_review", absent otherwise
}

"denied" added [2026-08-14] — the defect this box was written about, recurring one subsection later, in the section written to fix it. §6a.9.3's operations table returns register-result {status: "denied"} from deny-request, and this declaration enumerated two values. Both binding_hash and pending_hash are absent on "denied" — the request is decided and nothing was published, so there is no hash to hand back; the decided head is reached through the by-request pointer (§6a.9.3). All three implementations emitted "denied" as the table said and recorded the enumeration as the stale half; that reading is correct and is now the text.

The recurrence is the finding, not the fix. This box already stated the general rule — "an operation whose declared return type does not enumerate every branch of its own pseudocode is an interop bug already in flight" — and the very next subsection reproduced it, because the rule was written as prose next to one instance instead of as a check over the corpus. A rule that can only be obeyed by whoever remembers reading it does not bind the next author, who is usually the same author. Routed to the corpus gate as a mechanical rule (declared enumeration vs. emitted value).

Why this was a three-way divergence, and it is our defect [2026-08-12]. The signature above previously read → binding_hash | rejectiontwo outcomes — and then step 5 introduced a third in the pseudocode without extending the return type. pending_hash appeared nowhere in this specification at all. So each implementation invented a carrier: one a dedicated result type, one a result field, one system/protocol/status. Three shapes is what an undeclared outcome produces, and no implementation was wrong — there was nothing to be wrong against. An operation whose declared return type does not enumerate every branch of its own pseudocode is an interop bug already in flight.

system/protocol/error MUST NOT carry the 202. An error entity denotes a failed operation; 202 denotes accepted-pending. Emitting one on the other makes the status line and the result type disagree by construction, and a client branching on result type reaches the opposite conclusion from one branching on status. (Adopted from a design argument that stands on its own merits and not on how many implementations held it — see GUIDE-CONFORMANCE §4: the spec arbitrates, the cohort does not vote.)

A generic status type is rejected on structure, not on taste. The 200 must carry binding_hash and the 202 must carry a poll handle, so a carrier with no room for either forces the payload somewhere else and re-opens the divergence one field down. register-request also MUST NOT borrow another operation's result type because the payload happens to match — that coupling breaks silently the first time either operation's result grows a field.

The value's spelling does not change. pending_review stays snake_case: STYLE-NAMING-CONVENTIONS puts error and status codes in snake regardless of which field carries them. Only the carrier is being ruled.

pending_hash [MUST] [RULED 2026-08-12] — it names the stored pending request entity, not the request the client sent. It is the content_hash of the system/registry/pending-binding entity the registry stored at step 5 (§6a.9.3), resolvable by the ordinary tree:get / content:get machinery every other registry read uses (§6a.3). A handle the client can already compute is not a handle — echoing the request hash tells the requester nothing it did not have before sending, and nothing is fetchable at it. The divergence here was real and undecidable from the text: one implementation named the stored entity, another named the request.

Owed, named rather than invented [2026-08-12] — ✅ DISCHARGED [2026-08-13], see §6a.9.3. The manual-approval path itself — the system/registry/pending-binding schema, the by-request pointer a requester polls when it no longer holds the 202 response, and the operator's approve/deny operation — was not specified anywhere in this document, which is why step 5 could say "queue" and stop. That ruling pinned the cross-peer-observable surface (result type, status value, what pending_hash refers to) and deliberately stopped there. Stopping there had a cost that is worth recording: it left pending_hash a MUST naming an entity with no schema, so one implementation withheld the value on principle while the reference oracle failed peers for withholding it — the spec manufactured a conformance failure out of its own reserved section. A MUST may not name a referent the corpus does not define; if the referent must wait, the MUST waits with it. §6a.9.3 now defines it.

Statuses [MUST] [RATIFIED 2026-08-11] [RATIONALE CORRECTED 2026-08-12]. §6a.9 pinned the reject codes and left the statuses open, the way §6a.9.2 later pinned 400 / 501. It is now text:

OutcomeStatusValueCarried by
layer-1 proof absent / wrong signer401signature_invalidsystem/protocol/error .code
manual mode — queued for review202pending_reviewregister-result .statusnot an error code
name already bound409name_takensystem/protocol/error .code
layer-2 admission refused403not_entitled | policy_rejectedsystem/protocol/error .code

The fourth column exists because its absence caused the divergence [added 2026-08-12]. This table shipped with the third column headed Code, which is a category error on the 202 row: pending_review is a status field value, and §6a.9's own pseudocode said so (on queue: status "pending_review") while the table said otherwise. An implementation that trusted the table emitted an error entity on a 2xx — a faithful reading, and the same failure shape as §5.4/§8.3, where the normative artifact and the prose disagreed and the artifact won. A status table that names a value without naming what carries it is under-specified by exactly one column, and the missing column is the one a wire implementer needs.

401 (not 403) for layer 1 follows V7 §5.2a's discriminator: an unverifiable signer is an authentication failure, and §4.2/§4.4's F32 ruling already put that class at 401. The 409 / 403 rows are derived from V7 §3.3's class rules rather than observed in practice, so an implementer meeting a divergence on those two rows should report it rather than assume the defect is local.

Error strings are free; codes are the contract. The code values above are normative and are what a peer branches on. Human-readable messages accompanying them are impl-local and MAY differ — a conformance check MUST NOT assert on message text. Stated explicitly so that "no check reads them" remains a design choice rather than hardening into a latent expectation.

Conformance [MUST] [RULED 2026-08-12] — a check of a pinned row MUST assert the code, not the status alone. A layer-1 refusal check that asserts only 401 cannot distinguish a conformant peer from one answering an unpinned code, so it scores the contract's weaker half and reports green on a divergence. This is not hypothetical: it is exactly why the py divergence above survived a full cohort cycle. In the cross-impl instrument that found it, the layer-2 rows were asserted as status != 403 || code != not_entitled while every layer-1 row was asserted as status != 401 — the same file, two rows apart, one of them checking the contract and the other checking half of it. REG-REGISTER-PROOF-1 / REG-REVOKE-PROOF-1 / REG-RENEW-PROOF-1 therefore assert status + code + publishes-nothing, all three.

Scope [extended 2026-08-12] — this is not confined to the layer-1 row. The cohort audit prompted by this ruling found the same status-only shape on two further pinned rows, both naming their code only inside the check's own failure string: §6a.9's 202 pending_review and §6.5's 409 bind_already_exists. The rule binds every row of every pinned status table in this specification, not the three named vectors.

And audit the extractor in the same pass — [MUST], because a check can be unpassable by construction. The same audit found the harness helper feeding these assertions harvested a code only when status >= 400, encoding "codes ride failures." §6a.9 pins a code on a 2xx row, so pending_review was silently dropped to "" and that assertion could not have passed against any peer, however conformant. Moving the gate to >= 300 does not fix it — 202 is still excluded; the status class was never the right discriminator, the result's type is. Gate on the body being error-shaped, keeping the status class only as a fallback trigger. This matters cohort-wide because the failure presents as a sibling bug: anyone auditing assertions without auditing their plumbing writes checks that cannot pass and then hunts a peer defect that is not there. Generalized at GUIDE-CONFORMANCE §5.2b.2.

This is the third member of the family §2.4a opened, and the shape is now stable enough to name. GUIDE-CONFORMANCE §2.4a was a surface the suite reaches and scores backwards (asserting acceptance certifies the hole). EXTENSION-NETWORK §5.4a was a reachable state no vector visits (the §A1/§5.4 join). This one is a pinned value nothing asserts — the spec made code the contract and the instrument measured status. Common root: what gets implemented and what gets scored are both driven by the vector list, not the prose — the same finding §6a.9 already records one screen up, where all three impls skipped layer 1 on exactly the two ops with no named vector. A row pinned in a table is not covered until a vector reads that row's value.

Two follow-on ops, with explicit schemas (the design fold left these as bare "follow-on ops"; cohort impl surfaced the gap — each impl guessed a different shape, so they are pinned here):

Layer 1 binds all three write ops [MUST] — this was the hole [RULED 2026-08-11]. register-request, renew-request and revoke-request each MUST verify the layer-1 signature by target_peer_id (for revoke/renew, the target_peer_id of the binding named by binding_hash) before any state change and before any publication. A request failing layer 1 MUST be refused and MUST publish nothing — no revocation, no superseding binding, no queue entry. All three implementations shipped revoke and renew with no verification at all: any peer that could reach a registry could permanently revoke any binding in it, and revocation is monotonic, so there is no undo. It was found because core-py declined to converge and reported instead (go e91817f). Replay defense is not authorizationrenew's nonce/issued_at stops a captured request being re-run while leaving a fresh unsigned one accepted, which reads as authorization at a glance and is not.

"Or the operator" is not a second wire credential. The prior text read "signed by target_peer_id or the operator", and there is no operator identity, no operator key, and no request field on this operation that an operator could populate — so as a wire credential the phrase named nothing. The wire surface therefore accepts target_peer_id proof exclusively; an operator revokes by acting on its own registry through its own capability.

The justification is irreversibility, not the absence of an operator-gated operation. system/capability/registry-issue-binding does gate operations reachable over the wire — §6a.9.3's approve-request / deny-request are exactly that, an operator decision driven by a dispatched request under that capability. So "the operator's authority is local, therefore no wire surface" would prove too much, and it would be falsified by this document's own §6a.9.3. What actually decides it is the direction of the error: this is the op where guessing permissively is unrecoverable. A registry that widens its accepted proof later un-accepts nothing; one that guessed wide has already handed out permanent denial-of-name. This is the intersection of the three readings an implementer could have taken, and it is chosen deliberately for the op where guessing permissively is unrecoverable: a registry that widens later un-accepts nothing, while one that guessed wide has already handed out permanent denial-of-name. (core-go implemented exactly this reading as its interim and routed the ambiguity rather than picking — .)

Conformance — REG-REVOKE-PROOF-1 / REG-RENEW-PROOF-1 (new). Each requires both halves: a valid layer-1 proof is accepted, and an absent-or-wrong-signer proof is refused and publishes nothing. §6a.9 previously named REG-REGISTER-PROOF-1 and nothing for the other two ops, and all three implementations skipped layer 1 on exactly the two ops with no named vector — the vector list, not the prose, is what got read. See GUIDE-CONFORMANCE.md §2.4a: a check that asserts only acceptance certifies the hole.

§6a.9.1 Issuer policy — the registry's own admission decision

The substrate gates no name claims (§5); a live registry decides what it signs via its own local config (a knob, not a mandate):

type: "system/registry/issuer-policy"          ; registry-local config
data: {
  mode:             "open" | "allowlist" | "manual" | "domain-control",
  allowlist:        [<peer-id>] | null,
  name_constraints: <name pattern per §4 | null>,  ; §4's grammar — `*` only; e.g. only issue "*.lab"
  default_ttl:      <ms | null>,                ; MUST NOT exceed max_ttl
  max_ttl:          <ms duration>               ; REQUIRED for any mode reaching *approve* (§6a.9);
                                                ;   requests above it are CLAMPED, not refused
}

Two separable proof layers:

name_constraints uses §4's name matcher [MUST, v1.15], and therefore no name_constraints value is invalid. It globs the same user-facing name string §4 globs, in the same handler — so it is the same matcher, and * is its only metacharacter. Two consequences bind:

One matcher, two input domains [MUST, v1.16]. The grammar is one (above); the two sites that apply it do not receive the same inputs, and a vector written for one site does not transfer to the other. name_format_dispatch (§4) matches the raw argument to meta_resolve(name) — an arbitrary string, since dispatch runs before any backend is consulted and nothing has rejected or normalized it yet. name_constraints matches a name that has already passed §6a's name-path safety ("identical to §6.3 — no /, no C0/DEL control chars, NFC at issue time"), which a register-request fails with 400 bind_invalid_name before the policy is read at all. Consequently no name reaching name_constraints contains /, and whether * crosses / is unobservable at this gate — it is a real property of the matcher, asserted once, by REG-DISPATCH-GRAMMAR-1 at the site whose input domain admits it. A conformance row requiring this gate to admit a /-bearing name is unsatisfiable by construction: the only way to pass it is to remove the path-injection check that §6a makes normative.

This is an admission gate, so the divergence it hid is the expensive kind. name_constraints decides whether a binding is issued at all403 not_entitled versus a signed, published binding — so two registries running the same operator policy would admit different names. The field carried <glob> with the single example *.lab, which is grammar-identical under every candidate reading and therefore discriminates nothing; two implementations read it two ways and neither could have found the other by reading the spec.

registry-issue-binding (the internal sign+publish act) is gated by system/capability/registry-issue-binding, held by the policy logic / operator only. register-request is the external surface, gated by system/capability/registry-request-binding (open → granted broadly; allowlist → narrow). system/capability/registry-manage-issuer-policy gates editing the policy — via the two operations defined in §6a.9.2.

§6a.9.2 Managing the policy — set-issuer-policy / get-issuer-policy [RATIFIED 2026-08-10]

This capability named an act the corpus never defined, and all three implementations diverged into the vacuum: go 2d6c993 and rust b8e0ae2 expose only the three registration ops and arm the policy out-of-band (a CLI flag or a direct entity write); py e60c822 invented set-issuer-policy / get-issuer-policy (manifest.py). A client written against this spec had nothing to call anywhere. Python's names are the obvious ones and the payload type already existed, so its shape is ratified rather than replaced — it costs one subsection and converges three implementations.

OperationInputOutputGate
set-issuer-policysystem/registry/issuer-policysystem/registry/issuer-policy (the stored policy, as written)system/capability/registry-manage-issuer-policy
get-issuer-policynone — the §3.2 empty-params shapesystem/registry/issuer-policy, or 404 not_found when unsetsystem/capability/registry-manage-issuer-policy

Fail closed when the stored policy is already bad [MUST]. §6a.9.2's refusal binds set-issuer-policy; it does not answer the policy that is already there — seeded out-of-band by a CLI flag, written directly to the tree, or predating the rule, all of which §6a.9.2's store-first resolution admits. When the resolved ttl for a register-request would be null (the request omitted requested_ttl and the stored policy has no default_ttl), the registry MUST refuse with 403 policy_rejected and MUST publish nothing — no binding, no queue entry.

It MUST NOT substitute an implementation-chosen default. That is the same move §6a.9.2 already rejects one bullet up, where get-issuer-policy MUST NOT synthesize a default open: it converts an operator's omission into a silently-invented policy. On a security-relevant field it is worse — two registries would answer identically-stored policies with different binding lifetimes, a §5.10 cross-peer determinism split the operator never sees. A protocol-wide TTL floor is rejected for that reason plus one more: there is no defensible number, and choosing one makes every unconfigured registry look configured.

(Structure mirrors REG-ISSUER-DOMAINCTRL-STORED-1 beside REG-REGISTER-DOMAINCTRL-1 — the write-time refusal cannot be the only thing standing between a bad stored policy and a bad outcome.)

renew-request resolves its ttl by a three-step cascade and never refuses for a missing one [MUST, v1.9]. renew-request is a second producer of peer-issued bindings, and the null-ttl rules above swept only register-request. A renew omitting ttl against a policy with no default_ttl minted a null-ttl successor — a binding §6a.3 forbids and §6a.4 will not honor. The resolution order is:

StepSourceNote
1the request's own ttlas for register-request
2the issuer policy's default_ttlcurrent operator intent outranks history — an operator who lowers default_ttl sees renewals pick it up
3the superseded binding's ttlnon-null by §6a.3 on every conformant mint path
all three yielded nothingrefuse 403 policy_rejected, publish nothing — see the fail-closed clause below

The cascade fails closed when the predecessor itself is invalid [MUST, v1.10]. Step 3 is non-null on every conformant mint path, which is not the same as non-null. A predecessor carrying ttl: null can be already there — seeded out-of-band, written directly to the tree, or predating these rules — the identical "stored state is already bad" case that the set-issuer-policy refusal above does not answer and that D12's backstop exists for. A renew whose three steps all yield null MUST refuse with 403 policy_rejected and MUST publish nothing. It MUST NOT mint the successor with a null ttl, and it MUST NOT substitute a default.

This clause corrects v1.9, which asserted the cascade was total because §6a.3 guarantees step 3. That inference reads an invariant as a fact about stored bytes. It is the same mistake §6a.9.2 already documents one paragraph up — "the write-time refusal cannot be the only thing standing between a bad stored policy and a bad outcome" — and the cascade was written without carrying that lesson across. An unreachable branch that is asserted rather than enforced is how the shape it forbids gets minted: an implementation that guards the dereference to avoid a panic, and falls through, produces exactly the null-ttl binding the whole rule set exists to prevent. The refusal above is unreachable on any conformant path and is required precisely for that reason.

This is not the implementation-chosen default the paragraph above forbids, and the distinction is the whole ruling. A synthesized default is a number the implementation invents, so two registries answer identically-stored policies differently. The superseded binding's ttl is the registry's own prior signed act on this exact name — one value, already published, byte-identical at every conformant peer. It is recovered, not chosen, so it creates no §5.10 determinism split.

Refusing instead is the wrong side, by this subsection's own reasoning. §6a.9.2 puts the register-time gate at set-issuer-policy "because this is where the missing input lives," and refuses to bill the requester for the registry's misconfiguration. At renew the input is not missing — the registry holds a valid ttl it issued itself — so the argument that forces a refusal at register does not reach here. Refusing would revoke a name by inaction, for a policy defect the registrant cannot see or fix, on the one operation whose purpose is to keep the name alive.

Vector REG-RENEW-TTL-NULLPRED-1 [v1.10] — write a peer-issued binding with ttl: null directly to the tree (the same two-stage shape as REG-ISSUER-DOMAINCTRL-STORED-1), then renew it with no ttl against a policy with no default_ttl: 403 policy_rejected, nothing published. The control is REG-RENEW-TTL-CASCADE-1 row (b), which must still return 200. Without this row a peer that drops the final refusal passes every other renew vector.

What the cascade does not do: it does not extend a binding beyond what the registry already granted (step 3 re-grants the same duration to the same layer-1-authenticated target_peer_id), it does not weaken §6a.3 (the resolved ttl is non-null on every path), and it does not touch revocation (a revoked binding is not renewable regardless of ttl).

ttl is bounded on both sides, and the binding side is not the one that matters [MUST, v1.11]. Step 1 accepts the requester's own number and nothing capped it. Since §6a.3 makes ttl the only bound on a withheld revocation, an unbounded requester-chosen ttl reproduces the permanently-unrevokable binding §6a.3 exists to prevent, without ever setting the field to null.

Issuer side — max_ttl is REQUIRED on any policy that can reach approve. system/registry/issuer-policy gains max_ttl: <ms duration>. set-issuer-policy MUST reject with 400 a live-registration policy whose max_ttl is absent or null — the same trigger, the same site, and the same reason as the default_ttl rule above: it is the operator's field, set through the operator's operation, and this is where the missing input lives. default_ttl MUST NOT exceed max_ttl; a policy violating that is refused 400.

A request above the ceiling is CLAMPED, not refused [MUST]. register-request and renew-request resolve ttl through their cascades, then apply effective = min(resolved, policy.max_ttl). Refusing would bill a well-formed request for a policy the requester cannot read — §6a.9.2's own stated reason for not gating the requester — and it teaches requesters to probe for the ceiling. Clamping is silent to the requester by design: the issued binding carries the clamped value, which is signed, published, and readable.

Resolver side — a resolver MAY impose its own ceiling, and this is the half that protects the consumer [MUST when present]. A resolver that declares a local maximum MUST treat a binding's effective lifetime as min(binding.ttl, local_max), computed at resolution and never written back into the binding (the binding's content hash is unchanged; this is a use bound, not a re-issue).

The ceiling is declared at resolver_chain[].hints.max_ttl (ms) [MUST, v1.16]. Until v1.16 this rule named a value and no place to put it, and every implementation invented one — a [MUST when present] with no declared config site is a rule two conformant peers cannot both implement, and this one had four seats across three keys. hints is the slot §4 already declares for backend-scoped configuration (it carries neg_ttl likewise), so pinning the key here costs no change to system/registry/resolver-config's type hash. Per chain entry, not per peer, and that granularity is the point: the ceiling bounds how long this resolver will honor this backend's answers, and a registry you operate does not deserve the same number as one you barely trust.

It MUST be durable configuration read at resolution [MUST, v1.16] — not a process-lifetime setting fixed at construction. A ceiling read at start-up applies on a cold boot and silently does not on a warm one, and a security control present on one boot path and absent on the other is worse than absent: it tests green on whichever path the test happens to take. An operator editing resolver-config MUST be able to set it; out-of-band arming is a seed for that entity, never a parallel source consulted at resolution (the store-first rule of §6a.9.2, same reasoning).

max_ttl: 0 MUST be treated as undeclared [MUST, v1.16]. Honored literally it expires every binding instantly and the operator sees "no binding for this name" — indistinguishable from a bad signature, a revocation, or an offline registry, which is the worst available diagnostic for what is almost certainly a typo or an unset field serialized as zero. (All four implementing seats reached this independently before it was written down.)

A binding carrying no ttl takes the ceiling as its lifetime [MUST, v1.16]. min(binding.ttl, local_max) has no arm for a null ttl, and the sticky kinds (local-name, pinned) carry none. The resolver's ceiling is a bound on how long a value may be honored, so an absent lifetime becomes local_max rather than staying unbounded — applying the control only where a bound already exists would leave exactly the unbounded case uncovered, which inverts it. (peer-issued never reaches this arm: §6a.4 requires a non-null ttl before a result is surfaced at all.)

Why the resolver's ceiling is the load-bearing one. §6a.3's argument is entirely about the consumer: a hostile byte-server withholds a revocation, and ttl bounds the exposure. A ceiling enforced by the registry does not protect a consumer from that registry — a hostile or compromised issuer simply sets max_ttl high. Only the party bearing the risk can bound it. This is the split DNS settled decades ago: the authority sets the record's TTL, and the resolver caps what it will honor (max-cache-ttl), because the resolver is the one holding stale data. The issuer-side max_ttl is operator hygiene — it stops a careless registrant asking for a decade — while the resolver-side clamp is the actual security property.

The shape is §4.10's, applied to freshness: mandate that the bound exists and is declared and enforced; leave the value to the deployment. No number is written here for the same reason §4.10 writes none — there is no defensible constant, and choosing one makes every unconfigured deployment look configured. This is also the mainstream answer across the surveyed field: DNS caps at the resolver, TUF sets expiry per role, and the X.509 and ACME ecosystems put the ceiling in policy rather than in the protocol. None of them put an unbounded lifetime in the hands of the requesting party, and none of them writes the maximum into the wire format.

Conformance: REG-TTL-CEILING-1set-issuer-policy with a live mode and absent max_ttl400; with default_ttl > max_ttl400; control: a policy with both, default_ttl <= max_ttl, is accepted. REG-TTL-CLAMP-1 — a register-request and a renew-request each carrying ttl above max_ttl → both accepted 200, and both issued bindings carry exactly max_ttl. The clamp is asserted on the binding's value, not on the response code, because a peer that refuses instead of clamping also returns a non-200 and would otherwise be indistinguishable.

REG-TTL-RESOLVER-CEILING-1 [v1.16] — the resolver-side vector this spec has never had for the half it calls load-bearing. Both existing rows above test the issuer side; nothing tested the clamp that actually protects a consumer, which is how four seats put it in three places without any instrument noticing. Against a chain entry carrying hints.max_ttl, four rows: (a) a binding whose ttl exceeds max_ttl resolves with effective lifetime exactly max_ttl, and result.binding's content hash is unchanged — assert the hash, not only the number, because a resolver that rewrites the binding to carry the clamped value moves its address and invalidates every signature over it; (b) a binding whose ttl is below max_ttl is returned untouched; (c) hints.max_ttl: 0 behaves identically to an absent hints — the binding's own ttl survives (the undeclared rule above); (d) a sticky local-name binding (no ttl) resolves with effective lifetime max_ttl. Row (d) is the one an implementation passes by accident and fails on inspection, since min over a null has no natural answer.

"or pinned" is withdrawn from row (d) [v1.17] — it was unconstructible. A pin can never carry a hints.max_ttl: §4.1 step 1 returns the synthesized pin result before the step-2 filter and the chain are consulted, so no chain entry — and therefore no hints — is ever in scope. This is the identical category error §4.1a already names for pinned in backend_kinds ("not a backend kind and cannot be reached from dispatch"), repeated in a different field. And the substantive answer, so it is not re-added: a pin is the user's own assertion, not a backend's answer. The resolver ceiling bounds how long a backend's answer is honored; §4.1.2's pin carve-out already runs on that principle (reach is the user's problem). A pin is correctly outside the ceiling.

REG-TTL-CEILING-REREAD-1 [v1.17] — the check for the half of the MUST that had no instrument. Class: validate-peer check (behavioral, over the wire, one long-lived peer), category registry, authored per GUIDE-CONFORMANCE §7.0. Not a fixture-corpus vector. Neither ceiling check can discriminate latched-at-start from read-at-resolution, because a fresh peer per check reads its config exactly once either way — and read-at-resolution is precisely what the [MUST] above requires and what its whole security argument rests on. One peer process, three resolutions, the resolver-config rewritten between them via set-resolver-config (§4.3): hints.max_ttl absent → present → a lower value. The effective lifetime MUST track the currently stored config at every resolution. A peer that latches at start passes the first row and fails the second and third.

Constructibility, per GUIDE-CONFORMANCE §5.2b.1 [v1.18]. When this check was first pinned at v1.17 the state it requires was not constructible by a conformance client — no operation existed to rewrite a running peer's resolver-config, so the only satisfaction mode would have been in-process with a declared exclusion. §4.3 is the enabling surface and it makes this wire-driven, which is the mode this check now declares. That the same missing operation blocked both this check and §4.1 step 2's write-time MUST is not a coincidence: a configuration surface with no defined write operation cannot be validated, cannot be rebound, and cannot be conformance-driven at all.

Conformance: REG-RENEW-TTL-CASCADE-1 — three rows against a curated registry whose stored policy has no default_ttl: (a) renew with explicit ttl → accepted, successor carries it; (b) renew omitting ttl → accepted, successor carries the superseded binding's ttl, and the successor resolves under §6a.4; (c) the same renew against a policy that does carry default_ttl → successor carries the policy's value, not the predecessor's. Row (b) is the one that fails against both a null-minting peer and a refusing peer; row (c) is the one that fails against a peer that implemented inherit-first.

Replay defense (normative discriminator). A signed request carries nonce + issued_at (the registry tracks seen nonces per requester within an issued_at window; a replayed request is rejected) iff replay has a non-idempotent state effect. This holds for register-request (replay can roll a name back to a superseded binding) and renew-request (replay can extend a binding's life past intended lapse). It does not hold for revoke-request, which is monotonic on a content-addressed target (replay cannot un-revoke and cannot reach a later re-issued binding) — so revoke omits nonce / issued_at. The discriminator, not the op name, decides: future ops are replay-defended exactly when their replay mutates state.

Conformance: REG-REGISTER-PROOF-1 (signature not by target_peer_id → rejected), REG-REGISTER-POLICY-1 (allowlist reject → not_entitled; allow-listed → issued + resolvable), REG-REGISTER-REPLAY-1 (seen nonce → rejected). REG-REGISTER-DOMAINCTRL-1 gates the deferred domain-control mode; REG-ISSUER-DOMAINCTRL-STORED-1 gates the fail-closed 501 above (write the policy entity directly, then attempt live registration — the set-issuer-policy refusal cannot be the only thing standing between a stored unenforceable mode and an open registry).

Implementation status: the design is pinned here; the open / allowlist / manual modes are buildable now (no external dependency); domain-control waits on the web-native domain-proof co-design. A registry shipping curated-only (§6a.8) is conformant — it simply does not run the handler.

§6a.9.3 The manual-approval path — pending-binding, the by-request pointer, approve / deny

Reserved earlier, filled here. The earlier ruling pinned what pending_hash refers to — the content_hash of the stored system/registry/pending-binding — and then stopped, because that entity had no schema anywhere in this document. That left pending_hash naming a shape nobody had defined, and the three seats split accordingly: Rust deliberately emits no pending_hash at all (extensions/registry/src/registration.rs:303 — REQUIRED by the ruling, withheld because the referent is unspecified), Python invented the entity and an approve-request operation (entity_handlers/registry.py:1188, :1303), and core-go's oracle FAILs a peer that returns no pending_hash (cmd/internal/validate/registry_issuer.go:401). So the reference oracle currently fails a seat for withholding a value the spec gave it no way to compute. A MUST whose referent is unspecified is not a requirement, it is a trap, and this section removes it.

Python's shape was ratified rather than replaced — at ruling time it was the only worked implementation and it already followed §6.3's body/pointer split. One seat is not convergence, so the additions below (the by-request pointer, the decision states, deny, retention) were arch's design and were marked unbuilt. They are built in all three implementations (pins in the outcome note at the end of this section); the marking is kept because it is what the reader needs to know about how the section was derived, not because the build state is still open.

The storage shape follows §6.3 exactly — an immutable content-addressed body plus a mutable tree pointer. This is not a new pattern; re-deriving one here is how the two would drift.

type: "system/registry/pending-binding"
data: {
  name:           <string>,                  ; name-path safety per §6.3
  target_peer_id: <Base58 peer-id, V7 §1.5>,
  transports:     [<system/hash, BARE>],      ; §3 shape
  requested_ttl:  <ms | null>,
  queued_at:      <ms-since-epoch>,
  status:         "pending_review" | "approved" | "denied",
  binding_hash?:  <system/hash>,             ; REQUIRED on "approved", absent otherwise
  reason?:        <string>                   ; OPTIONAL on "denied"; operator-supplied, never parsed
}

target_peer_id precedes name in the pointer path, and the order is normative. A target_peer_id is a single Base58 segment; a name is name-path-safe but not guaranteed single-segment (§6.3 pathes names directly, and binding/local-name/{name} already relies on that). Putting the variable-depth value last keeps the prefix parseable and keeps pending/by-request/{peer}/ enumerable. (Same failure this corpus corrected in EXTENSION-NETWORK §4.1 the same day: a multi-segment value in a non-terminal position makes the path unwalkable at a fixed depth.)

pending_hash is registry-local and its bytes need no cross-peer agreement [MUST NOT be gated on reproducibility]. queued_at is a local wall-clock reading, so two registries will not compute the same hash for the same request — and they never need to. A pending-binding is pre-decision, single-registry state: no second registry stores it, aggregators do not re-publish it (§8 republishes bindings), and the only obligation is that the hash resolves at the registry that minted it. This is stated explicitly because the corpus's other content-hash rulings run the opposite way (EXTENSION-COMPUTE §2.4 materialized errors must agree byte-for-byte cross-impl), and an implementer generalizing from those would strip queued_at to chase a determinism this surface does not require.

One pending head per (target_peer_id, name) [MUST]. A register-request that queues while a pending head already exists for the same pair supersedes it: a new body is written, the pointer repoints, and the 202 returns the new pending_hash. Replace-whole, never merge — the same rule and the same reason as §6a.9.2's policy write, and it is what stops a requester's retries (each of which carries a fresh nonce, so each is a distinct request by construction) from filling an operator's queue with duplicates of one intent.

Operator decisions — both gated by system/capability/registry-issue-binding, the capability §6a.9.1 already defines for the internal sign-and-publish act. No new capability: approving a queued request is issuing a binding.

OperationInputOutputEffect
approve-request{pending_hash}system/registry/register-result {status: "bound", binding_hash}issues the binding (§6a.8), writes a new body status: "approved" carrying binding_hash, repoints
deny-request{pending_hash, reason?}system/registry/register-result {status: "denied"}writes a new body status: "denied", repoints; nothing is signed or published

The decision operations take an un-typed input, and handlers MUST decode by shape [MUST]. Every other write operation on this handler names a system/registry/* params type; these two deliberately do not. No implementation may register a type definition for system/registry/approve-request or .../deny-request — the names are not carried by this specification, and publishing a definition for one would manufacture a cross-impl type-census divergence out of a spec gap, making the divergence the publisher's. A handler MAY name a local type on the wire for its own dispatch, but MUST NOT assert the params type on receipt: an operator tool sending a differently-typed params entity carrying pending_hash interoperates. (This is the narrow exception, not the pattern. It is chosen because the alternative — arch inventing two type names right now — pins a wire surface that no operator tooling exists to consume, and the corpus's own history says an invented name is the one the next implementation invents differently. When operator tooling lands, the types land with it.)

Supersession must be observable, and the schema alone does not make it so [MUST]. A register-request retry carries a fresh nonce, but pending-binding does not carry the nonce, so two retries of one intent inside a single millisecond encode to identical bytes and content-address to one body — at which point the 202's "new pending_hash" is the old pending_hash and a superseding write is indistinguishable from a no-op. That collapse is correct and intended — one head, one hash, and adding the nonce to the body would defeat the dedup for no gain. What follows from it is a conformance obligation, not a schema change: REG-PENDING-DECIDE-1's supersession half MUST vary a field the schema actually carries (requested_ttl or transports), and an implementation MUST NOT rely on the returned pending_hash changing as its supersession signal. The observable invariant is the one stated above — exactly one head reachable through the pointer for the pair — which holds whether or not the two bodies collide. (Recorded because the obvious supersession test reaches for the nonce first, and that test would assert on an artifact rather than on the invariant.)

Retention [SHOULD]. A decided pending-binding (approved / denied) is GC-eligible after a configured retention window; the body stays content-addressed and auditable independently of the pointer. A pending_review head is never GC-eligible — it is live queue state. (A MUST-write paired with an unbounded queue is a leak by construction; the retention knob is what keeps the queue an operator's inbox rather than a log.)

Conformance — REG-PENDING-HANDLE-1 / REG-PENDING-DECIDE-1 (new). Per GUIDE-CONFORMANCE §2.4a both halves are required, and the negative half is the load-bearing one:

The four items ruled above (denied in the enumeration, the un-typed decision input, the superseded-head 404, supersession observability) were each found by a build, not by review, and three of the four were reached independently by all three seats before anything was ruled — which is the signal that the text was under-determined rather than misread, and the reason they are ratified as written rather than re-litigated.

§6a.10 What the peer-issued backend does NOT do (v1)


§7 Bootstrap-with-precedes pattern

§7.1 The pattern

A distribution ships its binary with:

New user inherits this trust by accepting the build. Immediate functionality without manual configuration.

§7.2 Swap discipline

The user can:

This is the substrate-exposed override. Distributions provide opinion; users own the configuration.

§7.3 Structural framing

This is structurally analogous to OS installations shipping with pre-loaded root CA certificates. Pre-trust by acceptance of the distribution; remove or override at any time. The substrate exposes the mechanism (resolver-config + pin store); the discipline is "distribution opinion is opinion; user owns config."

§7.4 Worked example — the preloaded Entity System Registry (release bootstrap)

The day-one release ships a real, signed base registry — not local nicknames. This worked example pins exactly what is preloaded and how "trusted" differs from a local-name.

Trusted (peer-issued) vs local-name (local):

Local-name bindingEntity System Registry binding
kindlocal-namepeer-issued
issuer_signaturenullsignature by the registry peer's identity key
trust sourcethe local user's assertiona key the distribution preloads + pins
trust_anchorlocal_namepeer_issued:{registry_peer_id}
scopelocal-only, no global meaningverifiable by anyone holding the registry's pinned identity

A local-name is "I say this name means this peer." The Entity System Registry binding is "the registry signed this, and I verify that signature against a key shipped with my build." That is the difference between asserted and verified.

What the distribution preloads (the bootstrap package / the "seed"):

  1. The registry peer's identity entity — peer-id + public key — pinned as a trusted authority (the verification root; like a root CA shipped with an OS).
  2. A resolver-config (§4) with a peer-issued backend entry for the registry, accepted_trust_anchors: [peer_issued:{registry_peer_id}], and the registry's static http-poll endpoint in hints so its binding tree can be fetched cold.
  3. Precedes (optional) — pre-cached signed coral-reef bindings (Bill's Lab, entitycoreprotocol.org, entitychurchfoundation.org) so first run works fully offline; absent these, they're fetched from the registry's static tree on first connect.
# preloaded, pinned — the root of trust
system/peer/{entity_system_registry_peer_id}      ; identity entity: peer-id + public key

# preloaded resolver-config
system/registry/resolver-config
  resolver_chain: [
    { backend_kind: "peer-issued",
      backend_id:   <entity_system_registry_peer_id>,
      priority:     0,
      accepted_trust_anchors: [ "peer_issued:<entity_system_registry_peer_id>" ],
      hints:        { http_poll_endpoint: <registry static URL> } }
  ]
  pinned_bindings: [ ]            ; optional name→peer_id pins

# preloaded precedes (optional; each SIGNED by the registry)
system/registry/binding/{hash}   ; kind: peer-issued, issuer_signature: <registry sig>,
                                 ;   name: "entitychurchfoundation.org", target_peer_id, transports: [http-poll ...]

The onboarding flow ("add the Entity System Registry"):

Fresh peer, no contacts. The UI offers: "Add the Entity System Registry to get connected." Accepting installs the package above. Then:

  1. resolve("entitychurchfoundation.org") consults the peer-issued backend.
  2. The registry's signed binding for entitychurchfoundation.org is fetched over HTTP poll (or read from precedes).
  3. The receiver verifies issuer_signature against the pinned registry identity and checks the peer_issued trust anchor passes accepted_trust_anchors.
  4. On success: ResolutionResult → entitychurchfoundation.org's peer_id + its http-poll endpoints (from the binding's transports).
  5. The browser pulls entitychurchfoundation.org's content by hash over HTTP poll.

No live registry peer is required at any step — the registry is itself a coral reef (a static publisher in dormancy). Trust is cryptographic, rooted in a key shipped out-of-band: bootstrap-with-precedes made concrete.


§8 Composition with RELAY (the aggregator-as-meta-registry pattern)

§8.1 Registry-peer-as-Mode-S-publisher

A registry peer publishes its bindings as entities at system/registry/binding/... in its own tree. Consumers fetch via standard content flow — the static-CDN-hosted case is Mode S relay (already shipped via STORAGE-SUBSTITUTE-HTTP).

No special registry transport. The registry's bindings are entities; the substrate's content-fetch machinery applies directly.

§8.2 Aggregator-as-meta-registry (federation) — v1-DEFERRED

A peer running RELAY Mode A subscribed to N registry peers' binding subtrees serves the union as its own system/registry/binding/... tree. Consumer installs this aggregator as ONE resolver backend; aggregator handles the multi-source mechanics.

v1 deferral. This composition depends on RELAY Mode A, which is deferred from v1 per PROPOSAL-EXTENSION-RELAY.md §11.1a (cross-peer subscription dependency — current substrate's subscription engine is local-tree-only). The aggregator-as-meta-registry pattern is named here for forward-compatibility; it is not shippable in v1. Cross-registry federation lands when Mode A lands.

The aggregator does NOT re-sign aggregated bindings; receivers verify against the original issuer's signature. Aggregator is transport.

§8.3 Conflict handling at aggregator level

When two upstream registries return different target_peer_id for the same name:


§9 GC posture (per GUIDE-GC.md)

All retention windows are operator-configurable knobs; defaults conservative (unlimited / off / largest).


§10 Configurations (deployment patterns)

These are deployment configurations, not a hierarchy. Each is a valid choice for some use case.


§11 Cross-impl conformance

§11.1 MUST implement

(Resolution-log is SHOULD per §11.2, not MUST: one log write per top-level meta_resolve puts a write on the resolution hot path, and resolution is the highest-frequency operation this extension defines.)

§11.2 SHOULD implement

type: "system/registry/resolution-log"
data: {
  seq:                   <uint>,                       ; per-peer monotonic, persistent across restarts
  name:                  <string>,                     ; the queried name
  backend_id:            <peer_id_hash | identifier | null>,  ; which backend answered (null if chain_exhausted)
  status:                "resolved" | "not_found" | "chain_exhausted",
  reason:                <string | null>,              ; e.g. "signature_failed", "policy_rejected", "pin_short_circuit" — null if status=resolved by normal path
  binding:               <hash | null>,                ; resolved binding, if any
  attempted_at:          <ms-since-epoch>,
  is_fallback_reresolve: bool                          ; true if invoked from §2.3 transport-fallback loop
}

§11.3 MAY implement

§11.4 MUST NOT


§12 What this extension does NOT cover


§13 Cross-references


§14 Open questions (informative)