Skip to content

Commit 7079aca

Browse files
committed
feat(ledger): scheduled checkpoint anchoring, tying Rekor and git-commit together (#9274)
Sixth and final scheduled-side sub-issue of #9267. Enqueued every ~2min cron tick unconditionally (added to IMMEDIATE_SCHEDULED_JOB_TYPES alongside the regate sweep) rather than gated to isHourly: the seq-threshold trigger must fire independent of the hourly clock per the issue's own requirement, and the job's own decideLedgerAnchorSchedule is what actually decides whether a given tick does anything — a cheap two-query no-op the overwhelming majority of ticks. decideLedgerAnchorSchedule is pure: anchors on the hourly tick when the tip changed since the last attempt (never-anchored counts as "changed"), anchors immediately once the seq delta reaches 256 regardless of the clock (treating a never-anchored ledger as starting from seq 0, so a big first burst doesn't wait for the next hourly tick), and skips an unchanged tip either way. Both backends run independently per checkpoint. The safety boundary is structural, not conventional: submitToRekor and submitToGitAnchor never reject on their own, but the REAL git path wraps installation-token minting around submitToGitAnchor (a genuine IO call that can throw, unlike anything inside submitToGitAnchor itself) — so the orchestrator catches each backend call centrally and records a status:'failed' row on an unexpected rejection, rather than trusting every future caller to uphold "never rejects" by convention. Also found and removed real dead code: the OLD unversioned {seq, rowHash, at} stub buildLedgerAnchorPayload, still sitting in decision-record.ts alongside the new signed/versioned one #9270 built to supersede it in ledger-anchor.ts. Never removed when #9270 landed — same function name, two implementations, the old one dead and untested by anything except its own now-removed tests. Added loadDecisionLedgerTip (decision-record.ts) and loadLastLedgerAnchorAttempt (ledger-anchor-persistence.ts) as the two reads the scheduler needs — the latter deliberately keyed on the last ATTEMPT regardless of status, not the last success, so a backend stuck failing doesn't get hammered against the same stale checkpoint forever while newer tips go unanchored. Updates ~10 existing exact-array assertions in index.test.ts and one in selfhost-queue-common.test.ts to include the new job type — real, necessary churn from enqueuing unconditionally on every tick, not routed around.
1 parent 7f4eda6 commit 7079aca

14 files changed

Lines changed: 579 additions & 26 deletions

src/env.d.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,20 @@ declare global {
621621
* updates this var at the next rotation rather than needing a code change. See
622622
* review/ledger-anchor-rekor.ts. */
623623
LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL?: string;
624+
/** External ledger anchoring (#9273/#9274, epic #9267): the target repo/branch/path for the git-commit
625+
* anchoring backend. Owner and repo are BOTH required for git anchoring to run at all -- unset means
626+
* that backend is simply not configured yet (Rekor still runs on its own), the same honest-degrade
627+
* posture as an unset signing key. Branch defaults to "main", path defaults to "anchors.jsonl" if unset.
628+
* See review/ledger-anchor-scheduler.ts's `resolveGitAnchorTarget`. */
629+
LOOPOVER_LEDGER_ANCHOR_GIT_OWNER?: string;
630+
LOOPOVER_LEDGER_ANCHOR_GIT_REPO?: string;
631+
LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH?: string;
632+
LOOPOVER_LEDGER_ANCHOR_GIT_PATH?: string;
633+
/** External ledger anchoring (#9274, epic #9267): the GitHub App installation id used to mint the token
634+
* the git-commit backend commits with (via the same makeInstallationOctokit/withInstallationTokenRetry
635+
* chokepoint every other GitHub write in this engine goes through). Unset (alongside the owner/repo
636+
* pair above) means the git backend does not run this tick; Rekor is unaffected. */
637+
LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID?: string;
624638
/** Convergence (port): public OAuth draft-submission flow ported from reviewbot. When truthy, the
625639
* /v1/drafts endpoints accept a contributor draft -> GitHub OAuth -> fork PR against the content repo.
626640
* Default OFF — unset/false makes every draft endpoint 404 and writes nothing (byte-identical worker). */

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ async function enqueueScheduledJobs(env: Env, controller: ScheduledController):
137137
// budget is reserved for webhooks (which drive timely reviews) instead of compounding the backlog; the next
138138
// tick (~2 min) retries, and after the bucket resets the sweep resumes. Webhooks never pre-yield.
139139
const jobs: JobMessage[] = [];
140+
// #9274 (epic #9267): enqueued EVERY tick, unconditionally, rather than gated behind isHourly here. The
141+
// seq-threshold trigger (>=256 records since the last anchor) must fire "independent of the hourly clock"
142+
// per the issue's own requirement -- gating the enqueue itself to isHourly would silently make that
143+
// impossible. The job's own decideLedgerAnchorSchedule (in ledger-anchor-scheduler.ts) is what actually
144+
// decides whether THIS tick does anything; a cheap two-query no-op the overwhelming majority of ticks.
145+
jobs.push({ type: "anchor-decision-ledger", requestedBy: "schedule", isHourly });
140146
const selfHostedReviews = isSelfHostedReviewRuntime(env);
141147
const queueSnapshot = selfHostedReviews
142148
? await queueSnapshotFromBinding(env.JOBS).catch((error) => {

src/queue/job-dispatch.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ import {
4848
setAprRepoDispatchPaused,
4949
} from "../orb/apr-repo-transfer";
5050
import { syncBrokeredInstalledRepos } from "../orb/installed-repos-sync";
51+
import { githubRateLimitAdmissionKeyForInstallation, makeInstallationOctokit } from "../github/client";
52+
import { withInstallationTokenRetry } from "../github/app";
53+
import { runScheduledLedgerAnchor, resolveGitAnchorTarget } from "../review/ledger-anchor-scheduler";
54+
import { submitToGitAnchor } from "../review/ledger-anchor-git";
5155
import { incr } from "../selfhost/metrics";
5256
import { generateSignalSnapshots } from "./signal-snapshot";
5357
import { isDecisionAuditEnabled, runDecisionAuditSample } from "../review/decision-audit";
@@ -93,6 +97,23 @@ export async function processJob(env: Env, message: JobMessage): Promise<void> {
9397
case "refresh-registry":
9498
await refreshRegistry(env);
9599
return;
100+
case "anchor-decision-ledger": {
101+
// Git anchoring runs only when BOTH the target (owner/repo, #9273) and an installation id (to mint a
102+
// token through) are configured -- unset means that backend simply isn't wired up yet; Rekor still
103+
// proceeds independently either way (runScheduledLedgerAnchor's own posture).
104+
const gitTarget = resolveGitAnchorTarget(env);
105+
const installationId = Number(env.LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID);
106+
const submitGit =
107+
gitTarget && Number.isInteger(installationId) && installationId > 0
108+
? async (signed: Parameters<typeof submitToGitAnchor>[1]) =>
109+
withInstallationTokenRetry(env, installationId, async (token) => {
110+
const octokit = makeInstallationOctokit(env, token, "live", githubRateLimitAdmissionKeyForInstallation(installationId));
111+
await submitToGitAnchor(env, signed, octokit, gitTarget);
112+
})
113+
: null;
114+
await runScheduledLedgerAnchor(env, { isHourly: message.isHourly }, { submitGit });
115+
return;
116+
}
96117
case "sync-brokered-installed-repos": {
97118
const syncResult = await syncBrokeredInstalledRepos(env);
98119
// syncBrokeredInstalledRepos is deliberately fail-safe (never throws -- a miss self-heals on the next

src/review/decision-record.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,20 @@ export async function ledgerRowHash(prevHash: string, fields: LedgerRowFields):
335335
* by the verify endpoint's record/ledger reconciliation, a follow-up check, rather than by losing the
336336
* decision itself).
337337
*/
338+
/** The chain's current tip -- {@link LEDGER_GENESIS_HASH}/seq 0/count 0 on an empty ledger. Deliberately
339+
* lighter than {@link verifyDecisionLedger} (which additionally walks and verifies a window): a caller that
340+
* only needs "what is the tip right now" (the scheduled anchoring job, #9274) shouldn't pay for a
341+
* self-consistency walk it isn't asking for. */
342+
export async function loadDecisionLedgerTip(env: Env): Promise<{ seq: number; rowHash: string; totalCount: number }> {
343+
const [totalRow, tipRow] = await Promise.all([
344+
env.DB.prepare("SELECT COUNT(*) AS n FROM decision_ledger").first<{ n: number }>(),
345+
env.DB.prepare("SELECT seq, row_hash AS rowHash FROM decision_ledger ORDER BY seq DESC LIMIT 1").first<{ seq: number; rowHash: string }>(),
346+
]);
347+
/* v8 ignore next -- defensive: a bare COUNT(*) always returns exactly one row, mirroring
348+
* verifyDecisionLedger's identical note. */
349+
return { seq: tipRow?.seq ?? 0, rowHash: tipRow?.rowHash ?? LEDGER_GENESIS_HASH, totalCount: totalRow?.n ?? 0 };
350+
}
351+
338352
export async function appendDecisionLedger(env: Env, recordId: string, recordDigest: string, attempts = 3): Promise<void> {
339353
for (let attempt = 1; attempt <= attempts; attempt += 1) {
340354
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 =
375389
// gap/predecessor/hash checks above can see, since those only ever compare ledger rows against each other).
376390
| { kind: "missing_record"; atSeq: number; recordId: string };
377391

378-
/** #9122: the exact shape a scheduled external-anchoring job (git-commit checkpoint, transparency log, or an
379-
* on-chain commitment — the actual publishing mechanism is a genuinely open infra/protocol decision tracked
380-
* on the issue, deliberately NOT built here) would publish for a given tip: enough for a third party to later
381-
* prove "the ledger's tip really was this, at this time" against whatever anchor eventually receives it. Pure
382-
* and synchronous — this module has no scheduler and calls this from nowhere yet; a future cron handler is
383-
* the natural caller, using the tipSeq/tipHash verifyDecisionLedger already returns on every call. */
384-
export function buildLedgerAnchorPayload(tip: { seq: number; rowHash: string }, at: string = nowIso()): { seq: number; rowHash: string; at: string } {
385-
return { seq: tip.seq, rowHash: tip.rowHash, at };
386-
}
387-
388392
/**
389393
* Verify a window of the chain, resumable via `afterSeq` (0 = genesis). Reports the FIRST break with its
390394
* class — a gap, a broken predecessor link, a rewritten row, a short tail, a content mismatch, or a missing

src/review/ledger-anchor-persistence.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,19 @@ function parseBackendRef(raw: string | null): unknown {
149149
return null;
150150
}
151151
}
152+
153+
/**
154+
* The most recent anchor ATTEMPT, regardless of backend or status -- the reference point the scheduler
155+
* (#9274) compares the live tip against. Deliberately not "the most recent SUCCESSFUL anchor": if a backend
156+
* is down for a stretch, treating each failed retry as if nothing had been attempted would mean hammering the
157+
* same stale checkpoint every cron tick against a backend that keeps failing, rather than advancing to a
158+
* newer tip as time passes and letting the gap be visible on #9271's own public attempt log. Returns null
159+
* only when nothing has ever been recorded (the very first anchor ever).
160+
*/
161+
export async function loadLastLedgerAnchorAttempt(env: Env): Promise<{ seq: number; rowHash: string } | null> {
162+
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<{
163+
seq: number;
164+
rowHash: string;
165+
}>();
166+
return row == null ? null : row;
167+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Scheduled checkpoint anchoring (#9274, epic #9267). Ties #9272 (Rekor) and #9273 (git-commit) together into
2+
// a periodic job -- checkpoint cadence, not per-record, per the mechanism research on #9267: anchoring every
3+
// decision record is wrong on every axis (Rekor is a donated public good; a git commit per PR review is
4+
// noise; a naive per-record job has no natural batching anyway).
5+
import { errorMessage, nowIso } from "../utils/json";
6+
import { buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, signLedgerAnchorPayload, type SignedLedgerAnchor } from "./ledger-anchor";
7+
import { loadDecisionLedgerTip } from "./decision-record";
8+
import { loadLastLedgerAnchorAttempt, recordLedgerAnchorAttempt, type LedgerAnchorBackend } from "./ledger-anchor-persistence";
9+
import { submitToRekor } from "./ledger-anchor-rekor";
10+
import type { LedgerAnchorGitTarget } from "./ledger-anchor-git";
11+
12+
/** Bounds the unanchored window by record count, independent of the hourly clock -- a burst of activity
13+
* cannot sit unanchored for a full hour just because it happened between ticks. */
14+
export const LEDGER_ANCHOR_SEQ_THRESHOLD = 256;
15+
16+
export type LedgerAnchorScheduleDecision =
17+
| { shouldAnchor: false; reason: "empty_ledger" | "unchanged" }
18+
| { shouldAnchor: true; reason: "hourly" | "seq_threshold" };
19+
20+
/**
21+
* Pure scheduling decision: given the live tip and the last attempt (regardless of that attempt's own
22+
* success/failure -- see {@link loadLastLedgerAnchorAttempt}'s own reasoning), should THIS tick anchor?
23+
*
24+
* `seq_threshold` is checked treating a never-anchored ledger as starting from seq 0 -- a burst of >= the
25+
* threshold worth of activity before the very first anchor ever still fires immediately, rather than waiting
26+
* for the next hourly tick just because there is no prior anchor to compare against.
27+
*/
28+
export function decideLedgerAnchorSchedule(input: {
29+
isHourly: boolean;
30+
currentTip: { seq: number; rowHash: string };
31+
lastAnchor: { seq: number; rowHash: string } | null;
32+
seqThreshold: number;
33+
}): LedgerAnchorScheduleDecision {
34+
if (input.currentTip.seq === 0) return { shouldAnchor: false, reason: "empty_ledger" };
35+
36+
const lastAnchorSeq = input.lastAnchor?.seq ?? 0;
37+
if (input.currentTip.seq - lastAnchorSeq >= input.seqThreshold) return { shouldAnchor: true, reason: "seq_threshold" };
38+
39+
const tipUnchanged = input.lastAnchor !== null && input.lastAnchor.rowHash === input.currentTip.rowHash;
40+
if (input.isHourly && !tipUnchanged) return { shouldAnchor: true, reason: "hourly" };
41+
42+
return { shouldAnchor: false, reason: "unchanged" };
43+
}
44+
45+
/** Reads the four `LOOPOVER_LEDGER_ANCHOR_GIT_*` config vars into a target, or `null` when the required
46+
* owner/repo pair is unset -- git anchoring is simply not configured yet, the same honest-degrade posture
47+
* as an unset signing key, not an error. */
48+
export function resolveGitAnchorTarget(env: {
49+
LOOPOVER_LEDGER_ANCHOR_GIT_OWNER?: string;
50+
LOOPOVER_LEDGER_ANCHOR_GIT_REPO?: string;
51+
LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH?: string;
52+
LOOPOVER_LEDGER_ANCHOR_GIT_PATH?: string;
53+
}): LedgerAnchorGitTarget | null {
54+
const owner = env.LOOPOVER_LEDGER_ANCHOR_GIT_OWNER;
55+
const repo = env.LOOPOVER_LEDGER_ANCHOR_GIT_REPO;
56+
if (!owner || !repo) return null;
57+
return { owner, repo, branch: env.LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH || "main", path: env.LOOPOVER_LEDGER_ANCHOR_GIT_PATH || "anchors.jsonl" };
58+
}
59+
60+
export type LedgerAnchorSchedulerDeps = {
61+
/** Defaults to the real Rekor backend. Injectable so a test exercises the scheduler's own decision logic
62+
* without a real network call. */
63+
submitRekor?: (signed: SignedLedgerAnchor, publicKeySpki: string) => Promise<void>;
64+
/** `null`/omitted when git anchoring isn't configured (no target, no installation) -- the scheduler simply
65+
* skips that backend, same posture as an unset signing key. The real caller (the queue processor) wraps
66+
* the actual `submitToGitAnchor` call in `withInstallationTokenRetry` here, so a stale token retries the
67+
* WHOLE attempt with a fresh one, matching every other GitHub write in this engine. */
68+
submitGit?: ((signed: SignedLedgerAnchor) => Promise<void>) | null;
69+
};
70+
71+
/**
72+
* Run one scheduling tick. Never blocks a review: this has no caller in the live decision-record persist
73+
* path at all -- it exists only as a queued background job (#9274's own dispatch wiring) -- and neither
74+
* backend it calls ever rejects (each records its own `status: 'failed'` via #9271 instead of throwing), so
75+
* a total anchoring failure cannot propagate anywhere. Returns the scheduling decision so a caller/test can
76+
* observe WHY nothing happened, without needing a second query.
77+
*/
78+
export async function runScheduledLedgerAnchor(env: Env, options: { isHourly: boolean; now?: string }, deps: LedgerAnchorSchedulerDeps = {}): Promise<LedgerAnchorScheduleDecision> {
79+
const now = options.now ?? nowIso();
80+
const [tip, lastAnchor] = await Promise.all([loadDecisionLedgerTip(env), loadLastLedgerAnchorAttempt(env)]);
81+
const decision = decideLedgerAnchorSchedule({ isHourly: options.isHourly, currentTip: tip, lastAnchor, seqThreshold: LEDGER_ANCHOR_SEQ_THRESHOLD });
82+
if (!decision.shouldAnchor) return decision;
83+
84+
const keys = parseAnchorPublicKeys(env.LOOPOVER_LEDGER_ANCHOR_KEYS);
85+
const current = currentAnchorKey(keys);
86+
if (!current || !env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY) {
87+
console.log(
88+
JSON.stringify({
89+
event: "ledger_anchor_skipped_unconfigured",
90+
reason: !current ? "no_current_signing_key_published" : "no_private_key_configured",
91+
}),
92+
);
93+
return decision;
94+
}
95+
96+
const payload = buildLedgerAnchorPayload(tip, now);
97+
const signed = await signLedgerAnchorPayload(payload, env.LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY, current.keyId);
98+
99+
const submitRekor = deps.submitRekor ?? ((s, publicKeySpki) => submitToRekor(env, s, publicKeySpki, fetch));
100+
// guardedSubmit is the actual safety boundary, not a convention every injected function is trusted to
101+
// honor: #9272's real submitToRekor and #9273's real submitToGitAnchor never reject on their own, but an
102+
// INJECTED submitGit (the real caller wraps token-minting around submitToGitAnchor, and minting a fresh
103+
// installation token is a genuine IO call that CAN throw, unlike anything inside submitToGitAnchor itself)
104+
// is not something this module controls. Catching here, once, centrally, is what makes "neither backend
105+
// ever rejects" true structurally rather than by every call site's own discipline.
106+
const guardedSubmit = async (backend: LedgerAnchorBackend, submit: () => Promise<void>): Promise<void> => {
107+
try {
108+
await submit();
109+
} catch (error) {
110+
await recordLedgerAnchorAttempt(env, { payload: signed.payload, signature: signed.signature, keyId: signed.keyId, backend, status: "failed", error: errorMessage(error) });
111+
}
112+
};
113+
114+
const attempts: Promise<void>[] = [guardedSubmit("rekor", () => submitRekor(signed, current.publicKeySpki))];
115+
if (deps.submitGit) {
116+
const submitGit = deps.submitGit;
117+
attempts.push(guardedSubmit("git", () => submitGit(signed)));
118+
}
119+
await Promise.all(attempts);
120+
121+
return decision;
122+
}

src/selfhost/queue-common.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,10 @@ export function scheduledEnqueueJitterMs(): number {
777777
const IMMEDIATE_SCHEDULED_JOB_TYPES = new Set<string>([
778778
"agent-regate-sweep",
779779
"retry-orb-relay",
780+
// #9274 (epic #9267): checked every ~2-min tick like the sweep above (the seq-threshold anchoring trigger
781+
// must fire independent of the hourly clock), and is a cheap 1-2 query no-op the overwhelming majority of
782+
// ticks -- not a heavy per-repo fan-out parent, so it needs no phase-spreading either.
783+
"anchor-decision-ledger",
780784
]);
781785

782786
export function scheduledEnqueueDelaySeconds(jobType: string): number {

src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ export type JobMessage =
5656
type: "refresh-registry";
5757
requestedBy: "schedule" | "api" | "test";
5858
}
59+
| {
60+
// Scheduled decision-ledger anchoring (#9274, epic #9267). `isHourly` is threaded through from the
61+
// dispatcher's own clock computation (src/index.ts) rather than re-derived here, so there is exactly
62+
// ONE place that decides "what time is it" for every scheduled job, this one included.
63+
type: "anchor-decision-ledger";
64+
requestedBy: "schedule" | "test";
65+
isHourly: boolean;
66+
}
5967
| {
6068
type: "sync-brokered-installed-repos";
6169
requestedBy: "schedule" | "api" | "test";

test/unit/decision-record.test.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
sha256Hex,
1010
type DecisionRecord,
1111
} from "../../src/review/decision-record";
12-
import { appendDecisionLedger, buildLedgerAnchorPayload, LEDGER_GENESIS_HASH, loadDecisionRecordCollapsible, loadPublicDecisionRecord, verifyDecisionLedger } from "../../src/review/decision-record";
12+
import { appendDecisionLedger, LEDGER_GENESIS_HASH, loadDecisionLedgerTip, loadDecisionRecordCollapsible, loadPublicDecisionRecord, verifyDecisionLedger } from "../../src/review/decision-record";
1313
import { createTestEnv } from "../helpers/d1";
1414

1515
// #8836: the digests are commitments a contributor can challenge — key-order invariance and unicode
@@ -333,18 +333,25 @@ describe("loadPublicDecisionRecord (#9123)", () => {
333333
});
334334
});
335335

336-
describe("buildLedgerAnchorPayload (#9122)", () => {
337-
it("returns exactly {seq, rowHash, at} from a tip, using a caller-supplied timestamp", () => {
338-
const payload = buildLedgerAnchorPayload({ seq: 5, rowHash: "a".repeat(64) }, "2026-01-01T00:00:00.000Z");
339-
expect(payload).toEqual({ seq: 5, rowHash: "a".repeat(64), at: "2026-01-01T00:00:00.000Z" });
336+
// #9270 superseded the old {seq, rowHash, at} stub that used to live here (buildLedgerAnchorPayload) with a
337+
// self-describing, SIGNED payload -- see src/review/ledger-anchor.ts and test/unit/ledger-anchor.test.ts.
338+
339+
describe("loadDecisionLedgerTip (#9274)", () => {
340+
it("returns genesis/seq 0/count 0 on an empty ledger", async () => {
341+
expect(await loadDecisionLedgerTip(createTestEnv())).toEqual({ seq: 0, rowHash: LEDGER_GENESIS_HASH, totalCount: 0 });
340342
});
341343

342-
it("defaults `at` to the current time when the caller omits it", () => {
343-
const beforeMs = Date.now();
344-
const payload = buildLedgerAnchorPayload({ seq: 1, rowHash: LEDGER_GENESIS_HASH });
345-
expect(payload.seq).toBe(1);
346-
expect(payload.rowHash).toBe(LEDGER_GENESIS_HASH);
347-
expect(Date.parse(payload.at)).toBeGreaterThanOrEqual(beforeMs);
344+
it("returns the real tip and total count once records exist, lighter than a full verify walk", async () => {
345+
const env = createTestEnv();
346+
await appendDecisionLedger(env, "record:acme/widgets#1", "digest1");
347+
await appendDecisionLedger(env, "record:acme/widgets#2", "digest2");
348+
const tip = await loadDecisionLedgerTip(env);
349+
expect(tip.seq).toBe(2);
350+
expect(tip.totalCount).toBe(2);
351+
expect(tip.rowHash).toMatch(/^[0-9a-f]{64}$/);
352+
// Matches what verifyDecisionLedger's own tip computation reports for the same chain.
353+
const verified = await verifyDecisionLedger(env);
354+
expect(tip).toEqual({ seq: verified.tipSeq, rowHash: verified.tipHash, totalCount: verified.totalCount });
348355
});
349356
});
350357

0 commit comments

Comments
 (0)