diff --git a/packages/gittensory-miner/lib/submission-freshness-check.d.ts b/packages/gittensory-miner/lib/submission-freshness-check.d.ts new file mode 100644 index 000000000..725660294 --- /dev/null +++ b/packages/gittensory-miner/lib/submission-freshness-check.d.ts @@ -0,0 +1,32 @@ +export const SUBMISSION_FRESHNESS_ABORT_EVENT: "submission_freshness_abort"; + +export type FreshnessAbortReason = "issue_closed" | "already_addressed" | "claim_superseded" | "live_state_unavailable"; + +export type SubmissionFreshnessCandidate = { + repoFullName: string; + issueNumber: number; + minerLogin: string; +}; + +export type LiveIssueSnapshot = { + state: "open" | "closed"; + referencingPrs: Array<{ number: number; state: "open" | "closed" | "merged"; authorLogin: string }>; +}; + +export type SubmissionFreshnessClaimLedger = { + listClaims(filter: { repoFullName?: string; status?: string }): Array<{ repoFullName: string; issueNumber: number; status: string }>; +}; + +export type SubmissionFreshnessEventLedger = { + appendEvent(event: { type: string; repoFullName?: string; payload: Record }): unknown; +}; + +export type SubmissionFreshnessDeps = { + claimLedger: SubmissionFreshnessClaimLedger; + fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise; + eventLedger: SubmissionFreshnessEventLedger; +}; + +export type SubmissionFreshnessResult = { fresh: true } | { fresh: false; reason: FreshnessAbortReason }; + +export function checkSubmissionFreshness(candidate: SubmissionFreshnessCandidate, deps: SubmissionFreshnessDeps): Promise; diff --git a/packages/gittensory-miner/lib/submission-freshness-check.js b/packages/gittensory-miner/lib/submission-freshness-check.js new file mode 100644 index 000000000..42fc88d9b --- /dev/null +++ b/packages/gittensory-miner/lib/submission-freshness-check.js @@ -0,0 +1,93 @@ +// Late-binding freshness check before open_pr fires (#3007). A soft-claim made at the start of a long +// create/iterate loop can go stale by the time a candidate reaches submission: the target issue may have been +// closed, already fixed by another author, or the miner's own claim may have been released/expired in the +// interim. This is a FINAL, read-only check immediately before open_pr spec construction -- complementing, not +// replacing, the claim-time check (src/miner/soft-claim.ts) -- so a stale submission never reaches the Governor +// chokepoint (governor-chokepoint.js) as a live write attempt: check freshness first, THEN prepareOpenPrSubmission +// (harness-submission-trigger.js), THEN the Governor. These are separate, sequentially-composed units, not nested +// calls -- a future real call site wires them together in that order. +// +// READ-ONLY BY CONTRACT: never writes anything except its own abort-reason audit event (on staleness only, not +// on every check -- mirrors this issue's own "log the abort reason" wording, not a per-decision audit trail). +// The live-state fetch is an injected dependency so this stays testable without real network I/O and agnostic +// to HOW the caller sources issue/PR state (raw GitHub API, gittensory's own cached MCP data, etc.). +// +// FAIL CLOSED: an unreachable/failed live-state fetch is treated as stale (aborts), never as "no evidence of +// staleness, so proceed" -- mirrors this package's fail-closed convention elsewhere (harness-submission- +// trigger.js's predicted_gate_unavailable/slop_assessment_unavailable, iterate-loop.ts's ambiguous-on-error). +// +// NOT a rejection outcome: staleness is caught BEFORE any PR exists, so it is not the same lifecycle event as +// rejection-state-machine.js's DISENGAGED_OUTCOME (which handles an EXISTING PR a maintainer closed). "No PR, +// no noisy failure" here just means: return a quiet not-fresh result, same shape as any other blocked gate +// decision in this package -- never throw, never surface anything to the target repo. + +export const SUBMISSION_FRESHNESS_ABORT_EVENT = "submission_freshness_abort"; + +/** + * Evaluate whether a submission candidate's live repo state is still fresh enough to proceed toward open_pr. + * Checks the miner's own claim-ledger status first (local, free) before spending a network round-trip on the + * live issue/PR snapshot. Fails closed (throws) on a malformed candidate or missing dependency. + * + * @param {{ repoFullName: string, issueNumber: number, minerLogin: string }} candidate + * @param {{ + * claimLedger: { listClaims(filter: { repoFullName?: string, status?: string }): Array<{ repoFullName: string, issueNumber: number, status: string }> }, + * fetchLiveIssueSnapshot: (repoFullName: string, issueNumber: number) => Promise<{ state: "open"|"closed", referencingPrs: Array<{ number: number, state: "open"|"closed"|"merged", authorLogin: string }> } | null>, + * eventLedger: { appendEvent(event: { type: string, repoFullName?: string, payload: Record }): unknown }, + * }} deps + */ +export async function checkSubmissionFreshness(candidate, deps) { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_freshness_candidate"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + if (!repoFullName) throw new Error("invalid_repo_full_name"); + if (!Number.isInteger(candidate.issueNumber) || candidate.issueNumber < 1) throw new Error("invalid_issue_number"); + const minerLogin = typeof candidate.minerLogin === "string" ? candidate.minerLogin.trim() : ""; + if (!minerLogin) throw new Error("invalid_miner_login"); + + if (!deps || typeof deps !== "object") throw new Error("invalid_freshness_deps"); + const { claimLedger, fetchLiveIssueSnapshot, eventLedger } = deps; + if (!claimLedger || typeof claimLedger.listClaims !== "function") throw new Error("invalid_claim_ledger"); + if (typeof fetchLiveIssueSnapshot !== "function") throw new Error("invalid_live_state_fetcher"); + if (!eventLedger || typeof eventLedger.appendEvent !== "function") throw new Error("invalid_event_ledger"); + + const claim = claimLedger.listClaims({ repoFullName }).find((c) => c.issueNumber === candidate.issueNumber); + if (!claim || claim.status !== "active") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "claim_superseded"); + } + + let snapshot; + try { + snapshot = await fetchLiveIssueSnapshot(repoFullName, candidate.issueNumber); + } catch { + snapshot = null; + } + if (!snapshot || typeof snapshot !== "object") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "live_state_unavailable"); + } + + if (snapshot.state === "closed") { + return abort(eventLedger, repoFullName, candidate.issueNumber, "issue_closed"); + } + + // GitHub logins are case-insensitive for identity purposes (the same account can be echoed back with + // different casing by different API responses), so a strict `!==` would misclassify the miner's own + // referencing PR as "another author" whenever the casing happens to differ -- compare case-normalized. + const minerLoginKey = minerLogin.toLowerCase(); + const referencingPrs = Array.isArray(snapshot.referencingPrs) ? snapshot.referencingPrs : []; + const addressedByAnotherAuthor = referencingPrs.some( + (pr) => typeof pr.authorLogin === "string" && pr.authorLogin.trim().toLowerCase() !== minerLoginKey && (pr.state === "merged" || pr.state === "open"), + ); + if (addressedByAnotherAuthor) { + return abort(eventLedger, repoFullName, candidate.issueNumber, "already_addressed"); + } + + return { fresh: true }; +} + +function abort(eventLedger, repoFullName, issueNumber, reason) { + eventLedger.appendEvent({ + type: SUBMISSION_FRESHNESS_ABORT_EVENT, + repoFullName, + payload: { issueNumber, reason }, + }); + return { fresh: false, reason }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 47d4491cd..92c6ce731 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/attempt-log.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/ci-poller.js && node --check lib/claim-adjudication.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger.js && node --check lib/cli.js && node --check lib/coding-agent-house-rules.js && node --check lib/deny-check.js && node --check lib/deny-hook-synthesis.js && node --check lib/deny-hooks.js && node --check lib/discover-cli.js && node --check lib/event-ledger-cli.js && node --check lib/event-ledger.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/governor-action-mode.js && node --check lib/governor-chokepoint.js && node --check lib/governor-kill-switch.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-ledger.js && node --check lib/governor-open-pr.js && node --check lib/governor-run-halt.js && node --check lib/governor-write-rate-limit.js && node --check lib/harness-submission-trigger.js && node --check lib/laptop-init.js && node --check lib/local-store.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/manage-poll.js && node --check lib/manage-status.js && node --check lib/opportunity-fanout.js && node --check lib/opportunity-ranker.js && node --check lib/orb-export.js && node --check lib/plan-store-cli.js && node --check lib/plan-store.js && node --check lib/portfolio-dashboard.js && node --check lib/portfolio-discovery.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-queue.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/pretooluse-hook.js && node --check lib/rejection-state-machine.js && node --check lib/rejection-templates.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/run-state-cli.js && node --check lib/run-state.js && node --check lib/status.js && node --check lib/submission-freshness-check.js && node --check lib/update-check.js && node --check lib/version.js && node --check lib/worktree-allocator.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-submission-freshness-check.test.ts b/test/unit/miner-submission-freshness-check.test.ts new file mode 100644 index 000000000..de8a9b954 --- /dev/null +++ b/test/unit/miner-submission-freshness-check.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it, vi } from "vitest"; + +import { checkSubmissionFreshness, SUBMISSION_FRESHNESS_ABORT_EVENT } from "../../packages/gittensory-miner/lib/submission-freshness-check.js"; + +function stubClaimLedger(claims: Array<{ repoFullName: string; issueNumber: number; status: string }> = []) { + const listClaims = vi.fn((filter: { repoFullName?: string; status?: string }) => + claims.filter((c) => (filter.repoFullName === undefined || c.repoFullName === filter.repoFullName) && (filter.status === undefined || c.status === filter.status)), + ); + return { claimLedger: { listClaims }, listClaims }; +} + +function stubEventLedger() { + const appendEvent = vi.fn((_event: { type: string; repoFullName?: string; payload: Record }) => undefined); + return { eventLedger: { appendEvent }, appendEvent }; +} + +const activeClaim = { repoFullName: "acme/widgets", issueNumber: 42, status: "active" }; + +describe("checkSubmissionFreshness (#3007)", () => { + it("a fresh claim proceeds: active claim, open issue, no other-author referencing PRs", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger, appendEvent } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + expect(appendEvent).not.toHaveBeenCalled(); // only aborts get logged + }); + + it("claim-superseded abort: no claim ledger entry at all for this issue, without ever fetching live state", async () => { + const { claimLedger } = stubClaimLedger([]); // no rows + const { eventLedger, appendEvent } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "claim_superseded" }); + expect(fetchLiveIssueSnapshot).not.toHaveBeenCalled(); // local, free check runs first + expect(appendEvent).toHaveBeenCalledWith({ + type: SUBMISSION_FRESHNESS_ABORT_EVENT, + repoFullName: "acme/widgets", + payload: { issueNumber: 42, reason: "claim_superseded" }, + }); + }); + + it("claim-superseded abort: a claim row exists but is released, not active", async () => { + const { claimLedger } = stubClaimLedger([{ repoFullName: "acme/widgets", issueNumber: 42, status: "released" }]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "claim_superseded" }); + expect(fetchLiveIssueSnapshot).not.toHaveBeenCalled(); + }); + + it("issue-closed abort", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger, appendEvent } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "closed" as const, referencingPrs: [] })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "issue_closed" }); + expect(appendEvent).toHaveBeenCalledWith({ + type: SUBMISSION_FRESHNESS_ABORT_EVENT, + repoFullName: "acme/widgets", + payload: { issueNumber: 42, reason: "issue_closed" }, + }); + }); + + it("already-addressed abort: an OPEN PR from another author already references the issue", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: "someone-else" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "already_addressed" }); + }); + + it("already-addressed abort: a MERGED PR from another author already references the issue", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "merged" as const, authorLogin: "someone-else" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "already_addressed" }); + }); + + it("a referencing PR authored by the miner ITSELF does not count as already-addressed", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: "miner-bot" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("a referencing PR authored by the miner's OWN login in a different case does not count as already-addressed", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: "Miner-Bot" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("already-addressed abort still fires for a differently-cased OTHER author (case-insensitivity isn't a blanket bypass)", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: "SOMEONE-ELSE" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "already_addressed" }); + }); + + it("a referencing PR with a non-string authorLogin is ignored rather than crashing or false-flagging", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "open" as const, authorLogin: undefined as unknown as string }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("a CLOSED (not merged) referencing PR from another author does not count as already-addressed", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ + state: "open" as const, + referencingPrs: [{ number: 99, state: "closed" as const, authorLogin: "someone-else" }], + })); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("live-state-unavailable abort when the fetch returns null", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => null); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "live_state_unavailable" }); + }); + + it("live-state-unavailable abort (fail closed) when the fetch throws, never treated as no-evidence-so-proceed", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => { + throw new Error("network down"); + }); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: false, reason: "live_state_unavailable" }); + }); + + it("tolerates a snapshot with no referencingPrs key at all (treated as empty, not a throw)", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const }) as never); + + const result = await checkSubmissionFreshness( + { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }, + { claimLedger, fetchLiveIssueSnapshot, eventLedger }, + ); + + expect(result).toEqual({ fresh: true }); + }); + + it("fails closed on a malformed candidate rather than silently proceeding", async () => { + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + const deps = { claimLedger, fetchLiveIssueSnapshot, eventLedger }; + + await expect(checkSubmissionFreshness(null as never, deps)).rejects.toThrow("invalid_freshness_candidate"); + await expect(checkSubmissionFreshness({ issueNumber: 42, minerLogin: "m" } as never, deps)).rejects.toThrow("invalid_repo_full_name"); + await expect(checkSubmissionFreshness({ repoFullName: "acme/widgets", minerLogin: "m" } as never, deps)).rejects.toThrow("invalid_issue_number"); + await expect(checkSubmissionFreshness({ repoFullName: "acme/widgets", issueNumber: 0, minerLogin: "m" }, deps)).rejects.toThrow("invalid_issue_number"); + await expect(checkSubmissionFreshness({ repoFullName: "acme/widgets", issueNumber: 42 } as never, deps)).rejects.toThrow("invalid_miner_login"); + }); + + it("fails closed on malformed or missing dependencies", async () => { + const candidate = { repoFullName: "acme/widgets", issueNumber: 42, minerLogin: "miner-bot" }; + const { claimLedger } = stubClaimLedger([activeClaim]); + const { eventLedger } = stubEventLedger(); + const fetchLiveIssueSnapshot = vi.fn(async () => ({ state: "open" as const, referencingPrs: [] })); + + await expect(checkSubmissionFreshness(candidate, null as never)).rejects.toThrow("invalid_freshness_deps"); + await expect(checkSubmissionFreshness(candidate, { fetchLiveIssueSnapshot, eventLedger } as never)).rejects.toThrow("invalid_claim_ledger"); + await expect(checkSubmissionFreshness(candidate, { claimLedger, eventLedger } as never)).rejects.toThrow("invalid_live_state_fetcher"); + await expect(checkSubmissionFreshness(candidate, { claimLedger, fetchLiveIssueSnapshot } as never)).rejects.toThrow("invalid_event_ledger"); + }); +});