# Signing registry

**Status: normative. This registry is the complete inventory of signed and
digest-bound byte formats in 2AYE.**

> **No signing family may exist in runtime code without an entry here.**
> `tests/verify-signing-registry.mjs` enumerates every signing and verification
> call site and fails the build when one is not registered.

Fourteen formats, in five representations, with three timestamp conventions,
three "absent" conventions and two signature algorithms. That is not a design —
it is what nine months of building produced, discovered by transcription and
written down here so that the next implementation does not have to rediscover
it.

The purpose of this document is to make the existing bytes **stable and
reproducible**, not consistent. Consistency is the job of
[canonical-authority-representation-v1.md](canonical-authority-representation-v1.md),
which governs **new** formats only.

> **Nothing in this registry may be "cleaned up" in place.** Every entry
> describes bytes that existing signatures were made over. A change to any of
> them invalidates evidence that has already been produced and relied upon. The
> remedy for an irregular format is a new version alongside it, never an edit.

---

## 1. Two axes, deliberately separated

A format has a **representation** (what bytes are produced from an object) and
an **algorithm** (what is computed over those bytes). They are separate here
because they change for different reasons and on different schedules.

| | |
|---|---|
| **Representation** | Field order, encoding, separators, decimal and timestamp form, collection ordering, absent semantics. Changing it changes what a signature *means*. |
| **Algorithm** | ES256, HS256, SHA-256. Changing it changes the *strength* of the claim, not its content. |

An algorithm migration — ES256 to ES384, or to a post-quantum suite — must be
possible without redefining a single business semantic. So no representation in
this registry is permitted to embed its algorithm in its canonical bytes, and
the statement families' `statement=` domain separator names the **format**, not
the curve.

Where an entry's algorithm is symmetric, that is recorded as a property of the
entry rather than of the representation, because the same bytes could be signed
asymmetrically later.

---

## 2. Cryptographic canonicalization is not display ordering

Two different orderings exist and one document conflated them, which is
recorded as a defect in
[signed-statements.md](signed-statements.md) §8.4.

| Term | Meaning | Used for |
|---|---|---|
| `CRYPTOGRAPHIC_CANONICALIZATION` | The one byte representation a signature or digest is computed over. Deterministic, reproducible across languages, never locale- or serializer-dependent. | Signing, verification, digests, hash chains |
| `DISPLAY_ORDERING` | The order facts are shown to a human, chosen for legibility. | Rendering an approval screen |

> **`DISPLAY_ORDERING` MUST NEVER be used to generate a signature.**

The one place they meet is legitimate and must not be confused for an
exception: the display's canonical form (§3.3) is itself cryptographically
canonicalized — it has a fixed representation and a digest — and that digest is
then bound into a signature. The display's *row order* is a display decision;
the *bytes* of the display statement are a cryptographic one. Both are fixed;
they are fixed for different reasons.

Concretely, the two orderings differ:

- The **envelope** orders predicates by the whole canonical form of each
  predicate.
- The **display** orders rows by predicate `type`.

Neither is wrong. The envelope README describing the envelope as ordering by
`type` **is** wrong, because that sentence describes the display.

---

## 3. Compatibility status vocabulary

Every entry carries exactly one status. These are compatibility facts, not
quality judgements.

| Status | Meaning | May a new format copy it? |
|---|---|---|
| `FROZEN` | Bytes are fixed. The representation already satisfies the portability rules a new format must meet. | Yes |
| `LEGACY_COMPATIBLE` | Bytes are fixed and must remain so, **and** the representation violates one or more portability rules. Kept because signatures exist over it. | **No** |
| `DEPRECATED` | Still verified for compatibility. Must not be used for new integrations, and should be removed once no producer remains. | **No** |
| `PLATFORM_ONLY` | The bytes are serializer-driven, so only this implementation can *produce* them. A third party can verify over the form as received but cannot generate it. | **No** |
| `SYMMETRIC` | Authenticated with a shared secret. Proves the holder of the secret produced it; proves nothing to a third party, because the verifier could have produced it too. | Only where that is the intent |

A format may be both `LEGACY_COMPATIBLE` and `SYMMETRIC`; the first column of
each entry gives its primary status and the notes give the rest.

---

## 4. The registry

### 4.1 Line-oriented statements

Five families share one representation. Specified in full in
[signed-statements.md](signed-statements.md); vectors in
[statement-vectors.v1.json](statement-vectors.v1.json).

