diff --git a/src/env.d.ts b/src/env.d.ts index 18d7c2faa1..0ab733c1d2 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -621,6 +621,20 @@ declare global { * updates this var at the next rotation rather than needing a code change. See * review/ledger-anchor-rekor.ts. */ LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL?: string; + /** External ledger anchoring (#9273/#9274, epic #9267): the target repo/branch/path for the git-commit + * anchoring backend. Owner and repo are BOTH required for git anchoring to run at all -- unset means + * that backend is simply not configured yet (Rekor still runs on its own), the same honest-degrade + * posture as an unset signing key. Branch defaults to "main", path defaults to "anchors.jsonl" if unset. + * See review/ledger-anchor-scheduler.ts's `resolveGitAnchorTarget`. */ + LOOPOVER_LEDGER_ANCHOR_GIT_OWNER?: string; + LOOPOVER_LEDGER_ANCHOR_GIT_REPO?: string; + LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH?: string; + LOOPOVER_LEDGER_ANCHOR_GIT_PATH?: string; + /** External ledger anchoring (#9274, epic #9267): the GitHub App installation id used to mint the token + * the git-commit backend commits with (via the same makeInstallationOctokit/withInstallationTokenRetry + * chokepoint every other GitHub write in this engine goes through). Unset (alongside the owner/repo + * pair above) means the git backend does not run this tick; Rekor is unaffected. */ + LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID?: string; /** Convergence (port): public OAuth draft-submission flow ported from reviewbot. When truthy, the * /v1/drafts endpoints accept a contributor draft -> GitHub OAuth -> fork PR against the content repo. * Default OFF — unset/false makes every draft endpoint 404 and writes nothing (byte-identical worker). */ diff --git a/src/index.ts b/src/index.ts index 486ce9ff2e..403ceaa3c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,6 +137,12 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController): // budget is reserved for webhooks (which drive timely reviews) instead of compounding the backlog; the next // tick (~2 min) retries, and after the bucket resets the sweep resumes. Webhooks never pre-yield. const jobs: JobMessage[] = []; + // #9274 (epic #9267): enqueued EVERY tick, unconditionally, rather than gated behind isHourly here. The + // seq-threshold trigger (>=256 records since the last anchor) must fire "independent of the hourly clock" + // per the issue's own requirement -- gating the enqueue itself to isHourly would silently make that + // impossible. The job's own decideLedgerAnchorSchedule (in ledger-anchor-scheduler.ts) is what actually + // decides whether THIS tick does anything; a cheap two-query no-op the overwhelming majority of ticks. + jobs.push({ type: "anchor-decision-ledger", requestedBy: "schedule", isHourly }); const selfHostedReviews = isSelfHostedReviewRuntime(env); const queueSnapshot = selfHostedReviews ? await queueSnapshotFromBinding(env.JOBS).catch((error) => { diff --git a/src/queue/job-dispatch.ts b/src/queue/job-dispatch.ts index 37dff44485..dd09bddfb4 100644 --- a/src/queue/job-dispatch.ts +++ b/src/queue/job-dispatch.ts @@ -48,6 +48,10 @@ import { setAprRepoDispatchPaused, } from "../orb/apr-repo-transfer"; import { syncBrokeredInstalledRepos } from "../orb/installed-repos-sync"; +import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "../github/client"; +import { withInstallationTokenRetry } from "../github/app"; +import { runScheduledLedgerAnchor, resolveGitAnchorTarget } from "../review/ledger-anchor-scheduler"; +import { submitToGitAnchor } from "../review/ledger-anchor-git"; import { incr } from "../selfhost/metrics"; import { generateSignalSnapshots } from "./signal-snapshot"; import { isDecisionAuditEnabled, runDecisionAuditSample } from "../review/decision-audit"; @@ -93,6 +97,23 @@ export async function processJob(env: Env, message: JobMessage): Promise { case "refresh-registry": await refreshRegistry(env); return; + case "anchor-decision-ledger": { + // Git anchoring runs only when BOTH the target (owner/repo, #9273) and an installation id (to mint a + // token through) are configured -- unset means that backend simply isn't wired up yet; Rekor still + // proceeds independently either way (runScheduledLedgerAnchor's own posture). + const gitTarget = resolveGitAnchorTarget(env); + const installationId = Number(env.LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID); + const submitGit = + gitTarget && Number.isInteger(installationId) && installationId > 0 + ? async (signed: Parameters[1]) => + withInstallationTokenRetry(env, installationId, async (token) => { + const octokit = makeInstallationOctokit(env, token, "live", githubRateLimitAdmissionKeyForInstallation(installationId)); + await submitToGitAnchor(env, signed, octokit, gitTarget); + }) + : null; + await runScheduledLedgerAnchor(env, { isHourly: message.isHourly }, { submitGit }); + return; + } case "sync-brokered-installed-repos": { const syncResult = await syncBrokeredInstalledRepos(env); // syncBrokeredInstalledRepos is deliberately fail-safe (never throws -- a miss self-heals on the next diff --git a/src/review/decision-record.ts b/src/review/decision-record.ts index 42af0310d7..9db79b5357 100644 --- a/src/review/decision-record.ts +++ b/src/review/decision-record.ts @@ -335,6 +335,20 @@ export async function ledgerRowHash(prevHash: string, fields: LedgerRowFields): * by the verify endpoint's record/ledger reconciliation, a follow-up check, rather than by losing the * decision itself). */ +/** The chain's current tip -- {@link LEDGER_GENESIS_HASH}/seq 0/count 0 on an empty ledger. Deliberately + * lighter than {@link verifyDecisionLedger} (which additionally walks and verifies a window): a caller that + * only needs "what is the tip right now" (the scheduled anchoring job, #9274) shouldn't pay for a + * self-consistency walk it isn't asking for. */ +export async function loadDecisionLedgerTip(env: Env): Promise<{ seq: number; rowHash: string; totalCount: number }> { + const [totalRow, tipRow] = await Promise.all([ + env.DB.prepare("SELECT COUNT(*) AS n FROM decision_ledger").first<{ n: number }>(), + env.DB.prepare("SELECT seq, row_hash AS rowHash FROM decision_ledger ORDER BY seq DESC LIMIT 1").first<{ seq: number; rowHash: string }>(), + ]); + /* v8 ignore next -- defensive: a bare COUNT(*) always returns exactly one row, mirroring + * verifyDecisionLedger's identical note. */ + return { seq: tipRow?.seq ?? 0, rowHash: tipRow?.rowHash ?? LEDGER_GENESIS_HASH, totalCount: totalRow?.n ?? 0 }; +} + export async function appendDecisionLedger(env: Env, recordId: string, recordDigest: string, attempts = 3): Promise { for (let attempt = 1; attempt <= attempts; attempt += 1) { const tip = await env.DB.prepare("SELECT seq, row_hash AS rowHash FROM decision_ledger ORDER BY seq DESC LIMIT 1").first<{ seq: number; rowHash: string }>(); @@ -375,16 +389,6 @@ export type LedgerBreak = // gap/predecessor/hash checks above can see, since those only ever compare ledger rows against each other). | { kind: "missing_record"; atSeq: number; recordId: string }; -/** #9122: the exact shape a scheduled external-anchoring job (git-commit checkpoint, transparency log, or an - * on-chain commitment — the actual publishing mechanism is a genuinely open infra/protocol decision tracked - * on the issue, deliberately NOT built here) would publish for a given tip: enough for a third party to later - * prove "the ledger's tip really was this, at this time" against whatever anchor eventually receives it. Pure - * and synchronous — this module has no scheduler and calls this from nowhere yet; a future cron handler is - * the natural caller, using the tipSeq/tipHash verifyDecisionLedger already returns on every call. */ -export function buildLedgerAnchorPayload(tip: { seq: number; rowHash: string }, at: string = nowIso()): { seq: number; rowHash: string; at: string } { - return { seq: tip.seq, rowHash: tip.rowHash, at }; -} - /** * Verify a window of the chain, resumable via `afterSeq` (0 = genesis). Reports the FIRST break with its * class — a gap, a broken predecessor link, a rewritten row, a short tail, a content mismatch, or a missing diff --git a/src/review/ledger-anchor-persistence.ts b/src/review/ledger-anchor-persistence.ts index 044ed53b20..35f4bc0157 100644 --- a/src/review/ledger-anchor-persistence.ts +++ b/src/review/ledger-anchor-persistence.ts @@ -149,3 +149,19 @@ function parseBackendRef(raw: string | null): unknown { return null; } } + +/** + * The most recent anchor ATTEMPT, regardless of backend or status -- the reference point the scheduler + * (#9274) compares the live tip against. Deliberately not "the most recent SUCCESSFUL anchor": if a backend + * is down for a stretch, treating each failed retry as if nothing had been attempted would mean hammering the + * same stale checkpoint every cron tick against a backend that keeps failing, rather than advancing to a + * newer tip as time passes and letting the gap be visible on #9271's own public attempt log. Returns null + * only when nothing has ever been recorded (the very first anchor ever). + */ +export async function loadLastLedgerAnchorAttempt(env: Env): Promise<{ seq: number; rowHash: string } | null> { + const row = await env.DB.prepare("SELECT seq, row_hash AS rowHash FROM decision_ledger_anchors ORDER BY created_at DESC, id DESC LIMIT 1").first<{ + seq: number; + rowHash: string; + }>(); + return row == null ? null : row; +} diff --git a/src/review/ledger-anchor-scheduler.ts b/src/review/ledger-anchor-scheduler.ts new file mode 100644 index 0000000000..aeee7c247b --- /dev/null +++ b/src/review/ledger-anchor-scheduler.ts @@ -0,0 +1,122 @@ +// Scheduled checkpoint anchoring (#9274, epic #9267). Ties #9272 (Rekor) and #9273 (git-commit) together into +// a periodic job -- checkpoint cadence, not per-record, per the mechanism research on #9267: anchoring every +// decision record is wrong on every axis (Rekor is a donated public good; a git commit per PR review is +// noise; a naive per-record job has no natural batching anyway). +import { errorMessage, nowIso } from "../utils/json"; +import { buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, signLedgerAnchorPayload, type SignedLedgerAnchor } from "./ledger-anchor"; +import { loadDecisionLedgerTip } from "./decision-record"; +import { loadLastLedgerAnchorAttempt, recordLedgerAnchorAttempt, type LedgerAnchorBackend } from "./ledger-anchor-persistence"; +import { submitToRekor } from "./ledger-anchor-rekor"; +import type { LedgerAnchorGitTarget } from "./ledger-anchor-git"; + +/** Bounds the unanchored window by record count, independent of the hourly clock -- a burst of activity + * cannot sit unanchored for a full hour just because it happened between ticks. */ +export const LEDGER_ANCHOR_SEQ_THRESHOLD = 256; + +export type LedgerAnchorScheduleDecision = + | { shouldAnchor: false; reason: "empty_ledger" | "unchanged" } + | { shouldAnchor: true; reason: "hourly" | "seq_threshold" }; + +/** + * Pure scheduling decision: given the live tip and the last attempt (regardless of that attempt's own + * success/failure -- see {@link loadLastLedgerAnchorAttempt}'s own reasoning), should THIS tick anchor? + * + * `seq_threshold` is checked treating a never-anchored ledger as starting from seq 0 -- a burst of >= the + * threshold worth of activity before the very first anchor ever still fires immediately, rather than waiting + * for the next hourly tick just because there is no prior anchor to compare against. + */ +export function decideLedgerAnchorSchedule(input: { + isHourly: boolean; + currentTip: { seq: number; rowHash: string }; + lastAnchor: { seq: number; rowHash: string } | null; + seqThreshold: number; +}): LedgerAnchorScheduleDecision { + if (input.currentTip.seq === 0) return { shouldAnchor: false, reason: "empty_ledger" }; + + const lastAnchorSeq = input.lastAnchor?.seq ?? 0; + if (input.currentTip.seq - lastAnchorSeq >= input.seqThreshold) return { shouldAnchor: true, reason: "seq_threshold" }; + + const tipUnchanged = input.lastAnchor !== null && input.lastAnchor.rowHash === input.currentTip.rowHash; + if (input.isHourly && !tipUnchanged) return { shouldAnchor: true, reason: "hourly" }; + + return { shouldAnchor: false, reason: "unchanged" }; +} + +/** Reads the four `LOOPOVER_LEDGER_ANCHOR_GIT_*` config vars into a target, or `null` when the required + * owner/repo pair is unset -- git anchoring is simply not configured yet, the same honest-degrade posture + * as an unset signing key, not an error. */ +export function resolveGitAnchorTarget(env: { + LOOPOVER_LEDGER_ANCHOR_GIT_OWNER?: string; + LOOPOVER_LEDGER_ANCHOR_GIT_REPO?: string; + LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH?: string; + LOOPOVER_LEDGER_ANCHOR_GIT_PATH?: string; +}): LedgerAnchorGitTarget | null { + const owner = env.LOOPOVER_LEDGER_ANCHOR_GIT_OWNER; + const repo = env.LOOPOVER_LEDGER_ANCHOR_GIT_REPO; + if (!owner || !repo) return null; + return { owner, repo, branch: env.LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH || "main", path: env.LOOPOVER_LEDGER_ANCHOR_GIT_PATH || "anchors.jsonl" }; +} + +export type LedgerAnchorSchedulerDeps = { + /** Defaults to the real Rekor backend. Injectable so a test exercises the scheduler's own decision logic + * without a real network call. */ + submitRekor?: (signed: SignedLedgerAnchor, publicKeySpki: string) => Promise; + /** `null`/omitted when git anchoring isn't configured (no target, no installation) -- the scheduler simply + * skips that backend, same posture as an unset signing key. The real caller (the queue processor) wraps + * the actual `submitToGitAnchor` call in `withInstallationTokenRetry` here, so a stale token retries the + * WHOLE attempt with a fresh one, matching every other GitHub write in this engine. */ + submitGit?: ((signed: SignedLedgerAnchor) => Promise) | null; +}; + +/** + * Run one scheduling tick. Never blocks a review: this has no caller in the live decision-record persist + * path at all -- it exists only as a queued background job (#9274's own dispatch wiring) -- and neither + * backend it calls ever rejects (each records its own `status: 'failed'` via #9271 instead of throwing), so + * 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. + */ +export async function runScheduledLedgerAnchor(env: Env, options: { isHourly: boolean; now?: string }, deps: LedgerAnchorSchedulerDeps = {}): Promise { + const now = options.now ?? nowIso(); + const [tip, lastAnchor] = await Promise.all([loadDecisionLedgerTip(env), loadLastLedgerAnchorAttempt(env)]); + const decision = decideLedgerAnchorSchedule({ isHourly: options.isHourly, currentTip: tip, lastAnchor, seqThreshold: LEDGER_ANCHOR_SEQ_THRESHOLD }); + if (!decision.shouldAnchor) return decision; + + const keys = parseAnchorPublicKeys(env.LOOPOVER_LEDGER_ANCHOR_KEYS); + const current = currentAnchorKey(keys); + if (!current || !env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY) { + console.log( + JSON.stringify({ + event: "ledger_anchor_skipped_unconfigured", + reason: !current ? "no_current_signing_key_published" : "no_private_key_configured", + }), + ); + return decision; + } + + const payload = buildLedgerAnchorPayload(tip, now); + const signed = await signLedgerAnchorPayload(payload, env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY, current.keyId); + + const submitRekor = deps.submitRekor ?? ((s, publicKeySpki) => submitToRekor(env, s, publicKeySpki, fetch)); + // guardedSubmit is the actual safety boundary, not a convention every injected function is trusted to + // honor: #9272's real submitToRekor and #9273's real submitToGitAnchor never reject on their own, but an + // INJECTED submitGit (the real caller wraps token-minting around submitToGitAnchor, and minting a fresh + // installation token is a genuine IO call that CAN throw, unlike anything inside submitToGitAnchor itself) + // is not something this module controls. Catching here, once, centrally, is what makes "neither backend + // ever rejects" true structurally rather than by every call site's own discipline. + const guardedSubmit = async (backend: LedgerAnchorBackend, submit: () => Promise): Promise => { + try { + await submit(); + } catch (error) { + await recordLedgerAnchorAttempt(env, { payload: signed.payload, signature: signed.signature, keyId: signed.keyId, backend, status: "failed", error: errorMessage(error) }); + } + }; + + const attempts: Promise[] = [guardedSubmit("rekor", () => submitRekor(signed, current.publicKeySpki))]; + if (deps.submitGit) { + const submitGit = deps.submitGit; + attempts.push(guardedSubmit("git", () => submitGit(signed))); + } + await Promise.all(attempts); + + return decision; +} diff --git a/src/selfhost/queue-common.ts b/src/selfhost/queue-common.ts index 90965f7ed4..92b5df7010 100644 --- a/src/selfhost/queue-common.ts +++ b/src/selfhost/queue-common.ts @@ -777,6 +777,10 @@ export function scheduledEnqueueJitterMs(): number { const IMMEDIATE_SCHEDULED_JOB_TYPES = new Set([ "agent-regate-sweep", "retry-orb-relay", + // #9274 (epic #9267): checked every ~2-min tick like the sweep above (the seq-threshold anchoring trigger + // must fire independent of the hourly clock), and is a cheap 1-2 query no-op the overwhelming majority of + // ticks -- not a heavy per-repo fan-out parent, so it needs no phase-spreading either. + "anchor-decision-ledger", ]); export function scheduledEnqueueDelaySeconds(jobType: string): number { diff --git a/src/types.ts b/src/types.ts index 2288230873..e51d886148 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,14 @@ export type JobMessage = type: "refresh-registry"; requestedBy: "schedule" | "api" | "test"; } + | { + // Scheduled decision-ledger anchoring (#9274, epic #9267). `isHourly` is threaded through from the + // dispatcher's own clock computation (src/index.ts) rather than re-derived here, so there is exactly + // ONE place that decides "what time is it" for every scheduled job, this one included. + type: "anchor-decision-ledger"; + requestedBy: "schedule" | "test"; + isHourly: boolean; + } | { type: "sync-brokered-installed-repos"; requestedBy: "schedule" | "api" | "test"; diff --git a/test/unit/decision-record.test.ts b/test/unit/decision-record.test.ts index 4d85ba9bb9..63944ddbe2 100644 --- a/test/unit/decision-record.test.ts +++ b/test/unit/decision-record.test.ts @@ -9,7 +9,7 @@ import { sha256Hex, type DecisionRecord, } from "../../src/review/decision-record"; -import { appendDecisionLedger, buildLedgerAnchorPayload, LEDGER_GENESIS_HASH, loadDecisionRecordCollapsible, loadPublicDecisionRecord, verifyDecisionLedger } from "../../src/review/decision-record"; +import { appendDecisionLedger, LEDGER_GENESIS_HASH, loadDecisionLedgerTip, loadDecisionRecordCollapsible, loadPublicDecisionRecord, verifyDecisionLedger } from "../../src/review/decision-record"; import { createTestEnv } from "../helpers/d1"; // #8836: the digests are commitments a contributor can challenge — key-order invariance and unicode @@ -333,18 +333,25 @@ describe("loadPublicDecisionRecord (#9123)", () => { }); }); -describe("buildLedgerAnchorPayload (#9122)", () => { - it("returns exactly {seq, rowHash, at} from a tip, using a caller-supplied timestamp", () => { - const payload = buildLedgerAnchorPayload({ seq: 5, rowHash: "a".repeat(64) }, "2026-01-01T00:00:00.000Z"); - expect(payload).toEqual({ seq: 5, rowHash: "a".repeat(64), at: "2026-01-01T00:00:00.000Z" }); +// #9270 superseded the old {seq, rowHash, at} stub that used to live here (buildLedgerAnchorPayload) with a +// self-describing, SIGNED payload -- see src/review/ledger-anchor.ts and test/unit/ledger-anchor.test.ts. + +describe("loadDecisionLedgerTip (#9274)", () => { + it("returns genesis/seq 0/count 0 on an empty ledger", async () => { + expect(await loadDecisionLedgerTip(createTestEnv())).toEqual({ seq: 0, rowHash: LEDGER_GENESIS_HASH, totalCount: 0 }); }); - it("defaults `at` to the current time when the caller omits it", () => { - const beforeMs = Date.now(); - const payload = buildLedgerAnchorPayload({ seq: 1, rowHash: LEDGER_GENESIS_HASH }); - expect(payload.seq).toBe(1); - expect(payload.rowHash).toBe(LEDGER_GENESIS_HASH); - expect(Date.parse(payload.at)).toBeGreaterThanOrEqual(beforeMs); + it("returns the real tip and total count once records exist, lighter than a full verify walk", async () => { + const env = createTestEnv(); + await appendDecisionLedger(env, "record:acme/widgets#1", "digest1"); + await appendDecisionLedger(env, "record:acme/widgets#2", "digest2"); + const tip = await loadDecisionLedgerTip(env); + expect(tip.seq).toBe(2); + expect(tip.totalCount).toBe(2); + expect(tip.rowHash).toMatch(/^[0-9a-f]{64}$/); + // Matches what verifyDecisionLedger's own tip computation reports for the same chain. + const verified = await verifyDecisionLedger(env); + expect(tip).toEqual({ seq: verified.tipSeq, rowHash: verified.tipHash, totalCount: verified.totalCount }); }); }); diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 2056d24ecc..56c5d468d9 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -363,7 +363,7 @@ describe("worker entrypoint", () => { // A regular */2 tick (not :00, not :30) enqueues ONLY the light auto-maintain sweep — the heavier sync/health // jobs are gated to :00/:30, so the tight cadence stays cheap while merges/closes fire promptly. - expect(sent).toEqual([{ type: "agent-regate-sweep", requestedBy: "schedule" }]); + expect(sent).toEqual([{ type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: false }, { type: "agent-regate-sweep", requestedBy: "schedule" }]); }); it("enqueues the APR repo-transfer poll on an hourly tick only when LOOPOVER_APR_TRANSFER_POLL is set (#7741)", async () => { @@ -409,6 +409,7 @@ describe("worker entrypoint", () => { await Promise.all(waitUntil); expect(sent).toEqual([ + { type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: false }, { type: "agent-regate-sweep", requestedBy: "schedule" }, { type: "backfill-registered-repos", requestedBy: "schedule", mode: "light" }, { type: "repair-data-fidelity", requestedBy: "schedule" }, @@ -439,6 +440,7 @@ describe("worker entrypoint", () => { // No SECOND "agent-regate-sweep" trigger is enqueued behind the one already in flight; the other :30 jobs // are unaffected since they never depended on the (removed, broad) backlog check. expect(sent).toEqual([ + { type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: false }, { type: "backfill-registered-repos", requestedBy: "schedule", mode: "light" }, { type: "repair-data-fidelity", requestedBy: "schedule" }, { type: "refresh-installation-health", requestedBy: "schedule" }, @@ -467,6 +469,7 @@ describe("worker entrypoint", () => { // No SECOND "backlog-convergence-sweep" trigger is enqueued behind the one already in flight; the other :30 // jobs (including agent-regate-sweep, whose OWN backlog is unaffected) still fire normally. expect(sent).toEqual([ + { type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: false }, { type: "agent-regate-sweep", requestedBy: "schedule" }, { type: "backfill-registered-repos", requestedBy: "schedule", mode: "light" }, { type: "repair-data-fidelity", requestedBy: "schedule" }, @@ -494,11 +497,11 @@ describe("worker entrypoint", () => { // Fails OPEN on a broken snapshot binding: the sweep still enqueues, and the failure is surfaced (not // silently swallowed) so an operator can see the introspection is unavailable. - expect(sent).toEqual([{ type: "agent-regate-sweep", requestedBy: "schedule" }]); + expect(sent).toEqual([{ type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: false }, { type: "agent-regate-sweep", requestedBy: "schedule" }]); expect(warn).toHaveBeenCalledWith(expect.stringContaining("selfhost_queue_snapshot_failed")); }); - it("does not enqueue review sweeps from a broker-only Cloudflare runtime", async () => { + it("does not enqueue review sweeps from a broker-only Cloudflare runtime (anchoring still runs -- it is not a review sweep, #9274)", async () => { const sent: Array = []; const env = createTestEnv({ JOBS: { @@ -513,7 +516,7 @@ describe("worker entrypoint", () => { await worker.scheduled(controllerFor("2026-05-25T05:14:00.000Z"), env, executionContext(waitUntil)); await Promise.all(waitUntil); - expect(sent).toEqual([]); + expect(sent).toEqual([{ type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: false }]); }); it("keeps broker-only Cloudflare maintenance cheap on :30 ticks", async () => { @@ -532,6 +535,7 @@ describe("worker entrypoint", () => { await Promise.all(waitUntil); expect(sent).toEqual([ + { type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: false }, { type: "repair-data-fidelity", requestedBy: "schedule" }, { type: "refresh-installation-health", requestedBy: "schedule" }, ]); @@ -601,6 +605,7 @@ describe("worker entrypoint", () => { // opted into the experimental gittensor plugin, so the job is never enqueued (#experimental-gittensor-plugin) // — see "re-includes refresh-registry once a repo opts into the experimental gittensor plugin" below. expect(sent).toEqual([ + { type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: true }, { type: "agent-regate-sweep", requestedBy: "schedule" }, { type: "backfill-registered-repos", requestedBy: "schedule", mode: "light" }, { type: "repair-data-fidelity", requestedBy: "schedule" }, @@ -671,6 +676,7 @@ describe("worker entrypoint", () => { // refresh-registry is absent here too — see the comment on "enqueues hourly refreshes..." above. expect(sent).toEqual([ + { type: "anchor-decision-ledger", requestedBy: "schedule", isHourly: true }, { type: "agent-regate-sweep", requestedBy: "schedule" }, { type: "backfill-registered-repos", requestedBy: "schedule", mode: "full" }, { type: "repair-data-fidelity", requestedBy: "schedule" }, @@ -715,6 +721,7 @@ describe("worker entrypoint", () => { // The enqueued SET is unchanged — jitter only spreads run_after timing, never which jobs are sent. // refresh-registry is absent here too — see the comment on "enqueues hourly refreshes..." above. expect(sent.map((s) => s.message.type)).toEqual([ + "anchor-decision-ledger", "agent-regate-sweep", "backfill-registered-repos", "repair-data-fidelity", diff --git a/test/unit/ledger-anchor-job-dispatch.test.ts b/test/unit/ledger-anchor-job-dispatch.test.ts new file mode 100644 index 0000000000..d3b44a9bb7 --- /dev/null +++ b/test/unit/ledger-anchor-job-dispatch.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from "vitest"; +import { createTestEnv } from "../helpers/d1"; +import { generatePrivateKeyPem } from "../helpers/github-app-key"; +import { processJob } from "../../src/queue/job-dispatch"; +import { buildDecisionRecord, contentDigest, persistDecisionRecord } from "../../src/review/decision-record"; +import { loadPublicLedgerAnchors } from "../../src/review/ledger-anchor-persistence"; +import { computeAnchorKeyId } from "../../src/review/ledger-anchor"; + +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +} + +async function generateAnchorKeypair(): Promise<{ privateKeyPem: string; publicKeySpki: string; keyId: string }> { + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; + const pkcs8 = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer)); + const publicKeySpki = bytesToBase64(new Uint8Array((await crypto.subtle.exportKey("spki", pair.publicKey)) as ArrayBuffer)); + const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${(pkcs8.match(/.{1,64}/g) ?? []).join("\n")}\n-----END PRIVATE KEY-----`; + return { privateKeyPem, publicKeySpki, keyId: await computeAnchorKeyId(publicKeySpki) }; +} + +// #9274 (epic #9267): the real dispatch wiring in job-dispatch.ts -- constructing the git-anchoring callback +// (or not) around the shared runScheduledLedgerAnchor logic, which is otherwise exhaustively unit-tested +// with dependency injection in ledger-anchor-scheduler.test.ts. This file exercises the actual "type: +// anchor-decision-ledger" case end to end, including a REAL installation-token mint for the git path. + +async function seedOneDecision(env: Env): Promise { + const { record, recordDigest } = await buildDecisionRecord({ + repoFullName: "acme/widgets", + pullNumber: 1, + headSha: "abc1", + baseSha: null, + action: "merge", + reasonCode: "gate_clean", + configDigest: await contentDigest({ gatePack: "oss-anti-slop" }), + gatePack: "oss-anti-slop", + ciState: null, + modelIds: null, + promptDigest: null, + aiConfidence: null, + salvageability: null, + }); + await persistDecisionRecord(env, record, recordDigest); +} + +describe("processJob('anchor-decision-ledger') (#9274)", () => { + it("resolves cleanly with git anchoring skipped when owner/repo/installation are unconfigured", async () => { + const env = createTestEnv(); + await seedOneDecision(env); + const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({ x: { logIndex: 1, uuid: "u", logId: { keyId: "k" } } }), { status: 201 })); + vi.stubGlobal("fetch", fetchSpy); + try { + await expect(processJob(env, { type: "anchor-decision-ledger", requestedBy: "test", isHourly: true })).resolves.toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + // No signing key configured in this env either -> nothing was ever attempted (honest degrade), but the + // job itself must not throw regardless. + const { anchors } = await loadPublicLedgerAnchors(env, { backend: "git" }); + expect(anchors).toEqual([]); + }); + + it("mints a REAL installation token and drives submitToGitAnchor's real Contents API call sequence when fully configured", async () => { + const { privateKeyPem, publicKeySpki, keyId } = await generateAnchorKeypair(); + const env = createTestEnv({ + GITHUB_APP_PRIVATE_KEY: await generatePrivateKeyPem(), + LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme", + LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors", + LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID: "123", + LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: privateKeyPem, + LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([{ keyId, publicKeySpki, notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]), + }); + await seedOneDecision(env); + + const contentsCalls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input.toString(); + if (url.includes("/access_tokens")) return Response.json({ token: "installation-token" }); + if (url.includes("/contents/")) { + const method = String(init?.method ?? "GET"); + contentsCalls.push(method); + if (method === "GET") return new Response("Not Found", { status: 404 }); // first anchor ever + return Response.json({ commit: { sha: "committed-sha" } }); + } + if (url.includes("rekor.sigstore.dev")) return new Response(JSON.stringify({ x: { logIndex: 1, uuid: "u", logId: { keyId: "k" } } }), { status: 201 }); + return new Response("unexpected", { status: 500 }); + }), + ); + try { + await expect(processJob(env, { type: "anchor-decision-ledger", requestedBy: "test", isHourly: true })).resolves.toBeUndefined(); + } finally { + vi.unstubAllGlobals(); + } + + // Proves the DISPATCH wiring itself: a real installation-token mint, real makeInstallationOctokit + // construction, and the real submitToGitAnchor call sequence (GET then PUT against Contents API) -- + // submitToGitAnchor's own internal logic is exhaustively covered with injected octokit in + // ledger-anchor-git.test.ts; this test's job is only to prove nothing is misconfigured at the boundary + // where job-dispatch.ts wires token-minting around it. + expect(contentsCalls).toEqual(["GET", "PUT"]); + + const { anchors } = await loadPublicLedgerAnchors(env, { backend: "git" }); + expect(anchors[0]).toMatchObject({ status: "ok", backendRef: { sha: "committed-sha" } }); + }); +}); diff --git a/test/unit/ledger-anchor-persistence.test.ts b/test/unit/ledger-anchor-persistence.test.ts index 2981bac9e9..8ec85be3d4 100644 --- a/test/unit/ledger-anchor-persistence.test.ts +++ b/test/unit/ledger-anchor-persistence.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createTestEnv } from "../helpers/d1"; -import { loadPublicLedgerAnchors, recordLedgerAnchorAttempt, type LedgerAnchorAttemptInput } from "../../src/review/ledger-anchor-persistence"; +import { loadLastLedgerAnchorAttempt, loadPublicLedgerAnchors, recordLedgerAnchorAttempt, type LedgerAnchorAttemptInput } from "../../src/review/ledger-anchor-persistence"; import { buildLedgerAnchorPayload } from "../../src/review/ledger-anchor"; // #9271 (epic #9267). The load-bearing property here is that a FAILURE is recorded and served exactly like a @@ -129,3 +129,25 @@ describe("recordLedgerAnchorAttempt / loadPublicLedgerAnchors (#9271)", () => { expect(nextBefore).toBeNull(); }); }); + +describe("loadLastLedgerAnchorAttempt (#9274)", () => { + it("returns null when nothing has ever been recorded", async () => { + expect(await loadLastLedgerAnchorAttempt(createTestEnv())).toBeNull(); + }); + + it("returns the most recent attempt's seq/rowHash, regardless of backend or status -- what the scheduler compares the live tip against", async () => { + const env = createTestEnv(); + await recordLedgerAnchorAttempt( + env, + { payload: buildLedgerAnchorPayload({ seq: 1, rowHash: "a".repeat(64), totalCount: 1 }, "2026-07-27T12:00:00.000Z"), signature: "s", keyId: "k", backend: "rekor", status: "ok", backendRef: {}, proofR2Key: null }, + "2026-07-27T12:00:00.000Z", + ); + // A LATER, FAILED git attempt still becomes "the last attempt" -- this is deliberately not "last success". + await recordLedgerAnchorAttempt( + env, + { payload: buildLedgerAnchorPayload({ seq: 2, rowHash: "b".repeat(64), totalCount: 2 }, "2026-07-27T12:05:00.000Z"), signature: "s", keyId: "k", backend: "git", status: "failed", error: "boom" }, + "2026-07-27T12:05:00.000Z", + ); + expect(await loadLastLedgerAnchorAttempt(env)).toEqual({ seq: 2, rowHash: "b".repeat(64) }); + }); +}); diff --git a/test/unit/ledger-anchor-scheduler.test.ts b/test/unit/ledger-anchor-scheduler.test.ts new file mode 100644 index 0000000000..6d165c4982 --- /dev/null +++ b/test/unit/ledger-anchor-scheduler.test.ts @@ -0,0 +1,211 @@ +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 { loadPublicLedgerAnchors } from "../../src/review/ledger-anchor-persistence"; +import { computeAnchorKeyId, type SignedLedgerAnchor } from "../../src/review/ledger-anchor"; + +// #9274 (epic #9267). decideLedgerAnchorSchedule is the pure decision this whole job hinges on -- tested +// exhaustively on its own before the orchestrator's IO/injection wiring. + +describe("decideLedgerAnchorSchedule (#9274)", () => { + const base = { isHourly: false, currentTip: { seq: 10, rowHash: "a".repeat(64) }, lastAnchor: null as { seq: number; rowHash: string } | null, seqThreshold: 256 }; + + it("never anchors an empty ledger, regardless of hourly or threshold", () => { + expect(decideLedgerAnchorSchedule({ ...base, isHourly: true, currentTip: { seq: 0, rowHash: "0".repeat(64) } })).toEqual({ shouldAnchor: false, reason: "empty_ledger" }); + }); + + it("anchors on the hourly tick when the tip has never been anchored before", () => { + expect(decideLedgerAnchorSchedule({ ...base, isHourly: true, lastAnchor: null })).toEqual({ shouldAnchor: true, reason: "hourly" }); + }); + + it("anchors on the hourly tick when the tip changed since the last anchor", () => { + expect(decideLedgerAnchorSchedule({ ...base, isHourly: true, lastAnchor: { seq: 5, rowHash: "b".repeat(64) } })).toEqual({ shouldAnchor: true, reason: "hourly" }); + }); + + it("skips the hourly tick when the tip is UNCHANGED since the last anchor -- idempotent, free on quiet days", () => { + expect(decideLedgerAnchorSchedule({ ...base, isHourly: true, lastAnchor: { seq: 10, rowHash: "a".repeat(64) } })).toEqual({ shouldAnchor: false, reason: "unchanged" }); + }); + + it("skips a non-hourly tick with an unchanged tip and no threshold breach", () => { + expect(decideLedgerAnchorSchedule({ ...base, isHourly: false, lastAnchor: { seq: 9, rowHash: "c".repeat(64) } })).toEqual({ shouldAnchor: false, reason: "unchanged" }); + }); + + it("anchors immediately once the seq delta reaches the threshold, independent of the hourly clock", () => { + const result = decideLedgerAnchorSchedule({ ...base, isHourly: false, currentTip: { seq: 300, rowHash: "d".repeat(64) }, lastAnchor: { seq: 40, rowHash: "e".repeat(64) } }); + expect(result).toEqual({ shouldAnchor: true, reason: "seq_threshold" }); + }); + + it("treats a never-anchored ledger as starting from seq 0 for the threshold check -- a big first burst anchors immediately, not on the next hourly tick", () => { + const result = decideLedgerAnchorSchedule({ ...base, isHourly: false, currentTip: { seq: LEDGER_ANCHOR_SEQ_THRESHOLD, rowHash: "f".repeat(64) }, lastAnchor: null }); + expect(result).toEqual({ shouldAnchor: true, reason: "seq_threshold" }); + }); + + it("does not anchor one short of the threshold", () => { + const result = decideLedgerAnchorSchedule({ ...base, isHourly: false, currentTip: { seq: 40 + LEDGER_ANCHOR_SEQ_THRESHOLD - 1, rowHash: "g".repeat(64) }, lastAnchor: { seq: 40, rowHash: "h".repeat(64) } }); + expect(result).toEqual({ shouldAnchor: false, reason: "unchanged" }); + }); + + it("threshold takes priority even ON an hourly tick when both would otherwise fire", () => { + const result = decideLedgerAnchorSchedule({ ...base, isHourly: true, currentTip: { seq: 400, rowHash: "i".repeat(64) }, lastAnchor: { seq: 1, rowHash: "j".repeat(64) } }); + expect(result).toEqual({ shouldAnchor: true, reason: "seq_threshold" }); + }); +}); + +describe("resolveGitAnchorTarget", () => { + it("returns null when owner or repo is unset -- git anchoring simply isn't configured", () => { + expect(resolveGitAnchorTarget({})).toBeNull(); + expect(resolveGitAnchorTarget({ LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme" })).toBeNull(); + expect(resolveGitAnchorTarget({ LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors" })).toBeNull(); + }); + + it("defaults branch to main and path to anchors.jsonl", () => { + expect(resolveGitAnchorTarget({ LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme", LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors" })).toEqual({ + owner: "acme", + repo: "anchors", + branch: "main", + path: "anchors.jsonl", + }); + }); + + it("honors an explicit branch and path", () => { + expect( + resolveGitAnchorTarget({ + LOOPOVER_LEDGER_ANCHOR_GIT_OWNER: "acme", + LOOPOVER_LEDGER_ANCHOR_GIT_REPO: "anchors", + LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH: "anchors-branch", + LOOPOVER_LEDGER_ANCHOR_GIT_PATH: "custom.jsonl", + }), + ).toEqual({ owner: "acme", repo: "anchors", branch: "anchors-branch", path: "custom.jsonl" }); + }); +}); + +async function seedOneDecision(env: Env): Promise { + const { record, recordDigest } = await buildDecisionRecord({ + repoFullName: "acme/widgets", + pullNumber: 1, + headSha: "abc1", + baseSha: null, + action: "merge", + reasonCode: "gate_clean", + configDigest: await contentDigest({ gatePack: "oss-anti-slop" }), + gatePack: "oss-anti-slop", + ciState: null, + modelIds: null, + promptDigest: null, + aiConfidence: null, + salvageability: null, + }); + await persistDecisionRecord(env, record, recordDigest); +} + +async function keyedEnv(): Promise<{ env: Env; privateKeyPem: string; publicKeySpki: string; keyId: string }> { + const pair = (await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"])) as CryptoKeyPair; + const toBase64 = (bytes: Uint8Array) => { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); + }; + const pkcs8 = toBase64(new Uint8Array((await crypto.subtle.exportKey("pkcs8", pair.privateKey)) as ArrayBuffer)); + const publicKeySpki = toBase64(new Uint8Array((await crypto.subtle.exportKey("spki", pair.publicKey)) as ArrayBuffer)); + const privateKeyPem = `-----BEGIN PRIVATE KEY-----\n${(pkcs8.match(/.{1,64}/g) ?? []).join("\n")}\n-----END PRIVATE KEY-----`; + const keyId = await computeAnchorKeyId(publicKeySpki); + const env = createTestEnv({ + LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY: privateKeyPem, + LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([{ keyId, publicKeySpki, notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]), + }); + return { env, privateKeyPem, publicKeySpki, keyId }; +} + +describe("runScheduledLedgerAnchor (#9274)", () => { + it("does nothing on an empty ledger", async () => { + const { env } = await keyedEnv(); + const submitRekor = vi.fn(); + const decision = await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor }); + expect(decision).toEqual({ shouldAnchor: false, reason: "empty_ledger" }); + expect(submitRekor).not.toHaveBeenCalled(); + }); + + it("calls submitRekor on the hourly tick when the tip changed, and records nothing extra when unconfigured", async () => { + const env = createTestEnv(); // no signing key configured + await seedOneDecision(env); + const submitRekor = vi.fn(); + const decision = await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor }); + expect(decision).toEqual({ shouldAnchor: true, reason: "hourly" }); + expect(submitRekor).not.toHaveBeenCalled(); // no signing key -> skipped, honest degrade + }); + + it("signs the payload and calls submitRekor when a signing key IS configured", async () => { + const { env } = await keyedEnv(); + await seedOneDecision(env); + const submitRekor = vi.fn().mockResolvedValue(undefined); + await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor }); + expect(submitRekor).toHaveBeenCalledTimes(1); + const [signed] = submitRekor.mock.calls[0] as [SignedLedgerAnchor, string]; + expect(signed.payload.seq).toBe(1); + expect(signed.signature).not.toBe(""); + }); + + it("attempts BOTH backends independently -- Rekor still runs even when submitGit rejects, and vice versa", async () => { + const { env } = await keyedEnv(); + await seedOneDecision(env); + const submitRekor = vi.fn().mockRejectedValue(new Error("rekor down")); + const submitGit = vi.fn().mockResolvedValue(undefined); + await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor, submitGit }); + expect(submitRekor).toHaveBeenCalledTimes(1); + expect(submitGit).toHaveBeenCalledTimes(1); // git still ran despite Rekor's rejection + }); + + it("a rejecting submitGit (e.g. a token-mint failure) is recorded as a failed git attempt, not an unhandled rejection", async () => { + const { env } = await keyedEnv(); + await seedOneDecision(env); + const submitRekor = vi.fn().mockResolvedValue(undefined); + const submitGit = vi.fn().mockRejectedValue(new Error("failed to mint installation token")); + + await expect(runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor, submitGit })).resolves.toBeDefined(); + + const { anchors } = await loadPublicLedgerAnchors(env, { backend: "git" }); + expect(anchors[0]).toMatchObject({ backend: "git", status: "failed", error: "failed to mint installation token" }); + }); + + it("does not attempt git anchoring when submitGit is omitted (not configured)", async () => { + const { env } = await keyedEnv(); + await seedOneDecision(env); + const submitRekor = vi.fn().mockResolvedValue(undefined); + await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor }); + const { anchors } = await loadPublicLedgerAnchors(env, { backend: "git" }); + expect(anchors).toEqual([]); + }); + + 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); + const submitRekor = vi.fn(); + await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor }); + expect(submitRekor).not.toHaveBeenCalled(); + }); + + it("skips entirely when a current key IS published but the private key is not configured (the other unconfigured arm)", async () => { + const { publicKeySpki, keyId } = await keyedEnv(); + const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([{ keyId, publicKeySpki, notBefore: "2026-01-01T00:00:00.000Z", notAfter: null }]) }); + await seedOneDecision(env); + const submitRekor = vi.fn(); + await runScheduledLedgerAnchor(env, { isHourly: true }, { submitRekor }); + expect(submitRekor).not.toHaveBeenCalled(); + }); + + it("uses the REAL submitToRekor by default when no submitRekor is injected", async () => { + const { env } = await keyedEnv(); + await seedOneDecision(env); + const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({ x: { logIndex: 1, uuid: "u", logId: { keyId: "k" } } }), { status: 201 })); + vi.stubGlobal("fetch", fetchSpy); + try { + await runScheduledLedgerAnchor(env, { isHourly: true }); // no deps at all -- exercises the real default + } finally { + vi.unstubAllGlobals(); + } + expect(fetchSpy).toHaveBeenCalledWith(expect.stringContaining("rekor.sigstore.dev"), expect.anything()); + const { anchors } = await loadPublicLedgerAnchors(env, { backend: "rekor" }); + expect(anchors[0]).toMatchObject({ status: "ok" }); + }); +}); diff --git a/test/unit/selfhost-queue-common.test.ts b/test/unit/selfhost-queue-common.test.ts index 5967f012d7..692d4a6600 100644 --- a/test/unit/selfhost-queue-common.test.ts +++ b/test/unit/selfhost-queue-common.test.ts @@ -1579,6 +1579,9 @@ describe("self-host queue common helpers", () => { // The timely-merge sweep and its Orb-relay retry run every ~2-min tick → never deferred. expect(scheduledEnqueueDelaySeconds("agent-regate-sweep")).toBe(0); expect(scheduledEnqueueDelaySeconds("retry-orb-relay")).toBe(0); + // #9274: the seq-threshold anchoring trigger must fire independent of the hourly clock, so this also + // runs every tick rather than being phase-spread. + expect(scheduledEnqueueDelaySeconds("anchor-decision-ledger")).toBe(0); // A periodic maintenance job gets a stable, in-window slot derived from the shared jitter helper. const window = 5 * 60_000;