Skip to content
Merged
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
54 changes: 52 additions & 2 deletions apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -19217,10 +19217,10 @@
"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)"
"description": "Window verified clean; nextAfterSeq is the resume cursor (null at the tip). Every response also carries tipSeq/tipHash/totalCount for third-party checkpointing."
},
"409": {
"description": "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch"
"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": [
Expand All @@ -19232,6 +19232,56 @@
}
]
}
},
"/v1/public/decision-records/{owner}/{repo}/{pull}": {
"get": {
"summary": "Fetch the latest published decision record for a PR, verbatim, plus its content digest",
"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": []
}
],
"parameters": [
{
"schema": {
"type": "string"
},
"required": true,
"name": "owner",
"in": "path"
},
{
"schema": {
"type": "string"
},
"required": true,
"name": "repo",
"in": "path"
},
{
"schema": {
"type": "string"
},
"required": true,
"name": "pull",
"in": "path"
}
]
}
}
},
"servers": [
Expand Down
44 changes: 40 additions & 4 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ import { getContributorTrustProfile } from "../review/contributor-trust-profile"
import { backfillContributorGateHistory } from "../review/contributor-gate-history-backfill";
import { isFairnessAnalyticsEnabled, resolveFairnessAnalyticsManifestOverride } from "../review/contributor-trust-profile-wire";
import { isRagEnabled } from "../review/rag-wire";
import { verifyDecisionLedger } from "../review/decision-record";
import { loadPublicDecisionRecord, verifyDecisionLedger } from "../review/decision-record";
import { getPublicStats, isPublicStatsEnabled, resolvePublicStatsManifestOverride } from "../review/public-stats";
import { loadPublicAccuracyTrend } from "../services/public-accuracy-trend";
import { loadPublicRulePrecision } from "../review/public-rule-precision";
Expand Down Expand Up @@ -1263,16 +1263,43 @@ export function createApp() {
}
});

// #8837: public chain-verification for the decision ledger. Hashes/ids only — no record contents — so it
// is safe unauthenticated; any observer can confirm no decision was deleted, reordered, or rewritten.
// Resumable: pass afterSeq from the previous response's nextAfterSeq until it returns null.
// #8837: public chain-verification for the decision ledger. Hashes/ids only — no record contents — so it is
// unauthenticated by design (fixed in #9120: this route was missing from the requiresApiToken exemption
// list below and 401'd in prod despite this comment always having claimed otherwise). #9122 correction: this
// only proves SELF-consistency (no reorder/rewrite/gap WITHIN what verify examined) plus — since this
// route's own fix — that no decision_records row exists past the verified tip with no chain entry over it
// (catches a truncated tail, see verifyDecisionLedger's own doc comment). It does NOT prove the operator
// never deleted the chain wholesale and started fresh from genesis; that needs an external anchor the
// operator does not control, which is tracked but not yet built (decision-record.ts's module header has the
// full honest-limit paragraph). Resumable: pass afterSeq from the previous response's nextAfterSeq until it
// returns null; every response also carries the CURRENT tipSeq/tipHash/totalCount so a third party can keep
// its own checkpoint independent of pagination position.
app.get("/v1/public/decision-ledger/verify", async (c) => {
const afterSeq = Math.max(0, Number(c.req.query("afterSeq")) || 0);
const limit = Number(c.req.query("limit")) || 500;
const result = await verifyDecisionLedger(c.env, afterSeq, limit);
return c.json(result, result.ok ? 200 : 409);
});

// #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
// decidedAt/baseSha/salvageability/repoFullName/pullNumber entirely). DecisionRecord is public-safe BY
// CONSTRUCTION (its own type doc: counts/digests/enums only, no diffs, no private config contents, no
// wallet/hotkey/trust-score/reward fields) — no field-level redaction needed before exposing it verbatim,
// unlike a route touching a type that carries any of those. Unauthenticated by design, mirroring the
// ledger-verify route immediately above; excluded from requiresApiToken below.
app.get("/v1/public/decision-records/:owner/:repo/:pull", async (c) => {
const owner = c.req.param("owner");
const repo = c.req.param("repo");
const pullNumber = Number(c.req.param("pull"));
if (!Number.isInteger(pullNumber) || pullNumber <= 0) return c.json({ error: "invalid_pull_number" }, 400);
const published = await loadPublicDecisionRecord(c.env, `${owner}/${repo}`, pullNumber);
if (!published) return c.json({ error: "not_found" }, 404);
c.header("Cache-Control", "public, max-age=60, stale-while-revalidate=300");
return c.json({ record: published.record, recordDigest: published.recordDigest });
});

