Skip to content

Commit a80e7ae

Browse files
committed
feat(review): per-repo opt-in to let a confident AI-judgment blocker gate the merge
Content/registry repos (metagraphed today) have no schema/lint/codecov net to catch a semantically-wrong-but-structurally-valid defect -- their own AI reviewer's judgment is the only thing that ever catches it, yet applySurfaceGate's guard #3 lets a decisive deterministic surface merge unconditionally override even a confidently-flagged AI-judgment blocker. Adds gate.aiJudgmentBlockers: "gate" | "advisory" (default "advisory", byte-identical everywhere that doesn't opt in), YML-only config mirroring contentLane's own shape since this only matters for repos already running the registry content lane. When "gate", the AI-judgment-only override is skipped and the finding survives into the deterministic gate's own blockers, demoting the decision away from merge -- reproducing exactly the PR #3910 shape (correct-in-prose, wrong-in-disposition) as structurally impossible once a repo opts in. Setting metagraphed's own .gittensory.yml to gate.aiJudgmentBlockers: "gate" is a separate follow-up PR in that repo, gated on this shipping.
1 parent 11fc55b commit a80e7ae

6 files changed

Lines changed: 172 additions & 6 deletions

File tree

.gittensory.yml.example

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,19 @@ gate:
230230
- build
231231
- test
232232

233+
# Promote a confident AI-judgment-only finding (one the reviewer itself placed under "Blockers", never
234+
# a "Nit") into a real, deterministic gate blocker instead of leaving it advisory (#3907). Only matters
235+
# for repos already running the registry content lane (see contentLane below) — content/registry repos
236+
# have no schema/lint/codecov net to catch a semantically-wrong-but-structurally-valid defect, so their
237+
# own AI reviewer's judgment is the only thing that ever catches it.
238+
# advisory — today's behavior: a decisive deterministic content-lane merge overrides an AI-judgment-only
239+
# failure (an AI opinion alone never one-shot-closes a structurally-clean submission).
240+
# gate — the AI-judgment finding survives into the gate's own blockers, demoting the decision away
241+
# from merge. Reopens the risk an AI hallucination can one-shot-close a clean PR — an
242+
# explicit, per-repo trade-off, never the default.
243+
# gate | advisory. Default: advisory. Config-as-code only — no DB column or dashboard toggle.
244+
aiJudgmentBlockers: advisory
245+
233246
# Composite merge-readiness gate (no min score).
234247
# off | advisory | block. Default: off.
235248
mergeReadiness: off

config/examples/gittensory.full.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,19 @@ gate:
243243
- build
244244
- test
245245

246+
# Promote a confident AI-judgment-only finding (one the reviewer itself placed under "Blockers", never
247+
# a "Nit") into a real, deterministic gate blocker instead of leaving it advisory (#3907). Only matters
248+
# for repos already running the registry content lane (see contentLane below) — content/registry repos
249+
# have no schema/lint/codecov net to catch a semantically-wrong-but-structurally-valid defect, so their
250+
# own AI reviewer's judgment is the only thing that ever catches it.
251+
# advisory — today's behavior: a decisive deterministic content-lane merge overrides an AI-judgment-only
252+
# failure (an AI opinion alone never one-shot-closes a structurally-clean submission).
253+
# gate — the AI-judgment finding survives into the gate's own blockers, demoting the decision away
254+
# from merge. Reopens the risk an AI hallucination can one-shot-close a clean PR — an
255+
# explicit, per-repo trade-off, never the default.
256+
# gate | advisory. Default: advisory. Config-as-code only — no DB column or dashboard toggle.
257+
aiJudgmentBlockers: advisory
258+
246259
# Composite merge-readiness gate (no min score).
247260
# off | advisory | block. Default: off.
248261
mergeReadiness: off