| | |
|---|---|
| Representation | `statement=<family>\n` then `<key>=<value>\n` per field, every line terminated |
| Encoding | UTF-8, no BOM |
| Line endings | `\n` only, including a trailing one |
| Unicode | No normalization applied. Control characters in a value are a refusal, not an escape |
| Field order | Fixed per family, **not uniform across families** |
| Collection ordering | Ordinal (byte order), never culture-aware |
| Absent | the literal `none` |
| Hash casing | **Per field.** `action_hash`, `content_hash`, `display_digest` uppercased; `credential_hash` not |
| Decimals | Carried as text with scale intact, except `cost` (§4.2) |
| Timestamps | **Two conventions.** See the per-family table |
| Key identification | The key registered for that device, provider or executor. **Never a key supplied with the signature** |
| Algorithm | ES256, P-256, raw 64-byte R‖S, Base64Url |
| Verification | Domain separator, then exact field set and order, then signature, then registered key |

| Family | Signer | Status | Irregularity |
|---|---|---|---|
| `verid.contract.sign.v1` | Enrolled device | `LEGACY_COMPATIBLE` | **Field order is not alphabetical** — `intent_id, version, content_hash, signer_id, device_id, tenant_id`. The only family of the five like this. |
| `verid.approval.displayed.v1` | Enrolled device | `FROZEN` | — |
| `verid.signin.approve.v1` | Enrolled device | `FROZEN` | — |
| `verid.resource.capability.v1` | Resource provider | `LEGACY_COMPATIBLE` | **`cost` is formatted from a native decimal**, not carried as text. |
| `verid.executor.attest.v1` | Executor attestor | `LEGACY_COMPATIBLE` | **Round-trip timestamp**: 7 fractional digits and `+00:00`. **`operator` is not trimmed** when present, unlike its neighbours. |
| `verid.payment.admission.v1` | **Platform grant key** | `LEGACY_COMPATIBLE` | **Round-trip timestamp**, as above. `credential_hash` and `currency` are trimmed only, not cased. Note the signer: this is a statement family signed by the platform, not by an enrolled device. |

### 4.2 The display statement

| | |
|---|---|
| Family | `verid.approval.display.v1` |
| Representation | Statement lines, but with **repeated `row=` keys**, one per displayed row, in display order |
| Separator | Label and value separated by **U+001F UNIT SEPARATOR** |
| Signed? | **No.** Digested with SHA-256, uppercase hex; the digest is bound into §4.1's `verid.approval.displayed.v1` |
| Row ordering | `DISPLAY_ORDERING` — five fixed rows, then predicates by ordinal `type`, then Expires |
| Status | `FROZEN` |
| Note | A parser that loads this into a map keyed by field name silently collapses the rows and digests fewer facts than were shown. Parse as an ordered list of lines. |

### 4.3 The legacy approval statement

| | |
|---|---|
| Family | *(none — carries no `statement=` line)* |
| Representation | `approval_id`, `action_hash`, `approver_id`, `device_id`, `tenant_id`, each terminated |
| Field order | **Not sorted.** `approval_id` precedes `action_hash` |
| Status | `DEPRECATED` |
| Algorithm | ES256, as §4.1 |
| Why it is still here | Enrolled devices may still sign it. Removing it requires evidence that none does, which is deployment evidence this repository does not have. |
| Risk | A statement with no domain separator is the one whose bytes are cheapest to collide with another format. New integrations sign §4.1's displayed approval, which covers the same facts plus what was shown. |

### 4.4 Authority grant

| | |
|---|---|
| Object | `SprintAuthorization` — the single-use grant evaluation issues |
| Representation | **Hand-built JSON.** `{"key":"value",...}`, no whitespace, keys in Unicode code point order |
| Builder | Explicitly **not** serializer-driven. See the note below |
| Keys | `action_hash`, `agent_id`, `expires_at`, `grant_id`, `intent_id`, `intent_version`, `single_use` |
| Values | All strings. GUIDs, hex, integers, a fixed literal, or a timestamp |
| Timestamps | `yyyy-MM-ddTHH:mm:ss.fffZ` — RFC 3339, UTC, `Z`, **exactly 3 fractional digits**, matching the envelope |
| Encoding | UTF-8 |
| Algorithm | ES256, P-256, raw 64-byte R‖S, Base64Url |
| Key identification | `kid` resolved against `/.well-known/verid-grant-keys`, which publishes the active key and retired keys whose grants have not all expired |
| Status | `FROZEN` |

`action_hash` carries the whole envelope, so signing it signs every parameter
the decision was made on without restating them. This is why the grant's own
canonical form is small.

