# The protocol

Edition 2026-08-14.

How a participant submits to a registry, and what the registry gives back.

## Two signed-action families

There are **two** families, and they are separate on purpose: they differ in
authorization, in transport floor, and in signing context.

| Family             | Context            | Who acts                 | Transport floor                 |
| ------------------ | ------------------ | ------------------------ | ------------------------------- |
| Secretariat action | `gra-action`       | A registry administrator | Mutual TLS, always              |
| Participant action | `authority-action` | An accredited authority  | The authority's own declaration |

The secretariat surface is unbounded — it can create, modify or suspend any
entry — which is why its floor is fixed at mutual TLS. A participant's floor is
its own declaration, because a serious operator may require client certificates
across their organisation while a smaller one uses a bearer token.

**Domain separation is checked by name at the door**, not left to the curve.
Without it, the two are signatures over structurally similar objects, and the
whole bounded/unbounded distinction would rest on which endpoint a caller chose
to post to.

Five operation names appear in both families deliberately — key and node-binding
management — because an authority whose only key is compromised cannot rotate it
and needs the secretariat. Allocation and record submission appear in the
participant family **only**: a registry administrator administers an authority
but must never speak **as** one, or custody's whole distinction is erased on the
one act that produces a permanent public reference.

## The action envelope

```json
{
  "op": "record.submit",
  "authority": "acme",
  "target": { "type": "record", "id": "GAVR-A7K2QXR8" },
  "params": {},
  "nonce": "<16-128 base64url characters>",
  "timestamp": "2026-01-02T00:00:00Z"
}
```

Signed with the signing context for its family. Every field is inside the
pre-image, so none can be rewritten after the fact: `op` and `target` are what
the action _means_, `nonce` and `timestamp` are what stop it being replayed.

**`authority` is not redundant with the key.** Under hosted custody the signing
key belongs to a node, and a node may be bound to several authorities at once —
that is what a hosting instance is. The key alone cannot say which of them is
speaking, and leaving the field out would let an action be attributed to
whichever the registry happened to resolve first.

`params` is validated per operation by the registry, not by the envelope.

## Freshness and replay

**The nonce is 16 to 128 base64url characters.** Bounded because the registry
stores every nonce inside the freshness window, and an unbounded nonce is an
unbounded write.

**The freshness window is 300 seconds and is bounded in BOTH directions.** The
future bound looks optional and is not: an action dated far ahead stays fresh
long after its nonce row has been swept, at which point it replays cleanly. A
clock skewed forward, or a caller choosing its own timestamp, would manufacture
exactly the window this exists to close.

**The nonce retention horizon must be at least the freshness window.** This
coupling is load-bearing and nothing fails a test when it is broken. A replay is
refused because its nonce is still on record, so pruning faster than the window
makes an action replayable the moment its row is swept while it is still fresh
enough to pass.

**A nonce is spent by PRESENTATION, not by success.** A failed action cannot be
resubmitted verbatim; the caller mints a new one. That is correct for a one-shot
privileged instruction, and it has a consequence a client must handle: see
`requestKey` below.

**The replay check runs LAST**, after authentication and signature verification,
because it is the only step that writes. A nonce recorded before the signature
is verified would let any unauthenticated caller fill that table, turning the
anti-replay gate into an amplifier.

**Freshness applies to the action, not to a signature.** A signature's own
`created` is metadata a relying party may read; it is not a freshness claim and
must not be treated as one.

## Allocation, and why retries are dangerous

The identifier is **inside the signed document**, so the registry cannot supply
one without invalidating the signature it is verifying. The order is therefore
fixed: **allocate, sign, submit.** "Allocate on demand at submission" is not
implementable.

A mint carries a caller-supplied **`requestKey`**, unique per authority. This is
not optional politeness. Because a nonce is spent by presentation, a mint whose
response was lost cannot be retried verbatim, and a fresh nonce would burn a
**second permanent identifier**. The `requestKey` is what makes the retry
idempotent, and the registry deduplicates on a unique index rather than a
read-then-write two racing retries would both pass.