packages/gittensory-engine/src/focus-manifest.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,17 @@ export type FocusManifestGateConfig = {
162162
* (unset) ⇒ no generic fallback configured — the live-CI aggregate keeps today's fold-all behavior
163163
* when branch protection is also unreadable. See {@link RepositorySettings.expectedCiContexts}. */
164164
expectedCiContexts: ReadonlyArray<string> | null;
165+
/** `gate.aiJudgmentBlockers` (#3907): "gate" | "advisory", null (unset) ⇒ "advisory" (byte-identical to
166+
* today everywhere that doesn't opt in). Config-as-code only, YML-only (no DB column, no dashboard
167+
* toggle) — mirrors `contentLane`'s own YML-only shape, since this only has an effect for repos already
168+
* running the registry content lane. When "gate", a confident AI-judgment-only finding that
169+
* applySurfaceGate's default "advisory" behavior would otherwise let a decisive surface merge override
170+
* instead SURVIVES into the deterministic gate's own blockers array, demoting `decision` away from
171+
* `merge` — see content-lane-wire.ts's `applySurfaceGate` guard #3 and `evaluateWithSurfaceLane` for the
172+
* wiring. This deliberately reopens exactly the risk #2592 accepted for the general case (an AI
173+
* hallucination can one-shot-close a structurally-clean PR) as an explicit, per-repo, documented
174+
* trade-off — never the default. */
175+
aiJudgmentBlockersMode: "gate" | "advisory" | null;
165176
};
166177

167178
// The converged per-PR review features a self-host operator toggles PER-REPO under `features:` in the private
@@ -845,6 +856,7 @@ const EMPTY_GATE_CONFIG: FocusManifestGateConfig = {
845856
claCheckRunName: null,
846857
claCheckRunAppSlug: null,
847858
expectedCiContexts: null,
859+
aiJudgmentBlockersMode: null,
848860
};
849861

850862
const EMPTY_FEATURES_CONFIG: FocusManifestFeaturesConfig = {
@@ -1176,6 +1188,7 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
11761188
claCheckRunName: parsePublicSafeText(claRecord?.checkRunName, "gate.cla.checkRunName", warnings),
11771189
claCheckRunAppSlug: parsePublicSafeText(claRecord?.checkRunAppSlug, "gate.cla.checkRunAppSlug", warnings),
11781190
expectedCiContexts: normalizeOptionalStringList(record.expectedCiContexts, "gate.expectedCiContexts", warnings),
1191+
aiJudgmentBlockersMode: normalizeOptionalEnum(record.aiJudgmentBlockers, "gate.aiJudgmentBlockers", ["gate", "advisory"] as const, warnings),
11791192
};
11801193
// #2266: the flag is parsed, clamped, and threaded end-to-end, but the gate evaluator never reads it — a
11811194
// maintainer who sets it to true believing it softens a blocker for newcomers gets no such effect. Surface
@@ -1218,7 +1231,8 @@ function parseGateConfig(value: JsonValue | undefined, warnings: string[]): Focu
12181231
gate.claConsentPhrase !== null ||
12191232
gate.claCheckRunName !== null ||
12201233
gate.claCheckRunAppSlug !== null ||
1221-
gate.expectedCiContexts !== null;
1234+
gate.expectedCiContexts !== null ||
1235+
gate.aiJudgmentBlockersMode !== null;
12221236
return gate;
12231237
}
12241238

@@ -1293,6 +1307,7 @@ export function gateConfigToJson(gate: FocusManifestGateConfig): JsonValue {
12931307
out.cla = cla;
12941308
}
12951309
if (gate.expectedCiContexts !== null) out.expectedCiContexts = gate.expectedCiContexts as JsonValue;
1310+
if (gate.aiJudgmentBlockersMode !== null) out.aiJudgmentBlockers = gate.aiJudgmentBlockersMode;
12961311
return out;
12971312
}
12981313

