Skip to content

Commit e3e083f

Browse files
committed
feat(miner-governor): gated-submission trigger requiring predicted-gate PASS + slop-under-threshold
Adds shouldSubmit (#2336): THE safety-critical chokepoint of Phase 4 -- the actual decision point that decides "call gittensory_open_pr NOW" for an autonomous run. Conservatively requires BOTH a clean predicted- gate pass AND a slop band at or under a configurable threshold before a local-write open_pr action spec is ever built. Any ambiguity (a missing/errored signal) resolves to NOT submitting -- never defaults to allow. Runs before buildOpenPrSpec (src/mcp/local-write-tools.ts) is ever called, i.e. before the Governor chokepoint (#2340) ever sees an open_pr action spec. Complementary, not redundant: this gates on content-quality signals (predicted-gate conclusion, slop risk) specific to the candidate diff; the chokepoint gates on resource/ governance signals (rate-limit, budget, reputation, self-plagiarism, dry-run mode) that apply to every write action class. Input shape mirrors SelfReviewVerdict's own fields (self-review-adapter.ts, #2334) so a caller can pass the same verdict the iterate-loop's self-review (#2333) already computed at handoff time, as a defense-in-depth re-check immediately before submission. A dry-run/observe mode (mirroring src/settings/autonomy.ts's deny-by- default AUTONOMY_LEVELS dial) structurally forces allow: false regardless of the underlying signals, for safe rollout of this function's own thresholds -- distinct from the Governor chokepoint's own dry-run/live action-mode dial (#2342). Wiring a real call site behind this gate is a later, separate issue (mirrors #2333/#2335's own split between mechanics and policy). Barrel-exported from the engine's public entrypoint.
1 parent 8f10e83 commit e3e083f

3 files changed

Lines changed: 266 additions & 0 deletions

File tree

packages/gittensory-engine/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,14 @@ export {
236236
type IterateLoopOutcome,
237237
type IterateLoopResult,
238238
} from "./miner/iterate-loop.js";
239+
export {
240+
isSlopBandWithinThreshold,
241+
shouldSubmit,
242+
SUBMISSION_GATE_PASSING_CONCLUSION,
243+
type SubmissionGateCandidate,
244+
type SubmissionGateDecision,
245+
type SubmissionGateMode,
246+
} from "./miner/submission-gate.js";
239247
export {
240248
codingAgentModeExecutes,
241249
isGlobalMinerCodingAgentPause,
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// Gated-submission trigger (#2336): THE safety-critical chokepoint of Phase 4 -- the actual decision point
2+
// that decides "call gittensory_open_pr NOW" for an autonomous run. Conservatively requires BOTH a predicted-
3+
// gate PASS AND a slop score under a configurable threshold before a local-write open_pr action spec is ever
4+
// built. Any ambiguity (a missing/errored signal) resolves to NOT submitting -- this function never defaults
5+
// to allow.
6+
//
7+
// SEQUENCING: runs BEFORE `buildOpenPrSpec` (src/mcp/local-write-tools.ts) is ever called -- i.e. before the
8+
// Governor chokepoint (#2340) ever sees an open_pr action spec to evaluate. The two are complementary, not
9+
// redundant: this gates on CONTENT-QUALITY signals (predicted-gate conclusion, slop risk) specific to the
10+
// candidate diff; the chokepoint gates on RESOURCE/GOVERNANCE signals (rate-limit, budget, reputation, self-
11+
// plagiarism, dry-run mode) that apply to every write action class, not just open_pr. "The actual call site
12+
// invoking buildOpenPrSpec / gittensory_open_pr is gated exclusively through this function" (this issue's own
13+
// deliverable) is a POLICY this and every future call site must honor -- wiring a real call site is a later,
14+
// separate issue (mirrors #2333/#2335's own split between loop mechanics and policy).
15+
//
16+
// INPUT SHAPE: `predictedGateVerdict`/`slopAssessment` are typed exactly as the fields `SelfReviewVerdict`
17+
// (self-review-adapter.ts, #2334) already carries, so a caller can pass the SAME verdict the iterate-loop's
18+
// own self-review (#2333) already computed at handoff time -- this is a defense-in-depth RE-CHECK of that
19+
// verdict immediately before submission, not a redundant re-computation from scratch.
20+
//
21+
// DRY-RUN: mirrors `src/settings/autonomy.ts`'s deny-by-default dial (`AUTONOMY_LEVELS`, `"observe"` as the
22+
// floor) for safe rollout of THIS function's own thresholds -- distinct from, and evaluated separately from,
23+
// the Governor chokepoint's own dry-run/live action-mode dial (#2342), which gates autonomous WRITING at all
24+
// for a repo. `"observe"` here is specifically for safely calibrating the predicted-gate/slop thresholds
25+
// against live traffic before ever trusting them to gate a real submission.
26+
27+
import type { PredictedGateVerdict } from "../predicted-gate.js";
28+
import type { SelfReviewSlopAssessment, SelfReviewSlopBand } from "./self-review-adapter.js";
29+
30+
/** The one literal conclusion value that counts as a clear predicted-gate pass -- same literal self-review-
31+
* adapter.ts's `SELF_REVIEW_PASSING_CONCLUSION` uses, kept as an independent constant here so this module has
32+
* no runtime dependency beyond types on self-review-adapter.ts. */
33+
export const SUBMISSION_GATE_PASSING_CONCLUSION = "success" as const;
34+
35+
const SLOP_BAND_SEVERITY: Readonly<Record<SelfReviewSlopBand, number>> = Object.freeze({
36+
clean: 0,
37+
low: 1,
38+
elevated: 2,
39+
high: 3,
40+
});
41+
42+
/** True when `band` is at or under `threshold`'s severity (inclusive) -- e.g. a `"low"` band is within a
43+
* `"elevated"` threshold, and a band exactly equal to the threshold still passes. */
44+
export function isSlopBandWithinThreshold(band: SelfReviewSlopBand, threshold: SelfReviewSlopBand): boolean {
45+
return SLOP_BAND_SEVERITY[band] <= SLOP_BAND_SEVERITY[threshold];
46+
}
47+
48+
/** `"observe"` mirrors `AUTONOMY_LEVELS`' deny-by-default floor: {@link shouldSubmit} still computes and
49+
* returns the real signal-based decision (for logging), but structurally forces `allow: false` regardless --
50+
* not left to an external caller to remember to also check the mode before acting on `allow: true`. */
51+
export type SubmissionGateMode = "observe" | "enforce";
52+
53+
export type SubmissionGateCandidate = {
54+
/** `null` means the predictor was unreachable or errored -- fails closed, exactly like a genuine non-passing
55+
* verdict, never treated as "no opinion, so allow". */
56+
predictedGateVerdict: PredictedGateVerdict | null;
57+
/** `null` means the slop check errored -- fails closed, exactly like a genuine over-threshold assessment. */
58+
slopAssessment: SelfReviewSlopAssessment | null;
59+
/** The maximum slop band that still permits submission (inclusive of this exact band). */
60+
slopThreshold: SelfReviewSlopBand;
61+
mode: SubmissionGateMode;
62+
};
63+
64+
export type SubmissionGateDecision = {
65+
allow: boolean;
66+
/** Always populated when `allow` is `false` (including in `"observe"` mode, prefixed to distinguish a
67+
* would-have-allowed dry-run from a real block) -- every decision is auditable, not just denials. */
68+
reasons: string[];
69+
};
70+
71+
/** The pure signal check, independent of `mode` -- {@link shouldSubmit} layers the observe/enforce dial on
72+
* top of this. Returns an empty array only when BOTH signals genuinely pass. */
73+
function evaluateSubmissionSignals(candidate: SubmissionGateCandidate): string[] {
74+
const reasons: string[] = [];
75+
76+
if (candidate.predictedGateVerdict === null) {
77+
reasons.push("predicted_gate_unavailable");
78+
} else if (candidate.predictedGateVerdict.conclusion !== SUBMISSION_GATE_PASSING_CONCLUSION) {
79+
const blockerCodes = candidate.predictedGateVerdict.blockers.map((blocker) => blocker.code).join(",");
80+
reasons.push(`predicted_gate_not_passing:${candidate.predictedGateVerdict.conclusion}${blockerCodes ? `:${blockerCodes}` : ""}`);
81+
}
82+
83+
if (candidate.slopAssessment === null) {
84+
reasons.push("slop_assessment_unavailable");
85+
} else if (!isSlopBandWithinThreshold(candidate.slopAssessment.band, candidate.slopThreshold)) {
86+
reasons.push(`slop_band_exceeds_threshold:${candidate.slopAssessment.band}>${candidate.slopThreshold}`);
87+
}
88+
89+
return reasons;
90+
}
91+
92+
/**
93+
* THE gate: build (or invoke) `gittensory_open_pr`'s action spec ONLY when this returns `allow: true`. Requires
94+
* BOTH a clean predicted-gate pass AND a slop band at or under the configured threshold; any missing signal, or
95+
* `mode: "observe"`, forces `allow: false`. Pure; identical inputs always yield the identical decision.
96+
*/
97+
export function shouldSubmit(candidate: SubmissionGateCandidate): SubmissionGateDecision {
98+
const reasons = evaluateSubmissionSignals(candidate);
99+
const signalsPass = reasons.length === 0;
100+
101+
if (candidate.mode === "observe") {
102+
return {
103+
allow: false,
104+
reasons: signalsPass ? ["observe_mode_active:would_have_allowed"] : ["observe_mode_active:would_have_blocked", ...reasons],
105+
};
106+
}
107+
return { allow: signalsPass, reasons };
108+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
4+
import {
5+
isSlopBandWithinThreshold,
6+
shouldSubmit,
7+
SUBMISSION_GATE_PASSING_CONCLUSION,
8+
type PredictedGateVerdict,
9+
type SelfReviewSlopAssessment,
10+
type SelfReviewSlopBand,
11+
type SubmissionGateCandidate,
12+
} from "../dist/index.js";
13+
14+
function passingVerdict(): PredictedGateVerdict {
15+
return {
16+
predicted: true,
17+
basis: "public_config",
18+
pack: "oss-anti-slop",
19+
conclusion: "success",
20+
title: "Predicted gate: pass",
21+
summary: "Every check is expected to pass.",
22+
readinessScore: 92,
23+
confirmedContributor: undefined,
24+
blockers: [],
25+
warnings: [],
26+
funnel: null,
27+
note: "",
28+
};
29+
}
30+
31+
function failingVerdict(blockers: PredictedGateVerdict["blockers"] = [{ code: "duplicate_pr_risk", title: "Likely duplicate", detail: "Matches an existing open PR." }]): PredictedGateVerdict {
32+
return {
33+
predicted: true,
34+
basis: "public_config",
35+
pack: "oss-anti-slop",
36+
conclusion: "failure",
37+
title: "Predicted gate: fail",
38+
summary: "At least one check is expected to fail.",
39+
readinessScore: 15,
40+
confirmedContributor: undefined,
41+
blockers,
42+
warnings: [],
43+
funnel: null,
44+
note: "",
45+
};
46+
}
47+
48+
function slop(band: SelfReviewSlopBand, slopRisk = 0): SelfReviewSlopAssessment {
49+
return { slopRisk, band, findings: [] };
50+
}
51+
52+
function baseCandidate(overrides: Partial<SubmissionGateCandidate> = {}): SubmissionGateCandidate {
53+
return {
54+
predictedGateVerdict: passingVerdict(),
55+
slopAssessment: slop("clean"),
56+
slopThreshold: "low",
57+
mode: "enforce",
58+
...overrides,
59+
};
60+
}
61+
62+
test("barrel: the public entrypoint re-exports the submission gate (#2336)", () => {
63+
assert.equal(typeof shouldSubmit, "function");
64+
assert.equal(typeof isSlopBandWithinThreshold, "function");
65+
assert.equal(SUBMISSION_GATE_PASSING_CONCLUSION, "success");
66+
});
67+
68+
test("pass/pass: a clean predicted-gate pass with slop under threshold allows, with no reasons", () => {
69+
const decision = shouldSubmit(baseCandidate());
70+
assert.deepEqual(decision, { allow: true, reasons: [] });
71+
});
72+
73+
test("fail/pass: a non-passing predicted-gate verdict blocks even with slop cleanly under threshold", () => {
74+
const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict() }));
75+
assert.equal(decision.allow, false);
76+
assert.equal(decision.reasons.length, 1);
77+
assert.match(decision.reasons[0] ?? "", /^predicted_gate_not_passing:failure:duplicate_pr_risk$/);
78+
});
79+
80+
test("fail/pass: a non-passing verdict with NO blockers listed still formats a reason, without a dangling separator", () => {
81+
const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict([]) }));
82+
assert.equal(decision.reasons[0], "predicted_gate_not_passing:failure");
83+
});
84+
85+
test("pass/fail: a clean predicted-gate pass blocks when slop exceeds the configured threshold", () => {
86+
const decision = shouldSubmit(baseCandidate({ slopAssessment: slop("high"), slopThreshold: "low" }));
87+
assert.equal(decision.allow, false);
88+
assert.deepEqual(decision.reasons, ["slop_band_exceeds_threshold:high>low"]);
89+
});
90+
91+
test("both-fail: a non-passing verdict AND over-threshold slop blocks with both reasons listed", () => {
92+
const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict(), slopAssessment: slop("high"), slopThreshold: "low" }));
93+
assert.equal(decision.allow, false);
94+
assert.equal(decision.reasons.length, 2);
95+
assert.ok(decision.reasons.some((r) => r.startsWith("predicted_gate_not_passing")));
96+
assert.ok(decision.reasons.some((r) => r.startsWith("slop_band_exceeds_threshold")));
97+
});
98+
99+
test("fail-closed: a null predictedGateVerdict (predictor unreachable) blocks, never treated as no-opinion-so-allow", () => {
100+
const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: null }));
101+
assert.equal(decision.allow, false);
102+
assert.deepEqual(decision.reasons, ["predicted_gate_unavailable"]);
103+
});
104+
105+
test("fail-closed: a null slopAssessment (slop check errored) blocks, never treated as no-opinion-so-allow", () => {
106+
const decision = shouldSubmit(baseCandidate({ slopAssessment: null }));
107+
assert.equal(decision.allow, false);
108+
assert.deepEqual(decision.reasons, ["slop_assessment_unavailable"]);
109+
});
110+
111+
test("fail-closed: both signals missing blocks with both unavailable reasons listed", () => {
112+
const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: null, slopAssessment: null }));
113+
assert.equal(decision.allow, false);
114+
assert.deepEqual(decision.reasons, ["predicted_gate_unavailable", "slop_assessment_unavailable"]);
115+
});
116+
117+
test("observe mode: forces allow: false even for signals that would otherwise cleanly pass", () => {
118+
const decision = shouldSubmit(baseCandidate({ mode: "observe" }));
119+
assert.equal(decision.allow, false);
120+
assert.deepEqual(decision.reasons, ["observe_mode_active:would_have_allowed"]);
121+
});
122+
123+
test("observe mode: a would-have-blocked decision is distinguishable from a would-have-allowed one, with the real reasons preserved", () => {
124+
const decision = shouldSubmit(baseCandidate({ mode: "observe", predictedGateVerdict: null }));
125+
assert.equal(decision.allow, false);
126+
assert.deepEqual(decision.reasons, ["observe_mode_active:would_have_blocked", "predicted_gate_unavailable"]);
127+
});
128+
129+
test("isSlopBandWithinThreshold: a band exactly equal to the threshold passes (inclusive boundary)", () => {
130+
assert.equal(isSlopBandWithinThreshold("elevated", "elevated"), true);
131+
});
132+
133+
test("isSlopBandWithinThreshold: a band one severity level under the threshold passes", () => {
134+
assert.equal(isSlopBandWithinThreshold("low", "elevated"), true);
135+
});
136+
137+
test("isSlopBandWithinThreshold: a band one severity level over the threshold fails", () => {
138+
assert.equal(isSlopBandWithinThreshold("high", "elevated"), false);
139+
});
140+
141+
test("isSlopBandWithinThreshold: the full clean..high ordering is respected end to end", () => {
142+
const order: SelfReviewSlopBand[] = ["clean", "low", "elevated", "high"];
143+
for (let i = 0; i < order.length; i += 1) {
144+
for (let j = 0; j < order.length; j += 1) {
145+
const band = order[i] as SelfReviewSlopBand;
146+
const threshold = order[j] as SelfReviewSlopBand;
147+
assert.equal(isSlopBandWithinThreshold(band, threshold), i <= j, `${band} within ${threshold}`);
148+
}
149+
}
150+
});

0 commit comments

Comments
 (0)