**Why it is hand-built, and why that is load-bearing.** It was
serializer-driven, and `System.Text.Json` rewrites `+` inside a string value as
the six-character escape `+`. The timestamp in the canonical form therefore
carried six characters where every other language emits one, and **no non-.NET
verifier could reproduce those bytes**. A frozen interop fixture caught it;
nothing else would have, because both sides of the signature were the same
runtime. This is the single best argument in the codebase for
[canonical-authority-representation-v1.md](canonical-authority-representation-v1.md)
rule 1.

### 4.5 Authority attenuation

| | |
|---|---|
| Object | `SprintAttenuation` — an automatic reduction of authority |
| Representation | Hand-built JSON, as §4.4, with a **nested object** for limits |
| Keys | `after`, `attenuation_id`, `before`, `created_at`, `intent_id`, `intent_version`, `reason_code`, `tenant_id`, `trigger` |
| Absent | **JSON `null`**, not omitted and not `none` |
| Decimals | Text, scale intact |
| Timestamps | Millisecond `Z`, as §4.4 |
| Enums | Member name (`trigger`) |
| Algorithm | ES256, raw R‖S, Base64Url |
| Status | `FROZEN` |

`null` rather than omitted is deliberate and correct for this object:
"unbounded" and "not stated" are different claims, and a verifier that cannot
tell them apart cannot check that a narrowing is actually a narrowing. It is
recorded here because it is the **third** absent convention in the system (§5).

### 4.6 Receiver effect evidence

| | |
|---|---|
| Object | `ReceiverEffect` — what the protected receiver signs about what it actually did |
| Representation | **Compact JWS.** `base64url(header) + "." + base64url(payload) + "." + base64url(signature)` |
| Header | `{"alg":"ES256","kid":"..."}`, serializer-produced |
| Payload | `JsonSerializer.SerializeToUtf8Bytes(effect)` — **serializer-produced** |
| Signing input | `header + "." + payload`, as **ASCII** bytes |
| Algorithm | ES256, raw R‖S (`IeeeP1363FixedFieldConcatenation`), Base64Url |
| Status | `PLATFORM_ONLY` |

A third party **can** verify this: verification is over the compact form exactly
as received, which is the documented rule. A third party **cannot produce** it,
because the payload bytes depend on `System.Text.Json` member order and
escaping.

The receiver's own verifier additionally re-serializes the payload and compares
in fixed time against the received bytes. That is an internal consistency check
only the producer can perform — it is checking its in-memory record against what
it signed — and an external verifier **must not** copy it. That bug has shipped
from this repository once; see
[canonicalization.md](canonicalization.md) §7.

### 4.7 Evidence package content hash

| | |
|---|---|
| Object | `EvidencePackage` |
| Representation | `JsonSerializer.Serialize(package, Wire)` — serializer-produced, property order fixed by record declaration order |
| Digest | SHA-256, `Convert.ToHexString` → **uppercase** hex |
| Signed? | No. A content digest over an exported package |
| Status | `PLATFORM_ONLY` |
| Note | Declaration order is fixed and reviewed rather than left to a settings change, which is the same reasoning as §4.4 but stops short of hand-building. Reproducible only by a .NET implementation with the same record definitions. |

### 4.8 Audit chain entry hash

| | |
|---|---|
| Representation | Each entry names the SHA-256 of its predecessor, hash-linked |
| Digest | SHA-256, uppercase hex |
| Signed? | No. Integrity by linkage, not by signature |
| Status | `FROZEN` |
| Property | A deleted entry is as detectable as an altered one. This is invariant I5's mechanism. |

### 4.9 Audit egress delivery

| | |
|---|---|
| Family | `verid.audit.egress.v1` |
| Representation | **JSON body**, snake_case field names via explicit `JsonPropertyName` |
| Signing input | `<unix seconds> + "." + <body>`, UTF-8 |
| Header | `Verid-Signature: t=<unix seconds>,v1=<hmac, lowercase hex>` |
| Algorithm | **HMAC-SHA256**, per-subscription shared secret |
| Replay defence | Timestamp inside the signed material, tolerance window checked **before** comparing, comparison in fixed time |
| Status | `SYMMETRIC`, `LEGACY_COMPATIBLE` |
| Vectors | **None. This format has no vector and should get one.** |

The timestamp is inside the signed material rather than beside it because a
signature covering only the body replays forever.

An HMAC proves the sender held the secret. It does **not** prove to a third
party who signed, because the receiver could have produced it too. That is
adequate for "this webhook is genuine" and inadequate for "this person approved
this payment", which is why approvals do not use it.

The event carries its own `hash` and `previous_hash` so a SIEM can verify the
chain it was sent rather than trusting that what arrived is what happened.

### 4.10 OAuth access token

