Registry v1: Candidate-Frozen, Not Final

The last development note ended with the Rust registry refusing to lie when storage, migration, and the local log went sideways. The work since then is the last stretch before the registry v1 wire surface can be called stable. The federated monitor endpoint stops silently dropping corrupted leaves, both registries speak one error envelope, and the spec now names a candidate-frozen contract operators can build against. None of that is a v1.0 release. The point of this note is to say what is frozen, what is not, and what has to close before the tag.

The monitor endpoint that used to lie by omission

/tlog/range is the route a federated monitor calls to read a slice of the local transparency log. Before this stretch of work, the Rust registry served it by parsing leaves.jsonl line by line and silently skipping malformed JSON. That is the wrong default for an evidence protocol. A monitor that reads a shortened range, with the corrupted entries simply missing, gets a false picture: it looks like a clean, smaller log, and the corruption is invisible. The Rust oversight-tlog crate gained a validated range_records(start, limit) API, and the registry route now reads through it. Disk truncation or a corrupted record fails closed with a parse or hash error instead of returning a quietly shorter answer.

The Python reference registry had the same posture and got the same fix. Recovered leaf records now validate JSON shape, contiguous index, a 32-byte leaf hash, and the SHA-256(0x00 || leaf_bytes) recomputation before they enter the in-memory tree. New appends write leaf_data_hex, so the exact bytes that fed the leaf hash survive recovery, while the older human-readable leaf_data field stays for registry event display. The Python /tlog/range route calls the same range_records path, returns HTTP 500 when local validation fails, and rejects a negative start with HTTP 400.

The part I want to be explicit about: the range reader and the startup recovery reader share one validation path. There is no permissive reader left anywhere that can hand a monitor or an auditor a lossy slice of the log. A verifier can recompute the RFC 6962 leaf hash from exact bytes instead of trusting a lossy UTF-8 display field, and the registry v1 spec now says so for /tlog/range.

One error envelope, on both sides

The registry v1 spec already defined a standard error shape, but the spec was stricter than the implementations. FastAPI and Pydantic would return their own validation error bodies, and the Rust registry's IntoResponse returned its own shape. An operator integrating against the registry had to handle framework-native errors the spec never promised.

Both sides now emit the spec envelope:

{"error":{"code":"not_found","message":"unknown file_id"}}

The Python server carries FastAPI exception handlers for HTTPException and request-validation failures. The Rust registry's IntoResponse maps signature, sidecar, auth, not-found, conflict, rate-limit, and server errors into the same {code, message} pair. The code vocabulary is fixed: missing_field, signature_invalid, sidecar_mismatch, issuer_mismatch, auth_required, rate_limited, not_found, and server_error. An operator writing a client writes one error parser, not two.

The conformance harness grew to cover it. The /tlog/range shape check from the parity push and four new envelope checks, signature_invalid, sidecar_mismatch, missing_field, and not_found, took the harness from 33 checks at the start of May to 38. That count is now part of the contract.

The candidate stability note

The capstone is docs/REGISTRY_V1_STABILITY.md. It names the v1.0 candidate-frozen route surface:

GET  /health
GET  /.well-known/oversight-registry
POST /register
POST /attribute
POST /dns_event
GET  /evidence/{file_id}
GET  /tlog/head
GET  /tlog/proof/{index}
GET  /tlog/range
GET  /p/{token_id}.png
GET  /r/{token_id}        and  GET /ocsp/r/{token_id}
GET  /v/{token_id}        and  GET /lic/v/{token_id}
GET  /candidates/semantic

Beyond the routes, the manifest-signature rules, the sidecar equality rule, the identifier length ceiling, the DNS bridge authentication rule, the operator-token header contract, evidence-bundle core fields, local tlog leaf record fields, and the standard error envelope are also candidate-frozen.

The compatibility rules are deliberately one-directional. A compatible implementation MAY add optional fields that older clients can ignore. It MUST NOT remove or rename fields, change paths, change canonicalization, weaken authentication, or return framework-native errors instead of the registry envelope. The same note lists the changes that would force a new profile or a v2 draft: changing manifest canonicalization or signature-verification bytes, accepting unsigned sidecars as registry authority, renaming required evidence-bundle fields, changing tlog leaf hashing or inclusion-proof shape, allowing unauthenticated non-loopback DNS events, changing route paths, or returning non-envelope errors for registry failures.

The conformance harness is the executable version of that contract. tests/test_registry_conformance.py runs the 38 checks against either the in-process Python reference or a live operator URL through OVERSIGHT_REGISTRY_URL. Passing it is what lets an operator claim registry v1 candidate compatibility. The harness checks identity, liveness, registration, manifest-signature rejection, sidecar rejection, attribution, evidence bundles, tlog head and range shape, browser CORS, beacon routes, DNS-event fail-closed behavior, and the representative error-envelope codes.

What this is not

This is a candidate freeze, not a final standard, and the note says so explicitly. Before the first v1.0 tag, the project still needs longer burn-in against a migrated operator database, a final live conformance run against the public deployment target, a release note that names the exact conformance count, and the tag itself. The route surface and the error envelope are the compatibility target operators can build against today; the final standards claim waits for the burn-in data.

I am comfortable calling a candidate freeze now because the hardening that earned it is done in public and checked by a test, not asserted by a document. The monitor endpoint reads through one validated path. The error envelope is the same bytes in Python and Rust. The route surface is a list a downstream implementer can hold us to. When something on that list has to change, the right answer is a new profile name, not a quiet edit. That is the discipline the candidate note is meant to enforce.

Relevant public references: the registry v1 stability note, the registry v1 spec, the deployment guide, and the roadmap.