Skip to content
Open
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
15 changes: 15 additions & 0 deletions doc/EVIDENCE_GATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Evidence Gate Operations

Transitions into `in_review` are rejected with HTTP 422 when required artifact evidence is missing. The response identifies the missing evidence shapes:

```json
{"error":"missing-evidence","missing":["test-output","checklist:done-when"]}
```

An operator can authorize a time-limited exception by adding a user-authored issue comment in this exact form:

```text
evidence-gate: override <reason>
```

The comment must be less than one hour old when the transition is attempted. Agent-authored comments, empty reasons, future timestamps, and expired comments are ignored. A successful override is recorded in `lastEvidenceVerdict` with `overridden: true` and `overrideReason`, and those fields are included in the server audit log. The gate only runs when entering `in_review`; updates to an issue already in review are grandfathered.
2 changes: 2 additions & 0 deletions packages/db/src/schema/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export const issues = pgTable(
missing: string[];
evidenceFound: string[];
unlabeledFallback: boolean;
overridden?: boolean;
overrideReason?: string;
evaluatedAt: string;
}>(),
// Materialized from lastEvidenceVerdict.evaluatedAt when the evidence gate
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/types/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,8 @@ export interface Issue {
missing: string[];
evidenceFound: string[];
unlabeledFallback: boolean;
overridden?: boolean;
overrideReason?: string;
evaluatedAt: string;
} | null;
activeRecoveryAction?: IssueRecoveryAction | null;
Expand Down
18 changes: 18 additions & 0 deletions server/src/__tests__/error-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,22 @@ describe("errorHandler", () => {
expect(res.err).toBe(err);
expect(res.__errorContext?.error?.message).toBe("db exploded");
});

it("returns the evidence-gate 422 contract without a details wrapper", () => {
const req = makeReq();
const res = makeRes();
const next = vi.fn() as unknown as NextFunction;
const err = new HttpError(422, "missing-evidence", {
code: "missing-evidence",
missing: ["test-output", "checklist:done-when"],
});

errorHandler(err, req, res, next);

expect(res.status).toHaveBeenCalledWith(422);
expect(res.json).toHaveBeenCalledWith({
error: "missing-evidence",
missing: ["test-output", "checklist:done-when"],
});
});
});
81 changes: 80 additions & 1 deletion server/src/__tests__/evidence-gate-wiring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("runEvidenceGate", () => {
);
const fixedNow = new Date("2026-05-11T22:00:00.000Z");
const result = await runEvidenceGate(fetch, "issue-1", fixedNow);
expect(fetch).toHaveBeenCalledWith("issue-1");
expect(fetch).toHaveBeenCalledWith("issue-1", fixedNow);
expect(result.verdict).toBe("pass");
expect(result.missing).toEqual([]);
expect(result.unlabeledFallback).toBe(false);
Expand Down Expand Up @@ -71,6 +71,85 @@ describe("runEvidenceGate", () => {
);
});

it("passes with the newest recent user-authored operator override", async () => {
const fetch = vi.fn<(id: string) => Promise<EvidenceFetchResult>>(async () => ({
description: FRONTEND_DONE_WHEN,
labels: [{ name: "frontend" }],
comments: [
{
body: "evidence-gate: override incident response requires landing now",
authorAgentId: null,
authorUserId: "operator-1",
createdAt: "2026-05-11T21:30:00.000Z",
},
],
workProducts: [],
}));

const result = await runEvidenceGate(fetch, "issue-override", new Date("2026-05-11T22:00:00.000Z"));

expect(result).toMatchObject({
verdict: "pass",
overridden: true,
overrideReason: "incident response requires landing now",
missing: [],
});
});

it("finds an operator override outside the evaluator comment window", async () => {
const override = {
body: "evidence-gate: override incident response requires landing now",
authorAgentId: null,
authorUserId: "operator-1",
createdAt: "2026-05-11T21:30:00.000Z",
};
const fetch = vi.fn<(id: string) => Promise<EvidenceFetchResult>>(async () => ({
description: FRONTEND_DONE_WHEN,
labels: [{ name: "frontend" }],
comments: Array.from({ length: 10 }, (_, index) => ({
body: `later agent comment ${index}`,
authorAgentId: "agent-1",
authorUserId: null,
createdAt: `2026-05-11T21:4${index}:00.000Z`,
})),
operatorOverrideComments: [override],
workProducts: [],
}));

const result = await runEvidenceGate(fetch, "issue-displaced-override", new Date("2026-05-11T22:00:00.000Z"));

expect(result).toMatchObject({
verdict: "pass",
overridden: true,
overrideReason: "incident response requires landing now",
});
});

