From e44c19330947c8feb26877714852ee26f0b72a6c Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:46:06 -0700 Subject: [PATCH 1/3] feat(ledger): versioned + signed anchor payload, published key rotation route (#9270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the unused {seq, rowHash, at} stub into a self-describing, signed payload, and publishes the verifying keys. Self-describing: an anchor read cold in two years, out of whatever log it landed in, must say what it is and what chain it commits to — hence an explicit schema version and ledger id. totalCount rides alongside seq so a truncate-and-rechain to the same length still has to match both. Signed: without a signature anyone could publish a plausible anchor for this ledger and then "prove" a rewrite that never happened — a denial-of-integrity attack against our own record. ECDSA P-256/SHA-256 via WebCrypto: no new dependency, and the same keypair is what Rekor's hashedrekord takes as a self-managed verifier key (#9272). Key rotation is first-class: the published list is the full history, since an anchor signed under a retired key must stay verifiable forever. keyId is derived from the key (sha256 of SPKI) so it cannot drift from what it names. An ambiguous rotation state (zero or several open-ended keys) fails closed rather than guessing. The private half is a Worker secret, never served by any route and never in the repo; unset simply means anchoring does not run, degrading to the honest pre-#9267 tamper-evident posture rather than failing. --- apps/loopover-ui/public/openapi.json | 18 ++ src/api/routes.ts | 19 ++ src/env.d.ts | 19 ++ src/openapi/spec.ts | 8 + src/review/ledger-anchor.ts | 214 ++++++++++++++++++ .../public-anchor-key-route.test.ts | 60 +++++ .../public-decision-ledger-routes.test.ts | 1 + test/unit/ledger-anchor.test.ts | 173 ++++++++++++++ 8 files changed, 512 insertions(+) create mode 100644 src/review/ledger-anchor.ts create mode 100644 test/integration/public-anchor-key-route.test.ts create mode 100644 test/unit/ledger-anchor.test.ts diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index eb25b146c2..bad608d658 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -19370,6 +19370,24 @@ } ] } + }, + "/v1/public/decision-ledger/anchor-key": { + "get": { + "summary": "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor", + "responses": { + "200": { + "description": "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index 08b8c2b9ef..f644e893a7 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -312,6 +312,7 @@ import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride } import { isRagEnabled } from "../review/rag-wire"; import { loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record"; import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records"; +import { currentAnchorKey, parseAnchorPublicKeys } from "../review/ledger-anchor"; import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats"; import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend"; import { loadPublicRulePrecision } from "../review/public-rule-precision"; @@ -1303,6 +1304,21 @@ export function createApp() { return c.json(row); }); + // #9270 (epic #9267): the anchor-signing public keys, with their rotation history. A verifier holding an + // anchor published elsewhere (transparency log, public git repo) fetches this to check that anchor's + // signature. The FULL history is served, not just the current key, because an anchor signed in 2026 must + // stay verifiable after a 2027 rotation -- retired keys are published forever rather than replaced. + // Unauthenticated by design, like every /v1/public/* sibling: a public key is public, and a verifier who had + // to authenticate to US in order to check OUR anchors would not be independently verifying anything. + // Unconfigured yields an empty list + null currentKeyId rather than an error -- "nothing is claimed to be + // verifiable yet" is the honest answer before the signing key is provisioned, and a verifier can tell that + // apart from a key that exists. + app.get("/v1/public/decision-ledger/anchor-key", (c) => { + const keys = parseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS); + c.header("Cache-Control", "public, max-age=300, stale-while-revalidate=3600"); + return c.json({ keys, currentKeyId: currentAnchorKey(keys)?.keyId ?? null }); + }); + // #9123: the decision record itself was persisted (decision_records) but never published anywhere a // contributor or a third party could fetch the full body — the only prior public surface was // renderDecisionRecordSection's bounded review-comment summary (12-char digest prefixes, and it omits @@ -6790,6 +6806,9 @@ function requiresApiToken(path: string): boolean { // #9269: the single-row read, added in the SAME PR as its route so the two can never drift the way #9120's // sibling did. Regex (not a literal) because of the :seq path parameter. if (/^\/v1\/public\/decision-ledger\/row\/[^/]+$/.test(path)) return false; + // #9270: the published anchor-signing public keys, added in the SAME PR as its route so the two cannot + // drift the way #9120's sibling did. + if (path === "/v1/public/decision-ledger/anchor-key") return false; // #9123: the new public decision-record read route — same unauthenticated posture as its ledger-verify // sibling immediately above, added in the SAME PR so the two can never drift apart the way #9120 did. The // pull segment matches any non-slash text (not just digits): an invalid pull number is the ROUTE's 400 to diff --git a/src/env.d.ts b/src/env.d.ts index 53c2bb03e2..934ba5c214 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -596,6 +596,25 @@ declare global { * not depend on this var at all. Default "" (unset) → the own-ledger side reports zero (fails safe, same * privacy stance as before) but the Orb aggregate still reports normally. See review/public-stats.ts. */ LOOPOVER_PUBLIC_STATS_REPOS?: string; + /** External ledger anchoring (#9270, epic #9267): the PUBLIC half of the anchor-signing keypair(s), as a + * JSON array of `{ keyId, publicKeySpki, notBefore, notAfter }` — served verbatim by the unauthenticated + * `GET /v1/public/decision-ledger/anchor-key` so a third party can verify an anchor published to a + * transparency log or public git repo. The full ROTATION HISTORY belongs here, not just the current key: + * an anchor signed under a retired key must stay verifiable forever, so retired entries get a non-null + * `notAfter` and are never removed. Exactly one entry may have `notAfter: null` (the key currently + * signing); zero or several is an ambiguous rotation state and `currentAnchorKey` fails closed rather + * than guessing. Public key material only — the private half is LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY and + * must never appear here. Default unset → an empty published list, meaning "no anchors are claimed to be + * verifiable yet", which a verifier can distinguish from a key that exists. See review/ledger-anchor.ts. */ + LOOPOVER_LEDGER_ANCHOR_KEYS?: string; + /** External ledger anchoring (#9270, epic #9267): the PRIVATE half of the anchor-signing keypair — an + * ECDSA P-256 key in PKCS8 PEM (the `\n`-escaped single-line form is accepted, since that is how a key + * survives a secret store). A real SECRET: set via `wrangler secret put`, never committed, never served + * by any route, and never logged. Only the scheduled anchoring job reads it (#9274). Unset simply means + * anchoring does not run — the ledger stays tamper-evident rather than tamper-proof, which is the exact + * pre-#9267 posture, so an unprovisioned key degrades honestly instead of failing. See + * review/ledger-anchor.ts. */ + LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY?: string; /** Convergence (port): public OAuth draft-submission flow ported from reviewbot. When truthy, the * /v1/drafts endpoints accept a contributor draft -> GitHub OAuth -> fork PR against the content repo. * Default OFF — unset/false makes every draft endpoint 404 and writes nothing (byte-identical worker). */ diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index b8bfea05be..4fc08e3af0 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1016,6 +1016,14 @@ export function buildOpenApiSpec() { 404: { description: "No ledger row at that seq (never appended -- distinct from a row with empty fields)" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/public/decision-ledger/anchor-key", + summary: "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor", + responses: { + 200: { description: "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/public/decision-records/{owner}/{repo}/{pull}", diff --git a/src/review/ledger-anchor.ts b/src/review/ledger-anchor.ts new file mode 100644 index 0000000000..f17d1df338 --- /dev/null +++ b/src/review/ledger-anchor.ts @@ -0,0 +1,214 @@ +// Signed, versioned decision-ledger anchor payloads (#9270, epic #9267). An anchor is the small artifact a +// scheduled job publishes to somewhere the operator does not control (a transparency log, a public git repo) +// so that "the ledger's tip really was X at time T" becomes checkable by a stranger later. Two properties +// this module exists to provide, neither of which the original {seq, rowHash, at} shape had: +// +// 1. SELF-DESCRIBING. An anchor read cold in two years, out of whatever log it landed in, must say what it +// is and what it commits to -- hence an explicit schema version and a ledger identifier. A bare +// {seq, rowHash, at} triple is unattributable: it names neither the chain it came from nor the rules +// under which it should be read. +// 2. SIGNED, so an anchor cannot be FORGED. Without a signature anyone could publish a plausible-looking +// anchor for this ledger and then "prove" a rewrite that never happened -- a denial-of-integrity attack +// against our own record. The signature says only "this operator published this tip claim"; it is +// deliberately NOT the tamper-proofness itself (that comes from the anchor landing somewhere append-only +// the operator cannot rewrite -- see #9272/#9273). +// +// ECDSA P-256 / SHA-256 via WebCrypto: native to the Workers runtime, so this adds no dependency, and it is +// exactly what Rekor's `hashedrekord` accepts as a self-managed verifier key (#9272) -- the same keypair +// serves both the local signature and the transparency-log submission. +import { canonicalJson, sha256Hex } from "./decision-record"; + +/** Bump when the payload's FIELD SET changes meaning. A verifier reads this FIRST and refuses shapes it does + * not understand, rather than silently misreading a future field set as the current one. */ +export const LEDGER_ANCHOR_PAYLOAD_VERSION = 1 as const; + +/** Identifies WHICH chain an anchor commits to. A fixed string today (one ledger), but present from v1 so a + * second anchored chain can never be confused for this one by a verifier holding both. */ +export const LEDGER_ANCHOR_LEDGER_ID = "loopover.decision_ledger"; + +/** The exact bytes an anchor commits to. `totalCount` is included alongside `seq` deliberately: a chain + * truncated and re-chained to the same length would still have to match BOTH, and the pair is what lets a + * verifier notice "the tip moved backwards" without fetching every row. */ +export type LedgerAnchorPayload = { + v: typeof LEDGER_ANCHOR_PAYLOAD_VERSION; + ledger: typeof LEDGER_ANCHOR_LEDGER_ID; + seq: number; + rowHash: string; + totalCount: number; + at: string; +}; + +/** A payload plus the operator signature over its canonical serialization, and the id of the key that signed + * it. `keyId` is REQUIRED: without it a verifier holding a rotation history cannot tell which published key + * a given anchor should be checked against, and would have to try them all -- turning a failed verification + * (a real signal) into an ambiguous one. */ +export type SignedLedgerAnchor = { + payload: LedgerAnchorPayload; + keyId: string; + /** base64 P-1363 (r||s) ECDSA signature over `canonicalJson(payload)`, as WebCrypto produces it. */ + signature: string; +}; + +/** One published anchor-signing public key and the window it was valid for. `notAfter: null` = still current. + * Rotation is why this is a LIST: an anchor signed in 2026 must stay verifiable after a 2027 rotation, so + * retired keys are published forever rather than replaced. */ +export type AnchorPublicKey = { + keyId: string; + /** base64 SPKI DER -- the same encoding Rekor's `verifier.publicKey.rawBytes` takes (#9272). */ + publicKeySpki: string; + notBefore: string; + notAfter: string | null; +}; + +function base64ToBytes(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +/** Strip PEM armor to raw DER bytes. Accepts the `\n`-escaped single-line form too, since that is how a key + * survives being pasted into a Worker secret / CI variable (same normalization signRs256Jwt already does). */ +function pemToBytes(pem: string): Uint8Array { + const normalized = pem.replace(/\\n/g, "\n"); + const base64 = normalized + .replace(/-----BEGIN [A-Z ]+-----/g, "") + .replace(/-----END [A-Z ]+-----/g, "") + .replace(/\s+/g, ""); + return base64ToBytes(base64); +} + +/** + * Derive a key's id FROM the key itself -- sha256(SPKI DER), first 16 hex chars. Deliberately not an operator- + * chosen label: a derived id cannot drift from the key it names, cannot be reused for a different key across + * a rotation, and lets a verifier confirm that the key they fetched is the key an anchor claims was used. + */ +export async function computeAnchorKeyId(publicKeySpkiBase64: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", base64ToBytes(publicKeySpkiBase64) as Uint8Array); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 16); +} + +/** + * Build the versioned, self-describing payload for a tip. PURE and synchronous -- the caller supplies `at` + * (or accepts the default) so a scheduled job's payload is reproducible in a test. + */ +export function buildLedgerAnchorPayload(tip: { seq: number; rowHash: string; totalCount: number }, at: string): LedgerAnchorPayload { + return { + v: LEDGER_ANCHOR_PAYLOAD_VERSION, + ledger: LEDGER_ANCHOR_LEDGER_ID, + seq: tip.seq, + rowHash: tip.rowHash, + totalCount: tip.totalCount, + at, + }; +} + +/** The exact bytes signed and verified -- one definition both sides share, so a serialization change can + * never silently break verification while leaving signing "working". */ +export function anchorSigningInput(payload: LedgerAnchorPayload): string { + return canonicalJson(payload); +} + +/** + * Sign an anchor payload with the operator's ECDSA P-256 private key (PKCS8 PEM, held as a Worker secret and + * never in the repo). `keyId` is supplied by the caller -- it is `computeAnchorKeyId` of the PUBLISHED public + * half, which the signer knows from config. It is not derived here on purpose: WebCrypto cannot export a + * public key from a non-extractable PKCS8 import, and importing a long-lived signing key as extractable + * purely to relabel it would be a strictly worse posture for a key that lives in a secret. + */ +export async function signLedgerAnchorPayload( + payload: LedgerAnchorPayload, + privateKeyPem: string, + keyId: string, +): Promise { + const key = await crypto.subtle.importKey( + "pkcs8", + pemToBytes(privateKeyPem) as Uint8Array, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + key, + new TextEncoder().encode(anchorSigningInput(payload)), + ); + return { payload, keyId, signature: bytesToBase64(new Uint8Array(signature)) }; +} + +/** + * Verify a signed anchor against a published public key. Returns a boolean rather than throwing for an + * ordinarily-invalid input (wrong key, tampered payload, malformed signature) -- those are all "not verified", + * a fact about the anchor, not a caller bug. This is the function a third party's own verifier reimplements; + * it is exported so our tests exercise the SAME path an outsider would, never a privileged shortcut. + */ +export async function verifyLedgerAnchorSignature(signed: SignedLedgerAnchor, publicKeySpkiBase64: string): Promise { + try { + const key = await crypto.subtle.importKey( + "spki", + base64ToBytes(publicKeySpkiBase64) as Uint8Array, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ); + return await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + key, + base64ToBytes(signed.signature) as Uint8Array, + new TextEncoder().encode(anchorSigningInput(signed.payload)), + ); + } catch { + return false; + } +} + +/** + * Parse the published key list from its configured JSON. Never throws: malformed config yields an EMPTY list, + * which fails closed at the route (no keys published => nothing claims to be verifiable) rather than a 500 on + * a public endpoint. Entries missing required fields are dropped individually, so one bad entry cannot hide + * every valid key alongside it. + */ +export function parseAnchorPublicKeys(raw: string | undefined): AnchorPublicKey[] { + if (!raw) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) return []; + return parsed.filter((entry): entry is AnchorPublicKey => { + if (typeof entry !== "object" || entry === null) return false; + const candidate = entry as Record; + return ( + typeof candidate["keyId"] === "string" && + candidate["keyId"] !== "" && + typeof candidate["publicKeySpki"] === "string" && + candidate["publicKeySpki"] !== "" && + typeof candidate["notBefore"] === "string" && + (candidate["notAfter"] === null || typeof candidate["notAfter"] === "string") + ); + }); +} + +/** The key currently signing anchors: the one entry with `notAfter: null`. Returns null when zero or MORE + * THAN ONE qualify -- an ambiguous rotation state must fail closed rather than silently pick one, since + * picking wrong would attribute anchors to a key that did not sign them. */ +export function currentAnchorKey(keys: readonly AnchorPublicKey[]): AnchorPublicKey | null { + const current = keys.filter((key) => key.notAfter === null); + return current.length === 1 ? (current[0] as AnchorPublicKey) : null; +} + +/** Find the key an anchor claims signed it, for a verifier walking a rotation history. */ +export function anchorKeyById(keys: readonly AnchorPublicKey[], keyId: string): AnchorPublicKey | null { + return keys.find((key) => key.keyId === keyId) ?? null; +} + +/** Digest helper re-exported so an anchor consumer never needs a second import just to hash a payload. */ +export { sha256Hex }; diff --git a/test/integration/public-anchor-key-route.test.ts b/test/integration/public-anchor-key-route.test.ts new file mode 100644 index 0000000000..f9161b5aa2 --- /dev/null +++ b/test/integration/public-anchor-key-route.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createTestEnv } from "../helpers/d1"; +import type { AnchorPublicKey } from "../../src/review/ledger-anchor"; + +// #9270 (epic #9267): the published anchor-signing keys. The load-bearing behaviours are that the FULL +// rotation history is served (so an anchor signed under a retired key stays verifiable) and that an ambiguous +// or unconfigured state answers honestly rather than guessing at a current key. + +const RETIRED: AnchorPublicKey = { keyId: "old0000000000000", publicKeySpki: "b2xka2V5", notBefore: "2025-01-01T00:00:00.000Z", notAfter: "2026-01-01T00:00:00.000Z" }; +const ACTIVE: AnchorPublicKey = { keyId: "new0000000000000", publicKeySpki: "bmV3a2V5", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }; + +describe("GET /v1/public/decision-ledger/anchor-key (#9270)", () => { + it("answers 200 with no Authorization header", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([ACTIVE]) }); + const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env); + expect(response.status).toBe(200); + }); + + it("serves the FULL rotation history, so an anchor signed under a retired key stays verifiable", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([RETIRED, ACTIVE]) }); + const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env); + const body = (await response.json()) as { keys: AnchorPublicKey[]; currentKeyId: string | null }; + + expect(body.keys).toEqual([RETIRED, ACTIVE]); // the retired key is still published, not dropped + expect(body.currentKeyId).toBe(ACTIVE.keyId); + }); + + it("reports an empty list and a null currentKeyId when unconfigured — 'nothing is claimed verifiable yet', distinguishable from a key that exists", async () => { + const env = createTestEnv(); + const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ keys: [], currentKeyId: null }); + }); + + it("fails closed to a null currentKeyId on an ambiguous rotation state rather than guessing", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([ACTIVE, { ...ACTIVE, keyId: "second0000000000" }]) }); + const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env); + const body = (await response.json()) as { keys: AnchorPublicKey[]; currentKeyId: string | null }; + + expect(body.keys).toHaveLength(2); // both still published... + expect(body.currentKeyId).toBeNull(); // ...but which one signs is genuinely ambiguous, so we do not claim + }); + + it("answers 200 with an empty list (never a 500) on malformed config", async () => { + const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: "{not valid json" }); + const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ keys: [], currentKeyId: null }); + }); + + it("never serves private key material, even if it is misconfigured into the public list", async () => { + const env = createTestEnv({ + LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([ACTIVE]), + LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nMIGHAgEA\n-----END PRIVATE KEY-----", + }); + const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env); + expect(JSON.stringify(await response.json())).not.toMatch(/PRIVATE KEY|MIGHAgEA/); + }); +}); diff --git a/test/integration/public-decision-ledger-routes.test.ts b/test/integration/public-decision-ledger-routes.test.ts index c70cc17b27..3e7b199dcc 100644 --- a/test/integration/public-decision-ledger-routes.test.ts +++ b/test/integration/public-decision-ledger-routes.test.ts @@ -76,6 +76,7 @@ describe("public decision-ledger/decision-records routes answer WITHOUT credenti ["stats", "/v1/public/stats"], ["decision-ledger/verify", "/v1/public/decision-ledger/verify"], ["decision-ledger/row/:seq", "/v1/public/decision-ledger/row/1"], + ["decision-ledger/anchor-key", "/v1/public/decision-ledger/anchor-key"], ["decision-records/:owner/:repo/:pull", "/v1/public/decision-records/acme/widgets/1"], ["github/repos/:owner/:repo/stats", "/v1/public/github/repos/acme/widgets/stats"], ["repos/:owner/:repo/badge.svg", "/v1/public/repos/acme/widgets/badge.svg"], diff --git a/test/unit/ledger-anchor.test.ts b/test/unit/ledger-anchor.test.ts new file mode 100644 index 0000000000..aac9dc0878 --- /dev/null +++ b/test/unit/ledger-anchor.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { + anchorKeyById, + anchorSigningInput, + buildLedgerAnchorPayload, + computeAnchorKeyId, + currentAnchorKey, + LEDGER_ANCHOR_LEDGER_ID, + LEDGER_ANCHOR_PAYLOAD_VERSION, + parseAnchorPublicKeys, + signLedgerAnchorPayload, + verifyLedgerAnchorSignature, + type AnchorPublicKey, + type SignedLedgerAnchor, +} from "../../src/review/ledger-anchor"; + +// #9270 (epic #9267). These tests use REAL generated ECDSA P-256 keypairs and real WebCrypto signatures -- +// never a stubbed signer -- because the property under test is precisely that an outsider holding only the +// published public key can verify (or refuse) an anchor. A mocked signature would prove nothing about that. + +const AT = "2026-07-27T12:00:00.000Z"; +const TIP = { seq: 42, rowHash: "a".repeat(64), totalCount: 42 }; + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function toPem(base64: string, label: string): string { + return `-----BEGIN ${label}-----\n${(base64.match(/.{1,64}/g) ?? []).join("\n")}\n-----END ${label}-----`; +} + +/** A real P-256 keypair, exported the way an operator would provision one. The two casts narrow WebCrypto's + * deliberately-wide lib types (`generateKey` unions CryptoKey with CryptoKeyPair for the symmetric case; + * `exportKey` unions ArrayBuffer with JsonWebKey for the "jwk" format) -- both are statically known here from + * the algorithm and format actually passed. */ +async function generateKeypair(): Promise<{ privateKeyPem: string; publicKeySpki: string; keyId: string }> { + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; + const pkcs8 = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer)); + const publicKeySpki = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("spki", pair.publicKey)) as ArrayBuffer)); + return { privateKeyPem: toPem(pkcs8, "PRIVATE KEY"), publicKeySpki, keyId: await computeAnchorKeyId(publicKeySpki) }; +} + +describe("buildLedgerAnchorPayload (#9270)", () => { + it("is self-describing: carries a schema version and the ledger it commits to, not a bare triple", () => { + const payload = buildLedgerAnchorPayload(TIP, AT); + expect(payload).toEqual({ + v: LEDGER_ANCHOR_PAYLOAD_VERSION, + ledger: LEDGER_ANCHOR_LEDGER_ID, + seq: 42, + rowHash: "a".repeat(64), + totalCount: 42, + at: AT, + }); + }); + + it("is pure — the same tip and timestamp always produce identical signing input", () => { + expect(anchorSigningInput(buildLedgerAnchorPayload(TIP, AT))).toBe(anchorSigningInput(buildLedgerAnchorPayload(TIP, AT))); + }); +}); + +describe("signing and verification round-trip with real keys", () => { + it("an anchor signed by the operator verifies against the published public key", async () => { + const { privateKeyPem, publicKeySpki, keyId } = await generateKeypair(); + const signed = await signLedgerAnchorPayload(buildLedgerAnchorPayload(TIP, AT), privateKeyPem, keyId); + + expect(signed.keyId).toBe(keyId); + expect(signed.signature).not.toBe(""); + expect(await verifyLedgerAnchorSignature(signed, publicKeySpki)).toBe(true); + }); + + it("REJECTS a tampered payload — changing the anchored rowHash breaks the signature", async () => { + const { privateKeyPem, publicKeySpki, keyId } = await generateKeypair(); + const signed = await signLedgerAnchorPayload(buildLedgerAnchorPayload(TIP, AT), privateKeyPem, keyId); + + const tampered: SignedLedgerAnchor = { ...signed, payload: { ...signed.payload, rowHash: "b".repeat(64) } }; + expect(await verifyLedgerAnchorSignature(tampered, publicKeySpki)).toBe(false); + }); + + it("REJECTS a tampered seq or totalCount — a rewound tip cannot reuse an old signature", async () => { + const { privateKeyPem, publicKeySpki, keyId } = await generateKeypair(); + const signed = await signLedgerAnchorPayload(buildLedgerAnchorPayload(TIP, AT), privateKeyPem, keyId); + + expect(await verifyLedgerAnchorSignature({ ...signed, payload: { ...signed.payload, seq: 41 } }, publicKeySpki)).toBe(false); + expect(await verifyLedgerAnchorSignature({ ...signed, payload: { ...signed.payload, totalCount: 41 } }, publicKeySpki)).toBe(false); + }); + + it("REJECTS an anchor signed by a DIFFERENT key — forging an anchor for this ledger fails", async () => { + const operator = await generateKeypair(); + const attacker = await generateKeypair(); + const forged = await signLedgerAnchorPayload(buildLedgerAnchorPayload(TIP, AT), attacker.privateKeyPem, operator.keyId); + + // The forger even claims the operator's keyId — verification against the real published key still fails. + expect(await verifyLedgerAnchorSignature(forged, operator.publicKeySpki)).toBe(false); + }); + + it("returns false (never throws) on a malformed signature or malformed key", async () => { + const { privateKeyPem, publicKeySpki, keyId } = await generateKeypair(); + const signed = await signLedgerAnchorPayload(buildLedgerAnchorPayload(TIP, AT), privateKeyPem, keyId); + + expect(await verifyLedgerAnchorSignature({ ...signed, signature: "not-base64-!!" }, publicKeySpki)).toBe(false); + expect(await verifyLedgerAnchorSignature(signed, "not-a-key")).toBe(false); + }); +}); + +describe("computeAnchorKeyId", () => { + it("derives the id FROM the key, so it cannot drift from the key it names", async () => { + const { publicKeySpki, keyId } = await generateKeypair(); + expect(await computeAnchorKeyId(publicKeySpki)).toBe(keyId); + expect(keyId).toMatch(/^[0-9a-f]{16}$/); + }); + + it("gives different keys different ids", async () => { + const [a, b] = await Promise.all([generateKeypair(), generateKeypair()]); + expect(a.keyId).not.toBe(b.keyId); + }); +}); + +describe("parseAnchorPublicKeys", () => { + const valid: AnchorPublicKey = { keyId: "abc123", publicKeySpki: "c3Bra2V5", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }; + + it("parses a valid list", () => { + expect(parseAnchorPublicKeys(JSON.stringify([valid]))).toEqual([valid]); + }); + + it("fails closed to an empty list on unset, malformed JSON, or a non-array", () => { + expect(parseAnchorPublicKeys(undefined)).toEqual([]); + expect(parseAnchorPublicKeys("{not json")).toEqual([]); + expect(parseAnchorPublicKeys(JSON.stringify({ keyId: "x" }))).toEqual([]); + }); + + it("drops only the invalid entries, so one bad entry cannot hide the valid keys beside it", () => { + const raw = JSON.stringify([valid, { keyId: "" }, { publicKeySpki: "x" }, null, "string"]); + expect(parseAnchorPublicKeys(raw)).toEqual([valid]); + }); + + // Every required-field arm rejected independently: a half-written key entry must never be published as if + // it were usable, and each field is a separate way for hand-edited config to be wrong. + it("rejects an entry missing or mistyping ANY required field", () => { + const retiredShape: AnchorPublicKey = { ...valid, keyId: "retired", notAfter: "2026-06-01T00:00:00.000Z" }; + const raw = JSON.stringify([ + valid, + retiredShape, // notAfter as a STRING is valid (a retired key), not just null + { ...valid, keyId: 42 }, // keyId not a string + { ...valid, publicKeySpki: "" }, // empty key material + { ...valid, publicKeySpki: 7 }, // key material not a string + { ...valid, notBefore: 1234 }, // notBefore not a string + { ...valid, notAfter: 99 }, // notAfter neither null nor a string + ]); + expect(parseAnchorPublicKeys(raw)).toEqual([valid, retiredShape]); + }); +}); + +describe("currentAnchorKey / anchorKeyById (rotation)", () => { + const retired: AnchorPublicKey = { keyId: "old", publicKeySpki: "b2xk", notBefore: "2025-01-01T00:00:00.000Z", notAfter: "2026-01-01T00:00:00.000Z" }; + const active: AnchorPublicKey = { keyId: "new", publicKeySpki: "bmV3", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }; + + it("picks the single open-ended key as current", () => { + expect(currentAnchorKey([retired, active])).toEqual(active); + }); + + it("fails closed on an ambiguous rotation state (zero or several open-ended keys)", () => { + expect(currentAnchorKey([retired])).toBeNull(); + expect(currentAnchorKey([active, { ...active, keyId: "other" }])).toBeNull(); + expect(currentAnchorKey([])).toBeNull(); + }); + + it("still resolves a RETIRED key by id, so anchors signed before a rotation stay verifiable", () => { + expect(anchorKeyById([retired, active], "old")).toEqual(retired); + expect(anchorKeyById([retired, active], "missing")).toBeNull(); + }); +}); From 87b8f1a9300b97d2651e5d4be1fedd2c3b8eed7f Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:15:33 -0700 Subject: [PATCH 2/3] fix(ledger): re-export canonicalJson from ledger-anchor.ts (#9270) Lost during editing -- the re-export line only carried sha256Hex, so any consumer importing canonicalJson from this module got undefined. Caught while building #9273's git-commit backend, which needs it to commit the same canonicalized payload Rekor anchors. --- src/review/ledger-anchor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/review/ledger-anchor.ts b/src/review/ledger-anchor.ts index f17d1df338..c57961107a 100644 --- a/src/review/ledger-anchor.ts +++ b/src/review/ledger-anchor.ts @@ -211,4 +211,7 @@ export function anchorKeyById(keys: readonly AnchorPublicKey[], keyId: string): } /** Digest helper re-exported so an anchor consumer never needs a second import just to hash a payload. */ -export { sha256Hex }; +/** Digest helpers re-exported so an anchor consumer (e.g. the git-commit backend, #9273, which commits the + * same canonicalized payload Rekor anchors) never needs a second import from decision-record.ts just to + * canonicalize or hash something alongside a signed anchor. */ +export { canonicalJson, sha256Hex }; From fa8c60ead872cf7a2da20dfde386e5097aacfd5c Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:44:41 -0700 Subject: [PATCH 3/3] feat(ledger): anchor persistence + public attempt log, success and failure alike (#9395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ledger): anchor persistence + public attempt log, success and failure alike (#9271) Third sub-issue of #9267. migrations/0195 adds decision_ledger_anchors: every backend calls the same recordLedgerAnchorAttempt whether it succeeded or failed — there is no separate failure path a backend could simply not call. Per the mechanism research on #9267: anchoring is best-effort (it must never block a review), which means a failure could otherwise be silent. An operator whose anchoring quietly fails could regress the ledger back to tamper-evident-only with no visible signal. Recording every attempt on the same public listing, in the same shape regardless of outcome, is what makes anchoring's own health a fact anyone can observe rather than something only the operator's own logs show. GET /v1/public/decision-ledger/anchors serves the log, paginated newest-first, filterable by backend. backend_ref stores the full resolvable reference (for Rekor: shard URL + log index + key id + uuid, not a bare index that becomes unresolvable after the annual shard rotation). * fix(db): add decision_ledger_anchors to RAW_SQL_ONLY_TABLES (#9271) check-schema-drift caught this on push: the migration 0195 table was never declared in src/db/schema.ts and wasn't in the raw-SQL-only allowlist either, matching decision_ledger's own existing entry (the table this one is the anchoring companion to). --- apps/loopover-ui/public/openapi.json | 4575 +++++++++-------- migrations/0195_decision_ledger_anchors.sql | 36 + scripts/check-schema-drift.ts | 1 + src/api/routes.ts | 24 + src/openapi/spec.ts | 9 + src/review/ledger-anchor-persistence.ts | 147 + .../public-decision-ledger-routes.test.ts | 1 + .../public-ledger-anchors-route.test.ts | 93 + test/unit/ledger-anchor-persistence.test.ts | 131 + 9 files changed, 2754 insertions(+), 2263 deletions(-) create mode 100644 migrations/0195_decision_ledger_anchors.sql create mode 100644 src/review/ledger-anchor-persistence.ts create mode 100644 test/integration/public-ledger-anchors-route.test.ts create mode 100644 test/unit/ledger-anchor-persistence.test.ts diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index bad608d658..bdb2032d46 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -434,6 +434,82 @@ "merged" ] }, + "rulePrecision": { + "type": "object", + "properties": { + "windowDays": { + "type": "number" + }, + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ruleId": { + "type": "string" + }, + "decided": { + "type": "number" + }, + "confirmed": { + "type": "number" + }, + "precision": { + "type": "number", + "nullable": true + } + }, + "required": [ + "ruleId", + "decided", + "confirmed", + "precision" + ] + } + }, + "reversals": { + "type": "object", + "properties": { + "reopened": { + "type": "number" + }, + "reverted": { + "type": "number" + }, + "superseded": { + "type": "number" + } + }, + "required": [ + "reopened", + "reverted", + "superseded" + ] + }, + "latestBacktestRun": { + "type": "object", + "nullable": true, + "properties": { + "corpusChecksum": { + "type": "string" + }, + "at": { + "type": "string" + } + }, + "required": [ + "corpusChecksum", + "at" + ] + } + }, + "required": [ + "windowDays", + "rules", + "reversals", + "latestBacktestRun" + ] + }, "byProject": { "type": "array", "items": { @@ -465,94 +541,6 @@ ] } }, - "accuracyTrend": { - "type": "array", - "items": { - "type": "object", - "properties": { - "weekStart": { - "type": "string" - }, - "merged": { - "type": "number", - "nullable": true - }, - "closed": { - "type": "number", - "nullable": true - }, - "reversed": { - "type": "number", - "nullable": true - }, - "accuracyPct": { - "type": "number", - "nullable": true - } - }, - "required": [ - "weekStart", - "merged", - "closed", - "reversed", - "accuracyPct" - ] - } - }, - "reuseRateTrend": { - "type": "array", - "items": { - "type": "object", - "properties": { - "weekStart": { - "type": "string" - }, - "hits": { - "type": "number" - }, - "misses": { - "type": "number" - }, - "reuseRatePct": { - "type": "number", - "nullable": true - } - }, - "required": [ - "weekStart", - "hits", - "misses", - "reuseRatePct" - ] - } - }, - "reviewVolumeTrend": { - "type": "array", - "items": { - "type": "object", - "properties": { - "weekStart": { - "type": "string" - }, - "reviewed": { - "type": "number" - }, - "merged": { - "type": "number" - }, - "filteredPct": { - "type": "number", - "nullable": true - } - }, - "required": [ - "weekStart", - "reviewed", - "merged", - "filteredPct" - ] - } - }, "fleetAccuracy": { "type": "object", "properties": { @@ -560,16 +548,6 @@ "type": "number", "nullable": true }, - "instanceCount": { - "type": "number" - }, - "windowDays": { - "type": "number" - }, - "gamingFlagsCaught": { - "type": "number", - "nullable": true - }, "accuracyCiPct": { "type": "object", "nullable": true, @@ -646,10 +624,10 @@ "lambda": { "type": "number" }, - "n": { + "aiJudgedCoveragePct": { "type": "number" }, - "aiJudgedCoveragePct": { + "n": { "type": "number" }, "backfilledPct": { @@ -675,10 +653,10 @@ "lambda": { "type": "number" }, - "n": { + "aiJudgedCoveragePct": { "type": "number" }, - "aiJudgedCoveragePct": { + "n": { "type": "number" }, "backfilledPct": { @@ -699,6 +677,16 @@ "close", "merge" ] + }, + "instanceCount": { + "type": "number" + }, + "windowDays": { + "type": "number" + }, + "gamingFlagsCaught": { + "type": "number", + "nullable": true } }, "required": [ @@ -716,81 +704,93 @@ "gamingFlagsCaught" ] }, - "rulePrecision": { - "type": "object", - "properties": { - "windowDays": { - "type": "number" - }, - "rules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "ruleId": { - "type": "string" - }, - "decided": { - "type": "number" - }, - "precision": { - "type": "number", - "nullable": true - }, - "confirmed": { - "type": "number" - } - }, - "required": [ - "ruleId", - "decided", - "confirmed", - "precision" - ] + "accuracyTrend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "weekStart": { + "type": "string" + }, + "merged": { + "type": "number", + "nullable": true + }, + "closed": { + "type": "number", + "nullable": true + }, + "reversed": { + "type": "number", + "nullable": true + }, + "accuracyPct": { + "type": "number", + "nullable": true } }, - "reversals": { - "type": "object", - "properties": { - "reopened": { - "type": "number" - }, - "reverted": { - "type": "number" - }, - "superseded": { - "type": "number" - } + "required": [ + "weekStart", + "merged", + "closed", + "reversed", + "accuracyPct" + ] + } + }, + "reuseRateTrend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "weekStart": { + "type": "string" }, - "required": [ - "reopened", - "reverted", - "superseded" - ] + "hits": { + "type": "number" + }, + "misses": { + "type": "number" + }, + "reuseRatePct": { + "type": "number", + "nullable": true + } }, - "latestBacktestRun": { - "type": "object", - "nullable": true, - "properties": { - "corpusChecksum": { - "type": "string" - }, - "at": { - "type": "string" - } + "required": [ + "weekStart", + "hits", + "misses", + "reuseRatePct" + ] + } + }, + "reviewVolumeTrend": { + "type": "array", + "items": { + "type": "object", + "properties": { + "weekStart": { + "type": "string" }, - "required": [ - "corpusChecksum", - "at" - ] - } - }, - "required": [ - "windowDays", - "rules", - "reversals", - "latestBacktestRun" - ] + "reviewed": { + "type": "number" + }, + "merged": { + "type": "number" + }, + "filteredPct": { + "type": "number", + "nullable": true + } + }, + "required": [ + "weekStart", + "reviewed", + "merged", + "filteredPct" + ] + } } }, "required": [ @@ -4776,6 +4776,43 @@ } ] }, + "ReviewRiskExplanation": { + "type": "object", + "properties": { + "preflight": { + "$ref": "#/components/schemas/PreflightResult" + }, + "roleContext": { + "allOf": [ + { + "$ref": "#/components/schemas/RoleContext" + }, + { + "nullable": true + } + ] + }, + "recommendation": { + "type": "string", + "enum": [ + "likely_duplicate", + "maintainer_lane", + "needs_author", + "review", + "watch" + ] + }, + "summary": { + "type": "string" + } + }, + "required": [ + "preflight", + "roleContext", + "recommendation", + "summary" + ] + }, "LocalBranchAnalysis": { "type": "object", "properties": { @@ -9037,6 +9074,10 @@ "type": "number", "nullable": true }, + "closeAuditHoldoutPct": { + "type": "number", + "nullable": true + }, "slopGateMode": { "type": "string", "enum": [ @@ -9137,6 +9178,12 @@ "minimum": 0, "exclusiveMinimum": true }, + "staleBaseAheadByThreshold": { + "type": "integer", + "nullable": true, + "minimum": 0, + "exclusiveMinimum": true + }, "mergeReadinessGateMode": { "type": "string", "enum": [ @@ -9169,6 +9216,22 @@ "block" ] }, + "contentLaneDeliverableGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "backtestRegressionGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, "slopGateMinScore": { "type": "number", "nullable": true @@ -9211,6 +9274,10 @@ "type": "number", "nullable": true }, + "aiReviewSalvageabilityMinScore": { + "type": "number", + "nullable": true + }, "aiReviewLowConfidenceDisposition": { "type": "string", "nullable": true, @@ -9274,6 +9341,18 @@ "type": "string" } }, + "issuePlanEnabled": { + "type": "boolean" + }, + "issuePlanExtraLabels": { + "type": "array", + "items": { + "type": "string" + } + }, + "issuePlanMilestoneReuse": { + "type": "boolean" + }, "linkedIssueLabelPropagation": { "type": "object", "properties": { @@ -9811,6 +9890,15 @@ "moderationBannedLabel": { "type": "string" }, + "fairnessAnalyticsMode": { + "type": "string", + "enum": [ + "inherit", + "off", + "enabled" + ], + "description": "Per-repo participation in cross-repo contributor fairness/accuracy analytics -- 'off' excludes this repo's gate decisions and moderation history from every aggregation, independent of whether the internal fairness-analytics routes are enabled fleet-wide." + }, "skipAutomationBotAuthors": { "type": "string", "enum": [ @@ -9827,6 +9915,22 @@ "enabled" ] }, + "openPrFileCollisionMode": { + "type": "string", + "enum": [ + "inherit", + "off", + "enabled" + ] + }, + "plannerMode": { + "type": "string", + "enum": [ + "inherit", + "off", + "enabled" + ] + }, "reviewEvasionProtection": { "type": "string", "enum": [ @@ -9842,6 +9946,22 @@ "reviewEvasionComment": { "type": "boolean" }, + "draftPrClosePolicy": { + "type": "string", + "enum": [ + "off", + "close" + ], + "description": "Off by default (opt-in, unlike reviewEvasionProtection's default-close). \"close\" enforces on ANY draft PR, including the very first one, before a review pass has had a chance to run -- distinct from reviewEvasionProtection's family, which only enforces after a review already ran or on the 2nd+ draft conversion." + }, + "synchronizeClosePolicy": { + "type": "string", + "enum": [ + "off", + "close" + ], + "description": "Off by default (opt-in, config-as-code only -- no dashboard/DB column). \"close\" closes a contributor's own PR immediately when they push an additional commit (synchronize) before it's been merged or closed -- this repo's review is one-shot, so the first push is the only push. Never fires for a push that isn't from the PR's own author (e.g. the engine's own rebase-if-behind), nor for the repo owner/admin, a protected automation author, or anyone with write+ collaborator access." + }, "mergeTrainMode": { "type": "string", "enum": [ @@ -9910,126 +10030,236 @@ "updatedAt": { "type": "string", "nullable": true + } + }, + "required": [ + "repoFullName", + "commentMode", + "publicAudienceMode", + "publicSignalLevel", + "checkRunMode", + "checkRunDetailLevel", + "regateSweepOrderMode", + "reviewCheckMode", + "gatePack", + "linkedIssueGateMode", + "duplicatePrGateMode", + "qualityGateMode", + "slopGateMode", + "mergeReadinessGateMode", + "manifestPolicyGateMode", + "selfAuthoredLinkedIssueGateMode", + "linkedIssueSatisfactionGateMode", + "contentLaneDeliverableGateMode", + "backtestRegressionGateMode", + "slopAiAdvisory", + "aiReviewMode", + "aiReviewByok", + "aiReviewAllAuthors", + "closeOwnerAuthors", + "autoLabelEnabled", + "typeLabelsEnabled", + "gittensorLabel", + "blacklistLabel", + "createMissingLabel", + "publicSurface", + "includeMaintainerAuthors", + "requireLinkedIssue", + "backfillEnabled", + "commandAuthorization" + ] + }, + "AutomationState": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" }, - "openPrFileCollisionMode": { - "type": "string", - "enum": [ - "inherit", - "off", - "enabled" - ] - }, - "plannerMode": { - "type": "string", - "enum": [ - "inherit", - "off", - "enabled" - ] - }, - "draftPrClosePolicy": { - "type": "string", - "enum": [ - "off", - "close" - ], - "description": "Off by default (opt-in, unlike reviewEvasionProtection's default-close). \"close\" enforces on ANY draft PR, including the very first one, before a review pass has had a chance to run -- distinct from reviewEvasionProtection's family, which only enforces after a review already ran or on the 2nd+ draft conversion." - }, - "fairnessAnalyticsMode": { - "type": "string", - "enum": [ - "inherit", - "off", - "enabled" - ], - "description": "Per-repo participation in cross-repo contributor fairness/accuracy analytics -- 'off' excludes this repo's gate decisions and moderation history from every aggregation, independent of whether the internal fairness-analytics routes are enabled fleet-wide." - }, - "issuePlanEnabled": { + "configured": { "type": "boolean" }, - "issuePlanExtraLabels": { - "type": "array", - "items": { - "type": "string" + "autonomy": { + "type": "object", + "properties": { + "review": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "request_changes": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "approve": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "merge": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "close": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "label": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "review_state_label": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "update_branch": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + }, + "assign": { + "type": "string", + "enum": [ + "observe", + "auto_with_approval", + "auto" + ] + } } }, - "issuePlanMilestoneReuse": { - "type": "boolean" + "autoMaintain": { + "type": "boolean", + "nullable": true }, - "staleBaseAheadByThreshold": { - "type": "integer", - "nullable": true, - "minimum": 0, - "exclusiveMinimum": true + "agentPaused": { + "type": "boolean" }, - "synchronizeClosePolicy": { - "type": "string", - "enum": [ - "off", - "close" - ], - "description": "Off by default (opt-in, config-as-code only -- no dashboard/DB column). \"close\" closes a contributor's own PR immediately when they push an additional commit (synchronize) before it's been merged or closed -- this repo's review is one-shot, so the first push is the only push. Never fires for a push that isn't from the PR's own author (e.g. the engine's own rebase-if-behind), nor for the repo owner/admin, a protected automation author, or anyone with write+ collaborator access." + "agentDryRun": { + "type": "boolean" }, - "contentLaneDeliverableGateMode": { + "mode": { "type": "string", "enum": [ - "off", - "advisory", - "block" + "paused", + "dry_run", + "live" ] }, - "backtestRegressionGateMode": { + "permissionReadiness": { "type": "string", "enum": [ - "off", - "advisory", - "block" + "not_required", + "ready", + "reconsent_required" ] }, - "closeAuditHoldoutPct": { - "type": "number", - "nullable": true + "actingActionClasses": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "review", + "request_changes", + "approve", + "merge", + "close", + "label", + "review_state_label", + "update_branch", + "assign" + ] + } }, - "aiReviewSalvageabilityMinScore": { - "type": "number", - "nullable": true + "pendingActionCount": { + "type": "number" } }, "required": [ "repoFullName", - "commentMode", - "publicAudienceMode", - "publicSignalLevel", - "checkRunMode", - "checkRunDetailLevel", - "regateSweepOrderMode", - "reviewCheckMode", - "gatePack", - "linkedIssueGateMode", - "duplicatePrGateMode", - "qualityGateMode", - "slopGateMode", - "mergeReadinessGateMode", - "manifestPolicyGateMode", - "selfAuthoredLinkedIssueGateMode", - "linkedIssueSatisfactionGateMode", - "contentLaneDeliverableGateMode", - "backtestRegressionGateMode", - "slopAiAdvisory", - "aiReviewMode", - "aiReviewByok", - "aiReviewAllAuthors", - "closeOwnerAuthors", - "autoLabelEnabled", - "typeLabelsEnabled", - "gittensorLabel", - "blacklistLabel", - "createMissingLabel", - "publicSurface", - "includeMaintainerAuthors", - "requireLinkedIssue", - "backfillEnabled", - "commandAuthorization" + "configured", + "autonomy", + "agentPaused", + "agentDryRun", + "mode", + "permissionReadiness", + "actingActionClasses", + "pendingActionCount" + ] + }, + "RepoDocRefreshResult": { + "oneOf": [ + { + "type": "object", + "properties": { + "opened": { + "type": "boolean", + "enum": [ + true + ] + }, + "reused": { + "type": "boolean" + }, + "pullNumber": { + "type": "integer" + }, + "url": { + "type": "string" + } + }, + "required": [ + "opened", + "reused", + "pullNumber", + "url" + ] + }, + { + "type": "object", + "properties": { + "opened": { + "type": "boolean", + "enum": [ + false + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "opened", + "reason" + ] + } ] }, "InstallationRepair": { @@ -10573,6 +10803,10 @@ "type": "number", "nullable": true }, + "closeAuditHoldoutPct": { + "type": "number", + "nullable": true + }, "slopGateMode": { "type": "string", "enum": [ @@ -10613,6 +10847,22 @@ "block" ] }, + "contentLaneDeliverableGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, + "backtestRegressionGateMode": { + "type": "string", + "enum": [ + "off", + "advisory", + "block" + ] + }, "slopGateMinScore": { "type": "number", "nullable": true @@ -10717,26 +10967,6 @@ "defaultAllowed", "commandOverrides" ] - }, - "contentLaneDeliverableGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "backtestRegressionGateMode": { - "type": "string", - "enum": [ - "off", - "advisory", - "block" - ] - }, - "closeAuditHoldoutPct": { - "type": "number", - "nullable": true } }, "required": [ @@ -11319,6 +11549,10 @@ "minimum": 1, "maximum": 100 }, + "offset": { + "type": "integer", + "minimum": 0 + }, "hasMore": { "type": "boolean" }, @@ -11385,10 +11619,6 @@ "remediation" ] } - }, - "offset": { - "type": "integer", - "minimum": 0 } }, "required": [ @@ -13913,6 +14143,78 @@ "report" ] }, + "GateConfigEffectiveResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "effective": { + "type": "object", + "properties": { + "confidenceFloor": { + "type": "number", + "nullable": true + }, + "scopeCap": { + "type": "object", + "properties": { + "files": { + "type": "integer", + "nullable": true + }, + "lines": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "files", + "lines" + ] + } + }, + "required": [ + "confidenceFloor", + "scopeCap" + ] + }, + "shadowPending": { + "type": "boolean" + } + }, + "required": [ + "repoFullName", + "effective", + "shadowPending" + ] + }, + "LiveGateThresholdsResponse": { + "type": "object", + "properties": { + "repoFullName": { + "type": "string" + }, + "confidence_floor": { + "type": "number", + "nullable": true + }, + "scope_cap_files": { + "type": "integer", + "nullable": true + }, + "scope_cap_lines": { + "type": "integer", + "nullable": true + } + }, + "required": [ + "repoFullName", + "confidence_floor", + "scope_cap_files", + "scope_cap_lines" + ] + }, "ContributorScoringProfile": { "type": "object", "properties": { @@ -14205,92 +14507,6 @@ "summary" ] }, - "PullRequestReviewability": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "pullNumber": { - "type": "number" - }, - "generatedAt": { - "type": "string" - }, - "score": { - "type": "number" - }, - "action": { - "type": "string", - "enum": [ - "review_now", - "needs_author", - "likely_duplicate", - "close_or_redirect", - "watch", - "maintainer_lane" - ] - }, - "noiseSources": { - "type": "array", - "items": { - "type": "string" - } - }, - "whyThisHelps": { - "type": "array", - "items": { - "type": "string" - } - }, - "maintainerNextSteps": { - "type": "array", - "items": { - "type": "string" - } - }, - "privateSummary": { - "type": "string" - } - }, - "required": [ - "repoFullName", - "pullNumber", - "generatedAt", - "score", - "action", - "noiseSources", - "whyThisHelps", - "maintainerNextSteps", - "privateSummary" - ] - }, - "LiveGateThresholdsResponse": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "confidence_floor": { - "type": "number", - "nullable": true - }, - "scope_cap_files": { - "type": "integer", - "nullable": true - }, - "scope_cap_lines": { - "type": "integer", - "nullable": true - } - }, - "required": [ - "repoFullName", - "confidence_floor", - "scope_cap_files", - "scope_cap_lines" - ] - }, "AmsMinerCohortComparison": { "type": "object", "properties": { @@ -14376,305 +14592,126 @@ "humanCohort" ] }, - "GateConfigEffectiveResponse": { - "type": "object", - "properties": { - "repoFullName": { - "type": "string" - }, - "effective": { - "type": "object", - "properties": { - "confidenceFloor": { - "type": "number", - "nullable": true - }, - "scopeCap": { - "type": "object", - "properties": { - "files": { - "type": "integer", - "nullable": true - }, - "lines": { - "type": "integer", - "nullable": true - } - }, - "required": [ - "files", - "lines" - ] - } - }, - "required": [ - "confidenceFloor", - "scopeCap" - ] - }, - "shadowPending": { - "type": "boolean" - } - }, - "required": [ - "repoFullName", - "effective", - "shadowPending" - ] - }, - "FindingTaxonomyDocument": { + "PullRequestReviewability": { "type": "object", "properties": { - "categories": { + "repoFullName": { + "type": "string" + }, + "pullNumber": { + "type": "number" + }, + "generatedAt": { + "type": "string" + }, + "score": { + "type": "number" + }, + "action": { + "type": "string", + "enum": [ + "review_now", + "needs_author", + "likely_duplicate", + "close_or_redirect", + "watch", + "maintainer_lane" + ] + }, + "noiseSources": { "type": "array", "items": { "type": "string" } }, - "severities": { + "whyThisHelps": { "type": "array", "items": { "type": "string" } - } - }, - "required": [ - "categories", - "severities" - ] - }, - "EnrichmentAnalyzersTaxonomyDocument": { - "type": "object", - "properties": { - "defaultProfile": { - "type": "string" }, - "analyzers": { + "maintainerNextSteps": { "type": "array", "items": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "category": { - "type": "string" - }, - "costClass": { - "type": "string" - }, - "profiles": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "name", - "category", - "costClass", - "profiles" - ] + "type": "string" } + }, + "privateSummary": { + "type": "string" } }, "required": [ - "defaultProfile", - "analyzers" + "repoFullName", + "pullNumber", + "generatedAt", + "score", + "action", + "noiseSources", + "whyThisHelps", + "maintainerNextSteps", + "privateSummary" ] }, - "AutomationState": { + "FindingTaxonomyDocument": { "type": "object", "properties": { - "repoFullName": { - "type": "string" - }, - "configured": { - "type": "boolean" - }, - "autonomy": { - "type": "object", - "properties": { - "review": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - }, - "request_changes": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - }, - "approve": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - }, - "merge": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - }, - "close": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - }, - "label": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - }, - "review_state_label": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - }, - "update_branch": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - }, - "assign": { - "type": "string", - "enum": [ - "observe", - "auto_with_approval", - "auto" - ] - } + "categories": { + "type": "array", + "items": { + "type": "string" } }, - "autoMaintain": { - "type": "boolean", - "nullable": true - }, - "agentPaused": { - "type": "boolean" - }, - "agentDryRun": { - "type": "boolean" - }, - "mode": { - "type": "string", - "enum": [ - "paused", - "dry_run", - "live" - ] - }, - "permissionReadiness": { - "type": "string", - "enum": [ - "not_required", - "ready", - "reconsent_required" - ] - }, - "actingActionClasses": { + "severities": { "type": "array", "items": { - "type": "string", - "enum": [ - "review", - "request_changes", - "approve", - "merge", - "close", - "label", - "review_state_label", - "update_branch", - "assign" - ] + "type": "string" } - }, - "pendingActionCount": { - "type": "number" } }, "required": [ - "repoFullName", - "configured", - "autonomy", - "agentPaused", - "agentDryRun", - "mode", - "permissionReadiness", - "actingActionClasses", - "pendingActionCount" + "categories", + "severities" ] }, - "RepoDocRefreshResult": { - "oneOf": [ - { - "type": "object", - "properties": { - "opened": { - "type": "boolean", - "enum": [ - true - ] - }, - "reused": { - "type": "boolean" - }, - "pullNumber": { - "type": "integer" - }, - "url": { - "type": "string" - } - }, - "required": [ - "opened", - "reused", - "pullNumber", - "url" - ] + "EnrichmentAnalyzersTaxonomyDocument": { + "type": "object", + "properties": { + "defaultProfile": { + "type": "string" }, - { - "type": "object", - "properties": { - "opened": { - "type": "boolean", - "enum": [ - false - ] + "analyzers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "category": { + "type": "string" + }, + "costClass": { + "type": "string" + }, + "profiles": { + "type": "array", + "items": { + "type": "string" + } + } }, - "reason": { - "type": "string" - } - }, - "required": [ - "opened", - "reason" - ] + "required": [ + "name", + "category", + "costClass", + "profiles" + ] + } } + }, + "required": [ + "defaultProfile", + "analyzers" ] }, "ContributorPrOutcomes": { @@ -14819,43 +14856,6 @@ "login", "marked" ] - }, - "ReviewRiskExplanation": { - "type": "object", - "properties": { - "preflight": { - "$ref": "#/components/schemas/PreflightResult" - }, - "roleContext": { - "allOf": [ - { - "$ref": "#/components/schemas/RoleContext" - }, - { - "nullable": true - } - ] - }, - "recommendation": { - "type": "string", - "enum": [ - "likely_duplicate", - "maintainer_lane", - "needs_author", - "review", - "watch" - ] - }, - "summary": { - "type": "string" - } - }, - "required": [ - "preflight", - "roleContext", - "recommendation", - "summary" - ] } }, "parameters": {}, @@ -14876,6 +14876,7 @@ "paths": { "/health": { "get": { + "summary": "Service liveness probe", "responses": { "200": { "description": "Service health", @@ -14887,12 +14888,12 @@ } } } - }, - "summary": "Service liveness probe" + } } }, "/v1/mcp/compatibility": { "get": { + "summary": "Public-safe API and MCP client compatibility metadata", "responses": { "200": { "description": "Public-safe API and MCP compatibility metadata", @@ -14904,12 +14905,12 @@ } } } - }, - "summary": "Public-safe API and MCP client compatibility metadata" + } } }, "/v1/public/stats": { "get": { + "summary": "Public homepage aggregate stats", "responses": { "200": { "description": "Public-safe homepage stats: lifetime PRs handled/merged/closed, gate + slop blocks, and reversal-grounded accuracy. Aggregate counts only.", @@ -14927,12 +14928,12 @@ "503": { "description": "Public stats are temporarily unavailable" } - }, - "summary": "Public homepage aggregate stats" + } } }, "/v1/public/github/repos/{owner}/{repo}/stats": { "get": { + "summary": "Public GitHub stars and forks for an allowlisted repository", "parameters": [ { "schema": { @@ -14968,12 +14969,12 @@ "503": { "description": "GitHub repository stats are unavailable" } - }, - "summary": "Public GitHub stars and forks for an allowlisted repository" + } } }, "/v1/public/repos/{owner}/{repo}/quality": { "get": { + "summary": "Public repository quality summary for an opted-in repository", "parameters": [ { "schema": { @@ -15009,12 +15010,12 @@ "503": { "description": "Public quality metrics are temporarily unavailable" } - }, - "summary": "Public repository quality summary for an opted-in repository" + } } }, "/v1/registry/snapshot": { "get": { + "summary": "Latest Gittensor registry snapshot", "responses": { "200": { "description": "Latest Gittensor registry snapshot", @@ -15034,12 +15035,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Latest Gittensor registry snapshot" + ] } }, "/v1/registry/changes": { "get": { + "summary": "Diff between the two latest registry snapshots", "responses": { "200": { "description": "Diff between latest registry snapshots", @@ -15059,12 +15060,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Diff between the two latest registry snapshots" + ] } }, "/v1/scoring/model": { "get": { + "summary": "Latest scoring model snapshot", "responses": { "200": { "description": "Latest private scoring model snapshot", @@ -15084,12 +15085,62 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Latest scoring model snapshot" + ] + } + }, + "/v1/finding-taxonomy": { + "get": { + "summary": "Canonical AI-review finding taxonomy", + "responses": { + "200": { + "description": "Finding categories and the severity ladder", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FindingTaxonomyDocument" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/enrichment-analyzers": { + "get": { + "summary": "REES enrichment analyzer taxonomy", + "responses": { + "200": { + "description": "Default profile and the registered enrichment analyzers", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EnrichmentAnalyzersTaxonomyDocument" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] } }, "/v1/upstream/status": { "get": { + "summary": "Upstream Gittensor source and ruleset drift status", "responses": { "200": { "description": "Upstream Gittensor source/ruleset drift status", @@ -15109,12 +15160,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Upstream Gittensor source and ruleset drift status" + ] } }, "/v1/upstream/ruleset": { "get": { + "summary": "Latest normalized upstream Gittensor ruleset snapshot", "responses": { "200": { "description": "Latest normalized upstream Gittensor ruleset snapshot", @@ -15137,12 +15188,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Latest normalized upstream Gittensor ruleset snapshot" + ] } }, "/v1/upstream/drift": { "get": { + "summary": "Open and historical upstream drift reports", "responses": { "200": { "description": "Open and historical upstream drift reports", @@ -15181,12 +15232,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Open and historical upstream drift reports" + ] } }, "/v1/scoring/preview": { "post": { + "summary": "Generate a scoring preview artifact for a candidate contribution", "responses": { "200": { "description": "Private scoring preview artifact", @@ -15209,12 +15260,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Generate a scoring preview artifact for a candidate contribution" + ] } }, "/v1/sync/status": { "get": { + "summary": "Repository and installation sync status", "responses": { "200": { "description": "Repository and installation sync status", @@ -15234,12 +15285,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Repository and installation sync status" + ] } }, "/v1/readiness": { "get": { + "summary": "Operational readiness summary for the hosted API", "responses": { "200": { "description": "Operational readiness summary for hosted API, signal fidelity, and public-review preparation", @@ -15259,12 +15310,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Operational readiness summary for the hosted API" + ] } }, "/v1/installations": { "get": { + "summary": "List GitHub App installations and their health", "responses": { "200": { "description": "GitHub App installations and health", @@ -15305,12 +15356,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "List GitHub App installations and their health" + ] } }, "/v1/installations/{id}/health": { "get": { + "summary": "GitHub App installation health detail", "parameters": [ { "schema": { @@ -15343,12 +15394,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "GitHub App installation health detail" + ] } }, "/v1/installations/{id}/repair": { "get": { + "summary": "GitHub App installation repair diagnostics", "parameters": [ { "schema": { @@ -15381,12 +15432,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "GitHub App installation repair diagnostics" + ] } }, "/v1/installations/{id}/repair/refresh": { "post": { + "summary": "Recompute GitHub App installation repair diagnostics", "parameters": [ { "schema": { @@ -15419,12 +15470,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Recompute GitHub App installation repair diagnostics" + ] } }, "/v1/app/notification-model": { "get": { + "summary": "Opt-in notification model and PWA-readiness metadata", "responses": { "200": { "description": "Opt-in notification model and PWA-readiness metadata for control-panel routes", @@ -15559,12 +15610,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Opt-in notification model and PWA-readiness metadata" + ] } }, "/v1/repos": { "get": { + "summary": "List known repositories", "responses": { "200": { "description": "Known repositories", @@ -15587,12 +15638,101 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "List known repositories" + ] } }, "/v1/repos/{owner}/{repo}": { "get": { + "summary": "Repository detail", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Repository detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Repository" + } + } + } + }, + "404": { + "description": "Repository not found" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/intelligence": { + "get": { + "summary": "Canonical repository intelligence bundle", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Canonical repository intelligence bundle", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepoIntelligence" + } + } + } + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/repos/{owner}/{repo}/issue-quality": { + "get": { + "summary": "Repository issue quality report", "parameters": [ { "schema": { @@ -15613,17 +15753,17 @@ ], "responses": { "200": { - "description": "Repository detail", + "description": "Cached or computed issue quality report for the repo", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Repository" + "$ref": "#/components/schemas/IssueQualityResponse" } } } }, "404": { - "description": "Repository not found" + "description": "Repo is unknown or has no issue-quality coverage yet" } }, "security": [ @@ -15633,12 +15773,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Repository detail" + ] } }, - "/v1/repos/{owner}/{repo}/intelligence": { + "/v1/repos/{owner}/{repo}/gate-config/effective": { "get": { + "summary": "Current effective self-tuned gate config for a repo (#6247)", "parameters": [ { "schema": { @@ -15659,14 +15799,20 @@ ], "responses": { "200": { - "description": "Canonical repository intelligence bundle", + "description": "Effective TunableOverride values (confidenceFloor / scopeCap.files / scopeCap.lines) with a shadowPending flag — never the raw override_audit history", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepoIntelligence" + "$ref": "#/components/schemas/GateConfigEffectiveResponse" } } } + }, + "401": { + "description": "Missing or invalid static protected API token" + }, + "403": { + "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" } }, "security": [ @@ -15676,12 +15822,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Canonical repository intelligence bundle" + ] } }, - "/v1/repos/{owner}/{repo}/issue-quality": { + "/v1/repos/{owner}/{repo}/live-gate-thresholds": { "get": { + "summary": "Live self-tuned gate thresholds for AMS probe (#6486)", "parameters": [ { "schema": { @@ -15702,17 +15848,20 @@ ], "responses": { "200": { - "description": "Cached or computed issue quality report for the repo", + "description": "Field-limited live (or soaking-shadow) TunableOverride values — confidence_floor / scope_cap_files / scope_cap_lines only", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/IssueQualityResponse" + "$ref": "#/components/schemas/LiveGateThresholdsResponse" } } } }, + "403": { + "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + }, "404": { - "description": "Repo is unknown or has no issue-quality coverage yet" + "description": "No live or shadow gate override is active for this repo" } }, "security": [ @@ -15722,12 +15871,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Repository issue quality report" + ] } }, "/v1/repos/{owner}/{repo}/outcome-patterns": { "get": { + "summary": "Accepted and rejected pull request outcome patterns for a repository", "parameters": [ { "schema": { @@ -15768,12 +15917,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Accepted and rejected pull request outcome patterns for a repository" + ] } }, "/v1/repos/{owner}/{repo}/registration-readiness": { "get": { + "summary": "Gittensor registration readiness signal for repository owners", "parameters": [ { "schema": { @@ -15811,12 +15960,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Gittensor registration readiness signal for repository owners" + ] } }, "/v1/repos/{owner}/{repo}/gittensor-config-recommendation": { "get": { + "summary": "Recommended Gittensor configuration for a repository", "parameters": [ { "schema": { @@ -15854,12 +16003,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Recommended Gittensor configuration for a repository" + ] } }, "/v1/repos/{owner}/{repo}/focus-manifest": { "get": { + "summary": "Repository focus manifest and compiled policy", "parameters": [ { "schema": { @@ -15903,10 +16052,10 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Repository focus manifest and compiled policy" + ] }, "put": { + "summary": "Persist an API-backed focus manifest for a repository", "parameters": [ { "schema": { @@ -15953,12 +16102,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Persist an API-backed focus manifest for a repository" + ] } }, "/v1/repos/{owner}/{repo}/focus-manifest/refresh": { "post": { + "summary": "Refresh the persisted focus manifest from the repository file", "parameters": [ { "schema": { @@ -16002,12 +16151,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Refresh the persisted focus manifest from the repository file" + ] } }, "/v1/repos/{owner}/{repo}/agent/audit-feed": { "get": { + "summary": "Maintainer-scoped agent audit feed of executed actions and approval decisions", "parameters": [ { "schema": { @@ -16149,12 +16298,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Maintainer-scoped agent audit feed of executed actions and approval decisions" + ] } }, "/v1/repos/{owner}/{repo}/pulls/{number}/incident-reports": { "post": { + "summary": "Record a post-merge incident report for a pull request", "parameters": [ { "schema": { @@ -16274,12 +16423,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Record a post-merge incident report for a pull request" + ] } }, "/v1/app/incident-reports": { "post": { + "summary": "Record a post-merge incident report from the operator side", "requestBody": { "content": { "application/json": { @@ -16385,224 +16534,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Record a post-merge incident report from the operator side" - } - }, - "/v1/app/self-dogfood/registration-pack": { - "get": { - "responses": { - "200": { - "description": "Private self-dogfood registration pack for the LoopOver repo", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "403": { - "description": "Insufficient role for maintainer-only self-dogfood report" - } - }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } - ], - "summary": "Self-dogfood registration pack for the LoopOver repository" - } - }, - "/v1/repos/{owner}/{repo}/self-dogfood-registration-pack": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Private self-dogfood registration pack when repo matches configured LoopOver target", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "403": { - "description": "Insufficient role or repo is not the configured self-dogfood target" - } - }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } - ], - "summary": "Self-dogfood registration pack when the repository matches the configured target" - } - }, - "/v1/repos/{owner}/{repo}/onboarding-pack/preview": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Preview-only repo onboarding pack for accepted repositories", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "403": { - "description": "Insufficient role" - }, - "404": { - "description": "Repository is not accepted or preview unavailable" - } - }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } - ], - "summary": "Preview the onboarding pack for an accepted repository" - } - }, - "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { - "post": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], - "responses": { - "200": { - "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid request or explicit create without dryRun false" - }, - "403": { - "description": "Insufficient role" - } - }, - "security": [ - { - "LoopOverBearer": [] - }, - { - "LoopOverSessionCookie": [] - } - ], - "summary": "Generate maintainer-reviewed contributor issue drafts" + ] } - }, - "/v1/repos/{owner}/{repo}/settings": { - "get": { - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], + }, + "/v1/app/self-dogfood/registration-pack": { + "get": { + "summary": "Self-dogfood registration pack for the LoopOver repository", "responses": { "200": { - "description": "LoopOver repository automation settings", + "description": "Private self-dogfood registration pack for the LoopOver repo", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepositorySettings" + "type": "object", + "additionalProperties": { + "nullable": true + } } } } + }, + "403": { + "description": "Insufficient role for maintainer-only self-dogfood report" } }, "security": [ @@ -16612,12 +16565,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Repository automation settings" + ] } }, - "/v1/repos/{owner}/{repo}/settings-preview": { - "post": { + "/v1/repos/{owner}/{repo}/self-dogfood-registration-pack": { + "get": { + "summary": "Self-dogfood registration pack when the repository matches the configured target", "parameters": [ { "schema": { @@ -16638,17 +16591,20 @@ ], "responses": { "200": { - "description": "Maintainer dry-run preview of the public surface decision for a sample PR (no GitHub mutation)", + "description": "Private self-dogfood registration pack when repo matches configured LoopOver target", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RepoSettingsPreview" + "type": "object", + "additionalProperties": { + "nullable": true + } } } } }, - "400": { - "description": "Invalid settings preview request" + "403": { + "description": "Insufficient role or repo is not the configured self-dogfood target" } }, "security": [ @@ -16658,12 +16614,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Dry-run the public surface decision for a sample pull request" + ] } }, - "/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet": { + "/v1/repos/{owner}/{repo}/onboarding-pack/preview": { "get": { + "summary": "Preview the onboarding pack for an accepted repository", "parameters": [ { "schema": { @@ -16680,26 +16636,27 @@ "required": true, "name": "repo", "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "number", - "in": "path" } ], "responses": { "200": { - "description": "PR-specific maintainer review packet", + "description": "Preview-only repo onboarding pack for accepted repositories", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PullRequestMaintainerPacket" + "type": "object", + "additionalProperties": { + "nullable": true + } } } } + }, + "403": { + "description": "Insufficient role" + }, + "404": { + "description": "Repository is not accepted or preview unavailable" } }, "security": [ @@ -16709,12 +16666,12 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Maintainer review packet for a pull request" + ] } }, - "/v1/repos/{owner}/{repo}/pulls/{number}/reviewability": { - "get": { + "/v1/repos/{owner}/{repo}/contributor-issue-drafts/generate": { + "post": { + "summary": "Generate maintainer-reviewed contributor issue drafts", "parameters": [ { "schema": { @@ -16731,26 +16688,27 @@ "required": true, "name": "repo", "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "number", - "in": "path" } ], "responses": { "200": { - "description": "Private PR reviewability score and maintainer action", + "description": "Generate maintainer-reviewed contributor issue drafts from repo policy (dry-run by default)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PullRequestReviewability" + "type": "object", + "additionalProperties": { + "nullable": true + } } } } + }, + "400": { + "description": "Invalid request or explicit create without dryRun false" + }, + "403": { + "description": "Insufficient role" } }, "security": [ @@ -16760,77 +16718,49 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Pull request reviewability score and maintainer action" + ] } }, - "/v1/contributors/{login}/profile": { - "get": { + "/v1/repos/{owner}/{repo}/issue-plan-drafts/generate": { + "post": { + "summary": "AI-plan repo issue drafts from a maintainer goal", "parameters": [ { "schema": { "type": "string" }, "required": true, - "name": "login", + "name": "owner", "in": "path" - } - ], - "responses": { - "200": { - "description": "Contributor evidence profile", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContributorProfile" - } - } - } - } - }, - "security": [ - { - "LoopOverBearer": [] }, - { - "LoopOverSessionCookie": [] - } - ], - "summary": "Contributor evidence profile" - } - }, - "/v1/contributors/{login}/decision-pack": { - "get": { - "parameters": [ { "schema": { "type": "string" }, "required": true, - "name": "login", + "name": "repo", "in": "path" } ], "responses": { "200": { - "description": "Canonical private contributor decision pack. May carry freshness 'stale' or 'rebuilding' when a background rebuild is in progress.", + "description": "AI-plan a small set of GitHub issue drafts from a maintainer-supplied planning goal (dry-run by default)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContributorDecisionPack" + "type": "object", + "additionalProperties": { + "nullable": true + } } } } }, - "202": { - "description": "Decision pack snapshot is missing; a background rebuild has been requested", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DecisionPackRefreshNeeded" - } - } - } + "400": { + "description": "Invalid request or explicit create without dryRun false" + }, + "403": { + "description": "Insufficient role" } }, "security": [ @@ -16840,29 +16770,37 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Canonical contributor decision pack" + ] } }, - "/v1/contributors/{login}/open-pr-monitor": { + "/v1/repos/{owner}/{repo}/settings": { "get": { + "summary": "Repository automation settings", "parameters": [ { "schema": { "type": "string" }, "required": true, - "name": "login", + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", "in": "path" } ], "responses": { "200": { - "description": "Contributor open-PR monitor with classifications and public-safe next-step packets from cached metadata.", + "description": "LoopOver repository automation settings", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ContributorOpenPrMonitor" + "$ref": "#/components/schemas/RepositorySettings" } } } @@ -16875,21 +16813,13 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Contributor open-PR monitor with classifications and next-step packets" + ] } }, - "/v1/contributors/{login}/repos/{owner}/{repo}/decision": { + "/v1/repos/{owner}/{repo}/automation-state": { "get": { + "summary": "Derived agent automation state for a repository", "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "login", - "in": "path" - }, { "schema": { "type": "string" @@ -16909,21 +16839,11 @@ ], "responses": { "200": { - "description": "Repo-specific contributor decision from decision pack. May carry freshness 'stale' or 'rebuilding'.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepoDecisionResponse" - } - } - } - }, - "202": { - "description": "Decision pack snapshot is missing; a background rebuild has been requested", + "description": "Maintainer-only derived automation view (mode, permission readiness, acting action classes, pending-approval count) that the raw /settings row does not include", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DecisionPackRefreshNeeded" + "$ref": "#/components/schemas/AutomationState" } } } @@ -16936,53 +16856,40 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Repository-specific contributor decision" + ] } }, - "/v1/preflight/pr": { + "/v1/repos/{owner}/{repo}/repo-docs/refresh": { "post": { - "responses": { - "200": { - "description": "Submission preflight result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/PreflightResult" - } - } - } - }, - "400": { - "description": "Invalid preflight input" - } - }, - "security": [ + "summary": "Open (or find the already-open) AGENTS.md/CLAUDE.md generation pull request", + "parameters": [ { - "LoopOverBearer": [] + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" }, { - "LoopOverSessionCookie": [] + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" } ], - "summary": "Run submission preflight for a pull request" - } - }, - "/v1/preflight/local-diff": { - "post": { "responses": { "200": { - "description": "Local diff preflight result", + "description": "The repo-doc pull request result -- opened (new or reused) or a reason it was not opened", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LocalDiffPreflightResult" + "$ref": "#/components/schemas/RepoDocRefreshResult" } } } - }, - "400": { - "description": "Invalid local diff preflight input" } }, "security": [ @@ -16992,59 +16899,43 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Run preflight against a local diff" + ] } }, - "/v1/local/branch-analysis": { - "post": { - "responses": { - "200": { - "description": "Private local branch analysis for MCP clients", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LocalBranchAnalysis" - } - } - } - }, - "400": { - "description": "Invalid local branch analysis input" - }, - "401": { - "description": "Unauthorized" - } - }, - "security": [ + "/v1/repos/{owner}/{repo}/settings-preview": { + "post": { + "summary": "Dry-run the public surface decision for a sample pull request", + "parameters": [ { - "LoopOverBearer": [] + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" }, { - "LoopOverSessionCookie": [] + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" } ], - "summary": "Analyze a local branch for MCP clients" - } - }, - "/v1/agent/runs": { - "post": { "responses": { - "202": { - "description": "Copilot-only agent run queued", + "200": { + "description": "Maintainer dry-run preview of the public surface decision for a sample PR (no GitHub mutation)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentRunBundle" + "$ref": "#/components/schemas/RepoSettingsPreview" } } } }, "400": { - "description": "Invalid agent run request" - }, - "401": { - "description": "Unauthorized" + "description": "Invalid settings preview request" } }, "security": [ @@ -17054,60 +16945,48 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue an agent run" - }, + ] + } + }, + "/v1/repos/{owner}/{repo}/pulls/{number}/maintainer-packet": { "get": { + "summary": "Maintainer review packet for a pull request", "parameters": [ { "schema": { - "type": "string", - "minLength": 1, - "example": "jsonbored" + "type": "string" }, "required": true, - "description": "GitHub login that owns the agent runs.", - "name": "actorLogin", - "in": "query" + "name": "owner", + "in": "path" }, { "schema": { - "type": "string", - "example": "50" + "type": "string" }, - "required": false, - "description": "Maximum run bundles to return, clamped from 1 to 100.", - "name": "limit", - "in": "query" + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", + "in": "path" } ], "responses": { "200": { - "description": "Recent agent run bundles for an authenticated actor", + "description": "PR-specific maintainer review packet", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "runs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentRunBundle" - } - } - }, - "required": [ - "runs" - ] + "$ref": "#/components/schemas/PullRequestMaintainerPacket" } } } - }, - "400": { - "description": "Missing actor login" - }, - "401": { - "description": "Unauthorized" } }, "security": [ @@ -17117,35 +16996,48 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "List persisted agent runs for an actor" + ] } }, - "/v1/agent/runs/{id}": { + "/v1/repos/{owner}/{repo}/pulls/{number}/reviewability": { "get": { + "summary": "Pull request reviewability score and maintainer action", "parameters": [ { "schema": { "type": "string" }, "required": true, - "name": "id", + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "number", "in": "path" } ], "responses": { "200": { - "description": "Persisted agent run bundle", + "description": "Private PR reviewability score and maintainer action", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentRunBundle" + "$ref": "#/components/schemas/PullRequestReviewability" } } } - }, - "404": { - "description": "Agent run not found" } }, "security": [ @@ -17155,38 +17047,32 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Persisted agent run bundle" + ] } }, - "/v1/agent/plan-next-work": { - "post": { + "/v1/contributors/{login}/profile": { + "get": { + "summary": "Contributor evidence profile", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], "responses": { "200": { - "description": "Agent run completed with deterministic ranked actions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentRunBundle" - } - } - } - }, - "202": { - "description": "Agent run needs snapshot refresh", + "description": "Contributor evidence profile", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentRunBundle" + "$ref": "#/components/schemas/ContributorProfile" } } } - }, - "400": { - "description": "Invalid agent request" - }, - "401": { - "description": "Unauthorized" } }, "security": [ @@ -17196,38 +17082,42 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Rank the next work items for an agent run" + ] } }, - "/v1/agent/preflight-branch": { - "post": { + "/v1/contributors/{login}/decision-pack": { + "get": { + "summary": "Canonical contributor decision pack", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], "responses": { "200": { - "description": "Agent run completed with deterministic ranked actions", + "description": "Canonical private contributor decision pack. May carry freshness 'stale' or 'rebuilding' when a background rebuild is in progress.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentRunBundle" + "$ref": "#/components/schemas/ContributorDecisionPack" } } } }, "202": { - "description": "Agent run needs snapshot refresh", + "description": "Decision pack snapshot is missing; a background rebuild has been requested", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentRunBundle" + "$ref": "#/components/schemas/DecisionPackRefreshNeeded" } } } - }, - "400": { - "description": "Invalid agent request" - }, - "401": { - "description": "Unauthorized" } }, "security": [ @@ -17237,38 +17127,32 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Preflight an agent branch before submission" + ] } }, - "/v1/agent/prepare-pr-packet": { - "post": { + "/v1/contributors/{login}/open-pr-monitor": { + "get": { + "summary": "Contributor open-PR monitor with classifications and next-step packets", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], "responses": { "200": { - "description": "Agent run completed with deterministic ranked actions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentRunBundle" - } - } - } - }, - "202": { - "description": "Agent run needs snapshot refresh", + "description": "Contributor open-PR monitor with classifications and public-safe next-step packets from cached metadata.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentRunBundle" + "$ref": "#/components/schemas/ContributorOpenPrMonitor" } } } - }, - "400": { - "description": "Invalid agent request" - }, - "401": { - "description": "Unauthorized" } }, "security": [ @@ -17278,38 +17162,43 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Prepare a pull request packet for an agent run" + ] } }, - "/v1/agent/explain-blockers": { - "post": { - "responses": { - "200": { - "description": "Agent run completed with deterministic ranked actions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/AgentRunBundle" - } - } - } + "/v1/contributors/{login}/pr-outcomes": { + "get": { + "summary": "Contributor post-merge PR outcome history", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" }, - "202": { - "description": "Agent run needs snapshot refresh", + { + "schema": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "maximum": 100 + }, + "required": false, + "name": "limit", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Self-scoped post-merge outcome records with public-safe attribution (mirrors loopover_pr_outcome).", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AgentRunBundle" + "$ref": "#/components/schemas/ContributorPrOutcomes" } } } - }, - "400": { - "description": "Invalid agent request" - }, - "401": { - "description": "Unauthorized" } }, "security": [ @@ -17319,22 +17208,29 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Explain an agent run's current blockers" + ] } }, - "/v1/bounties": { + "/v1/contributors/{login}/notifications": { "get": { + "summary": "Contributor badge notification feed", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "login", + "in": "path" + } + ], "responses": { "200": { - "description": "Known bounty records", + "description": "The contributor's own badge notification feed (self-scoped), newest first, with an unread count.", "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Bounty" - } + "$ref": "#/components/schemas/NotificationFeed" } } } @@ -17347,35 +17243,52 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "List known bounty records" + ] } }, - "/v1/bounties/{id}/advisory": { - "get": { + "/v1/contributors/{login}/notifications/read": { + "post": { + "summary": "Mark contributor notifications read", "parameters": [ { "schema": { "type": "string" }, "required": true, - "name": "id", + "name": "login", "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, "responses": { "200": { - "description": "Bounty lifecycle advisory", + "description": "Marks the contributor's delivered badge notifications read; an absent/empty ids array marks all.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BountyAdvisory" + "$ref": "#/components/schemas/NotificationsMarked" } } } }, - "404": { - "description": "Bounty not found" + "400": { + "description": "Invalid mark-read body" } }, "security": [ @@ -17385,35 +17298,58 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Bounty lifecycle advisory" + ] } }, - "/v1/bounties/{id}/lifecycle": { + "/v1/contributors/{login}/repos/{owner}/{repo}/decision": { "get": { + "summary": "Repository-specific contributor decision", "parameters": [ { "schema": { "type": "string" }, "required": true, - "name": "id", + "name": "login", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", "in": "path" } ], "responses": { "200": { - "description": "Bounty lifecycle transition history", + "description": "Repo-specific contributor decision from decision pack. May carry freshness 'stale' or 'rebuilding'.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BountyLifecycleEvents" + "$ref": "#/components/schemas/RepoDecisionResponse" } } } }, - "404": { - "description": "Bounty not found" + "202": { + "description": "Decision pack snapshot is missing; a background rebuild has been requested", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DecisionPackRefreshNeeded" + } + } + } } }, "security": [ @@ -17423,31 +17359,25 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Bounty lifecycle transition history" - } - }, - "/v1/github/webhook": { - "post": { - "responses": { - "202": { - "description": "Webhook queued" - }, - "401": { - "description": "Invalid webhook signature" - } - }, - "summary": "Receive a GitHub webhook delivery" + ] } }, - "/v1/orb/ingest": { + "/v1/preflight/pr": { "post": { + "summary": "Run submission preflight for a pull request", "responses": { "200": { - "description": "Batch accepted; returns { accepted: number }" + "description": "Submission preflight result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreflightResult" + } + } + } }, "400": { - "description": "Malformed JSON or invalid payload shape" + "description": "Invalid preflight input" } }, "security": [ @@ -17457,152 +17387,56 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Ingest a batch of Orb events" - } - }, - "/v1/auth/github/start": { - "get": { - "responses": { - "302": { - "description": "Redirects to GitHub web OAuth" - }, - "503": { - "description": "GitHub OAuth app secret is not configured" - } - }, - "summary": "Start GitHub web OAuth" - } - }, - "/v1/auth/github/callback": { - "get": { - "responses": { - "302": { - "description": "Completes GitHub web OAuth and redirects to the app" - } - }, - "summary": "Complete GitHub web OAuth and redirect to the app" - } - }, - "/v1/auth/github/device/start": { - "post": { - "responses": { - "200": { - "description": "Auth request completed" - }, - "201": { - "description": "Auth session created" - }, - "400": { - "description": "Invalid auth request" - }, - "401": { - "description": "Unauthorized" - }, - "429": { - "description": "Rate limited" - } - }, - "summary": "Start GitHub device-flow authentication" + ] } }, - "/v1/auth/github/device/poll": { + "/v1/preflight/review-risk": { "post": { + "summary": "Explain review risk for a planned pull request", "responses": { "200": { - "description": "Auth request completed" - }, - "201": { - "description": "Auth session created" + "description": "Review-risk explanation with preflight, role context, and recommendation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewRiskExplanation" + } + } + } }, "400": { - "description": "Invalid auth request" - }, - "401": { - "description": "Unauthorized" + "description": "Invalid preflight input" }, - "429": { - "description": "Rate limited" + "403": { + "description": "Forbidden when contributorLogin does not match the authenticated session" } }, - "summary": "Poll a pending GitHub device-flow authorization" - } - }, - "/v1/auth/github/session": { - "post": { - "responses": { - "200": { - "description": "Auth request completed" - }, - "201": { - "description": "Auth session created" - }, - "400": { - "description": "Invalid auth request" - }, - "401": { - "description": "Unauthorized" + "security": [ + { + "LoopOverBearer": [] }, - "429": { - "description": "Rate limited" + { + "LoopOverSessionCookie": [] } - }, - "summary": "Exchange a GitHub token for a LoopOver session" + ] } }, - "/v1/auth/logout": { + "/v1/preflight/local-diff": { "post": { + "summary": "Run preflight against a local diff", "responses": { "200": { - "description": "Auth request completed" - }, - "201": { - "description": "Auth session created" - }, - "400": { - "description": "Invalid auth request" - }, - "401": { - "description": "Unauthorized" - }, - "429": { - "description": "Rate limited" - } - }, - "summary": "End the current session" - } - }, - "/v1/auth/session": { - "get": { - "responses": { - "200": { - "description": "Current auth session, or signed_out when no app session is present" - } - }, - "summary": "Current authentication session" - } - }, - "/v1/app/overview": { - "get": { - "responses": { - "200": { - "description": "Live app overview assembled from backend data", + "description": "Local diff preflight result", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/LocalDiffPreflightResult" } } } }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient role" + "400": { + "description": "Invalid local diff preflight input" } }, "security": [ @@ -17612,26 +17446,26 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Live app overview assembled from backend data" + ] } }, - "/v1/app/roles": { - "get": { + "/v1/local/branch-analysis": { + "post": { + "summary": "Analyze a local branch for MCP clients", "responses": { "200": { - "description": "Live app API response", + "description": "Private local branch analysis for MCP clients", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/LocalBranchAnalysis" } } } }, + "400": { + "description": "Invalid local branch analysis input" + }, "401": { "description": "Unauthorized" } @@ -17643,26 +17477,26 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "App roles granted to the current session" + ] } }, - "/v1/app/miner-dashboard": { - "get": { + "/v1/agent/runs": { + "post": { + "summary": "Queue an agent run", "responses": { - "200": { - "description": "Live app API response", + "202": { + "description": "Copilot-only agent run queued", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/AgentRunBundle" } } } }, + "400": { + "description": "Invalid agent run request" + }, "401": { "description": "Unauthorized" } @@ -17674,26 +17508,58 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Miner dashboard data" - } - }, - "/v1/app/maintainer-dashboard": { + ] + }, "get": { + "summary": "List persisted agent runs for an actor", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "example": "jsonbored" + }, + "required": true, + "description": "GitHub login that owns the agent runs.", + "name": "actorLogin", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "50" + }, + "required": false, + "description": "Maximum run bundles to return, clamped from 1 to 100.", + "name": "limit", + "in": "query" + } + ], "responses": { "200": { - "description": "Live app API response", + "description": "Recent agent run bundles for an authenticated actor", "content": { "application/json": { "schema": { "type": "object", - "additionalProperties": { - "nullable": true - } + "properties": { + "runs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentRunBundle" + } + } + }, + "required": [ + "runs" + ] } } } }, + "400": { + "description": "Missing actor login" + }, "401": { "description": "Unauthorized" } @@ -17705,28 +17571,35 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Maintainer dashboard data" + ] } }, - "/v1/app/operator-dashboard": { + "/v1/agent/runs/{id}": { "get": { + "summary": "Persisted agent run bundle", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "id", + "in": "path" + } + ], "responses": { "200": { - "description": "Live app API response", + "description": "Persisted agent run bundle", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/AgentRunBundle" } } } }, - "401": { - "description": "Unauthorized" + "404": { + "description": "Agent run not found" } }, "security": [ @@ -17736,26 +17609,36 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Operator dashboard data" + ] } }, - "/v1/app/commands": { - "get": { + "/v1/agent/plan-next-work": { + "post": { + "summary": "Rank the next work items for an agent run", "responses": { "200": { - "description": "Live app API response", + "description": "Agent run completed with deterministic ranked actions", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/AgentRunBundle" + } + } + } + }, + "202": { + "description": "Agent run needs snapshot refresh", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunBundle" } } } }, + "400": { + "description": "Invalid agent request" + }, "401": { "description": "Unauthorized" } @@ -17767,26 +17650,36 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "@loopover command catalog" + ] } }, - "/v1/app/commands/usefulness": { - "get": { + "/v1/agent/preflight-branch": { + "post": { + "summary": "Preflight an agent branch before submission", "responses": { "200": { - "description": "Live app API response", + "description": "Agent run completed with deterministic ranked actions", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/AgentRunBundle" + } + } + } + }, + "202": { + "description": "Agent run needs snapshot refresh", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunBundle" } } } }, + "400": { + "description": "Invalid agent request" + }, "401": { "description": "Unauthorized" } @@ -17798,26 +17691,36 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "@loopover command usefulness rollup" + ] } }, - "/v1/app/digest": { - "get": { + "/v1/agent/prepare-pr-packet": { + "post": { + "summary": "Prepare a pull request packet for an agent run", "responses": { "200": { - "description": "Live app API response", + "description": "Agent run completed with deterministic ranked actions", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/AgentRunBundle" + } + } + } + }, + "202": { + "description": "Agent run needs snapshot refresh", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunBundle" } } } }, + "400": { + "description": "Invalid agent request" + }, "401": { "description": "Unauthorized" } @@ -17829,26 +17732,36 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Maintainer digest content" + ] } }, - "/v1/app/analytics/daily-rollups": { - "get": { + "/v1/agent/explain-blockers": { + "post": { + "summary": "Explain an agent run's current blockers", "responses": { "200": { - "description": "Live app API response", + "description": "Agent run completed with deterministic ranked actions", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/AgentRunBundle" + } + } + } + }, + "202": { + "description": "Agent run needs snapshot refresh", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentRunBundle" } } } }, + "400": { + "description": "Invalid agent request" + }, "401": { "description": "Unauthorized" } @@ -17860,28 +17773,25 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Daily analytics rollups" + ] } }, - "/v1/app/analytics/mcp-compatibility": { + "/v1/bounties": { "get": { + "summary": "List known bounty records", "responses": { "200": { - "description": "Live app API response", + "description": "Known bounty records", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true + "type": "array", + "items": { + "$ref": "#/components/schemas/Bounty" } } } } - }, - "401": { - "description": "Unauthorized" } }, "security": [ @@ -17891,52 +17801,35 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "MCP client compatibility analytics" + ] } }, - "/v1/app/selfhost/queue/dead/{id}/replay": { - "post": { + "/v1/bounties/{id}/advisory": { + "get": { + "summary": "Bounty lifecycle advisory", "parameters": [ { "schema": { - "type": "string", - "example": "812" + "type": "string" }, "required": true, - "description": "Dead-letter job id.", "name": "id", "in": "path" } ], "responses": { "200": { - "description": "Job replayed", + "description": "Bounty lifecycle advisory", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/BountyAdvisory" } } } }, - "400": { - "description": "Invalid job id" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role (operator only)" - }, "404": { - "description": "Dead-letter job not found" - }, - "501": { - "description": "This deployment's queue backend does not expose dead-letter admin" + "description": "Bounty not found" } }, "security": [ @@ -17946,52 +17839,35 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Replay a dead-letter queue job" + ] } }, - "/v1/app/selfhost/queue/dead/{id}": { - "delete": { + "/v1/bounties/{id}/lifecycle": { + "get": { + "summary": "Bounty lifecycle transition history", "parameters": [ { "schema": { - "type": "string", - "example": "812" + "type": "string" }, "required": true, - "description": "Dead-letter job id.", "name": "id", "in": "path" } ], "responses": { "200": { - "description": "Job deleted", + "description": "Bounty lifecycle transition history", "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } + "$ref": "#/components/schemas/BountyLifecycleEvents" } } } }, - "400": { - "description": "Invalid job id" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role (operator only)" - }, "404": { - "description": "Dead-letter job not found" - }, - "501": { - "description": "This deployment's queue backend does not expose dead-letter admin" + "description": "Bounty not found" } }, "security": [ @@ -18001,34 +17877,31 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Delete a dead-letter queue job" + ] } }, - "/v1/app/selfhost/queue/dead": { - "delete": { + "/v1/github/webhook": { + "post": { + "summary": "Receive a GitHub webhook delivery", "responses": { - "200": { - "description": "Dead-letter jobs purged", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } + "202": { + "description": "Webhook queued" }, "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role (operator only)" + "description": "Invalid webhook signature" + } + } + } + }, + "/v1/public/decision-ledger/verify": { + "get": { + "summary": "Verify a window of the hash-chained decision ledger (resumable via afterSeq)", + "responses": { + "200": { + "description": "Window verified clean; nextAfterSeq is the resume cursor (null at the tip). Every response also carries tipSeq/tipHash/totalCount for third-party checkpointing." }, - "501": { - "description": "This deployment's queue backend does not expose dead-letter admin" + "409": { + "description": "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch | short_tail (a record exists past the verified tip with no chain entry)" } }, "security": [ @@ -18038,57 +17911,49 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Purge all dead-letter queue jobs" - }, + ] + } + }, + "/v1/public/decision-ledger/row/{seq}": { "get": { + "summary": "Fetch one decision-ledger row by seq, so an external anchor can be bound back to the live chain", "parameters": [ { "schema": { - "type": "string", - "example": "25" - }, - "required": false, - "description": "Maximum rows to return, clamped from 1 to 100.", - "name": "limit", - "in": "query" - }, - { - "schema": { - "type": "string", - "example": "0" + "type": "string" }, - "required": false, - "description": "Pagination offset, floored to 0.", - "name": "offset", - "in": "query" + "required": true, + "name": "seq", + "in": "path" } ], "responses": { "200": { - "description": "Paginated dead-letter jobs for the self-host queue backend", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } + "description": "The chain row: seq, recordId, recordDigest, prevHash, rowHash, createdAt. Recompute sha256(prevHash || canonicalJson({seq, recordId, recordDigest, createdAt})) and compare against an anchored rowHash." }, "400": { - "description": "Invalid query" - }, - "401": { - "description": "Unauthorized" + "description": "seq is not a positive integer" }, - "403": { - "description": "Insufficient app role (operator only)" + "404": { + "description": "No ledger row at that seq (never appended -- distinct from a row with empty fields)" + } + }, + "security": [ + { + "LoopOverBearer": [] }, - "501": { - "description": "This deployment's queue backend does not expose dead-letter admin (e.g. Cloudflare)" + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/public/decision-ledger/anchor-key": { + "get": { + "summary": "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor", + "responses": { + "200": { + "description": "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" } }, "security": [ @@ -18098,77 +17963,46 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "List dead-letter queue jobs" + ] } }, - "/v1/app/analytics/weekly-value-report": { + "/v1/public/decision-ledger/anchors": { "get": { + "summary": "Every external anchoring attempt, success and failure, paginated newest-first — anchoring's own health as a public fact", "parameters": [ { "schema": { "type": "string", "enum": [ - "public", - "operator" - ], - "example": "public" + "rekor", + "git", + "ots" + ] }, "required": false, - "description": "Report variant. Operator reports require the operator app role.", - "name": "variant", + "name": "backend", "in": "query" }, { "schema": { - "type": "string", - "example": "7" + "type": "string" }, "required": false, - "description": "Report window in days, clamped from 1 to 31.", - "name": "days", + "name": "before", "in": "query" }, { "schema": { - "type": "string", - "enum": [ - "json", - "markdown" - ], - "example": "markdown" + "type": "string" }, "required": false, - "description": "Response format. Omit or use json for the structured report; use markdown for copy-ready text.", - "name": "format", + "name": "limit", "in": "query" } ], "responses": { "200": { - "description": "Weekly value report as structured JSON or copy-ready Markdown", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - }, - "text/markdown": { - "schema": { - "type": "string", - "example": "# Weekly LoopOver value report\n\n## Adoption metrics\n- Active users: 4\n" - } - } - } - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role for requested report variant" + "description": "{ anchors: [{ id, seq, rowHash, keyId, backend, backendRef, status, error, createdAt }], nextBefore } — a failed attempt is returned identically to a successful one, never filtered out or reshaped" } }, "security": [ @@ -18178,92 +18012,86 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Weekly value report" + ] } }, - "/v1/app/skipped-pr-audit": { + "/v1/public/decision-records/{owner}/{repo}/{pull}": { "get": { + "summary": "Fetch the latest published decision record for a PR, verbatim, plus its content digest", "parameters": [ { "schema": { - "type": "string", - "example": "50" + "type": "string" }, - "required": false, - "description": "Maximum rows to return, clamped from 1 to 100.", - "name": "limit", - "in": "query" + "required": true, + "name": "owner", + "in": "path" }, { "schema": { - "type": "string", - "example": "0" + "type": "string" }, - "required": false, - "description": "Number of parsed skip events to skip before returning rows (non-negative).", - "name": "offset", - "in": "query" + "required": true, + "name": "repo", + "in": "path" }, { "schema": { - "type": "string", - "example": "JSONbored/loopover" + "type": "string" }, - "required": false, - "description": "Optional repository filter. Browser sessions must have control-panel access to this repo.", - "name": "repoFullName", - "in": "query" + "required": true, + "name": "pull", + "in": "path" + } + ], + "responses": { + "200": { + "description": "The latest DecisionRecord for this PR + its recordDigest" + }, + "400": { + "description": "Invalid pull number" + }, + "404": { + "description": "No decision record persisted yet for this PR" + } + }, + "security": [ + { + "LoopOverBearer": [] }, + { + "LoopOverSessionCookie": [] + } + ] + } + }, + "/v1/public/eval-scores": { + "get": { + "summary": "Fetch EvalScoreRecords (#9215) -- the objective-eval-provider transport, digest-committed and independently re-derivable", + "parameters": [ { "schema": { - "type": "string", - "example": "not_official_gittensor_miner", - "enum": [ - "surface_off", - "missing_author", - "bot_author", - "ignored_author", - "maintainer_author", - "miner_detection_unavailable", - "not_official_gittensor_miner" - ] + "type": "string" }, "required": false, - "description": "Optional PR skip reason filter.", - "name": "reason", + "name": "subject", "in": "query" }, { "schema": { - "type": "string", - "example": "2026-05-30T00:00:00.000Z" + "type": "string" }, "required": false, - "description": "Optional lower timestamp bound.", "name": "since", "in": "query" } ], "responses": { "200": { - "description": "Private bounded audit export for skipped PR public-surface decisions", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SkippedPrAuditExport" - } - } - } - }, - "400": { - "description": "Invalid query" - }, - "401": { - "description": "Unauthorized" + "description": "{ records: EvalScoreRecord[] }, optionally filtered by subject id and/or minimum issuedAt -- degrades to an empty array on an internal read error rather than a non-200 status, matching loadPublicRulePrecision's own fail-safe contract" }, - "403": { - "description": "Insufficient app role or repository scope" + "404": { + "description": "Public stats disabled (same flag as /v1/public/stats)" } }, "security": [ @@ -18273,34 +18101,18 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Audit of pull requests the review agent skipped" + ] } }, - "/v1/app/commands/preview": { + "/v1/orb/ingest": { "post": { + "summary": "Ingest a batch of Orb events", "responses": { "200": { - "description": "Maintainer dry-run preview of a sanitized @loopover command response (no GitHub mutation)", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CommandPreviewResponse" - } - } - } + "description": "Batch accepted; returns { accepted: number }" }, "400": { - "description": "Invalid request" - }, - "401": { - "description": "Unauthorized" - }, - "403": { - "description": "Insufficient app role" - }, - "404": { - "description": "Command not found" + "description": "Malformed JSON or invalid payload shape" } }, "security": [ @@ -18310,112 +18122,160 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Dry-run a sanitized @loopover command response" + ] } }, - "/v1/app/commands/feedback": { + "/v1/auth/github/start": { + "get": { + "summary": "Start GitHub web OAuth", + "responses": { + "302": { + "description": "Redirects to GitHub web OAuth" + }, + "503": { + "description": "GitHub OAuth app secret is not configured" + } + } + } + }, + "/v1/auth/github/callback": { + "get": { + "summary": "Complete GitHub web OAuth and redirect to the app", + "responses": { + "302": { + "description": "Completes GitHub web OAuth and redirects to the app" + } + } + } + }, + "/v1/auth/github/device/start": { "post": { + "summary": "Start GitHub device-flow authentication", "responses": { "200": { - "description": "Live app mutation or preview response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } + "description": "Auth request completed" }, "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } + "description": "Auth session created" }, "400": { - "description": "Invalid request" + "description": "Invalid auth request" }, "401": { "description": "Unauthorized" - } - }, - "security": [ - { - "LoopOverBearer": [] }, - { - "LoopOverSessionCookie": [] + "429": { + "description": "Rate limited" } - ], - "summary": "Submit feedback on an @loopover command response" + } } }, - "/v1/app/digest/subscriptions": { + "/v1/auth/github/device/poll": { "post": { + "summary": "Poll a pending GitHub device-flow authorization", "responses": { "200": { - "description": "Live app mutation or preview response", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } + "description": "Auth request completed" }, "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } + "description": "Auth session created" }, "400": { - "description": "Invalid request" + "description": "Invalid auth request" }, "401": { "description": "Unauthorized" + }, + "429": { + "description": "Rate limited" } - }, - "security": [ - { - "LoopOverBearer": [] + } + } + }, + "/v1/auth/github/session": { + "post": { + "summary": "Exchange a GitHub token for a LoopOver session", + "responses": { + "200": { + "description": "Auth request completed" }, - { - "LoopOverSessionCookie": [] + "201": { + "description": "Auth session created" + }, + "400": { + "description": "Invalid auth request" + }, + "401": { + "description": "Unauthorized" + }, + "429": { + "description": "Rate limited" } - ], - "summary": "Manage maintainer digest subscriptions" + } } }, - "/v1/internal/jobs/refresh-registry": { + "/v1/auth/logout": { "post": { + "summary": "End the current session", "responses": { - "202": { - "description": "Registry refresh queued" + "200": { + "description": "Auth request completed" + }, + "201": { + "description": "Auth session created" + }, + "400": { + "description": "Invalid auth request" }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" + }, + "429": { + "description": "Rate limited" + } + } + } + }, + "/v1/auth/session": { + "get": { + "summary": "Current authentication session", + "responses": { + "200": { + "description": "Current auth session, or signed_out when no app session is present" + } + } + } + }, + "/v1/auth/github/token": { + "post": { + "summary": "Fetch the current session's live GitHub token (for AMS git operations)", + "responses": { + "200": { + "description": "The session's GitHub token", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + }, + "required": [ + "token" + ] + } + } + } + }, + "403": { + "description": "A browser session is required" + }, + "404": { + "description": "No GitHub token is available for this session" + }, + "429": { + "description": "Rate limited" } }, "security": [ @@ -18425,18 +18285,31 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a registry refresh job" + ] } }, - "/v1/internal/jobs/backfill-registered-repos": { - "post": { + "/v1/app/overview": { + "get": { + "summary": "Live app overview assembled from backend data", "responses": { - "202": { - "description": "Registered repo backfill queued" + "200": { + "description": "Live app overview assembled from backend data", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient role" } }, "security": [ @@ -18446,21 +18319,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a registered-repository backfill job" + ] } }, - "/v1/internal/jobs/backfill-repo-segment": { - "post": { + "/v1/app/roles": { + "get": { + "summary": "App roles granted to the current session", "responses": { - "202": { - "description": "Repository segment backfill queued" - }, - "400": { - "description": "Invalid segment request" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18470,21 +18350,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a repository segment backfill job" + ] } }, - "/v1/internal/jobs/backfill-pr-details": { - "post": { + "/v1/app/miner-dashboard": { + "get": { + "summary": "Miner dashboard data", "responses": { - "202": { - "description": "Open PR detail backfill queued" - }, - "400": { - "description": "Invalid PR detail backfill request" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18494,21 +18381,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue an open pull request detail backfill job" + ] } }, - "/v1/internal/jobs/generate-review-recap": { - "post": { + "/v1/app/maintainer-dashboard": { + "get": { + "summary": "Maintainer dashboard data", "responses": { - "202": { - "description": "Maintainer review recap digest queued (#1963)" - }, - "400": { - "description": "Missing repoFullName" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18518,18 +18412,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a maintainer review recap digest job" + ] } }, - "/v1/internal/jobs/refresh-scoring-model": { - "post": { + "/v1/app/operator-dashboard": { + "get": { + "summary": "Operator dashboard data", "responses": { - "202": { - "description": "Internal job queued" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18539,18 +18443,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a scoring model refresh job" + ] } }, - "/v1/internal/jobs/refresh-upstream-drift": { - "post": { + "/v1/app/commands": { + "get": { + "summary": "@loopover command catalog", "responses": { - "202": { - "description": "Internal job queued" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18560,18 +18474,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue an upstream drift refresh job" + ] } }, - "/v1/internal/jobs/file-upstream-drift-issues": { - "post": { + "/v1/app/commands/usefulness": { + "get": { + "summary": "@loopover command usefulness rollup", "responses": { - "202": { - "description": "Internal job queued" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18581,18 +18505,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a job that files upstream drift issues" + ] } }, - "/v1/internal/jobs/build-contributor-evidence": { - "post": { + "/v1/app/digest": { + "get": { + "summary": "Maintainer digest content", "responses": { - "202": { - "description": "Internal job queued" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18602,18 +18536,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a contributor evidence build job" + ] } }, - "/v1/internal/jobs/build-contributor-decision-packs": { - "post": { + "/v1/app/analytics/daily-rollups": { + "get": { + "summary": "Daily analytics rollups", "responses": { - "202": { - "description": "Internal job queued" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18623,18 +18567,28 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a contributor decision pack build job" + ] } }, - "/v1/internal/jobs/build-burden-forecasts": { - "post": { + "/v1/app/analytics/mcp-compatibility": { + "get": { + "summary": "MCP client compatibility analytics", "responses": { - "202": { - "description": "Internal job queued" + "200": { + "description": "Live app API response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" } }, "security": [ @@ -18644,18 +18598,52 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a burden forecast build job" + ] } }, - "/v1/internal/jobs/generate-signal-snapshots": { + "/v1/app/selfhost/queue/dead/{id}/replay": { "post": { + "summary": "Replay a dead-letter queue job", + "parameters": [ + { + "schema": { + "type": "string", + "example": "812" + }, + "required": true, + "description": "Dead-letter job id.", + "name": "id", + "in": "path" + } + ], "responses": { - "202": { - "description": "Internal job queued" + "200": { + "description": "Job replayed", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid job id" }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "404": { + "description": "Dead-letter job not found" + }, + "501": { + "description": "This deployment's queue backend does not expose dead-letter admin" } }, "security": [ @@ -18665,39 +18653,52 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a signal snapshot generation job" + ] } }, - "/v1/internal/jobs/generate-weekly-value-report": { - "post": { - "responses": { - "202": { - "description": "Internal job queued" - }, - "401": { - "description": "Invalid internal token" - } - }, - "security": [ - { - "LoopOverBearer": [] - }, + "/v1/app/selfhost/queue/dead/{id}": { + "delete": { + "summary": "Delete a dead-letter queue job", + "parameters": [ { - "LoopOverSessionCookie": [] + "schema": { + "type": "string", + "example": "812" + }, + "required": true, + "description": "Dead-letter job id.", + "name": "id", + "in": "path" } ], - "summary": "Queue a weekly value report job" - } - }, - "/v1/internal/jobs/repair-data-fidelity": { - "post": { "responses": { - "202": { - "description": "Internal job queued" + "200": { + "description": "Job deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "400": { + "description": "Invalid job id" }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "404": { + "description": "Dead-letter job not found" + }, + "501": { + "description": "This deployment's queue backend does not expose dead-letter admin" } }, "security": [ @@ -18707,18 +18708,34 @@ { "LoopOverSessionCookie": [] } - ], - "summary": "Queue a data fidelity repair job" + ] } }, - "/v1/internal/bounties/import": { - "post": { + "/v1/app/selfhost/queue/dead": { + "delete": { + "summary": "Purge all dead-letter queue jobs", "responses": { "200": { - "description": "Bounty snapshot imported" + "description": "Dead-letter jobs purged", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } }, "401": { - "description": "Invalid internal token" + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role (operator only)" + }, + "501": { + "description": "This deployment's queue backend does not expose dead-letter admin" } }, "security": [ @@ -18728,40 +18745,57 @@ { "LoopOverSessionCookie": [] } + ] + }, + "get": { + "summary": "List dead-letter queue jobs", + "parameters": [ + { + "schema": { + "type": "string", + "example": "25" + }, + "required": false, + "description": "Maximum rows to return, clamped from 1 to 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "0" + }, + "required": false, + "description": "Pagination offset, floored to 0.", + "name": "offset", + "in": "query" + } ], - "summary": "Import a bounty snapshot" - } - }, - "/v1/auth/github/token": { - "post": { - "summary": "Fetch the current session's live GitHub token (for AMS git operations)", "responses": { "200": { - "description": "The session's GitHub token", + "description": "Paginated dead-letter jobs for the self-host queue backend", "content": { "application/json": { "schema": { "type": "object", - "properties": { - "token": { - "type": "string" - } - }, - "required": [ - "token" - ] + "additionalProperties": { + "nullable": true + } } } } }, - "403": { - "description": "A browser session is required" + "400": { + "description": "Invalid query" }, - "404": { - "description": "No GitHub token is available for this session" + "401": { + "description": "Unauthorized" }, - "429": { - "description": "Rate limited" + "403": { + "description": "Insufficient app role (operator only)" + }, + "501": { + "description": "This deployment's queue backend does not expose dead-letter admin (e.g. Cloudflare)" } }, "security": [ @@ -18774,43 +18808,74 @@ ] } }, - "/v1/repos/{owner}/{repo}/live-gate-thresholds": { + "/v1/app/analytics/weekly-value-report": { "get": { - "summary": "Live self-tuned gate thresholds for AMS probe (#6486)", + "summary": "Weekly value report", "parameters": [ { "schema": { - "type": "string" + "type": "string", + "enum": [ + "public", + "operator" + ], + "example": "public" }, - "required": true, - "name": "owner", - "in": "path" + "required": false, + "description": "Report variant. Operator reports require the operator app role.", + "name": "variant", + "in": "query" }, { "schema": { - "type": "string" + "type": "string", + "example": "7" }, - "required": true, - "name": "repo", - "in": "path" + "required": false, + "description": "Report window in days, clamped from 1 to 31.", + "name": "days", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "json", + "markdown" + ], + "example": "markdown" + }, + "required": false, + "description": "Response format. Omit or use json for the structured report; use markdown for copy-ready text.", + "name": "format", + "in": "query" } ], "responses": { "200": { - "description": "Field-limited live (or soaking-shadow) TunableOverride values — confidence_floor / scope_cap_files / scope_cap_lines only", + "description": "Weekly value report as structured JSON or copy-ready Markdown", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/LiveGateThresholdsResponse" + "type": "object", + "additionalProperties": { + "nullable": true + } + } + }, + "text/markdown": { + "schema": { + "type": "string", + "example": "# Weekly LoopOver value report\n\n## Adoption metrics\n- Active users: 4\n" } } } }, - "403": { - "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + "401": { + "description": "Unauthorized" }, - "404": { - "description": "No live or shadow gate override is active for this repo" + "403": { + "description": "Insufficient app role for requested report variant" } }, "security": [ @@ -18823,43 +18888,89 @@ ] } }, - "/v1/repos/{owner}/{repo}/gate-config/effective": { + "/v1/app/skipped-pr-audit": { "get": { - "summary": "Current effective self-tuned gate config for a repo (#6247)", + "summary": "Audit of pull requests the review agent skipped", "parameters": [ { "schema": { - "type": "string" + "type": "string", + "example": "50" }, - "required": true, - "name": "owner", - "in": "path" + "required": false, + "description": "Maximum rows to return, clamped from 1 to 100.", + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "0" + }, + "required": false, + "description": "Number of parsed skip events to skip before returning rows (non-negative).", + "name": "offset", + "in": "query" + }, + { + "schema": { + "type": "string", + "example": "JSONbored/loopover" + }, + "required": false, + "description": "Optional repository filter. Browser sessions must have control-panel access to this repo.", + "name": "repoFullName", + "in": "query" + }, + { + "schema": { + "type": "string", + "enum": [ + "surface_off", + "missing_author", + "bot_author", + "ignored_author", + "maintainer_author", + "miner_detection_unavailable", + "not_official_gittensor_miner" + ], + "example": "not_official_gittensor_miner" + }, + "required": false, + "description": "Optional PR skip reason filter.", + "name": "reason", + "in": "query" }, { "schema": { - "type": "string" + "type": "string", + "example": "2026-05-30T00:00:00.000Z" }, - "required": true, - "name": "repo", - "in": "path" + "required": false, + "description": "Optional lower timestamp bound.", + "name": "since", + "in": "query" } ], "responses": { "200": { - "description": "Effective TunableOverride values (confidenceFloor / scopeCap.files / scopeCap.lines) with a shadowPending flag — never the raw override_audit history", + "description": "Private bounded audit export for skipped PR public-surface decisions", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/GateConfigEffectiveResponse" + "$ref": "#/components/schemas/SkippedPrAuditExport" } } } }, + "400": { + "description": "Invalid query" + }, "401": { - "description": "Missing or invalid static protected API token" + "description": "Unauthorized" }, "403": { - "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + "description": "Insufficient app role or repository scope" } }, "security": [ @@ -18872,19 +18983,31 @@ ] } }, - "/v1/finding-taxonomy": { - "get": { - "summary": "Canonical AI-review finding taxonomy", + "/v1/app/commands/preview": { + "post": { + "summary": "Dry-run a sanitized @loopover command response", "responses": { "200": { - "description": "Finding categories and the severity ladder", + "description": "Maintainer dry-run preview of a sanitized @loopover command response (no GitHub mutation)", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FindingTaxonomyDocument" + "$ref": "#/components/schemas/CommandPreviewResponse" } } } + }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Insufficient app role" + }, + "404": { + "description": "Command not found" } }, "security": [ @@ -18897,19 +19020,41 @@ ] } }, - "/v1/enrichment-analyzers": { - "get": { - "summary": "REES enrichment analyzer taxonomy", + "/v1/app/commands/feedback": { + "post": { + "summary": "Submit feedback on an @loopover command response", "responses": { "200": { - "description": "Default profile and the registered enrichment analyzers", + "description": "Live app mutation or preview response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EnrichmentAnalyzersTaxonomyDocument" + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } } } } + }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" } }, "security": [ @@ -18922,37 +19067,41 @@ ] } }, - "/v1/repos/{owner}/{repo}/automation-state": { - "get": { - "summary": "Derived agent automation state for a repository", - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], + "/v1/app/digest/subscriptions": { + "post": { + "summary": "Manage maintainer digest subscriptions", "responses": { "200": { - "description": "Maintainer-only derived automation view (mode, permission readiness, acting action classes, pending-approval count) that the raw /settings row does not include", + "description": "Live app mutation or preview response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AutomationState" + "type": "object", + "additionalProperties": { + "nullable": true + } + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "nullable": true + } } } } + }, + "400": { + "description": "Invalid request" + }, + "401": { + "description": "Unauthorized" } }, "security": [ @@ -18965,37 +19114,36 @@ ] } }, - "/v1/repos/{owner}/{repo}/repo-docs/refresh": { + "/v1/internal/jobs/refresh-registry": { "post": { - "summary": "Open (or find the already-open) AGENTS.md/CLAUDE.md generation pull request", - "parameters": [ + "summary": "Queue a registry refresh job", + "responses": { + "202": { + "description": "Registry refresh queued" + }, + "401": { + "description": "Invalid internal token" + } + }, + "security": [ { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" + "LoopOverBearer": [] }, { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" + "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/internal/jobs/backfill-registered-repos": { + "post": { + "summary": "Queue a registered-repository backfill job", "responses": { - "200": { - "description": "The repo-doc pull request result -- opened (new or reused) or a reason it was not opened", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepoDocRefreshResult" - } - } - } + "202": { + "description": "Registered repo backfill queued" + }, + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19008,40 +19156,42 @@ ] } }, - "/v1/contributors/{login}/pr-outcomes": { - "get": { - "summary": "Contributor post-merge PR outcome history", - "parameters": [ + "/v1/internal/jobs/backfill-repo-segment": { + "post": { + "summary": "Queue a repository segment backfill job", + "responses": { + "202": { + "description": "Repository segment backfill queued" + }, + "400": { + "description": "Invalid segment request" + }, + "401": { + "description": "Invalid internal token" + } + }, + "security": [ { - "schema": { - "type": "string" - }, - "required": true, - "name": "login", - "in": "path" + "LoopOverBearer": [] }, { - "schema": { - "type": "integer", - "minimum": 0, - "exclusiveMinimum": true, - "maximum": 100 - }, - "required": false, - "name": "limit", - "in": "query" + "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/internal/jobs/backfill-pr-details": { + "post": { + "summary": "Queue an open pull request detail backfill job", "responses": { - "200": { - "description": "Self-scoped post-merge outcome records with public-safe attribution (mirrors loopover_pr_outcome).", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ContributorPrOutcomes" - } - } - } + "202": { + "description": "Open PR detail backfill queued" + }, + "400": { + "description": "Invalid PR detail backfill request" + }, + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19054,29 +19204,18 @@ ] } }, - "/v1/contributors/{login}/notifications": { - "get": { - "summary": "Contributor badge notification feed", - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "login", - "in": "path" - } - ], + "/v1/internal/jobs/generate-review-recap": { + "post": { + "summary": "Queue a maintainer review recap digest job", "responses": { - "200": { - "description": "The contributor's own badge notification feed (self-scoped), newest first, with an unread count.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationFeed" - } - } - } + "202": { + "description": "Maintainer review recap digest queued (#1963)" + }, + "400": { + "description": "Missing repoFullName" + }, + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19089,49 +19228,15 @@ ] } }, - "/v1/contributors/{login}/notifications/read": { + "/v1/internal/jobs/refresh-scoring-model": { "post": { - "summary": "Mark contributor notifications read", - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "login", - "in": "path" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ids": { - "type": "array", - "items": { - "type": "string" - } - } - } - } - } - } - }, + "summary": "Queue a scoring model refresh job", "responses": { - "200": { - "description": "Marks the contributor's delivered badge notifications read; an absent/empty ids array marks all.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotificationsMarked" - } - } - } + "202": { + "description": "Internal job queued" }, - "400": { - "description": "Invalid mark-read body" + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19144,25 +19249,15 @@ ] } }, - "/v1/preflight/review-risk": { + "/v1/internal/jobs/refresh-upstream-drift": { "post": { - "summary": "Explain review risk for a planned pull request", + "summary": "Queue an upstream drift refresh job", "responses": { - "200": { - "description": "Review-risk explanation with preflight, role context, and recommendation", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReviewRiskExplanation" - } - } - } - }, - "400": { - "description": "Invalid preflight input" + "202": { + "description": "Internal job queued" }, - "403": { - "description": "Forbidden when contributorLogin does not match the authenticated session" + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19175,46 +19270,15 @@ ] } }, - "/v1/repos/{owner}/{repo}/issue-plan-drafts/generate": { + "/v1/internal/jobs/file-upstream-drift-issues": { "post": { - "summary": "AI-plan repo issue drafts from a maintainer goal", - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" - }, - { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" - } - ], + "summary": "Queue a job that files upstream drift issues", "responses": { - "200": { - "description": "AI-plan a small set of GitHub issue drafts from a maintainer-supplied planning goal (dry-run by default)", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": { - "nullable": true - } - } - } - } - }, - "400": { - "description": "Invalid request or explicit create without dryRun false" + "202": { + "description": "Internal job queued" }, - "403": { - "description": "Insufficient role" + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19227,15 +19291,15 @@ ] } }, - "/v1/public/decision-ledger/verify": { - "get": { - "summary": "Verify a window of the hash-chained decision ledger (resumable via afterSeq)", + "/v1/internal/jobs/build-contributor-evidence": { + "post": { + "summary": "Queue a contributor evidence build job", "responses": { - "200": { - "description": "Window verified clean; nextAfterSeq is the resume cursor (null at the tip). Every response also carries tipSeq/tipHash/totalCount for third-party checkpointing." + "202": { + "description": "Internal job queued" }, - "409": { - "description": "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch | short_tail (a record exists past the verified tip with no chain entry)" + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19248,18 +19312,15 @@ ] } }, - "/v1/public/decision-records/{owner}/{repo}/{pull}": { - "get": { - "summary": "Fetch the latest published decision record for a PR, verbatim, plus its content digest", + "/v1/internal/jobs/build-contributor-decision-packs": { + "post": { + "summary": "Queue a contributor decision pack build job", "responses": { - "200": { - "description": "The latest DecisionRecord for this PR + its recordDigest" - }, - "400": { - "description": "Invalid pull number" + "202": { + "description": "Internal job queued" }, - "404": { - "description": "No decision record persisted yet for this PR" + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19269,62 +19330,60 @@ { "LoopOverSessionCookie": [] } - ], - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "owner", - "in": "path" + ] + } + }, + "/v1/internal/jobs/build-burden-forecasts": { + "post": { + "summary": "Queue a burden forecast build job", + "responses": { + "202": { + "description": "Internal job queued" }, + "401": { + "description": "Invalid internal token" + } + }, + "security": [ { - "schema": { - "type": "string" - }, - "required": true, - "name": "repo", - "in": "path" + "LoopOverBearer": [] }, { - "schema": { - "type": "string" - }, - "required": true, - "name": "pull", - "in": "path" + "LoopOverSessionCookie": [] } ] } }, - "/v1/public/eval-scores": { - "get": { - "summary": "Fetch EvalScoreRecords (#9215) -- the objective-eval-provider transport, digest-committed and independently re-derivable", - "parameters": [ + "/v1/internal/jobs/generate-signal-snapshots": { + "post": { + "summary": "Queue a signal snapshot generation job", + "responses": { + "202": { + "description": "Internal job queued" + }, + "401": { + "description": "Invalid internal token" + } + }, + "security": [ { - "schema": { - "type": "string" - }, - "required": false, - "name": "subject", - "in": "query" + "LoopOverBearer": [] }, { - "schema": { - "type": "string" - }, - "required": false, - "name": "since", - "in": "query" + "LoopOverSessionCookie": [] } - ], + ] + } + }, + "/v1/internal/jobs/generate-weekly-value-report": { + "post": { + "summary": "Queue a weekly value report job", "responses": { - "200": { - "description": "{ records: EvalScoreRecord[] }, optionally filtered by subject id and/or minimum issuedAt -- degrades to an empty array on an internal read error rather than a non-200 status, matching loadPublicRulePrecision's own fail-safe contract" + "202": { + "description": "Internal job queued" }, - "404": { - "description": "Public stats disabled (same flag as /v1/public/stats)" + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19337,28 +19396,15 @@ ] } }, - "/v1/public/decision-ledger/row/{seq}": { - "get": { - "summary": "Fetch one decision-ledger row by seq, so an external anchor can be bound back to the live chain", - "parameters": [ - { - "schema": { - "type": "string" - }, - "required": true, - "name": "seq", - "in": "path" - } - ], + "/v1/internal/jobs/repair-data-fidelity": { + "post": { + "summary": "Queue a data fidelity repair job", "responses": { - "200": { - "description": "The chain row: seq, recordId, recordDigest, prevHash, rowHash, createdAt. Recompute sha256(prevHash || canonicalJson({seq, recordId, recordDigest, createdAt})) and compare against an anchored rowHash." - }, - "400": { - "description": "seq is not a positive integer" + "202": { + "description": "Internal job queued" }, - "404": { - "description": "No ledger row at that seq (never appended -- distinct from a row with empty fields)" + "401": { + "description": "Invalid internal token" } }, "security": [ @@ -19371,12 +19417,15 @@ ] } }, - "/v1/public/decision-ledger/anchor-key": { - "get": { - "summary": "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor", + "/v1/internal/bounties/import": { + "post": { + "summary": "Import a bounty snapshot", "responses": { "200": { - "description": "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" + "description": "Bounty snapshot imported" + }, + "401": { + "description": "Invalid internal token" } }, "security": [ diff --git a/migrations/0195_decision_ledger_anchors.sql b/migrations/0195_decision_ledger_anchors.sql new file mode 100644 index 0000000000..f7d76702bc --- /dev/null +++ b/migrations/0195_decision_ledger_anchors.sql @@ -0,0 +1,36 @@ +-- #9271 (epic #9267): persistence for external decision-ledger anchoring attempts, success AND failure both. +-- +-- migrations/0180_decision_ledger.sql named its own honest limit up front: a self-operated chain is +-- tamper-EVIDENT, not tamper-PROOF. Anchoring (#9270 for the signed payload; #9272/#9273 for the Rekor and +-- git-commit backends) closes that by publishing a periodic checkpoint somewhere the operator does not +-- control. But per the mechanism research on #9267: if anchoring can fail SILENTLY (best-effort, so a failure +-- must never block a review), an operator could make every attempt "fail" and quietly regress the ledger back +-- to tamper-evident-only with no visible signal. Recording every attempt -- success or failure -- as a public +-- row is what closes that: "anchoring has been failing for a week" becomes a fact anyone can observe, not +-- something only the operator's own logs show. +CREATE TABLE IF NOT EXISTS decision_ledger_anchors ( + id TEXT PRIMARY KEY, + seq INTEGER NOT NULL, -- the ledger seq this anchor commits to (decision_ledger.seq) + row_hash TEXT NOT NULL, -- the anchored decision_ledger.row_hash at seq + payload_json TEXT NOT NULL, -- canonical-JSON'd LedgerAnchorPayload (#9270) -- the exact signed bytes + signature TEXT NOT NULL, -- base64 ECDSA signature over payload_json (#9270) + key_id TEXT NOT NULL, -- which published anchor-signing key signed this attempt + -- 'ots' (OpenTimestamps) is a named, tracked-but-not-yet-built backend per #9267's research -- included in + -- the CHECK now so a future implementation issue does not need its own migration just to widen this list. + backend TEXT NOT NULL CHECK (backend IN ('rekor', 'git', 'ots')), + -- Backend-specific resolvable reference, JSON. For Rekor: {shardBaseUrl, logIndex, logIdKeyId, uuid} -- + -- deliberately the FULL reference, not a bare logIndex, since Rekor's annual shard rotation makes a bare + -- index unresolvable later. For git: {repo, sha}. NULL on a failed attempt (there is no reference yet). + backend_ref TEXT, + -- R2 key for the full stored proof (Rekor's TransparencyLogEntry -- inclusion proof + signed checkpoint -- + -- needed for fully OFFLINE verification later, without trusting Rekor's continued availability). NULL on + -- a failed attempt, and NULL for backends with no separate proof blob to store. + proof_r2_key TEXT, + status TEXT NOT NULL CHECK (status IN ('ok', 'failed')), + error TEXT, -- populated on status='failed', NULL on 'ok' + created_at TEXT NOT NULL +); +-- The public listing (`GET /v1/public/decision-ledger/anchors`) paginates newest-first per backend and +-- overall; this index serves both without a table scan as the table grows. +CREATE INDEX IF NOT EXISTS decision_ledger_anchors_created_at ON decision_ledger_anchors (created_at DESC); +CREATE INDEX IF NOT EXISTS decision_ledger_anchors_backend_created_at ON decision_ledger_anchors (backend, created_at DESC); diff --git a/scripts/check-schema-drift.ts b/scripts/check-schema-drift.ts index d7d261d4a6..602de96dda 100644 --- a/scripts/check-schema-drift.ts +++ b/scripts/check-schema-drift.ts @@ -43,6 +43,7 @@ export const RAW_SQL_ONLY_TABLES: Set = new Set([ "contributor_gate_history", "decision_audit_labels", "decision_ledger", + "decision_ledger_anchors", "decision_records", "decision_replay_inputs", "ai_review_verdict_flips", diff --git a/src/api/routes.ts b/src/api/routes.ts index f644e893a7..3dd40bbdec 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -313,6 +313,7 @@ import { isRagEnabled } from "../review/rag-wire"; import { loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record"; import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records"; import { currentAnchorKey, parseAnchorPublicKeys } from "../review/ledger-anchor"; +import { loadPublicLedgerAnchors } from "../review/ledger-anchor-persistence"; import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats"; import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend"; import { loadPublicRulePrecision } from "../review/public-rule-precision"; @@ -1319,6 +1320,27 @@ export function createApp() { return c.json({ keys, currentKeyId: currentAnchorKey(keys)?.keyId ?? null }); }); + // #9271 (epic #9267): every anchoring attempt, success AND failure, paginated newest-first. This is what + // makes anchoring's own health a publicly checkable fact -- a failure is recorded and served exactly like a + // success (same shape, same listing), so "anchoring has been failing for a week" is something anyone can + // observe rather than only visible in the operator's own logs. Unauthenticated by design, same posture as + // every /v1/public/* sibling above. + app.get("/v1/public/decision-ledger/anchors", async (c) => { + // Built with spreads, not literal undefined-valued keys: exactOptionalPropertyTypes means an optional + // filter field must be OMITTED to mean "no filter", not present-with-value-undefined. + const backendParam = c.req.query("backend"); + const backend = backendParam === "rekor" || backendParam === "git" || backendParam === "ots" ? backendParam : undefined; + const before = c.req.query("before"); + const limit = Number(c.req.query("limit")) || undefined; + const result = await loadPublicLedgerAnchors(c.env, { + ...(backend !== undefined && { backend }), + ...(before !== undefined && { before }), + ...(limit !== undefined && { limit }), + }); + c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300"); + return c.json(result); + }); + // #9123: the decision record itself was persisted (decision_records) but never published anywhere a // contributor or a third party could fetch the full body — the only prior public surface was // renderDecisionRecordSection's bounded review-comment summary (12-char digest prefixes, and it omits @@ -6809,6 +6831,8 @@ function requiresApiToken(path: string): boolean { // #9270: the published anchor-signing public keys, added in the SAME PR as its route so the two cannot // drift the way #9120's sibling did. if (path === "/v1/public/decision-ledger/anchor-key") return false; + // #9271: the public anchor-attempt listing, added in the SAME PR as its route. + if (path === "/v1/public/decision-ledger/anchors") return false; // #9123: the new public decision-record read route — same unauthenticated posture as its ledger-verify // sibling immediately above, added in the SAME PR so the two can never drift apart the way #9120 did. The // pull segment matches any non-slash text (not just digits): an invalid pull number is the ROUTE's 400 to diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index 4fc08e3af0..67f085e8c2 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -1024,6 +1024,15 @@ export function buildOpenApiSpec() { 200: { description: "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/public/decision-ledger/anchors", + summary: "Every external anchoring attempt, success and failure, paginated newest-first — anchoring's own health as a public fact", + request: { query: z.object({ backend: z.enum(["rekor", "git", "ots"]).optional(), before: z.string().optional(), limit: z.string().optional() }) }, + responses: { + 200: { description: "{ anchors: [{ id, seq, rowHash, keyId, backend, backendRef, status, error, createdAt }], nextBefore } — a failed attempt is returned identically to a successful one, never filtered out or reshaped" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/public/decision-records/{owner}/{repo}/{pull}", diff --git a/src/review/ledger-anchor-persistence.ts b/src/review/ledger-anchor-persistence.ts new file mode 100644 index 0000000000..a21820253c --- /dev/null +++ b/src/review/ledger-anchor-persistence.ts @@ -0,0 +1,147 @@ +// Persistence for external decision-ledger anchoring attempts (#9271, epic #9267; migrations/0195). Every +// backend (#9272 Rekor, #9273 git-commit, and any future one) calls ONE recording function whether it +// succeeded or failed -- there is no separate "failure log" a backend could simply not call. That is the +// whole point: per the mechanism research on #9267, an operator whose anchoring silently fails could quietly +// regress the ledger back to tamper-evident-only with no visible signal. A failure recorded exactly like a +// success, on the same public listing, is what makes anchoring's own health itself a publicly checkable fact. +import { nowIso, errorMessage } from "../utils/json"; +import type { LedgerAnchorPayload } from "./ledger-anchor"; + +export type LedgerAnchorBackend = "rekor" | "git" | "ots"; + +/** What a backend passes in to record ONE attempt -- success or failure, same shape either way. */ +export type LedgerAnchorAttemptInput = { + payload: LedgerAnchorPayload; + signature: string; + keyId: string; + backend: LedgerAnchorBackend; +} & ( + | { status: "ok"; backendRef: unknown; proofR2Key: string | null } + | { status: "failed"; error: unknown } +); + +/** One row exactly as it will be served publicly -- deliberately the SAME shape whether the attempt + * succeeded or failed, so a listing consumer cannot special-case failures into a different, hideable form. */ +export type PublicLedgerAnchor = { + id: string; + seq: number; + rowHash: string; + keyId: string; + backend: LedgerAnchorBackend; + backendRef: unknown; + status: "ok" | "failed"; + error: string | null; + createdAt: string; +}; + +/** + * Record one anchoring attempt. Never throws for the caller's OWN backend failure (that IS what `status: + * "failed"` records) -- only a genuine local persistence error propagates, matching `appendDecisionLedger`'s + * posture of letting a storage-layer failure be its own distinct problem rather than silently swallowing it + * into "the anchor attempt failed" too. + * + * `createdAt` is injectable (defaults to `nowIso()`), matching `loadPublicRulePrecision`'s own + * injectable-clock pattern -- this is NOT `payload.at` (the anchored tip's own captured timestamp, a + * property of the CHAIN) but "when this attempt was recorded" (a property of THIS row), and the two are + * naturally different values. Making it injectable is what lets a test control ordering deterministically + * instead of racing real wall-clock resolution across several inserts in a tight loop. + */ +export async function recordLedgerAnchorAttempt(env: Env, attempt: LedgerAnchorAttemptInput, createdAt: string = nowIso()): Promise { + const id = crypto.randomUUID(); + const payloadJson = JSON.stringify(attempt.payload); + const backendRef = attempt.status === "ok" ? JSON.stringify(attempt.backendRef) : null; + const proofR2Key = attempt.status === "ok" ? attempt.proofR2Key : null; + const error = attempt.status === "failed" ? errorMessage(attempt.error).slice(0, 500) : null; + + await env.DB.prepare( + `INSERT INTO decision_ledger_anchors + (id, seq, row_hash, payload_json, signature, key_id, backend, backend_ref, proof_r2_key, status, error, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + id, + attempt.payload.seq, + attempt.payload.rowHash, + payloadJson, + attempt.signature, + attempt.keyId, + attempt.backend, + backendRef, + proofR2Key, + attempt.status, + error, + createdAt, + ) + .run(); +} + +export type LedgerAnchorListFilter = { + backend?: LedgerAnchorBackend; + /** Cursor: return rows strictly older than this ISO timestamp. Omit for the first (newest) page. */ + before?: string; + limit?: number; +}; + +const DEFAULT_ANCHOR_LIST_LIMIT = 50; +const MAX_ANCHOR_LIST_LIMIT = 200; + +/** + * Public, paginated listing -- newest first, success and failure rows returned identically (no filtering out + * failures, no separate shape). `backendRef`/`error` come back parsed/typed exactly as `PublicLedgerAnchor` + * declares; a row with unparseable `backend_ref` JSON (never written by `recordLedgerAnchorAttempt` itself, + * but defensive against any future direct write) degrades to `null` rather than throwing a public endpoint. + */ +export async function loadPublicLedgerAnchors(env: Env, filter: LedgerAnchorListFilter = {}): Promise<{ anchors: PublicLedgerAnchor[]; nextBefore: string | null }> { + const limit = Math.max(1, Math.min(MAX_ANCHOR_LIST_LIMIT, filter.limit ?? DEFAULT_ANCHOR_LIST_LIMIT)); + const conditions: string[] = []; + const binds: unknown[] = []; + if (filter.backend) { + conditions.push("backend = ?"); + binds.push(filter.backend); + } + if (filter.before) { + conditions.push("created_at < ?"); + binds.push(filter.before); + } + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + // Fetch one extra row to know whether a next page exists, without a separate COUNT query. + const rows = await env.DB.prepare( + `SELECT id, seq, row_hash AS rowHash, key_id AS keyId, backend, backend_ref AS backendRef, status, error, created_at AS createdAt + FROM decision_ledger_anchors ${where} + ORDER BY created_at DESC, id DESC + LIMIT ?`, + ) + .bind(...binds, limit + 1) + .all<{ id: string; seq: number; rowHash: string; keyId: string; backend: LedgerAnchorBackend; backendRef: string | null; status: "ok" | "failed"; error: string | null; createdAt: string }>(); + + /* v8 ignore next -- defensive: D1's .all() always returns a results array (even [] for zero rows); the ?? + * guards a driver-shape change, mirroring loadPublicRulePrecision's identical note on COUNT(*). */ + const results = rows.results ?? []; + const page = results.slice(0, limit); + // `> limit` guarantees `page` has exactly `limit` (>=1) elements, so `page[page.length - 1]` is always + /* v8 ignore next -- defined; the ?. only guards TypeScript's array-index type, not a reachable runtime case. */ + const nextBefore = results.length > limit ? (page[page.length - 1]?.createdAt ?? null) : null; + + const anchors: PublicLedgerAnchor[] = page.map((row) => ({ + id: row.id, + seq: row.seq, + rowHash: row.rowHash, + keyId: row.keyId, + backend: row.backend, + backendRef: parseBackendRef(row.backendRef), + status: row.status, + error: row.error, + createdAt: row.createdAt, + })); + + return { anchors, nextBefore }; +} + +function parseBackendRef(raw: string | null): unknown { + if (raw === null) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} diff --git a/test/integration/public-decision-ledger-routes.test.ts b/test/integration/public-decision-ledger-routes.test.ts index 3e7b199dcc..82e110cb79 100644 --- a/test/integration/public-decision-ledger-routes.test.ts +++ b/test/integration/public-decision-ledger-routes.test.ts @@ -77,6 +77,7 @@ describe("public decision-ledger/decision-records routes answer WITHOUT credenti ["decision-ledger/verify", "/v1/public/decision-ledger/verify"], ["decision-ledger/row/:seq", "/v1/public/decision-ledger/row/1"], ["decision-ledger/anchor-key", "/v1/public/decision-ledger/anchor-key"], + ["decision-ledger/anchors", "/v1/public/decision-ledger/anchors"], ["decision-records/:owner/:repo/:pull", "/v1/public/decision-records/acme/widgets/1"], ["github/repos/:owner/:repo/stats", "/v1/public/github/repos/acme/widgets/stats"], ["repos/:owner/:repo/badge.svg", "/v1/public/repos/acme/widgets/badge.svg"], diff --git a/test/integration/public-ledger-anchors-route.test.ts b/test/integration/public-ledger-anchors-route.test.ts new file mode 100644 index 0000000000..8936e1db6e --- /dev/null +++ b/test/integration/public-ledger-anchors-route.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "../../src/api/routes"; +import { createTestEnv } from "../helpers/d1"; +import { recordLedgerAnchorAttempt } from "../../src/review/ledger-anchor-persistence"; +import { buildLedgerAnchorPayload } from "../../src/review/ledger-anchor"; + +// #9271 (epic #9267). The load-bearing behaviour: a failed attempt is served on this public listing exactly +// like a success, since that's the entire point of recording failures at all. + +describe("GET /v1/public/decision-ledger/anchors (#9271)", () => { + it("answers 200 with no Authorization header", async () => { + const env = createTestEnv(); + const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ anchors: [], nextBefore: null }); + }); + + it("serves a FAILED anchor attempt on the public listing, identically shaped to a success", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt( + env, + { + payload: buildLedgerAnchorPayload({ seq: 7, rowHash: "e".repeat(64), totalCount: 7 }, "2026-07-27T12:00:00.000Z"), + signature: "c2ln", + keyId: "key1", + backend: "git", + status: "failed", + error: new Error("rate limited by the git remote"), + }, + "2026-07-27T12:00:00.000Z", + ); + + const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, env); + const body = (await response.json()) as { anchors: Array>; nextBefore: string | null }; + expect(body.anchors).toHaveLength(1); + expect(body.anchors[0]).toMatchObject({ seq: 7, backend: "git", status: "failed", error: "rate limited by the git remote" }); + }); + + it("filters by ?backend=", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt( + env, + { payload: buildLedgerAnchorPayload({ seq: 1, rowHash: "a".repeat(64), totalCount: 1 }, "2026-07-27T12:00:00.000Z"), signature: "s", keyId: "k", backend: "rekor", status: "ok", backendRef: {}, proofR2Key: null }, + "2026-07-27T12:00:00.000Z", + ); + await recordLedgerAnchorAttempt( + env, + { payload: buildLedgerAnchorPayload({ seq: 2, rowHash: "b".repeat(64), totalCount: 2 }, "2026-07-27T12:01:00.000Z"), signature: "s", keyId: "k", backend: "git", status: "ok", backendRef: {}, proofR2Key: null }, + "2026-07-27T12:01:00.000Z", + ); + + const response = await createApp().request("/v1/public/decision-ledger/anchors?backend=rekor", {}, env); + const body = (await response.json()) as { anchors: Array<{ backend: string }> }; + expect(body.anchors.map((a) => a.backend)).toEqual(["rekor"]); + }); + + it("ignores an invalid ?backend= value rather than erroring (falls back to unfiltered)", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt( + env, + { payload: buildLedgerAnchorPayload({ seq: 1, rowHash: "a".repeat(64), totalCount: 1 }, "2026-07-27T12:00:00.000Z"), signature: "s", keyId: "k", backend: "rekor", status: "ok", backendRef: {}, proofR2Key: null }, + "2026-07-27T12:00:00.000Z", + ); + const response = await createApp().request("/v1/public/decision-ledger/anchors?backend=nonsense", {}, env); + expect(response.status).toBe(200); + const body = (await response.json()) as { anchors: unknown[] }; + expect(body.anchors).toHaveLength(1); + }); + + it("supports pagination via ?limit= and ?before=", async () => { + const env = createTestEnv(); + for (let i = 1; i <= 3; i += 1) { + await recordLedgerAnchorAttempt( + env, + { payload: buildLedgerAnchorPayload({ seq: i, rowHash: `${i}`.repeat(64).slice(0, 64), totalCount: i }, `2026-07-27T12:0${i}:00.000Z`), signature: "s", keyId: "k", backend: "rekor", status: "ok", backendRef: {}, proofR2Key: null }, + `2026-07-27T12:0${i}:00.000Z`, + ); + } + const first = await createApp().request("/v1/public/decision-ledger/anchors?limit=2", {}, env); + const firstBody = (await first.json()) as { anchors: Array<{ seq: number }>; nextBefore: string }; + expect(firstBody.anchors.map((a) => a.seq)).toEqual([3, 2]); + + const second = await createApp().request(`/v1/public/decision-ledger/anchors?limit=2&before=${encodeURIComponent(firstBody.nextBefore)}`, {}, env); + const secondBody = (await second.json()) as { anchors: Array<{ seq: number }>; nextBefore: string | null }; + expect(secondBody.anchors.map((a) => a.seq)).toEqual([1]); + expect(secondBody.nextBefore).toBeNull(); + }); + + it("sets the same Cache-Control posture as its public siblings", async () => { + const response = await createApp().request("/v1/public/decision-ledger/anchors", {}, createTestEnv()); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=60, stale-while-revalidate=300"); + }); +}); diff --git a/test/unit/ledger-anchor-persistence.test.ts b/test/unit/ledger-anchor-persistence.test.ts new file mode 100644 index 0000000000..2981bac9e9 --- /dev/null +++ b/test/unit/ledger-anchor-persistence.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import { createTestEnv } from "../helpers/d1"; +import { loadPublicLedgerAnchors, recordLedgerAnchorAttempt, type LedgerAnchorAttemptInput } from "../../src/review/ledger-anchor-persistence"; +import { buildLedgerAnchorPayload } from "../../src/review/ledger-anchor"; + +// #9271 (epic #9267). The load-bearing property here is that a FAILURE is recorded and served exactly like a +// success -- no special-casing that could hide it, per the mechanism research on #9267. + +function okAttempt(overrides: Partial = {}): LedgerAnchorAttemptInput { + return { + payload: buildLedgerAnchorPayload({ seq: 1, rowHash: "a".repeat(64), totalCount: 1 }, "2026-07-27T12:00:00.000Z"), + signature: "c2ln", + keyId: "key1", + backend: "rekor", + status: "ok", + backendRef: { shardBaseUrl: "https://log2026-1.rekor.sigstore.dev", logIndex: 42, logIdKeyId: "kid", uuid: "uuid-1" }, + proofR2Key: "anchors/rekor/1.json", + ...overrides, + }; +} + +function failedAttempt(overrides: Partial = {}): LedgerAnchorAttemptInput { + return { + payload: buildLedgerAnchorPayload({ seq: 2, rowHash: "b".repeat(64), totalCount: 2 }, "2026-07-27T12:05:00.000Z"), + signature: "c2ln", + keyId: "key1", + backend: "git", + status: "failed", + error: new Error("rate limited"), + ...overrides, + }; +} + +describe("recordLedgerAnchorAttempt / loadPublicLedgerAnchors (#9271)", () => { + it("records a successful attempt with its backend reference and proof key", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt(env, okAttempt()); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors).toHaveLength(1); + expect(anchors[0]).toMatchObject({ + seq: 1, + rowHash: "a".repeat(64), + keyId: "key1", + backend: "rekor", + status: "ok", + error: null, + backendRef: { shardBaseUrl: "https://log2026-1.rekor.sigstore.dev", logIndex: 42, logIdKeyId: "kid", uuid: "uuid-1" }, + }); + }); + + it("records a FAILED attempt with the SAME shape as success — not filtered out, not hidden", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt(env, failedAttempt()); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors).toHaveLength(1); + expect(anchors[0]).toMatchObject({ seq: 2, backend: "git", status: "failed", error: "rate limited", backendRef: null }); + // Same field set on a failure as on a success — no special-cased shape that could hide it. + expect(Object.keys(anchors[0]!).sort()).toEqual(["backend", "backendRef", "createdAt", "error", "id", "keyId", "rowHash", "seq", "status"]); + }); + + it("a failed attempt is queryable identically to a success — filtering by backend returns both statuses", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt(env, okAttempt({ backend: "git", payload: buildLedgerAnchorPayload({ seq: 3, rowHash: "c".repeat(64), totalCount: 3 }, "2026-07-27T12:10:00.000Z") })); + await recordLedgerAnchorAttempt(env, failedAttempt({ backend: "git" })); + + const { anchors } = await loadPublicLedgerAnchors(env, { backend: "git" }); + expect(anchors.map((a) => a.status).sort()).toEqual(["failed", "ok"]); + }); + + it("filters by backend", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt(env, okAttempt({ backend: "rekor" })); + await recordLedgerAnchorAttempt(env, failedAttempt({ backend: "git" })); + + expect((await loadPublicLedgerAnchors(env, { backend: "rekor" })).anchors).toHaveLength(1); + expect((await loadPublicLedgerAnchors(env, { backend: "ots" })).anchors).toHaveLength(0); + }); + + it("paginates newest-first with a nextBefore cursor, and the cursor actually advances the page", async () => { + const env = createTestEnv(); + // createdAt (the row's OWN recorded time) is passed explicitly and distinctly per row -- several inserts + // in a tight loop would otherwise race real wall-clock resolution and could land on the same millisecond. + for (let i = 1; i <= 5; i += 1) { + await recordLedgerAnchorAttempt( + env, + okAttempt({ payload: buildLedgerAnchorPayload({ seq: i, rowHash: `${i}`.repeat(64).slice(0, 64), totalCount: i }, `2026-07-27T12:0${i}:00.000Z`) }), + `2026-07-27T12:0${i}:00.000Z`, + ); + } + + const first = await loadPublicLedgerAnchors(env, { limit: 2 }); + expect(first.anchors.map((a) => a.seq)).toEqual([5, 4]); // newest first + expect(first.nextBefore).not.toBeNull(); + + const second = await loadPublicLedgerAnchors(env, { limit: 2, before: first.nextBefore! }); + expect(second.anchors.map((a) => a.seq)).toEqual([3, 2]); + expect(second.nextBefore).not.toBeNull(); + + const third = await loadPublicLedgerAnchors(env, { limit: 2, before: second.nextBefore! }); + expect(third.anchors.map((a) => a.seq)).toEqual([1]); + expect(third.nextBefore).toBeNull(); // no more pages + }); + + it("clamps limit to [1, 200] rather than trusting caller input", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt(env, okAttempt()); + expect((await loadPublicLedgerAnchors(env, { limit: 0 })).anchors).toHaveLength(1); // clamped up to 1 + expect((await loadPublicLedgerAnchors(env, { limit: -5 })).anchors).toHaveLength(1); + }); + + it("degrades an unparseable backendRef to null rather than throwing a public endpoint", async () => { + const env = createTestEnv(); + await env.DB.prepare( + `INSERT INTO decision_ledger_anchors (id, seq, row_hash, payload_json, signature, key_id, backend, backend_ref, proof_r2_key, status, error, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind("manual-1", 9, "d".repeat(64), "{}", "sig", "key1", "rekor", "{not valid json", null, "ok", null, "2026-07-27T12:00:00.000Z") + .run(); + + const { anchors } = await loadPublicLedgerAnchors(env); + expect(anchors[0]?.backendRef).toBeNull(); + }); + + it("returns an empty list with no nextBefore on a fresh ledger", async () => { + const { anchors, nextBefore } = await loadPublicLedgerAnchors(createTestEnv()); + expect(anchors).toEqual([]); + expect(nextBefore).toBeNull(); + }); +});