Zion Boggan zionboggan.com ↗

Align registry error envelopes

Return the registry v1 {error:{code,message}} envelope from both Python and Rust registry errors. Extend the conformance harness to verify representative signature, sidecar, missing-field, not-found, and tlog range shapes; update docs and test counts.

Co-authored-by: Codex (GPT-5.4) <noreply@openai.com>
7beb471   Zion Boggan committed on May 29, 2026 (3 weeks ago)
CHANGELOG.md +7 -3
@@ -41,9 +41,13 @@
records fail the range request instead of being silently omitted from
monitor responses. The Python reference tlog now matches that behavior:
startup and `/tlog/range` fail closed on corrupt leaf records, and new
- leaves carry `leaf_data_hex` so exact leaf bytes survive recovery. The
- registry v1 conformance harness now checks `/tlog/range` response shape,
- raising the live/in-process harness to 34 checks.
+ leaves carry `leaf_data_hex` so exact leaf bytes survive recovery.
+- **Registry v1 error envelope parity.** Python and Rust registry errors now
+ return the spec envelope `{error: {code, message}}` for registry failures
+ instead of the framework-native string-only shapes. The conformance harness
+ now checks `/tlog/range` response shape plus representative
+ `signature_invalid`, `sidecar_mismatch`, `missing_field`, and `not_found`
+ error envelopes, raising the live/in-process harness to 38 checks.
- **GitHub Actions runtime hygiene.** Main CI workflows opt into the GitHub
Actions Node 24 runtime before the hosted runner default changes.
- **Rust policy test parity.** Fixed the `oversight-policy` crate's manifest
README.md +3 -3
@@ -236,7 +236,7 @@ now exposes the full read-only and beacon surface
`/v/{token_id}`, `/candidates/semantic`) and ships strict CORS
restricted to the public browser-inspector origins with GET and
OPTIONS only. The Axum server now passes `tests/test_registry_conformance.py`
-(34/34) in live-URL mode. `oversight-rust/oversight-manifest` learned
+(38/38) in live-URL mode. `oversight-rust/oversight-manifest` learned
to verify Python-signed v0.4.5+ manifests by carrying
`canonical_content_hash` and `l3_policy` in the signed model, with
a fallback path for older manifests that lack those fields.
@@ -448,13 +448,13 @@ current stable line.
| Rust oversight-formats | 40 | green |
| Rust oversight-manifest | 3 | green |
| Rust oversight-policy | 7 | green |
-| Rust oversight-registry | 11 | green |
+| Rust oversight-registry | 12 | green |
| Rust oversight-rekor | 10 | green |
| Rust oversight-semantic | 8 | green |
| Rust oversight-tlog | 14 | green |
| Rust oversight-watermark | 4 | green |
| Cross-language conformance | 3 | green |
-| Total automated Rust unit tests | 135 | all green |
+| Total automated Rust unit tests | 136 | all green |
## Design principles (what Oversight never does)
docs/REGISTRY_DEPLOYMENT.md +3 -0
@@ -148,6 +148,9 @@ of disappearing from monitor output.
The Python reference registry uses the same fail-closed local tlog validation
for startup recovery and `/tlog/range`; newly appended records include
`leaf_data_hex` so exact event bytes can be recomputed by monitors.
+Both reference registries return the registry v1 error envelope
+`{"error":{"code":"...","message":"..."}}` for registry failures, and the
+live conformance harness checks representative envelope codes.
## Rust Registry Burn-In Checklist
docs/ROADMAP.md +6 -3
@@ -17,7 +17,7 @@ threat-model honesty, not on a calendar date.
at `docs/spec/registry-v1.md` is aligned against the reference server:
canonical-JSON algorithm, uniform error envelope, normative endpoint and
beacon paths, `/evidence` bundle shape, and `/tlog/head|proof|range` are
- pinned. `tests/test_registry_conformance.py` runs 34 checks in-process
+ pinned. `tests/test_registry_conformance.py` runs 38 checks in-process
or against a live URL. An operator claims v1 compatibility with
`OVERSIGHT_REGISTRY_URL=https://registry.example.org python3 tests/test_registry_conformance.py`.
4. **Browser inspector and classic-suite decrypt** shipped on
@@ -143,7 +143,7 @@ the reference server actually serves. The spec now pins:
- `/evidence/{file_id}` bundle fields
- `/tlog/head|proof|range` for federated verifiers
-`tests/test_registry_conformance.py` is a 34-check harness with two
+`tests/test_registry_conformance.py` is a 38-check harness with two
modes. In-process against a FastAPI TestClient for CI, or against a
live URL when `OVERSIGHT_REGISTRY_URL` is set. An independent operator
who passes the harness claims v1 compatibility.
@@ -235,7 +235,7 @@ require hardware backing for sensitive material.
`oversight-rust/oversight-registry` is scaffolded with all endpoints
implemented under `#![forbid(unsafe_code)]`. As of 2026-05-14, the Axum
-server passes the existing 34-check `tests/test_registry_conformance.py`
+server passes the existing 38-check `tests/test_registry_conformance.py`
harness in live-URL mode against the registry v1 surface with
`OVERSIGHT_OPERATOR_TOKEN` enabled. The Rust registry now matches the Python
reference for write-side operator-token auth and DNS bridge bearer/header
@@ -258,6 +258,9 @@ instead of parsing `leaves.jsonl` directly, so monitor responses fail closed
when an on-disk leaf is malformed or hash-mismatched.
The Python reference registry now mirrors that fail-closed tlog recovery and
range behavior, with `leaf_data_hex` on newly appended local tlog records.
+Both registry implementations now return the registry v1 `{error: {code,
+message}}` envelope for representative client and server errors, and the
+conformance harness checks those envelopes.
Remaining work: longer-running deployment tests and a wire-format stability
declaration before declaring v1.0 ready.
docs/spec/registry-v1.md +3 -2
@@ -333,6 +333,7 @@ OVERSIGHT_REGISTRY_URL=https://registry.example.org \
```
The harness uses a throwaway issuer identity, posts a minimal valid
-manifest, and then validates the responses. Runs against the local
-reference registry are included in CI; operator-hosted runs are the
+manifest, and then validates the responses. It also checks representative
+error envelope codes for malformed or missing inputs. Runs against the
+local reference registry are included in CI; operator-hosted runs are the
interop acceptance gate for federation.
oversight-rust/oversight-registry/src/error.rs +58 -1
@@ -27,6 +27,29 @@ pub enum RegistryError {
Internal(String),
}
+impl RegistryError {
+ fn code_for_bad_request(message: &str) -> &'static str {
+ if message.contains("signature") {
+ return "signature_invalid";
+ }
+ if message.contains("beacons do not match") || message.contains("watermarks do not match") {
+ return "sidecar_mismatch";
+ }
+ "missing_field"
+ }
+
+ fn envelope_code(&self, message: &str) -> &'static str {
+ match self {
+ RegistryError::BadRequest(_) => Self::code_for_bad_request(message),
+ RegistryError::NotFound(_) => "not_found",
+ RegistryError::Conflict(_) => "issuer_mismatch",
+ RegistryError::Unauthorized(_) => "auth_required",
+ RegistryError::RateLimited => "rate_limited",
+ RegistryError::Database(_) | RegistryError::Internal(_) => "server_error",
+ }
+ }
+}
+
impl IntoResponse for RegistryError {
fn into_response(self) -> Response {
let (status, message) = match &self {
@@ -48,9 +71,43 @@ impl IntoResponse for RegistryError {
_ => tracing::error!(%status, %message, "server error"),
}
- let body = serde_json::json!({ "error": message });
+ let code = self.envelope_code(&message);
+ let body = serde_json::json!({
+ "error": {
+ "code": code,
+ "message": message,
+ },
+ });
(status, Json(body)).into_response()
}
}
pub type Result<T> = std::result::Result<T, RegistryError>;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn registry_error_codes_match_v1_envelope() {
+ assert_eq!(
+ RegistryError::BadRequest("manifest signature invalid".into())
+ .envelope_code("manifest signature invalid"),
+ "signature_invalid"
+ );
+ assert_eq!(
+ RegistryError::BadRequest("request beacons do not match signed manifest".into())
+ .envelope_code("request beacons do not match signed manifest"),
+ "sidecar_mismatch"
+ );
+ assert_eq!(
+ RegistryError::Unauthorized("operator authentication required".into())
+ .envelope_code("operator authentication required"),
+ "auth_required"
+ );
+ assert_eq!(
+ RegistryError::NotFound("unknown file_id".into()).envelope_code("unknown file_id"),
+ "not_found"
+ );
+ }
+}
registry/server.py +42 -0
@@ -32,6 +32,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
)
from cryptography.hazmat.primitives import serialization
from fastapi import FastAPI, Request, HTTPException
+from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response, JSONResponse
from pydantic import BaseModel
@@ -278,6 +279,47 @@ app.add_middleware(
)
+def _registry_error_code(status_code: int, message: str) -> str:
+ text = message.lower()
+ if status_code == 401:
+ return "auth_required"
+ if status_code == 404:
+ return "not_found"
+ if status_code == 409:
+ return "issuer_mismatch"
+ if status_code == 429:
+ return "rate_limited"
+ if status_code >= 500:
+ return "server_error"
+ if "signature" in text:
+ return "signature_invalid"
+ if "beacons do not match" in text or "watermarks do not match" in text:
+ return "sidecar_mismatch"
+ return "missing_field"
+
+
+def _error_envelope(code: str, message: str) -> dict:
+ return {"error": {"code": code, "message": message}}
+
+
+@app.exception_handler(HTTPException)
+async def _http_exception_handler(_request: Request, exc: HTTPException):
+ message = str(exc.detail)
+ return JSONResponse(
+ status_code=exc.status_code,
+ content=_error_envelope(_registry_error_code(exc.status_code, message), message),
+ headers=exc.headers,
+ )
+
+
+@app.exception_handler(RequestValidationError)
+async def _validation_exception_handler(_request: Request, exc: RequestValidationError):
+ return JSONResponse(
+ status_code=400,
+ content=_error_envelope("missing_field", f"request validation failed: {exc}"),
+ )
+
+
class RegistrationRequest(BaseModel):
manifest: dict
beacons: list[dict]
tests/test_registry_conformance.py +30 -0
@@ -57,6 +57,22 @@ def check(name: str, condition: bool, detail: str = "") -> None:
print(f" {FAIL} {name} ({detail})")
+def check_error_envelope(name: str, response, expected_status: int, expected_code: str) -> None:
+ try:
+ body = response.json()
+ except Exception:
+ body = {}
+ error = body.get("error") if isinstance(body, dict) else None
+ ok = (
+ response.status_code == expected_status
+ and isinstance(error, dict)
+ and error.get("code") == expected_code
+ and isinstance(error.get("message"), str)
+ and bool(error.get("message"))
+ )
+ check(name, ok, f"status={response.status_code} body={body}")
+
+
# ---- Client abstraction -----------------------------------------------------
@@ -228,12 +244,14 @@ def check_register_rejects_unsigned(cli: Client, manifest: dict, beacons: list,
tampered["file_id"] = str(uuid.uuid4())
r = cli.post("/register", json={"manifest": tampered, "beacons": beacons, "watermarks": watermarks})
check("register-rejects-bad-sig", r.status_code == 400, f"status={r.status_code}")
+ check_error_envelope("register-bad-sig-error-envelope", r, 400, "signature_invalid")
def check_register_rejects_sidecar_mismatch(cli: Client, manifest: dict, beacons: list, watermarks: list) -> None:
bad = list(beacons) + [{"token_id": "sneaky", "kind": "dns"}]
r = cli.post("/register", json={"manifest": manifest, "beacons": bad, "watermarks": watermarks})
check("register-rejects-sidecar-mismatch", r.status_code == 400, f"status={r.status_code}")
+ check_error_envelope("register-sidecar-error-envelope", r, 400, "sidecar_mismatch")
def check_attribute_by_token(cli: Client, beacons: list) -> None:
@@ -249,6 +267,11 @@ def check_attribute_miss(cli: Client) -> None:
check("attribute-miss-found-false", r.json().get("found") is False)
+def check_attribute_missing_field_error(cli: Client) -> None:
+ r = cli.post("/attribute", json={})
+ check_error_envelope("attribute-missing-field-error-envelope", r, 400, "missing_field")
+
+
def check_evidence(cli: Client, file_id: str) -> None:
r = cli.get(f"/evidence/{file_id}")
check("evidence-200", r.status_code == 200, f"status={r.status_code}")
@@ -267,6 +290,11 @@ def check_evidence(cli: Client, file_id: str) -> None:
isinstance(body.get("bundle_signature_ed25519"), str))
+def check_evidence_missing_error(cli: Client) -> None:
+ r = cli.get("/evidence/missing-file-id")
+ check_error_envelope("evidence-missing-error-envelope", r, 404, "not_found")
+
+
def check_tlog_head(cli: Client) -> None:
r = cli.get("/tlog/head")
check("tlog-head-200", r.status_code == 200, f"status={r.status_code}")
@@ -361,7 +389,9 @@ def run(cli: Client) -> None:
print("\n[*] Attribution and evidence")
check_attribute_by_token(cli, beacons)
check_attribute_miss(cli)
+ check_attribute_missing_field_error(cli)
check_evidence(cli, file_id)
+ check_evidence_missing_error(cli)
print("\n[*] Transparency log")
check_tlog_head(cli)