it.each([
["agent-authored", "agent-1", null, "2026-05-11T21:30:00.000Z"],
["expired", null, "operator-1", "2026-05-11T20:59:59.999Z"],
["future", null, "operator-1", "2026-05-11T22:00:00.001Z"],
["malformed", null, "operator-1", "2026-05-11T21:30:00.000Z"],
["blank", null, "operator-1", "2026-05-11T21:30:00.000Z"],
])("ignores %s override comments", async (kind, authorAgentId, authorUserId, createdAt) => {
const body = kind === "malformed"
? "evidence-gate: override"
: kind === "blank"
? "evidence-gate: override "
: "evidence-gate: override reason";
const fetch = vi.fn<(id: string) => Promise<EvidenceFetchResult>>(async () => ({
description: FRONTEND_DONE_WHEN,
labels: [{ name: "frontend" }],
comments: [{ body, authorAgentId, authorUserId, createdAt }],
workProducts: [],
}));

const result = await runEvidenceGate(fetch, `issue-${kind}`, new Date("2026-05-11T22:00:00.000Z"));

expect(result.verdict).toBe("block");
expect(result.overridden).toBeUndefined();
});

it("maps work-product `type` to evaluator `kind` (screenshot pickup)", async () => {
const fetch = vi.fn<(id: string) => Promise<EvidenceFetchResult>>(
async () => ({
Expand Down
18 changes: 14 additions & 4 deletions server/src/middleware/error-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,20 @@ export function errorHandler(
const tc = getTelemetryClient();
if (tc) trackErrorHandlerCrash(tc, { errorCode: err.name });
}
res.status(err.status).json({
error: err.message,
...(err.details ? { details: err.details } : {}),
});
if (
err.status === 422 &&
err.message === "missing-evidence" &&
typeof err.details === "object" &&
err.details !== null &&
Array.isArray((err.details as { missing?: unknown }).missing)
) {
res.status(422).json({
error: "missing-evidence",
missing: (err.details as { missing: unknown[] }).missing,
});
return;
}
res.status(err.status).json({ error: err.message, ...(err.details ? { details: err.details } : {}) });
return;
}

Expand Down
37 changes: 36 additions & 1 deletion server/src/services/evidence-gate-wiring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface EvidenceFetchResult {
doneWhenBulletsRemoved?: boolean;
labels: Array<{ name: string }>;
comments: EvidenceCommentLite[];
operatorOverrideComments?: EvidenceCommentLite[];
workProducts: Array<{
type: string;
metadata: Record<string, unknown> | null;
Expand All @@ -30,6 +31,7 @@ export interface EvidenceFetchResult {

export type FetchEvidenceForGate = (
issueId: string,
now: Date,
) => Promise<EvidenceFetchResult>;

export interface EvidenceVerdictRecord {
Expand All @@ -40,9 +42,14 @@ export interface EvidenceVerdictRecord {
allDetected: string[];
unlabeledFallback: boolean;
diagnostics: string[];
overridden?: boolean;
overrideReason?: string;
evaluatedAt: string;
}

const OPERATOR_OVERRIDE_PATTERN = /^evidence-gate: override (.+)$/;
const OPERATOR_OVERRIDE_MAX_AGE_MS = 60 * 60 * 1000;

/**
* Run the gate for one issue. Returns the verdict record the caller should
* persist to `issues.lastEvidenceVerdict`. Caller is responsible for
Expand All @@ -63,7 +70,35 @@ export async function runEvidenceGate(
issueId: string,
now: Date = new Date(),
): Promise<EvidenceVerdictRecord> {
const data = await fetch(issueId);
const data = await fetch(issueId, now);
const override = (data.operatorOverrideComments ?? data.comments)
.filter((comment) => comment.authorUserId !== null && comment.authorAgentId === null)
.map((comment) => ({
createdAt: new Date(comment.createdAt).getTime(),
match: OPERATOR_OVERRIDE_PATTERN.exec(comment.body),
}))
.filter(({ createdAt, match }) =>
match !== null &&
match[1]!.trim().length > 0 &&
Number.isFinite(createdAt) &&
createdAt <= now.getTime() &&
now.getTime() - createdAt <= OPERATOR_OVERRIDE_MAX_AGE_MS
)
.sort((a, b) => b.createdAt - a.createdAt)[0];
if (override?.match) {
return {
verdict: "pass",
missing: [],
evidenceFound: [],
requiredFound: [],
allDetected: [],
unlabeledFallback: false,
diagnostics: [],
overridden: true,
overrideReason: override.match[1]!.trim(),
evaluatedAt: now.toISOString(),
};
}
const evaluation = evaluateEvidence({
issue: {
description: data.description,
Expand Down
Loading
Loading