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..f5ed08c4b9 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 { deleteLiveOverride, listOverrideAudit, loadOverride, loadShadowOverride, sanitizeOverridePayload, authoritativeGateOverride, 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,27 @@ export function createApp() { }); }); + // AMS probe surface for live gate thresholds (#6486 / #6209). Field-limited snake_case payload (no audit / + // applied_at / clear_at). Live row wins; soaking shadow fills in only when live is absent. 404 when neither + // is active — same not-found convention as issue-quality. Auth matches gate-config/effective above. + app.get("/v1/repos/:owner/:repo/live-gate-thresholds", async (c) => { + const unauthorized = await requireStaticProtectedApiToken(c); + /* v8 ignore next -- both arms hit by integration 401 + success; codecov still marks this branch patch-partial across shards. */ + if (unauthorized) return unauthorized; + const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const identity = await authenticateRequestIdentity(c); + /* v8 ignore next -- requireStaticProtectedApiToken above already rejected null and session identities, so only static tokens reach here. */ + if (!identity || identity.kind !== "static") return c.json({ error: "unauthorized" }, 401); + // Only the shared, end-user-obtainable static `mcp` token is allowlist-scoped; operator-only api/internal + // tokens stay trusted — same repo-scoped read precedent the reviewability route (#6154) uses. + if (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..2b79d936e1 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1739,6 +1739,16 @@ export const IssueQualityResponseSchema = z }) .openapi("IssueQualityResponse"); +/** AMS probe payload for ORB live gate thresholds (#6486) — snake_case column names only. */ +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..c8bf157bcb 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,20 @@ 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..4c7313b99d 100644 --- a/src/review/auto-apply.ts +++ b/src/review/auto-apply.ts @@ -234,6 +234,31 @@ export interface ShadowOverride { validatedUntil: string | null; } +/** Field-limited AMS/MCP payload for live gate thresholds (#6486). Snake_case matches the + * tunables_overrides column names AMS will probe for; never includes audit/clear/applied metadata. */ +export type LiveGateThresholdFields = { + confidence_floor: number | null; + scope_cap_files: number | null; + scope_cap_lines: number | null; +}; + +/** Prefer the live override; if absent, fall through to a soaking shadow's queued override (#6486 / #6209). */ +export function authoritativeGateOverride(live: TunableOverride | null, shadow: ShadowOverride | null): TunableOverride | null { + if (live) return live; + if (shadow) return shadow.override; + return null; +} + +/** Project an authoritative TunableOverride into the exact snake_case allowlist, or null when none is active. */ +export function toLiveGateThresholdFields(override: TunableOverride | null): LiveGateThresholdFields | null { + if (!override) return null; + const confidence_floor = override.confidenceFloor === undefined ? null : override.confidenceFloor; + const scope_cap_files = override.scopeCap === undefined ? null : override.scopeCap.files; + const scope_cap_lines = override.scopeCap === undefined ? null : override.scopeCap.lines; + if (confidence_floor === null && scope_cap_files === null && scope_cap_lines === null) return null; + return { confidence_floor, scope_cap_files, scope_cap_lines }; +} + /** Internal: raw row fetch shared by loadShadowOverride + writeShadowOverride, so a write can preserve the * existing clear_at column (ShadowOverride, loadShadowOverride's public return, doesn't carry clear_at). * Fail-safe: null on a DB blip. */ diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index d7020df770..f7304690ad 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -6787,6 +6787,53 @@ 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 AMS probe (#6486)", async () => { + const app = createApp(); + const env = createTestEnv(); + 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" }); + + 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 Record; + 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/i); + + // Shadow-only fills in when live is absent. + const shadowOnlyEnv = createTestEnv(); + await writeShadowOverride(shadowOnlyEnv as unknown as StorageEnv, "entrius/allways-ui", { confidenceFloor: 0.66, scopeCap: { files: 3, lines: 90 } }, "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: 3, + scope_cap_lines: 90, + }); + + const mcpAllowed = await app.request("/v1/repos/entrius/allways-ui/live-gate-thresholds", { headers: mcpHeaders(env) }, env); + expect(mcpAllowed.status).toBe(200); + + 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" }); + + 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..131995c5eb 100644 --- a/test/unit/auto-apply.test.ts +++ b/test/unit/auto-apply.test.ts @@ -18,12 +18,52 @@ import { SHADOW_PROMOTION_MIN_DECIDED, type StorageEnv, type StorageLike, + toLiveGateThresholdFields, + authoritativeGateOverride, type TunableOverride, writeLiveOverride, writeShadowOverride, } from "../../src/review/auto-apply"; import type { TuningRec } from "../../src/review/auto-tune"; +describe("live gate threshold projection (#6486)", () => { + it("prefers live over shadow and projects snake_case fields only", () => { + const live = { confidenceFloor: 0.9, scopeCap: { files: 10, lines: 300 } }; + const shadow = { override: { confidenceFloor: 0.5 }, validatedUntil: "2099-01-01T00:00:00.000Z" }; + expect(authoritativeGateOverride(live, shadow)).toEqual(live); + expect(toLiveGateThresholdFields(live)).toEqual({ + confidence_floor: 0.9, + scope_cap_files: 10, + scope_cap_lines: 300, + }); + }); + + it("falls through to shadow when live is absent, and returns null when neither is active", () => { + const shadow = { override: { confidenceFloor: 0.7 }, validatedUntil: null }; + expect(toLiveGateThresholdFields(authoritativeGateOverride(null, shadow))).toEqual({ + confidence_floor: 0.7, + scope_cap_files: null, + scope_cap_lines: null, + }); + expect(toLiveGateThresholdFields(authoritativeGateOverride(null, null))).toBeNull(); + expect(toLiveGateThresholdFields(null)).toBeNull(); + }); + + it("projects a scopeCap-only override and rejects an empty override object", () => { + expect(toLiveGateThresholdFields({ scopeCap: { files: 4, lines: 120 } })).toEqual({ + confidence_floor: null, + scope_cap_files: 4, + scope_cap_lines: 120, + }); + expect(toLiveGateThresholdFields({ confidenceFloor: 0.81 })).toEqual({ + confidence_floor: 0.81, + scope_cap_files: null, + scope_cap_lines: null, + }); + expect(toLiveGateThresholdFields({})).toBeNull(); + }); +}); + 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({