From b9b9041b7371a04e29b5daa9c4f4d78aa26f9d35 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:09:57 -0700 Subject: [PATCH] docs(openapi): publish the anchor-attempts request body, and stop the pattern from shipping a regex flag POST /v1/decision-ledger/anchor-attempts exists to be called by a submitter this repo deliberately does not ship -- ledger-anchor-bittensor.ts's own header says so: "a small process on the operator's own node infrastructure ... never in this repo". Its only reference is the OpenAPI document, which registered responses and no body at all, leaving the entire contract discoverable only by reading the TypeScript that rejects it. The schema lives beside parseBittensorAnchorReport rather than in the spec file, because the two are one contract described twice and must be edited together. The validator stays the runtime authority: each of its arms returns a NAMED rejection identifying the exact field it refused, which is what makes a submitter bug diagnosable from the 400 body alone, and a generic zod issue list would be a downgrade for precisely the caller this endpoint is built for. A cross-check suite runs 25 payloads through BOTH and asserts they agree on accept/reject, so neither can drift without failing CI -- including the ones that matter most: status/backendRef and status/error coupling, the u16 netuid ceiling, and blank -after-trim hotkeys and errors. Two fixes fell out of actually generating the document: - The hash regexes carried an `i` FLAG. JSON Schema has no flags concept, so they serialized as `^[0-9a-f]{64}$/i` -- a pattern requiring a literal "/i" suffix, which would reject every valid hash in any generated client. Case-insensitivity is now spelled into the character class, which is behaviourally identical for hex and safe to publish. - SpecEntry in internal-and-public-route-specs.ts declared only `params` under `request`, so an entry there could not describe its own body even though registerRouteSpec already knew how to render one (define-route.ts's RouteSpecOptions has carried `body` since #9705). Widened to match the seam it feeds. Closes #9770 --- apps/loopover-ui/public/openapi.json | 122 +++++++++++++++++- .../internal-and-public-route-specs.ts | 13 +- src/review/ledger-anchor-bittensor.ts | 67 +++++++++- test/unit/ledger-anchor-bittensor.test.ts | 64 +++++++++ 4 files changed, 261 insertions(+), 5 deletions(-) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bb41b1537c..2f38293def 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -26362,7 +26362,127 @@ { "OrbBearer": [] } - ] + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "signed": { + "type": "object", + "properties": { + "payload": { + "type": "object", + "properties": { + "v": { + "type": "number", + "enum": [ + 1 + ] + }, + "ledger": { + "type": "string", + "enum": [ + "loopover.decision_ledger" + ] + }, + "seq": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "rowHash": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$" + }, + "totalCount": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "at": { + "type": "string", + "minLength": 1, + "maxLength": 40 + } + }, + "required": [ + "v", + "ledger", + "seq", + "rowHash", + "totalCount", + "at" + ] + }, + "keyId": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "signature": { + "type": "string", + "minLength": 1, + "maxLength": 512 + } + }, + "required": [ + "payload", + "keyId", + "signature" + ] + }, + "status": { + "type": "string", + "enum": [ + "ok", + "failed" + ] + }, + "backendRef": { + "type": "object", + "properties": { + "netuid": { + "type": "integer", + "minimum": 0, + "maximum": 65535 + }, + "blockNumber": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true + }, + "blockHash": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "hotkey": { + "type": "string", + "minLength": 1, + "maxLength": 64 + } + }, + "required": [ + "netuid", + "blockNumber", + "blockHash", + "hotkey" + ] + }, + "error": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "signed", + "status" + ] + } + } + } + } } }, "/v1/public/repos/{owner}/{repo}/proof": { diff --git a/src/openapi/internal-and-public-route-specs.ts b/src/openapi/internal-and-public-route-specs.ts index 8fc1d4f770..adb85db876 100644 --- a/src/openapi/internal-and-public-route-specs.ts +++ b/src/openapi/internal-and-public-route-specs.ts @@ -12,6 +12,7 @@ import type { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; import { z } from "zod"; import { registerRouteSpec, type RouteAuth, type RouteMethod } from "./define-route"; +import { BittensorAnchorReportRequestSchema } from "../review/ledger-anchor-bittensor"; type SpecEntry = { method: RouteMethod; @@ -20,8 +21,11 @@ type SpecEntry = { tags: [string, ...string[]]; summary: string; auth: RouteAuth; - /** Narrower path parameters than the derived string ones; only for a closed-set segment (#9707). */ - request?: { params?: z.ZodObject }; + /** Narrower path parameters than the derived string ones; only for a closed-set segment (#9707), plus the + * request body for an operation that publishes one (#9770). Both are already honoured by + * registerRouteSpec -- `body` was reachable through RouteSpecOptions but not through THIS local type, so + * an entry here could not describe its own body even though the seam knew how to render it. */ + request?: { params?: z.ZodObject; body?: z.ZodTypeAny }; /** `schema` is optional: most entries here describe a status and nothing more, but an operation that * already published a response body must not lose it on the way through the seam (#9707). */ responses: Record; @@ -492,6 +496,11 @@ const CREDENTIAL_GATED: SpecEntry[] = [ // `orb`, not `token`: the gate is LOOPOVER_LEDGER_ANCHOR_REPORT_TOKEN, an ingest bearer that is not a // LoopOver API token -- the same posture the `orb` level already exists for. auth: "orb", + // #9770: this endpoint exists to be called by a submitter this repo deliberately does not ship, so the + // OpenAPI document is its only reference -- and it published responses with no body at all, leaving the + // whole shape discoverable only by reading the TypeScript that rejects it. The schema lives beside + // parseBittensorAnchorReport, which remains the runtime authority; a cross-check test holds them together. + request: { body: BittensorAnchorReportRequestSchema }, responses: { 200: { description: "{ recorded: true, status: 'ok' | 'failed' }" }, 400: { description: "Unparseable body, or a report whose named field failed validation" }, diff --git a/src/review/ledger-anchor-bittensor.ts b/src/review/ledger-anchor-bittensor.ts index 4ba27a4c18..37b3784b33 100644 --- a/src/review/ledger-anchor-bittensor.ts +++ b/src/review/ledger-anchor-bittensor.ts @@ -19,6 +19,7 @@ // // Scope (#9277's own framing): optional, Gittensor/SN74-audience corroboration. Rekor + git remain the // default every verifier is told to check; nothing here is a required verification step. +import { z } from "zod"; import type { LedgerAnchorPayload, SignedLedgerAnchor } from "./ledger-anchor"; import { anchorKeyById, parseAnchorPublicKeys, verifyLedgerAnchorSignature } from "./ledger-anchor"; import { LEDGER_ANCHOR_LEDGER_ID, LEDGER_ANCHOR_PAYLOAD_VERSION } from "./ledger-anchor"; @@ -41,8 +42,70 @@ export type BittensorAnchorReport = { signed: SignedLedgerAnchor; } & ({ status: "ok"; backendRef: BittensorAnchorRef } | { status: "failed"; error: string }); -const HEX_32_BYTES = /^0x[0-9a-f]{64}$/i; -const ROW_HASH = /^[0-9a-f]{64}$/i; +// Case-insensitivity is spelled into the character class rather than carried as an `i` FLAG, because these +// regexes are now published as JSON Schema `pattern`s (#9770) and JSON Schema has no flags concept: a +// flagged regex serializes its source with the flag appended, so `^[0-9a-f]{64}$/i` reaches a generated +// client as a pattern requiring a literal "/i" suffix -- rejecting every valid hash. Behaviourally identical +// to the flagged form for these two inputs, and safe to publish. +const HEX_32_BYTES = /^0x[0-9a-fA-F]{64}$/; +const ROW_HASH = /^[0-9a-fA-F]{64}$/; + +/** + * The published request shape for `POST /v1/decision-ledger/anchor-attempts` (#9770). + * + * This endpoint exists to be called by a submitter this repo deliberately does NOT ship (see the module + * header): a process on the operator's own node infrastructure. Its only reference is the OpenAPI document, + * which registered responses and no body at all — so the contract's entire shape lived in the TypeScript of + * the thing rejecting it, and an implementer had to read source to discover the bounds. + * + * It lives HERE, immediately beside {@link parseBittensorAnchorReport}, rather than in the OpenAPI spec file, + * because the two are one contract described twice and must be edited together. `parseBittensorAnchorReport` + * stays the runtime authority — it is not replaced by this schema — because each of its arms returns a NAMED + * rejection naming the exact field it refused, which is what makes a submitter bug diagnosable from the 400 + * body alone; a generic zod issue list would be a downgrade for the caller this is built for. The two are + * held together by a cross-check test that runs the same corpus of payloads through both and asserts they + * agree on accept/reject, so neither can drift without failing CI. + */ +export const BittensorAnchorReportRequestSchema = z + .object({ + signed: z.object({ + payload: z.object({ + v: z.literal(LEDGER_ANCHOR_PAYLOAD_VERSION), + ledger: z.literal(LEDGER_ANCHOR_LEDGER_ID), + /** 1-based row number of the ledger tip being anchored. */ + seq: z.number().int().positive(), + /** The tip row's hash — 64 hex chars, case-insensitive. */ + rowHash: z.string().regex(ROW_HASH), + /** Total rows in the ledger at the time of signing. */ + totalCount: z.number().int().positive(), + /** When the checkpoint was signed. Bounded rather than format-validated, matching the runtime check. */ + at: z.string().min(1).max(40), + }), + /** Which published key signed this checkpoint; must appear in LOOPOVER_LEDGER_ANCHOR_KEYS. */ + keyId: z.string().min(1).max(64), + /** base64 ECDSA P-256 signature over `anchorSigningInput(payload)`. */ + signature: z.string().min(1).max(512), + }), + status: z.enum(["ok", "failed"]), + /** Required when `status` is "ok". The on-chain coordinates a verifier needs to retrieve the commitment + * from archive state, since `CommitmentOf` is overwritten in place. */ + backendRef: z + .object({ + netuid: z.number().int().min(0).max(65535), + blockNumber: z.number().int().positive(), + /** 0x-prefixed 32-byte block hash, case-insensitive; normalized to lowercase on ingest. */ + blockHash: z.string().regex(HEX_32_BYTES), + /** ss58 account of the anchor hotkey — a public on-chain identity, never key material. */ + hotkey: z.string().trim().min(1).max(64), + }) + .optional(), + /** Required when `status` is "failed". Recorded verbatim (trimmed, truncated to 500) so a submitter that + * could not commit is still visible in the public attempt log rather than silently absent. */ + error: z.string().trim().min(1).optional(), + }) + .refine((body) => (body.status === "ok" ? body.backendRef !== undefined : body.error !== undefined), { + message: 'backendRef is required when status is "ok"; error is required when status is "failed"', + }); /** Parse + bound one submitter report. Returns a typed report or a NAMED rejection reason — every arm names * the exact field it refused so a submitter bug is diagnosable from the 400 body alone. PURE. */ diff --git a/test/unit/ledger-anchor-bittensor.test.ts b/test/unit/ledger-anchor-bittensor.test.ts index a44e6776bf..e1aced45a8 100644 --- a/test/unit/ledger-anchor-bittensor.test.ts +++ b/test/unit/ledger-anchor-bittensor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + BittensorAnchorReportRequestSchema, ingestBittensorAnchorReport, parseBittensorAnchorReport, type BittensorAnchorReport, @@ -242,3 +243,66 @@ describe("#9277 routes — the submitter's two HTTP touchpoints", () => { } }); }); + +// #9770: the published request schema and the runtime validator are one contract described twice -- +// parseBittensorAnchorReport stays the authority (its per-field named rejections are what make a submitter +// bug diagnosable from the 400 body), and the zod schema exists so the OpenAPI document actually describes +// the shape a caller must send. This suite is the mechanism that stops them drifting: the SAME payloads go +// through both, and the two must agree on accept/reject. +describe("BittensorAnchorReportRequestSchema agrees with parseBittensorAnchorReport (#9770)", () => { + const okBody = () => ({ + signed: { + payload: { + v: LEDGER_ANCHOR_PAYLOAD_VERSION, + ledger: LEDGER_ANCHOR_LEDGER_ID, + seq: 7, + rowHash: "a".repeat(64), + totalCount: 7, + at: "2026-07-29T00:00:00.000Z", + }, + keyId: "k1", + signature: "c2ln", + }, + status: "ok" as const, + backendRef: { netuid: 42, blockNumber: 1234, blockHash: `0x${"b".repeat(64)}`, hotkey: "5Fabc" }, + }); + const failedBody = () => ({ signed: okBody().signed, status: "failed" as const, error: "commitment reverted" }); + + /** Every case below is run through BOTH. `accepted` is what they must agree on. */ + const cases: Array<{ name: string; body: unknown; accepted: boolean }> = [ + { name: "a well-formed ok report", body: okBody(), accepted: true }, + { name: "a well-formed failed report", body: failedBody(), accepted: true }, + { name: "an UPPERCASE rowHash / blockHash (case-insensitive by class, not by flag)", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, rowHash: "A".repeat(64) } }, backendRef: { ...okBody().backendRef, blockHash: `0x${"B".repeat(64)}` } }, accepted: true }, + { name: "a non-object body", body: "nope", accepted: false }, + { name: "a missing signed block", body: { status: "ok" }, accepted: false }, + { name: "a wrong payload version", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, v: 2 } } }, accepted: false }, + { name: "a wrong ledger id", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, ledger: "someone.else" } } }, accepted: false }, + { name: "a zero seq", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, seq: 0 } } }, accepted: false }, + { name: "a non-integer seq", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, seq: 1.5 } } }, accepted: false }, + { name: "a short rowHash", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, rowHash: "abc" } } }, accepted: false }, + { name: "an empty at", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, at: "" } } }, accepted: false }, + { name: "an over-long at", body: { ...okBody(), signed: { ...okBody().signed, payload: { ...okBody().signed.payload, at: "x".repeat(41) } } }, accepted: false }, + { name: "an empty keyId", body: { ...okBody(), signed: { ...okBody().signed, keyId: "" } }, accepted: false }, + { name: "an over-long keyId", body: { ...okBody(), signed: { ...okBody().signed, keyId: "k".repeat(65) } }, accepted: false }, + { name: "an over-long signature", body: { ...okBody(), signed: { ...okBody().signed, signature: "s".repeat(513) } }, accepted: false }, + { name: "an unknown status", body: { ...okBody(), status: "maybe" }, accepted: false }, + { name: "an ok report with no backendRef", body: { signed: okBody().signed, status: "ok" }, accepted: false }, + { name: "a failed report with no error", body: { signed: okBody().signed, status: "failed" }, accepted: false }, + { name: "a failed report with a blank error", body: { ...failedBody(), error: " " }, accepted: false }, + { name: "a netuid above the u16 ceiling", body: { ...okBody(), backendRef: { ...okBody().backendRef, netuid: 65_536 } }, accepted: false }, + { name: "a negative netuid", body: { ...okBody(), backendRef: { ...okBody().backendRef, netuid: -1 } }, accepted: false }, + { name: "a zero blockNumber", body: { ...okBody(), backendRef: { ...okBody().backendRef, blockNumber: 0 } }, accepted: false }, + { name: "a blockHash missing its 0x prefix", body: { ...okBody(), backendRef: { ...okBody().backendRef, blockHash: "b".repeat(64) } }, accepted: false }, + { name: "a blank hotkey", body: { ...okBody(), backendRef: { ...okBody().backendRef, hotkey: " " } }, accepted: false }, + { name: "an over-long hotkey", body: { ...okBody(), backendRef: { ...okBody().backendRef, hotkey: "h".repeat(65) } }, accepted: false }, + ]; + + it.each(cases)("$name", ({ body, accepted }) => { + const runtime = !("error" in parseBittensorAnchorReport(body)); + const published = BittensorAnchorReportRequestSchema.safeParse(body).success; + expect(runtime).toBe(accepted); + // The load-bearing assertion: the document never promises a shape the endpoint refuses, and never + // refuses one the document promises. + expect(published).toBe(runtime); + }); +});