Pick a `requestKey` you can reproduce after a crash, and be aware it is
withheld from the public view: in practice it is somebody's internal ticket id,
and publishing it would leak the authority's workflow and let anyone correlate
their reservations.

**A mint carries no subject.** Scope enforcement happens at intake, where the
affected products are known; see [scope.md](scope.md).

## Intake: two signatures, and neither is redundant

`record.submit` carries:

1. **The envelope signature**, which authenticates the _submission_: it is
   replay-protected and bound to one registered key.
2. **The record's own signature**, in the record signing context, which is the
   portable **content attestation** a mirror re-verifies years later with no
   access to this endpoint.

**The envelope's `params` carry the document's DIGEST, never the document.** The
envelope is stored verbatim in an append-only hash chain that is never pruned,
and a chain carrying every document is a chain nobody can keep.

**The digest is taken over the document as SENT**, never over the parsed result.
A schema applies defaults, and the authority signed what it sent. Digest the
bytes.

**An unsigned record is refused.** The unsigned exemption exists so publishing
survives the registry being _unreachable_, and a record arriving here is proof
that it is reachable, so the exemption cannot apply on this path by
construction.

## Contributing to a record you do not own

`record.submit` refuses anyone but the owner, so a second accredited body uses
**`record.contribute`**: one more container beside the owner's, signed by and
attributed to the peer, with `role: "peer"`.

It is bounded by `compareContribution`, which is **strictly narrower** than a
root's intervention. A root may additionally move the lifecycle fields; a
contribution may move nothing at all outside its own container, save
`updatedAt`. Three consequences follow rather than being separate rules: a
contribution cannot change `state`, cannot release the `wasWithdrawnOnce` latch,
and is not subject to scope enforcement — a scope of authority bounds what a
body may _mint_, and a contribution mints nothing.

**The container's `role` is checked against who is submitting.** A peer
attaching one marked `root` is presenting its opinion as an accrediting body's
intervention on every surface that renders by role, and the author field does
not catch it, because the container really is the peer's.

### Editing the owner's container

A revision that changes `cna` must additionally carry **the owner's own
signature** over the same document, verified against a key the owner authority
may speak with — not merely verified as a signature, since a submitter can
generate a key a moment beforehand.

This is how the registry enforces an access grant it has no model of. Grants
live in the participant's own system; expressing the authorisation as
cryptography means there is nothing to mirror, nothing to keep in step, and
nothing that can go stale. The cost, accepted deliberately, is that it is
per-revision rather than standing.

A failed co-signature answers `owner-cosignature-invalid`. It is deliberately
not the underlying reason, because those are phrased about the _acting_
authority's key and would send a caller whose own signature is perfectly good to
go and debug it.

The declared custody is checked against the one the registry resolves from the
credentials presented. A signature claiming `SELF` on a hosting node's key is a
stronger claim than the facts support.

## Refusals

Refusals are **machine-readable, never prose**. A submitter has to act on the
answer without a human reading it: retrying a transient failure, refusing a
permanent one and surfacing an operator-fixable one are three different
behaviours, and a sentence cannot be branched on.

The enumeration is grouped by **what the submitter should do**, which is the
only grouping that matters to the party receiving it:

| Group                | Examples                                                                                                                               |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Fix the client       | `bad-params`, `malformed-document`, `malformed-signature`, `digest-mismatch`                                                           |
| Fix the signing      | `wrong-signing-context`, `bad-signature`, `unknown-signing-key`, `signing-key-not-active`, `signer-not-the-acting-authority`           |
| Fix the allocation   | `unknown-identifier`, `identifier-expired`, `identifier-not-yours`                                                                     |
| Fix the record       | `out-of-scope`, `illegal-transition`, `not-the-owner`, `withdrawal-history-erased`, `identifier-mismatch`, `foreign-container-altered` |
| Fix the intervention | `intervention-out-of-bounds`, `no-such-record`, `own-record`                                                                           |
| Sign it              | `signature-required`                                                                                                                   |

A schema failure arrives as `malformed-document`, with a **`violations`** array
naming the numbered rules it broke. Over HTTP that is a 400 whose body carries
the reason in `message` and the rules beside it:

