What's wrong
GET https://api.loopover.ai/v1/public/decision-ledger/anchors returns {"anchors":[],"nextBefore":null} and always has. The docs present anchoring as a live trust surface.
This is not a scheduler or Rekor bug. Both are wired correctly and the job runs.
Root cause: topology
The anchor-decision-ledger job is enqueued unconditionally on every 2-minute cron tick (src/index.ts:141-145, wrangler.jsonc:398-402) with no feature flag, reaches processJob, and calls runScheduledLedgerAnchor. It then early-returns on the first guard, every time:
if (input.currentTip.seq === 0) return { shouldAnchor: false, reason: "empty_ledger" };
— src/review/ledger-anchor-scheduler.ts:37
Confirmed live: GET /v1/public/decision-ledger/verify reports tipSeq: 0, totalCount: 0, ok: true.
The ledger is empty because appendDecisionLedger is only reached via persistDecisionRecord, called from src/queue/processors.ts:3810 and src/services/agent-action-executor.ts:358 — both on the review-execution path, which the hosted Worker acks-and-drops (src/index.ts:61-75). Self-host runtimes that do execute reviews write anchors into their own database, invisible to api.loopover.ai.
The API that publishes anchors is not the runtime that produces the ledger being anchored. No code fix in ledger-anchor-scheduler.ts or ledger-anchor-rekor.ts changes that.
(The anchors route returning 200 [] rather than 500 also confirms migrations/0195_decision_ledger_anchors.sql is applied — the table exists and is genuinely empty. Producer and reader are the same table on the same binding, so this is not a store mismatch.)
Second, independent blocker: no signing key
Even with a non-empty ledger, src/review/ledger-anchor-scheduler.ts:111-121 returns early with ledger_anchor_skipped_unconfigured / no_current_signing_key_published, because LOOPOVER_LEDGER_ANCHOR_KEYS and LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY are unset. Confirmed live: GET /v1/public/decision-ledger/anchor-key returns {"keys":[],"currentKeyId":null}.
No file outside src/ and test/ mentions LEDGER_ANCHOR at all — not wrangler.jsonc, not docker-compose.yml, not any runbook. There is no documented procedure for generating the P-256 keypair or publishing the public half. Rekor itself needs no credentials (ledger-anchor-rekor.ts:13, public shard default), so once a key exists and a ledger is non-empty, submission should work.
Observability gap that hid this
Both early returns are invisible from outside: :37 returns without writing anything, :113-121 writes only a console.log. From the public endpoint, "never configured", "never had a ledger to anchor", and "never deployed" are indistinguishable — all three render as {"anchors":[]}. ledger-anchor-persistence.ts:1-6 states the design goal ("an operator whose anchoring silently fails could quietly regress the ledger back to tamper-evident-only with no visible signal"), but that guarantee only holds after both guards pass.
Relatedly, no test covers the production shape: every scheduler test calls seedOneDecision(env) and keyedEnv() first. empty_ledger is asserted only as correct behavior (test/unit/ledger-anchor-scheduler.test.ts:120-126), never as an alarm.
What needs deciding
- Where anchoring lives. Either the hosted Worker gets a ledger to anchor (ingest decision records from self-host instances, the way
orb_pr_outcomes already ingests outcomes), or anchoring is a self-host-only capability and the public endpoint plus docs should say so instead of implying a live central anchor log.
- Provision the keypair. Generate the P-256 key, publish the public half via
LOOPOVER_LEDGER_ANCHOR_KEYS, set LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY as a Worker secret, and write the runbook that currently does not exist.
- Make the silence visible — distinguish "not configured" from "nothing to anchor" on the public surface, so this cannot look identical to a healthy empty state again.
Same class as #9636 and #9676: a reader still runs on the hosted Worker and renders the absence of a retired writer's output as a healthy state.
What's wrong
GET https://api.loopover.ai/v1/public/decision-ledger/anchorsreturns{"anchors":[],"nextBefore":null}and always has. The docs present anchoring as a live trust surface.This is not a scheduler or Rekor bug. Both are wired correctly and the job runs.
Root cause: topology
The
anchor-decision-ledgerjob is enqueued unconditionally on every 2-minute cron tick (src/index.ts:141-145,wrangler.jsonc:398-402) with no feature flag, reachesprocessJob, and callsrunScheduledLedgerAnchor. It then early-returns on the first guard, every time:—
src/review/ledger-anchor-scheduler.ts:37Confirmed live:
GET /v1/public/decision-ledger/verifyreportstipSeq: 0, totalCount: 0, ok: true.The ledger is empty because
appendDecisionLedgeris only reached viapersistDecisionRecord, called fromsrc/queue/processors.ts:3810andsrc/services/agent-action-executor.ts:358— both on the review-execution path, which the hosted Worker acks-and-drops (src/index.ts:61-75). Self-host runtimes that do execute reviews write anchors into their own database, invisible toapi.loopover.ai.The API that publishes anchors is not the runtime that produces the ledger being anchored. No code fix in
ledger-anchor-scheduler.tsorledger-anchor-rekor.tschanges that.(The anchors route returning
200 []rather than500also confirmsmigrations/0195_decision_ledger_anchors.sqlis applied — the table exists and is genuinely empty. Producer and reader are the same table on the same binding, so this is not a store mismatch.)Second, independent blocker: no signing key
Even with a non-empty ledger,
src/review/ledger-anchor-scheduler.ts:111-121returns early withledger_anchor_skipped_unconfigured/no_current_signing_key_published, becauseLOOPOVER_LEDGER_ANCHOR_KEYSandLOOPOVER_LEDGER_ANCHOR_PRIVATE_KEYare unset. Confirmed live:GET /v1/public/decision-ledger/anchor-keyreturns{"keys":[],"currentKeyId":null}.No file outside
src/andtest/mentionsLEDGER_ANCHORat all — notwrangler.jsonc, notdocker-compose.yml, not any runbook. There is no documented procedure for generating the P-256 keypair or publishing the public half. Rekor itself needs no credentials (ledger-anchor-rekor.ts:13, public shard default), so once a key exists and a ledger is non-empty, submission should work.Observability gap that hid this
Both early returns are invisible from outside:
:37returns without writing anything,:113-121writes only aconsole.log. From the public endpoint, "never configured", "never had a ledger to anchor", and "never deployed" are indistinguishable — all three render as{"anchors":[]}.ledger-anchor-persistence.ts:1-6states the design goal ("an operator whose anchoring silently fails could quietly regress the ledger back to tamper-evident-only with no visible signal"), but that guarantee only holds after both guards pass.Relatedly, no test covers the production shape: every scheduler test calls
seedOneDecision(env)andkeyedEnv()first.empty_ledgeris asserted only as correct behavior (test/unit/ledger-anchor-scheduler.test.ts:120-126), never as an alarm.What needs deciding
orb_pr_outcomesalready ingests outcomes), or anchoring is a self-host-only capability and the public endpoint plus docs should say so instead of implying a live central anchor log.LOOPOVER_LEDGER_ANCHOR_KEYS, setLOOPOVER_LEDGER_ANCHOR_PRIVATE_KEYas a Worker secret, and write the runbook that currently does not exist.Same class as #9636 and #9676: a reader still runs on the hosted Worker and renders the absence of a retired writer's output as a healthy state.