src/review/content-lane-wire.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ export function surfaceVerdictToGate(result: SurfaceReviewResult): {
102102
* result rather than silently dropped — see `evaluateWithSurfaceLane` for the companion `advisory.findings`
103103
* cleanup that keeps the public comment from re-surfacing the overridden AI defect via a separate path.
104104
*
105+
* `opts.aiJudgmentBlockersMode` (#3907): a per-repo `.gittensory.yml` `gate.aiJudgmentBlockers` opt-in that
106+
* SKIPS this exception when set to `"gate"` — an AI-judgment-only failure then falls through to the union
107+
* below like any other blocker, letting a confidently-flagged content-correctness defect actually gate the
108+
* merge. Default (`null`/`undefined`/`"advisory"`) preserves this exception exactly as documented above,
109+
* byte-identical to pre-#3907 behavior for every repo that doesn't opt in.
110+
*
105111
* A second, analogous exception (guard #4) applies when the generic gate's blockers are ALL duplicate-only
106112
* (a same-linked-issue `duplicate_pr_risk` finding escalated into a blocker by `duplicatePrGateMode: "block"`,
107113
* see `isDuplicateOnlyFailure`): a decisive surface merge downgrades that failure to a HOLD (neutral) rather than
@@ -117,6 +123,7 @@ export function surfaceVerdictToGate(result: SurfaceReviewResult): {
117123
export function applySurfaceGate(
118124
generic: GateCheckEvaluation | undefined,
119125
surface: GateCheckEvaluation | null,
126+
opts?: { aiJudgmentBlockersMode?: "gate" | "advisory" | null | undefined },
120127
): GateCheckEvaluation | undefined {
121128
if (surface === null) return generic;
122129
if (!generic) return surface; // gate off → surface stands
@@ -127,7 +134,11 @@ export function applySurfaceGate(
127134
if (surface.conclusion === "success") return generic;
128135
return surface;
129136
}
130-
if (isAiJudgmentOnlyFailure(generic) && surface.conclusion === "success") {
137+
// #3907: opt-in escape hatch from guard #3 below. Default (null/undefined/"advisory") preserves today's
138+
// behavior byte-identically. "gate" skips the override entirely, so an AI-judgment-only failure falls
139+
// through to the unconditional union+failure return at the bottom of this function like any other
140+
// blocker — the opted-in repo's own AI reviewer becomes a real, deterministic-gate-blocking signal.
141+
if (opts?.aiJudgmentBlockersMode !== "gate" && isAiJudgmentOnlyFailure(generic) && surface.conclusion === "success") {
131142
return { ...surface, warnings: [...generic.warnings, ...surface.warnings] };
132143
}
133144
if (isDuplicateOnlyFailure(generic) && surface.conclusion === "success") {
@@ -295,8 +306,16 @@ export async function evaluateWithSurfaceLane(
295306
advisory: args.advisory,
296307
files: await args.getChangedFiles(),
297308
});
298-
const result = applySurfaceGate(gateEvaluation, surfaceGate);
299-
if (gateEvaluation && surfaceGate?.conclusion === "success" && isAiJudgmentOnlyFailure(gateEvaluation)) {
309+
// #3907: null/undefined manifest.gate is treated the same as an explicit "advisory" — see
310+
// applySurfaceGate's own doc comment for what "gate" mode does.
311+
const aiJudgmentBlockersMode = manifest?.gate.aiJudgmentBlockersMode ?? undefined;
312+
const result = applySurfaceGate(gateEvaluation, surfaceGate, { aiJudgmentBlockersMode });
313+
if (
314+
aiJudgmentBlockersMode !== "gate" &&
315+
gateEvaluation &&
316+
surfaceGate?.conclusion === "success" &&
317+
isAiJudgmentOnlyFailure(gateEvaluation)
318+
) {
300319
args.advisory.findings = args.advisory.findings.filter((finding) => !AI_JUDGMENT_BLOCKER_CODES.has(finding.code));
301320
}
302321
return result;

test/unit/content-lane-wire.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,32 @@ describe("applySurfaceGate", () => {
130130
expect(out?.conclusion).toBe("failure");
131131
expect(out?.blockers).toEqual([split, ...surfaceClose.blockers]); // union — the AI-only exception only applies to a surface MERGE
132132
});
133+
it("#3907: aiJudgmentBlockersMode 'gate' skips the AI-judgment-only override — the finding survives into the failure union, reproducing PR #3910's shape", () => {
134+
// The exact repro this issue is about: a confidently-flagged, correct AI-judgment finding (a registry
135+
// provider slug semantically wrong for its domain) that the deterministic surface lane's own schema/shape
136+
// scan can never catch, so only AI judgment ever surfaces it.
137+
const providerMisattribution: AdvisoryFinding = {
138+
code: "ai_consensus_defect",
139+
title: "AI reviewers agree on a likely critical defect",
140+
severity: "critical",
141+
detail: "provider is set to \"gittensory\" (an unrelated tool's slug) instead of \"gittensor\"",
142+
};
143+
const genericAiOnly = gate({ conclusion: "failure", blockers: [providerMisattribution], warnings: [] });
144+
const surfaceMerge = gate({ conclusion: "success", title: "Surface", summary: "structurally valid entry" });
145+
const out = applySurfaceGate(genericAiOnly, surfaceMerge, { aiJudgmentBlockersMode: "gate" });
146+
// Opted in: the AI-judgment finding is NOT overridden — decision is no longer merge.
147+
expect(out?.conclusion).toBe("failure");
148+
expect(out?.blockers).toEqual([providerMisattribution]);
149+
});
150+
it("#3907: aiJudgmentBlockersMode 'advisory' (explicit) behaves identically to the default (unset) — byte-identical override", () => {
151+
const aiConsensusDefect: AdvisoryFinding = { code: "ai_consensus_defect", title: "AI defect", severity: "critical", detail: "" };
152+
const genericAiOnly = gate({ conclusion: "failure", blockers: [aiConsensusDefect], warnings: [] });
153+
const surfaceMerge = gate({ conclusion: "success", title: "Surface", summary: "valid entry" });
154+
const withExplicitAdvisory = applySurfaceGate(genericAiOnly, surfaceMerge, { aiJudgmentBlockersMode: "advisory" });
155+
const withDefault = applySurfaceGate(genericAiOnly, surfaceMerge);
156+
expect(withExplicitAdvisory).toEqual(withDefault);
157+
expect(withExplicitAdvisory?.conclusion).toBe("success");
158+
});
133159
it("a MIXED generic failure (an AI-judgment code plus a real blocker) is not AI-judgment-only — still overrides a surface merge", () => {
134160
const secret: AdvisoryFinding = { code: "secret_leak", title: "Secret", severity: "critical", detail: "leaked key" };
135161
const aiConsensusDefect: AdvisoryFinding = { code: "ai_consensus_defect", title: "AI defect", severity: "critical", detail: "" };
@@ -366,6 +392,85 @@ describe("evaluateWithSurfaceLane (the processor seam helper)", () => {
366392
expect(advisory.findings).toEqual([otherWarning]);
367393
});
368394

395+
it("#3907 REGRESSION: on an opted-in repo (gate.aiJudgmentBlockers: 'gate'), a confident AI-judgment-only finding survives a decisive surface merge — decision is no longer merge, and the finding stays in advisory.findings for the public comment", async () => {
396+
const bodies: Record<string, string> = {
397+
"HEAD:registry/subnets/foo.json": doc([existing, newEntry]),
398+
"BASE:registry/subnets/foo.json": doc([existing]),
399+
};
400+
vi.stubGlobal("fetch", async (url: string | URL) => {
401+
const m = /\/contents\/(.+)\?ref=(.+)$/.exec(String(url));
402+
if (!m) return new Response("nope", { status: 404 });
403+
const path = m[1]!.split("/").map(decodeURIComponent).join("/");
404+
const body = bodies[`${decodeURIComponent(m[2]!)}:${path}`];
405+
return body === undefined ? new Response("missing", { status: 404 }) : new Response(body);
406+
});
407+
const providerMisattribution: AdvisoryFinding = {
408+
code: "ai_consensus_defect",
409+
title: "AI reviewers agree on a likely critical defect",
410+
severity: "critical",
411+
detail: "provider is set to \"gittensory\" instead of \"gittensor\"",
412+
};
413+
const advisory = { findings: [providerMisattribution] };
414+
const genericAiOnly = gate({ conclusion: "failure", blockers: [providerMisattribution], warnings: [] });
415+
const wiredEnv = { GITTENSORY_REVIEW_CONTENT_LANE: "true", GITTENSORY_REVIEW_REPOS: REPO } as unknown as Env;
416+
const optedInManifest = (): Promise<FocusManifest> =>
417+
Promise.resolve(parseFocusManifest({ wantedPaths: ["src/"], gate: { aiJudgmentBlockers: "gate" } }));
418+
const out = await evaluateWithSurfaceLane(
419+
wiredEnv,
420+
REPO,
421+
true,
422+
genericAiOnly,
423+
{
424+
installationId: null,
425+
pr: { headSha: "HEAD", baseRef: "BASE" },
426+
repo: { defaultBranch: "main" },
427+
advisory,
428+
getChangedFiles: async () => [{ path: SUBNET, status: "modified" }],
429+
},
430+
optedInManifest,
431+
);
432+
// The core deliverable: opted in, the AI-judgment finding is no longer overridden by the clean surface merge.
433+
expect(out?.conclusion).not.toBe("success");
434+
expect(out?.blockers).toEqual([providerMisattribution]);
435+
// Unlike the opted-out REGRESSION test above, the finding must NOT be stripped from advisory.findings —
436+
// it's now a real blocker, so the public comment needs to keep showing it.
437+
expect(advisory.findings).toEqual([providerMisattribution]);
438+
});
439+
440+
it("#3907: an opted-OUT repo (no gate.aiJudgmentBlockers configured) is unaffected — byte-identical to the pre-#3907 REGRESSION test above", async () => {
441+
const bodies: Record<string, string> = {
442+
"HEAD:registry/subnets/foo.json": doc([existing, newEntry]),
443+
"BASE:registry/subnets/foo.json": doc([existing]),
444+
};
445+
vi.stubGlobal("fetch", async (url: string | URL) => {
446+
const m = /\/contents\/(.+)\?ref=(.+)$/.exec(String(url));
447+
if (!m) return new Response("nope", { status: 404 });
448+
const path = m[1]!.split("/").map(decodeURIComponent).join("/");
449+
const body = bodies[`${decodeURIComponent(m[2]!)}:${path}`];
450+
return body === undefined ? new Response("missing", { status: 404 }) : new Response(body);
451+
});
452+
const aiConsensusDefect: AdvisoryFinding = { code: "ai_consensus_defect", title: "AI defect", severity: "critical", detail: "hallucinated" };
453+
const advisory = { findings: [aiConsensusDefect] };
454+
const genericAiOnly = gate({ conclusion: "failure", blockers: [aiConsensusDefect], warnings: [] });
455+
const wiredEnv = { GITTENSORY_REVIEW_CONTENT_LANE: "true", GITTENSORY_REVIEW_REPOS: REPO } as unknown as Env;
456+
const out = await evaluateWithSurfaceLane(
457+
wiredEnv,
458+
REPO,
459+
true,
460+
genericAiOnly,
461+
{
462+
installationId: null,
463+
pr: { headSha: "HEAD", baseRef: "BASE" },
464+
repo: { defaultBranch: "main" },
465+
advisory,
466+
getChangedFiles: async () => [{ path: SUBNET, status: "modified" }],
467+
},
468+
noConfigManifest, // same zero-config manifest as the default-behavior tests above
469+
);
470+
expect(out?.conclusion).toBe("success"); // default advisory behavior: override still fires
471+
expect(advisory.findings).toEqual([]); // stripped, same as the pre-#3907 REGRESSION test
472+
});
473+
369474
it("does NOT touch advisory.findings when the surface lane defers or the generic gate isn't AI-judgment-only", async () => {
370475
const aiConsensusDefect: AdvisoryFinding = { code: "ai_consensus_defect", title: "AI defect", severity: "critical", detail: "" };
371476
const secret: AdvisoryFinding = { code: "secret_leak", title: "Secret", severity: "critical", detail: "leaked" };

0 commit comments

Comments
 (0)