```json
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": "record-refused: malformed-document: at most one identifier may be PRIMARY, found 2",
  "violations": [
    {
      "rule": "GAVR-R-004",
      "path": "identifiers",
      "message": "at most one identifier may be PRIMARY, found 2"
    }
  ]
}
```

`violations` is additive: `message` keeps the shape it always had, and the
machine-readable half arrives alongside rather than instead of it. The
enumerated reason remains what you branch on, because every rule lands in the
same "fix the document" bucket; `violations` is what tells you _which_ rule, and
it is how you find out that the rule your validator did not catch was one of the
companion rules.

Two absences carry meaning.

**A violation with no `rule`** is a purely structural failure — a missing
required property, a wrong type. The published JSON Schema already expresses
those, so your own validator caught it before you sent anything. A violation
that _does_ carry a rule is the interesting one.

**No `violations` key at all** means the refusal was not a schema failure: a bad
signature, an unknown identifier, an out-of-scope record. Those have their own
reasons and no rule to cite.

`violationsTruncated: true` appears when the list was cut at 20. It is reported
rather than applied silently, because a list that quietly stops reads as the
complete set of problems.

The human-readable `detail` folded into `message` is context for an operator. It
is never the thing a client branches on.

## Revisions, and the countersignature

Every acceptance appends a **revision**. Nothing is overwritten. Retrofitting
history later gives a history that starts partway through, which is an apology
rather than a record.

The registry countersigns each accepted revision. The countersignature covers:

- **the document**,
- **the authority's signature over it**, and
- an explicit acceptance block: `acceptedAt`, `revision`, `schemaVersion`.

The authority's signature is inside it, and that is the whole design. Covering
the document alone would let a valid countersignature be lifted onto a
differently-signed copy of the same document, so the registry would appear to
have vouched for an attestation it never saw. Whether the registry checked _that_
signature is exactly what a relying party reads a countersignature for.

`revision` is in the acceptance block for the mirror-image reason: a
countersignature on revision 3 lifted onto revision 5 is the same record, the
same authority and the same registry, and would otherwise verify.

`acceptedAt` must be the value the registry actually publishes. Deriving it from
a database default while the signed block carries a different clock makes the
countersignature unverifiable from the registry's own resolver — the one
property it exists for — and nothing fails if your tests verify against the
acceptance object they just built rather than against the published projection.

**The acceptance block a resolver publishes is exactly the block that was
signed, and nothing else.** It once carried a fourth key, `digest`, described as
riding along for convenience — an unsigned value inside the object presented as
the registry's signed assertion, and the one value a verifier is most likely to
trust, since it names which bytes were accepted. Worse, the schema stripped it
silently, so an implementation building the payload from what a resolver serves
got a verification that passed over three of the four fields in front of it with
nothing saying so. The digest is a SIBLING of `acceptance`, where being unsigned
is legible, and the acceptance schema refuses any key the signature does not
cover rather than narrowing it quietly. There is no loss: the countersignature
covers the document ITSELF, which binds it more tightly than a hash would, so a
reader who needs the digest proven recomputes it.

**Verify a countersignature against the registry's key obtained
independently**, from `/.well-known/gavr-registry-key`. Verifying it against
whatever key the countersignature itself names proves only internal consistency,
which any forger can arrange. That document also publishes the registry's own
`signer` block, so the identifier inside a countersignature can be checked
against the registry it was fetched from rather than merely read.

## Serving your own records

An accredited authority serves its own records **in addition to** the registry,
never instead of it. Two independent hosts serving identical signed bytes is
what lets a consumer compare digests with no per-participant code.

The registry probes a declared resolver and **verifies the countersignature
against its own key**, which is what makes the probe more than a digest
comparison: a mirror composing and countersigning its own record would otherwise
pass. The probe never follows a redirect, because a declared public host could
bounce the request inward.

A failing resolver never causes suspension. Suspension stops mints, and the
registry serves every one of the authority's records throughout, so a web server
being down is the wrong thing to punish.
