From fa59eb0016c39114e7f7b48e04b9d412f4dfa45f Mon Sep 17 00:00:00 2001 From: galuis116 Date: Thu, 16 Jul 2026 09:44:40 -0400 Subject: [PATCH] feat(review): add a read-only endpoint exposing ORB's live self-tuned gate thresholds New GET /v1/repos/:owner/:repo/live-gate-thresholds (#6486, implementing #6209's decision): a field-limited, snake_case-only projection of a repo's authoritative gate threshold override -- confidence_floor, scope_cap_files, scope_cap_lines only. Never applied_at/clear_at or the override_audit history. The live override wins; a soaking shadow's queued value fills in when live is absent, so AMS sees a pending tightening too, not just a promoted one. Auth matches the existing intelligence/issue-quality/ reviewability precedent routes (isMcpReadRepoAllowed gating a static mcp actor, everything else trusted) rather than gate-config/effective's stricter static-token-only gate, per #6209's decision to reuse the simpler, already-established pattern. Two new pure helpers in auto-apply.ts: authoritativeGateOverride (live-wins-over-shadow) and toLiveGateThresholdFields (the snake_case projection, null when the override carries neither field). Both are exhaustively branch-tested, including the specific confidenceFloor- undefined-but-scopeCap-defined combination. Closes #6486 --- apps/loopover-ui/public/openapi.json | 75 ++++++++++++++++++++++++++++ src/api/routes.ts | 21 +++++++- src/openapi/schemas.ts | 11 ++++ src/openapi/spec.ts | 13 +++++ src/review/auto-apply.ts | 27 ++++++++++ test/integration/api.test.ts | 66 ++++++++++++++++++++++++ test/unit/auto-apply.test.ts | 54 ++++++++++++++++++++ 7 files changed, 266 insertions(+), 1 deletion(-) diff --git a/apps/loopover-ui/public/openapi.json b/apps/loopover-ui/public/openapi.json index 47d67b242f..4ae989dc83 100644 --- a/apps/loopover-ui/public/openapi.json +++ b/apps/loopover-ui/public/openapi.json @@ -13945,6 +13945,32 @@ "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" + ] } }, "parameters": {}, @@ -17954,6 +17980,55 @@ } ] } + }, + "/v1/repos/{owner}/{repo}/live-gate-thresholds": { + "get": { + "summary": "Live self-tuned gate thresholds for AMS probe (#6486)", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "owner", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "repo", + "in": "path" + } + ], + "responses": { + "200": { + "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/LiveGateThresholdsResponse" + } + } + } + }, + "403": { + "description": "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" + }, + "404": { + "description": "No live or shadow gate override is active for this repo" + } + }, + "security": [ + { + "LoopOverBearer": [] + }, + { + "LoopOverSessionCookie": [] + } + ] + } } }, "servers": [ diff --git a/src/api/routes.ts b/src/api/routes.ts index e2fb91aa9f..782e9d1723 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -264,7 +264,7 @@ import { buildMaintainerActivationPreview, recommendedAdvisoryActivationSettings import { buildRepoOutcomeCalibration } from "../services/outcome-calibration"; import { loadGatePrecisionReport } from "../services/gate-precision"; import { computeOpsStats, isOpsEnabled, resolveOpsManifestOverride } from "../review/ops-wire"; -import { deleteLiveOverride, listOverrideAudit, loadOverride, loadShadowOverride, sanitizeOverridePayload, type StorageEnv } from "../review/auto-apply"; +import { authoritativeGateOverride, deleteLiveOverride, listOverrideAudit, loadOverride, loadShadowOverride, sanitizeOverridePayload, toLiveGateThresholdFields, type StorageEnv } from "../review/auto-apply"; import { handleInternalCalibration, handleInternalDecision, type OpsAgentConfig } from "../review/ops"; import { computeParityReadiness, isParityAuditEnabled } from "../review/parity-wire"; import { computePredictedGateAgreement } from "../review/predicted-gate-agreement"; @@ -3018,6 +3018,25 @@ export function createApp() { }); }); + // AMS probe surface for a repo's live self-tuned gate thresholds (#6486, implementing #6209's decision). + // Distinct from gate-config/effective above: a field-limited, snake_case-only payload (no shadowPending, no + // nested effective object) matching tunables_overrides' own column names 1:1, and the live row wins but a + // soaking shadow's queued value fills in when live is absent -- #6209 decided AMS should see a pending + // tightening too, not just a promoted one. Never includes applied_at/clear_at or override_audit history. + app.get("/v1/repos/:owner/:repo/live-gate-thresholds", async (c) => { + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const identity = await authenticateRequestIdentity(c); + // Only the shared, end-user-obtainable static `mcp` token is allowlist-scoped; every other identity + // (an authenticated session, or an operator-only api/internal static token) stays trusted, matching the + // intelligence/issue-quality/reviewability precedent routes above. + if (identity?.kind === "static" && identity.actor === "mcp" && !(await import("../auth/security")).isMcpReadRepoAllowed(c.env.MCP_READ_REPO_ALLOWLIST, fullName)) return c.json({ error: "forbidden_repo" }, 403); + const storageEnv = c.env as unknown as StorageEnv; + const [live, shadow] = await Promise.all([loadOverride(storageEnv, fullName), loadShadowOverride(storageEnv, fullName)]); + const fields = toLiveGateThresholdFields(authoritativeGateOverride(live, shadow)); + if (!fields) return c.json({ error: "live_gate_thresholds_not_found", repoFullName: fullName }, 404); + return c.json({ repoFullName: fullName, ...fields }); + }); + app.get("/v1/repos/:owner/:repo/outcome-patterns", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; const response = await buildRepoOutcomePatternsResponse(c.env, fullName); diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 223ac8394d..42eb74a948 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1739,6 +1739,17 @@ export const IssueQualityResponseSchema = z }) .openapi("IssueQualityResponse"); +/** Field-limited AMS/MCP probe payload for a repo's live gate thresholds (#6486) — snake_case column names + * matching `tunables_overrides` 1:1; deliberately excludes applied_at/clear_at and override_audit history. */ +export const LiveGateThresholdsResponseSchema = z + .object({ + repoFullName: z.string(), + confidence_floor: z.number().nullable(), + scope_cap_files: z.number().int().nullable(), + scope_cap_lines: z.number().int().nullable(), + }) + .openapi("LiveGateThresholdsResponse"); + export const BurdenForecastSchema = z .object({ repoFullName: z.string(), diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index a10323935f..7d9701162c 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -33,6 +33,7 @@ import { IssueQualityResponseSchema, LabelAuditSchema, LaneAdviceSchema, + LiveGateThresholdsResponseSchema, LocalBranchAnalysisSchema, LocalDiffPreflightResultSchema, MaintainerPacketSchema, @@ -148,6 +149,7 @@ export function buildOpenApiSpec() { registry.register("ScorePreview", ScorePreviewSchema); registry.register("IssueQualityReport", IssueQualityReportSchema); registry.register("IssueQualityResponse", IssueQualityResponseSchema); + registry.register("LiveGateThresholdsResponse", LiveGateThresholdsResponseSchema); registry.register("BurdenForecast", BurdenForecastSchema); registry.register("ContributorScoringProfile", ContributorScoringProfileSchema); registry.register("ContributorStrategy", ContributorStrategySchema); @@ -419,6 +421,17 @@ export function buildOpenApiSpec() { 404: { description: "Repo is unknown or has no issue-quality coverage yet" }, }, }); + registry.registerPath({ + method: "get", + path: "/v1/repos/{owner}/{repo}/live-gate-thresholds", + summary: "Live self-tuned gate thresholds for AMS probe (#6486)", + request: { params: z.object({ owner: z.string(), repo: z.string() }) }, + responses: { + 200: { description: "Field-limited live (or soaking-shadow) TunableOverride values — confidence_floor / scope_cap_files / scope_cap_lines only", content: { "application/json": { schema: LiveGateThresholdsResponseSchema } } }, + 403: { description: "Static mcp credential is outside MCP_READ_REPO_ALLOWLIST for this repo" }, + 404: { description: "No live or shadow gate override is active for this repo" }, + }, + }); registry.registerPath({ method: "get", path: "/v1/repos/{owner}/{repo}/outcome-patterns", diff --git a/src/review/auto-apply.ts b/src/review/auto-apply.ts index 4e13594e72..d813451ec3 100644 --- a/src/review/auto-apply.ts +++ b/src/review/auto-apply.ts @@ -276,6 +276,33 @@ export async function deleteShadowOverride(env: StorageEnv, project: string): Pr await storage(env).prepare("DELETE FROM tunables_overrides_shadow WHERE project = ?").bind(project).run(); } +/** PURE: prefer a project's LIVE override; when absent, fall through to a soaking SHADOW override's queued + * value (#6486/#6209 — AMS probes this so a repo mid-soak still reports its pending threshold, not "none"). */ +export function authoritativeGateOverride(live: TunableOverride | null, shadow: ShadowOverride | null): TunableOverride | null { + return live ?? shadow?.override ?? null; +} + +/** Field-limited AMS/MCP payload for a repo's live gate thresholds (#6486). Snake_case names match the + * tunables_overrides column names AMS probes for; deliberately excludes applied_at/clear_at and the + * override_audit history — only the resolved effective values ever leave this projection. */ +export type LiveGateThresholdFields = { + confidence_floor: number | null; + scope_cap_files: number | null; + scope_cap_lines: number | null; +}; + +/** PURE: project an authoritative TunableOverride into the exact snake_case allowlist, or null when the + * override carries neither a confidenceFloor nor a scopeCap (nothing to report). */ +export function toLiveGateThresholdFields(override: TunableOverride | null): LiveGateThresholdFields | null { + if (!override) return null; + if (override.confidenceFloor === undefined && override.scopeCap === undefined) return null; + return { + confidence_floor: override.confidenceFloor ?? null, + scope_cap_files: override.scopeCap?.files ?? null, + scope_cap_lines: override.scopeCap?.lines ?? null, + }; +} + /** Record one override-lifecycle event to the dedicated (target-free) audit table. Fail-safe: a write error * never breaks the apply path, but it IS surfaced at error level (this is the operator's ONLY visibility * into an autonomous config change — a silently-dropped log line here defeats that entirely). */ diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index d7020df770..481c8ed3af 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -6787,6 +6787,72 @@ describe("api routes", () => { const unauth = await app.request("/v1/repos/entrius/allways-ui/gate-config/effective", {}, env); expect(unauth.status).toBe(401); }); + + it("exposes field-limited snake_case live gate thresholds for the AMS probe (#6486)", async () => { + const app = createApp(); + const env = createTestEnv(); + // No live or shadow override yet → not-found, matching the issue-quality-route not-found convention. + const missing = await app.request("/v1/repos/entrius/allways-ui/live-gate-thresholds", { headers: apiHeaders(env) }, env); + expect(missing.status).toBe(404); + await expect(missing.json()).resolves.toEqual({ error: "live_gate_thresholds_not_found", repoFullName: "entrius/allways-ui" }); + + // A live override surfaces as flat snake_case fields; audit rows and shadow queue detail must never leak. + const storageEnv = env as unknown as StorageEnv; + await writeLiveOverride(storageEnv, "entrius/allways-ui", { confidenceFloor: 0.91, scopeCap: { files: 8, lines: 250 } }); + await writeShadowOverride(storageEnv, "entrius/allways-ui", { confidenceFloor: 0.4 }, "2099-01-01T00:00:00.000Z"); + await recordOverrideAudit(storageEnv, "entrius/allways-ui", "apply", { note: "must-never-surface-on-6486" }); + const live = await app.request("/v1/repos/entrius/allways-ui/live-gate-thresholds", { headers: apiHeaders(env) }, env); + expect(live.status).toBe(200); + const liveBody = (await live.json()) as unknown; + expect(liveBody).toEqual({ + repoFullName: "entrius/allways-ui", + confidence_floor: 0.91, + scope_cap_files: 8, + scope_cap_lines: 250, + }); + expect(JSON.stringify(liveBody)).not.toMatch(/override_audit|applied_at|clear_at|must-never-surface|shadowPending|effective/i); + + // A soaking shadow's queued override fills in when live is absent (#6209's decision: AMS should see a + // pending tightening too, not just a promoted one) — and a floor-only override nulls both scope_cap fields. + const shadowOnlyEnv = createTestEnv(); + await writeShadowOverride(shadowOnlyEnv as unknown as StorageEnv, "entrius/allways-ui", { confidenceFloor: 0.66 }, "2099-01-01T00:00:00.000Z"); + const shadowed = await app.request("/v1/repos/entrius/allways-ui/live-gate-thresholds", { headers: apiHeaders(shadowOnlyEnv) }, shadowOnlyEnv); + expect(shadowed.status).toBe(200); + await expect(shadowed.json()).resolves.toEqual({ + repoFullName: "entrius/allways-ui", + confidence_floor: 0.66, + scope_cap_files: null, + scope_cap_lines: null, + }); + + // A live override carrying only a scope cap (no confidence floor) still resolves confidence_floor to null. + const scopeCapOnlyEnv = createTestEnv(); + await writeLiveOverride(scopeCapOnlyEnv as unknown as StorageEnv, "entrius/allways-ui", { scopeCap: { files: 4, lines: 120 } }); + const scopeCapOnly = await app.request("/v1/repos/entrius/allways-ui/live-gate-thresholds", { headers: apiHeaders(scopeCapOnlyEnv) }, scopeCapOnlyEnv); + expect(scopeCapOnly.status).toBe(200); + await expect(scopeCapOnly.json()).resolves.toEqual({ + repoFullName: "entrius/allways-ui", + confidence_floor: null, + scope_cap_files: 4, + scope_cap_lines: 120, + }); + + // A static mcp credential within the allowlist reads successfully (default MCP_READ_REPO_ALLOWLIST is "*"). + const mcpAllowed = await app.request("/v1/repos/entrius/allways-ui/live-gate-thresholds", { headers: mcpHeaders(env) }, env); + expect(mcpAllowed.status).toBe(200); + await expect(mcpAllowed.json()).resolves.toMatchObject({ confidence_floor: 0.91 }); + + // The shared static mcp credential is scoped to MCP_READ_REPO_ALLOWLIST, same precedent as gate-config/effective. + const forbiddenEnv = createTestEnv({ MCP_READ_REPO_ALLOWLIST: "" }); + await writeLiveOverride(forbiddenEnv as unknown as StorageEnv, "entrius/allways-ui", { confidenceFloor: 0.9 }); + const forbidden = await app.request("/v1/repos/entrius/allways-ui/live-gate-thresholds", { headers: mcpHeaders(forbiddenEnv) }, forbiddenEnv); + expect(forbidden.status).toBe(403); + await expect(forbidden.json()).resolves.toMatchObject({ error: "forbidden_repo" }); + + // Unauthenticated calls are rejected before any repo lookup. + const unauth = await app.request("/v1/repos/entrius/allways-ui/live-gate-thresholds", {}, env); + expect(unauth.status).toBe(401); + }); }); async function signWebhook(body: string, secret: string | undefined): Promise { diff --git a/test/unit/auto-apply.test.ts b/test/unit/auto-apply.test.ts index 20b6cf64b9..e7272fa0d2 100644 --- a/test/unit/auto-apply.test.ts +++ b/test/unit/auto-apply.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { type AutoApplyContext, applyOverrideRecommendation, + authoritativeGateOverride, deleteLiveOverride, deleteShadowOverride, describeOverride, @@ -16,14 +17,67 @@ import { runAutoApplyRecommendations, sanitizeOverridePayload, SHADOW_PROMOTION_MIN_DECIDED, + type ShadowOverride, type StorageEnv, type StorageLike, + toLiveGateThresholdFields, type TunableOverride, writeLiveOverride, writeShadowOverride, } from "../../src/review/auto-apply"; import type { TuningRec } from "../../src/review/auto-tune"; +describe("authoritativeGateOverride (#6486)", () => { + const live: TunableOverride = { confidenceFloor: 0.9 }; + const shadow: ShadowOverride = { override: { confidenceFloor: 0.5 }, validatedUntil: null }; + + it("prefers a present live override over a present shadow override", () => { + expect(authoritativeGateOverride(live, shadow)).toBe(live); + }); + + it("falls through to the shadow's queued override when live is absent", () => { + expect(authoritativeGateOverride(null, shadow)).toBe(shadow.override); + }); + + it("returns null when neither a live nor a shadow override is active", () => { + expect(authoritativeGateOverride(null, null)).toBeNull(); + }); +}); + +describe("toLiveGateThresholdFields (#6486)", () => { + it("returns null for a null override", () => { + expect(toLiveGateThresholdFields(null)).toBeNull(); + }); + + it("returns null for an override with neither confidenceFloor nor scopeCap set", () => { + expect(toLiveGateThresholdFields({})).toBeNull(); + }); + + it("projects confidenceFloor-only into confidence_floor, nulling both scope_cap fields", () => { + expect(toLiveGateThresholdFields({ confidenceFloor: 0.85 })).toEqual({ + confidence_floor: 0.85, + scope_cap_files: null, + scope_cap_lines: null, + }); + }); + + it("projects scopeCap-only into scope_cap_files/scope_cap_lines, nulling confidence_floor", () => { + expect(toLiveGateThresholdFields({ scopeCap: { files: 6, lines: 180 } })).toEqual({ + confidence_floor: null, + scope_cap_files: 6, + scope_cap_lines: 180, + }); + }); + + it("projects both fields when both confidenceFloor and scopeCap are set", () => { + expect(toLiveGateThresholdFields({ confidenceFloor: 0.7, scopeCap: { files: 3, lines: 90 } })).toEqual({ + confidence_floor: 0.7, + scope_cap_files: 3, + scope_cap_lines: 90, + }); + }); +}); + describe("rowToOverride (#273 — D1 row → validated override)", () => { it("maps a full row", () => { expect(rowToOverride({ confidence_floor: 0.95, scope_cap_files: 5, scope_cap_lines: 200, clear_at: null })).toEqual({