| | |
|---|---|
| Representation | Compact JWS (JWT). `{"alg":"HS256","typ":"JWT","kid":"..."}` |
| Payload | Serializer-produced claims |
| Signing input | `header + "." + payload`, UTF-8 |
| Algorithm | **HMAC-SHA256**, platform signing secret |
| Status | `SYMMETRIC`, `PLATFORM_ONLY` |
| Note | A bearer token presented back to its issuer, which is the case symmetric authentication is correct for. It is **not** authority evidence and must never be treated as such: an access token says a session exists, a grant says an action is authorized. |

### 4.11 Predicate envelope digest

Not a signature, but the digest every grant binds. Specified in
[canonicalization.md](canonicalization.md) and
[`../schemas/predicate-envelope/README.md`](../schemas/predicate-envelope/README.md).

| | |
|---|---|
| Representation | Canonical JSON, snake_case keys, sorted by code point at every level, no whitespace, absent keys omitted |
| Excluded from digest | `tenantId`, `intentId`, `requestedAt` |
| Decimals | Strings. Compared numerically, hashed textually; scale significant |
| Timestamps | Millisecond `Z`; `+00:00` **rejected by name** |
| Absent | **Omitted.** `null` is never valid |
| Predicate ordering | By the whole canonical form of each predicate |
| Digest | SHA-256, uppercase hex |
| Status | `FROZEN` |
| Vectors | [`vectors.v1.json`](../schemas/predicate-envelope/vectors.v1.json), reproduced independently by [`canonicalize.mjs`](../schemas/predicate-envelope/canonicalize.mjs) |

### 4.12 WebAuthn assertion — verified, not defined

| | |
|---|---|
| Representation | W3C Web Authentication. `clientDataJSON` + `authenticatorData`, signed by the authenticator |
| Signing input | `authenticatorData ‖ SHA-256(clientDataJSON)` |
| Algorithm | ES256 (and whatever else the credential registered) |
| Status | **External.** 2AYE does not define these bytes and must not canonicalize them |
| Verification | Over `clientDataJSON` **exactly as received**. The challenge is server-issued and single-use; the RP ID hash is compared against SHA-256 of the registered `rpId` in fixed time |

Listed because it is a signed format in runtime code and the registry claims to
be complete. The rule that matters: **a format defined by somebody else is
verified on its own terms.** Reserializing `clientDataJSON` to "normalize" it
breaks every assertion, because the authenticator signed the bytes the browser
produced.

The same discipline applies to any future external format — a cloud KMS
attestation, an OIDC `id_token`, a SAML assertion. CAR v1 governs what 2AYE
**defines**, never what it **receives**.

---

## 5. The divergences, in one place

This table is the reason this registry exists. Each row is a place where two
signed formats in the same system answer the same question differently.

| Question | Answers in use | Where |
|---|---|---|
| **Timestamp** | `…fffZ` (3 digits, `Z`) | Envelope, grant, attenuation |
| | `…fffffff+00:00` (7 digits, offset) | `executor.attest`, `payment.admission` |
| | Unix seconds | Egress delivery, OAuth claims |
| **Absent** | Omitted | Envelope |
| | the literal `none` | Statement families |
| | JSON `null` | Attenuation |
| **Hash casing** | Uppercase | `action_hash`, `content_hash`, `display_digest`, all digests |
| | Unchanged | `credential_hash` |
| | Lowercase | Egress HMAC |
| **Representation** | Terminated `key=value` lines | Statement families |
| | Hand-built JSON | Grant, attenuation |
| | Serializer JSON | Receipt payload, evidence package, OAuth claims |
| | JSON body + header MAC | Egress |
| | Canonical JSON | Envelope |
| **Collection order** | Ordinal | Statements, envelope |
| | By `type` | Display rows (`DISPLAY_ORDERING`) |
| | Record declaration order | Evidence package |
| **Decimal** | Text, scale preserved | Envelope, `payment.admission`, attenuation |
| | Native formatting | `resource.capability` `cost` |
| **Algorithm** | ES256 asymmetric | Statements, grant, attenuation, receipt |
| | HMAC-SHA256 symmetric | Egress, OAuth token |

Three timestamp conventions, three absent conventions, three hash casings and
five representations. **None of this can be fixed in place.** What it can do is
stop growing, which is what the next document is for.

---

## 6. What a new signed format must do

1. Read
   [canonical-authority-representation-v1.md](canonical-authority-representation-v1.md)
   and conform to it. It exists so that none of §5 happens again.
2. Take a new family identifier and its own domain separator. Never extend an
   existing family's field set.
3. Publish positive **and negative** vectors before the first signature is
   produced in anger.
