From 7821b8d228ded9254923c7189b91193c9693aa8c Mon Sep 17 00:00:00 2001 From: shin-core <153108882+shin-core@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:27:17 +0900 Subject: [PATCH] fix(ledger): require git anchoring only when a git submitter is configured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `runScheduledLedgerAnchor` asked `anchorBackendsMissingForRowHash` about a FIXED `["rekor", "git"]` required-success set, but git is only ever attempted when `deps.submitGit` is non-null. So on an instance with no git backend, a tip fully anchored to Rekor was permanently reported as "missing git" — every hourly tick re-anchored a quiet tip to Rekor, forever. Compute the required-success list from the backends this tick will actually attempt: `rekor` always, plus `git` only when `deps.submitGit` is configured — the same predicate the submission below already gates git on, so the check and the attempts can never disagree. A configured git backend still makes a git-less tip retry; a failed rekor row still retries. Closes #9646 --- src/review/ledger-anchor-scheduler.ts | 13 ++++-- test/unit/ledger-anchor-scheduler.test.ts | 48 ++++++++++++++++++++++- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/review/ledger-anchor-scheduler.ts b/src/review/ledger-anchor-scheduler.ts index 25ca625d67..f0802f9dc9 100644 --- a/src/review/ledger-anchor-scheduler.ts +++ b/src/review/ledger-anchor-scheduler.ts @@ -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 { const now = options.now ?? nowIso(); @@ -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, diff --git a/test/unit/ledger-anchor-scheduler.test.ts b/test/unit/ledger-anchor-scheduler.test.ts index f64de3af9b..aead87c67f 100644 --- a/test/unit/ledger-anchor-scheduler.test.ts +++ b/test/unit/ledger-anchor-scheduler.test.ts @@ -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"; @@ -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);