app.get("/v1/public/github/repos/:owner/:repo/stats", async (c) => {
try {
const stats = await fetchPublicRepoStats(c.env, c.req.param("owner"), c.req.param("repo"));
Expand Down Expand Up @@ -6639,6 +6666,15 @@ function requiresApiToken(path: string): boolean {
if (/^\/v1\/public\/repos\/[^/]+\/[^/]+\/quality$/.test(path)) return false;
if (path === "/v1/public/subnet-interface") return false;
if (path === "/v1/public/stats") return false;
// #9120: this route's own doc comment always claimed "safe unauthenticated" but was missing from this exact
// list, so it 401'd in prod — verified live: subnet-interface/stats/quality answered 200, this one 401'd.
if (path === "/v1/public/decision-ledger/verify") 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
// return, not the auth gate's 401 — mirrors every other dynamic-segment exemption below (owner/repo use the
// same unvalidated [^/]+).
if (/^\/v1\/public\/decision-records\/[^/]+\/[^/]+\/[^/]+$/.test(path)) return false;
if (path === "/openapi.json") return false;
if (path === "/mcp") return false;
// Public OAuth draft-submission flow (LOOPOVER_REVIEW_DRAFT): the submission entry points are unauthenticated
Expand Down
15 changes: 13 additions & 2 deletions src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1001,8 +1001,19 @@ export function buildOpenApiSpec() {
path: "/v1/public/decision-ledger/verify",
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)" },
409: { description: "First break found: sequence_gap | predecessor_mismatch | row_hash_mismatch" },
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." },
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)" },
},
});
registry.registerPath({
method: "get",
path: "/v1/public/decision-records/{owner}/{repo}/{pull}",
summary: "Fetch the latest published decision record for a PR, verbatim, plus its content digest",
request: { params: z.object({ owner: z.string(), repo: z.string(), pull: z.string() }) },
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" },
},
});
registry.registerPath({
Expand Down
48 changes: 40 additions & 8 deletions src/queue/processors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2907,6 +2907,12 @@ async function maybeCloseForContributorCapOnOpen(
moderationWarningLabel: settings.moderationWarningLabel,
moderationBannedLabel: settings.moderationBannedLabel,
},
// #9134: this early cap-on-open close previously wrote NO decision record at all — the exact bias the
// issue flagged (a contributor most likely to dispute a cap close had nothing joinable in the
// risk-control calibration set). The generic policy_close:contributor_cap reasonCode derivation
// (agent-action-executor.ts's defaultDecisionRecordReasonCode) is exactly right here — this path has
// no gate evaluation to derive a richer blockerClass-based code from.
decisionRecord: { configDigest: await contentDigest(settings) },
},
planned,
);
Expand Down Expand Up @@ -3438,11 +3444,18 @@ async function runAgentMaintenancePlanAndExecute(
// #8836/#8834: the content-addressed decision record — the public commitment that lets a contributor
// argue "clause X of config abc123 decided this". Persist-only here (best-effort inside); the same
// reasonCode expression as recordNativeGateDecision above so the record and the calibration row can never
// disagree about WHY. When an AI JUDGMENT shaped this decision (an AI_JUDGMENT_BLOCKER_CODES finding is
// present), the record carries the prompt-template commitment and the finding's calibrated confidence —
// the confidence is what joins every decision to the risk-control calibration set (#8835). modelId stays
// null at this site: the finding does not carry which concrete models ran, and recording a guess would be
// worse than recording nothing (the reviewDiagnostics ledger holds the per-run model identities).
// disagree about WHY. Built and persisted UNCONDITIONALLY for whatever disposition.actionClass this pass
// resolved to (merge/close/HOLD) — a hold never reaches executeAgentMaintenanceActions at all (this function
// returns early below once holdoutOnPlan is empty), so this is the only place a hold's decision record can
// be written; merge/close records built here stay independent of whether the ACTUAL mutation below ends up
// completing (#9134: this site's own ctx sets `decisionRecord: { managedByCaller: true }` on its
// executeAgentMaintenanceActions call, deliberately opting OUT of that executor's own completed-action
// record hook, to avoid double-recording the same decision here and there). When an AI JUDGMENT shaped this
// decision (an AI_JUDGMENT_BLOCKER_CODES finding is present), the record carries the prompt-template
// commitment and the finding's calibrated confidence — the confidence is what joins every decision to the
// risk-control calibration set (#8835). modelId stays null at this site: the finding does not carry which
// concrete models ran, and recording a guess would be worse than recording nothing (the reviewDiagnostics
// ledger holds the per-run model identities).
{
const aiJudgment = gate.blockers.find((blocker) => AI_JUDGMENT_BLOCKER_CODES.has(blocker.code));
// #8962: recomputed here (two cheap reads, only when an AI judgment shaped the decision) rather than
Expand All @@ -3465,11 +3478,13 @@ async function runAgentMaintenancePlanAndExecute(
aiConfidence: aiJudgment?.confidence ?? null,
salvageability,
});
await persistDecisionRecord(env, record, recordDigest);
const recordId = await persistDecisionRecord(env, record, recordDigest);
// #8838: persist the evaluation's own exact inputs beside the record (PRIVATE sibling, migration 0181)
// so the replay harness can re-derive this decision bit-exactly. Best-effort, like the record itself;
// the no-replay no-op (synthetic content-lane/bridge evaluations) lives inside the helper.
await persistDecisionReplayInputForGate(env, `record:${record.repoFullName}#${record.pullNumber}@${record.headSha}`.slice(0, 250), gate, policyCloseKind ?? null);
// the no-replay no-op (synthetic content-lane/bridge evaluations) lives inside the helper. Keyed to the
// id persistDecisionRecord actually wrote (#9123: a supersession at the same head gets a revisioned id,
// not the base one this used to always recompute independently).
if (recordId !== null) await persistDecisionReplayInputForGate(env, recordId, gate, policyCloseKind ?? null);
}
// #2349 (PR 1): additive per-contributor calibration data, gated identically to recordNativeGateDecision
// above -- see src/review/contributor-calibration.ts's doc comment. Currently write-only; nothing reads
Expand Down Expand Up @@ -3661,6 +3676,11 @@ async function runAgentMaintenancePlanAndExecute(
// #3472 split-brain: the executor's own live manual-review hold guard (immediately before approve/merge)
// must check the SAME configured label the planner itself resolves labels.manualReview from.
manualReviewLabel: settings.manualReviewLabel,
// #9134: this site already builds + persists its own decision record UNCONDITIONALLY, above (for every
// disposition — hold included, since a hold never even reaches this call) — see that block's own doc
// comment for why. `managedByCaller: true` deliberately opts OUT of the executor's generic
// completed-action record hook here, so the same decision is never recorded twice.
decisionRecord: { managedByCaller: true },
},
holdoutOnPlan,
);
Expand Down Expand Up @@ -4020,6 +4040,9 @@ async function prReadyForReview(
agentDryRun: settings.agentDryRun,
installationPermissions: installation?.permissions ?? null,
authorLogin: pr.authorLogin,
// #9134: required on every ctx, though this path only ever plans `update_branch` (never merge/close),
// so the decision-record path inside the executor is never actually exercised for it.
decisionRecord: { configDigest: await contentDigest(settings) },
},
[
{
Expand Down Expand Up @@ -4464,6 +4487,9 @@ async function maybeForceFreshRebase(
/* v8 ignore next -- an installed-App PR webhook always carries an installation record; the null is defensive (mirrors runAgentMaintenancePlanAndExecute's own identical merge-time read). */
installationPermissions: installation?.permissions ?? null,
authorLogin: pr.authorLogin,
// #9134: required on every ctx, though this path only ever plans `update_branch` (never merge/close), so
// the decision-record path inside the executor is never actually exercised for it.
decisionRecord: { configDigest: await contentDigest(settings) },
},
[
{
Expand Down Expand Up @@ -14008,6 +14034,9 @@ async function maybeThrottleReviewNagPing(
installationPermissions: installation?.permissions ?? null,
authorLogin: pr.authorLogin,
moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel },
// #9134: this review-nag close previously wrote NO decision record at all -- exactly the kind of
// contributor-disputable close the issue flagged as biasing the risk-control calibration join.
decisionRecord: { configDigest: await contentDigest(settings) },
},
planned,
);
Expand Down Expand Up @@ -14199,6 +14228,9 @@ async function maybeThrottleMonitoredMentions(
installationPermissions: installation?.permissions ?? null,
authorLogin: pr.authorLogin,
moderationSettings: { moderationGateMode: settings.moderationGateMode, moderationRules: settings.moderationRules, moderationWarningLabel: settings.moderationWarningLabel, moderationBannedLabel: settings.moderationBannedLabel },
// #9134: this review-nag close (the @loopover-mention variant) previously wrote NO decision record at
// all -- the same gap as its comment-thread-cooldown sibling immediately above.
decisionRecord: { configDigest: await contentDigest(settings) },
},
planned,
);
Expand Down
Loading
Loading