4. Add an entry here, with every attribute in §4's tables filled in.
5. Never copy a `LEGACY_COMPATIBLE`, `DEPRECATED` or `PLATFORM_ONLY`
   representation. Those statuses exist to mark what must not be imitated.

A format that cannot be reproduced by an implementation in another language is
not finished, however well it works here.

---

## 7. Call-site inventory

Every file in `modules/` or `apps/` that signs, authenticates or verifies, and
the entry it implements. `tests/verify-signing-registry.mjs` sweeps for signing
primitives and fails the build when a file appears in the source and not in this
table — the same rule `RouteAuthorizationTests` applies to routes: **guarded, or
explicitly argued.**

A file listed here with `-` in the entry column signs nothing itself; it
delegates, and is listed so the sweep does not report it as unregistered.

| File | Role | Entry |
|---|---|---|
| `modules/core/DeviceDecisionVerifier.cs` | Builds and verifies every line-oriented statement, including the deprecated one | §4.1, §4.2, §4.3 |
| `modules/core/VerifiedActionDisplay.cs` | Builds the display statement and its digest | §4.2 |
| `modules/core/GrantSigner.cs` | Grant canonical form; signs grants, attenuations and payment admissions with the platform key | §4.4, §4.5, §4.1 |
| `modules/core/GrantSigningKey.cs` | Key abstraction. Raw R‖S, never DER | §2 |
| `modules/core/Attenuation.cs` | Attenuation canonical form and verification | §4.5 |
| `apps/protected-receiver/ReceiverLedger.cs` | Produces and verifies receiver effect evidence | §4.6 |
| `modules/core/EvidencePackage.cs` | Evidence package content hash; verifies grants within a package | §4.7 |
| `modules/core/EventEgress.cs` | Egress delivery HMAC | §4.9 |
| `modules/core/OAuthService.cs` | Access token HS256 | §4.10 |
| `modules/core/PasskeyService.cs` | Verifies WebAuthn assertions. Defines nothing | §4.12 |
| `modules/core/PaymentCredentialGate.cs` | Verifies admission evidence | §4.1 |
| `modules/core/Sprint1Service.cs` | Verifies grants and device decisions | - |
| `modules/core/Sprint1NodeService.cs` | Verifies node-bound contract signatures | - |
| `modules/core/ManagedSigningKey.cs` | Remote signing shape: digest-or-message, DER-or-raw | §2 |
| `modules/core/GrantKeyRing.cs` | Active and retired key set behind `/.well-known/verid-grant-keys` | §4.4 |

---

## 8. Known portability debt

Stated here, in the registry, because this is where somebody evaluating 2AYE's
cryptography will look — and because the easy overclaim is to present the
existing families as the canonical representation they are not.

| Debt | Status |
|---|---|
| Nine statement vectors exist, covering six of the eight formats | [statement-vectors.v1.json](statement-vectors.v1.json), reproduced by [verify-statements.mjs](verify-statements.mjs) |
| `verid.audit.egress.v1` has **no vector** | Open. It is HMAC over a JSON body, so it needs a receiver-side vector of a different shape from the statement ones |
| `verid.resource.capability.v1` `cost` exposes **cross-language decimal debt** | Open. The published vector must declare it as a string although the domain type is a decimal, because `JSON.parse` turns `12.50` into `12.5`. §4.1 |
| `verid.executor.attest.v1` and `verid.payment.admission.v1` expose **.NET timestamp debt** | Open. Seven fractional digits and `+00:00`, the form the envelope rejects by name. §4.1 |
| Legacy families remain **compatibility-bound** | By design. Every `LEGACY_COMPATIBLE` entry describes bytes existing signatures were made over |
| CAR v1 governs **no live format** | By design. It is for the next format; see [canonical-authority-representation-v1.md](canonical-authority-representation-v1.md) §14 |

> **No runtime migration is in progress, and none should be started to resolve
> this debt.** Each item is a property of bytes that have already been signed.
> The remedy is a new version alongside, chosen deliberately, when there is a
> reason beyond tidiness.

### The distinction that must not blur

What is demonstrated is that **nine existing statement families reproduce from
published raw inputs**, independently, in another language. That is good evidence
the specifications are sufficient to implement.

What is **not** demonstrated is a unified canonical representation agreed across
independent implementations. CAR v1 has three worked objects and one second
implementation, by the same authors, and nothing in production uses it.

The two concrete failures above — a decimal losing its scale and a timestamp
contradicting the envelope's own rule in the same specification — are precisely
why the existing families stay compatibility formats rather than becoming the
foundation for new authority objects.
