Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down Expand Up @@ -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": [
Expand Down
23 changes: 22 additions & 1 deletion src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 10 additions & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
16 changes: 16 additions & 0 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
IssueQualityResponseSchema,
LabelAuditSchema,
LaneAdviceSchema,
LiveGateThresholdsResponseSchema,
LocalBranchAnalysisSchema,
LocalDiffPreflightResultSchema,
MaintainerPacketSchema,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions src/review/auto-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
47 changes: 47 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, 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/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<string> {
Expand Down
40 changes: 40 additions & 0 deletions test/unit/auto-apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading