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
13 changes: 9 additions & 4 deletions src/review/ledger-anchor-scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,14 @@ export type LedgerAnchorSchedulerDeps = {
* a total anchoring failure cannot propagate anywhere. Returns the scheduling decision so a caller/test can
* observe WHY nothing happened, without needing a second query.
*/
/** #9489: the backends whose success actually matters for "is this tip anchored". `ots` is tracked-but-not-
* built (#9267), so requiring it would make every tip permanently unanchored. */
const ANCHOR_BACKENDS_REQUIRING_SUCCESS = ["rekor", "git"] as const;
/** #9489/#9646: the backends whose success actually matters for "is this tip anchored" — computed from the
* backends this tick will ACTUALLY attempt, not a fixed constant. `rekor` always; `git` only when a git
* submitter is configured (deps.submitGit non-null), so an unconfigured git backend cannot leave a
* fully-rekor-anchored quiet tip permanently "missing git" and re-anchoring every hourly tick. `ots` is
* tracked-but-not-built (#9267), so requiring it would make every tip permanently unanchored. */
function requiredSuccessBackends(deps: LedgerAnchorSchedulerDeps): LedgerAnchorBackend[] {
return deps.submitGit ? ["rekor", "git"] : ["rekor"];
}

export async function runScheduledLedgerAnchor(env: Env, options: { isHourly: boolean; now?: string }, deps: LedgerAnchorSchedulerDeps = {}): Promise<LedgerAnchorScheduleDecision> {
const now = options.now ?? nowIso();
Expand All @@ -98,7 +103,7 @@ export async function runScheduledLedgerAnchor(env: Env, options: { isHourly: bo
// which backend wrote it.
/* v8 ignore next -- fail-open: an unreadable anchors table degrades to "nothing known to be missing", i.e.
exactly the pre-#9489 scheduling behaviour, rather than forcing an anchor on every tick. */
const unanchoredBackends = await anchorBackendsMissingForRowHash(env, tip.rowHash, ANCHOR_BACKENDS_REQUIRING_SUCCESS).catch(() => []);
const unanchoredBackends = await anchorBackendsMissingForRowHash(env, tip.rowHash, requiredSuccessBackends(deps)).catch(() => []);
const decision = decideLedgerAnchorSchedule({
isHourly: options.isHourly,
currentTip: tip,
Expand Down
48 changes: 47 additions & 1 deletion test/unit/ledger-anchor-scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { createTestEnv } from "../helpers/d1";
import { decideLedgerAnchorSchedule, LEDGER_ANCHOR_SEQ_THRESHOLD, resolveGitAnchorTarget, runScheduledLedgerAnchor } from "../../src/review/ledger-anchor-scheduler";
import { buildDecisionRecord, contentDigest, persistDecisionRecord } from "../../src/review/decision-record";
import { buildDecisionRecord, contentDigest, loadDecisionLedgerTip, persistDecisionRecord } from "../../src/review/decision-record";
import { loadPublicLedgerAnchors } from "../../src/review/ledger-anchor-persistence";
import { computeAnchorKeyId, type SignedLedgerAnchor } from "../../src/review/ledger-anchor";

Expand Down Expand Up @@ -177,6 +177,52 @@ describe("runScheduledLedgerAnchor (#9274)", () => {
expect(anchors).toEqual([]);
});

// #9646: the required-success backend list must come from the backends this tick will ACTUALLY attempt.
// Insert an anchor row for the tip's exact row_hash at a given backend/status.
const recordAnchor = async (env: Env, rowHash: string, backend: string, status: "ok" | "failed") => {
await env.DB.prepare(
"INSERT INTO decision_ledger_anchors (id, seq, row_hash, payload_json, signature, key_id, backend, status, created_at) VALUES (?, 1, ?, '{}', 'sig', 'k1', ?, ?, ?)",
)
.bind(`${backend}-${status}-${Math.random()}`, rowHash, backend, status, new Date().toISOString())
.run();
};

it("REGRESSION: with submitGit omitted, a tip already anchored to rekor stays quiet — no re-anchor, submitRekor called zero times (#9646)", async () => {
const { env } = await keyedEnv();
await seedOneDecision(env);
const tip = await loadDecisionLedgerTip(env);
await recordAnchor(env, tip.rowHash, "rekor", "ok");
const submitRekor = vi.fn().mockResolvedValue(undefined);
// git unconfigured (submitGit omitted): rekor is the ONLY required backend, and it is already ok.
const decision = await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor });
expect(decision).toEqual({ shouldAnchor: false, reason: "unchanged" });
expect(submitRekor).not.toHaveBeenCalled();
});

it("still retries when the rekor row is FAILED, even with submitGit omitted (#9646)", async () => {
const { env } = await keyedEnv();
await seedOneDecision(env);
const tip = await loadDecisionLedgerTip(env);
await recordAnchor(env, tip.rowHash, "rekor", "failed");
const submitRekor = vi.fn().mockResolvedValue(undefined);
const decision = await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor });
expect(decision).toEqual({ shouldAnchor: true, reason: "retry_unanchored" });
expect(submitRekor).toHaveBeenCalledTimes(1);
});

it("still requires git when submitGit IS wired: rekor ok but git missing → retries and both submitters run (#9646)", async () => {
const { env } = await keyedEnv();
await seedOneDecision(env);
const tip = await loadDecisionLedgerTip(env);
await recordAnchor(env, tip.rowHash, "rekor", "ok"); // rekor done, git has no ok row
const submitRekor = vi.fn().mockResolvedValue(undefined);
const submitGit = vi.fn().mockResolvedValue(undefined);
const decision = await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor, submitGit });
expect(decision).toEqual({ shouldAnchor: true, reason: "retry_unanchored" });
expect(submitRekor).toHaveBeenCalledTimes(1);
expect(submitGit).toHaveBeenCalledTimes(1);
});

it("skips entirely (no signing attempted) when the published key set has no unambiguous current key", async () => {
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: "irrelevant", LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([]) });
await seedOneDecision(env);
Expand Down