Skip to content

feat(ledger): anchor persistence + public attempt log, success and failure alike - #9395

Merged
loopover-orb[bot] merged 2 commits into
feat/ledger-anchor-payloadfrom
feat/ledger-anchor-persistence
Jul 27, 2026
Merged

feat(ledger): anchor persistence + public attempt log, success and failure alike#9395
loopover-orb[bot] merged 2 commits into
feat/ledger-anchor-payloadfrom
feat/ledger-anchor-persistence

Conversation

@JSONbored

Copy link
Copy Markdown
Owner

Summary

Third sub-issue of #9267 (external ledger anchoring), stacked on #9270 (#9392). migrations/0195 adds decision_ledger_anchors: every backend (#9272 Rekor, #9273 git) calls the same recordLedgerAnchorAttempt whether it succeeded or failed — there is no separate failure path a backend could simply not call.

Why this matters, per the mechanism research on #9267: anchoring is best-effort — it must never block a review — which means a failure could otherwise be silent. An operator whose anchoring quietly fails could regress the ledger back to tamper-evident-only with no visible signal. Recording every attempt on the same public listing, in the same shape regardless of outcome, is what makes anchoring's own health a fact anyone can observe, not something only the operator's own logs show.

GET /v1/public/decision-ledger/anchors serves the log, paginated newest-first (?before= cursor, ?limit=), filterable by ?backend=. backend_ref stores the full resolvable reference — for Rekor, shard base URL + log index + key id + uuid, not a bare index that becomes unresolvable after the annual shard rotation.

One bug caught and fixed during testing: recordLedgerAnchorAttempt originally called nowIso() internally with no way to override it, so several inserts in a tight loop could land on the same millisecond and sort arbitrarily. Made createdAt injectable, matching loadPublicRulePrecision's existing pattern for exactly this class of testability.

Closes #9271.

Test plan

  • npx vitest run test/unit/ledger-anchor-persistence.test.ts — 8/8, 100% statement/branch/function/line coverage
  • The load-bearing test: a failed attempt is served on the public listing with the exact same field set as a success — no special-cased shape that could hide it
  • A failed attempt is queryable identically to a success when filtering by backend
  • Pagination proven end-to-end: cursor advances the page, terminates with nextBefore: null
  • limit clamped to [1, 200]; malformed backend_ref degrades to null rather than throwing
  • npx vitest run test/integration/public-ledger-anchors-route.test.ts — 6/6: no-auth-required, failure served on the public route, ?backend=/?before=/?limit= query handling, invalid backend value falls back to unfiltered rather than erroring
  • Added to the shared exempt-public-routes table
  • npm run db:migrations:check — 199 migrations OK, contiguous
  • npx tsc --noEmit clean (including an exactOptionalPropertyTypes fix in the route handler) · npm run ui:openapi:check clean

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
loopover-ui 67ea1fe Commit Preview URL

Branch Preview URL
Jul 27 2026, 04:24 PM

@JSONbored
JSONbored changed the base branch from main to feat/ledger-anchor-payload July 27, 2026 16:00
@JSONbored

Copy link
Copy Markdown
Owner Author

Stacked on #9392 (#9270) — the base branch is feat/ledger-anchor-payload, not main, since this needs the LedgerAnchorPayload/SignedLedgerAnchor types from that PR. Diff shown here should only include this PR's own 6 files once GitHub picks up the new base. Merge order: #9392 first, then this.

@superagent-security

Copy link
Copy Markdown
Contributor

Superagent didn't find any vulnerabilities or security issues in this PR.

@loopover-orb

loopover-orb Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Tip

✅ LoopOver review result - approve/merge recommended

Review updated: 2026-07-27 16:44:35 UTC

9 files · 1 AI reviewer · no blockers · readiness 95/100 · CI green · clean

✅ Suggested Action - Approve/Merge

  • safe to merge

Review summary
Adds a decision_ledger_anchors table + migration, a recordLedgerAnchorAttempt/loadPublicLedgerAnchors persistence module, and a paginated unauthenticated GET /v1/public/decision-ledger/anchors route that serves success and failure attempts identically. The wiring is complete end-to-end (migration, schema-drift allowlist, route, auth exemption, OpenAPI entry, cursor pagination tested including exhaustion), and the injectable-createdAt fix for tight-loop ordering is real and tested. The failing validate-code check has no detail provided and this branch is 2 commits behind default, so per the grounding rules it cannot be attributed to a content defect in this diff.

Nits — 7 non-blocking
  • src/review/ledger-anchor-persistence.ts:54 truncates error messages to a bare 500-char literal; consider naming it (e.g. `MAX_ANCHOR_ERROR_CHARS`) for consistency with `DEFAULT_ANCHOR_LIST_LIMIT`/`MAX_ANCHOR_LIST_LIMIT` just below.
  • src/review/ledger-anchor-persistence.ts:144's `parseBackendRef` catch returning null on unparseable JSON is intentional defensive behavior per the comment, but worth confirming no caller needs to distinguish 'null because failed' from 'null because corrupt row'.
  • src/api/routes.ts's anchors route builds `limit` via `Number(c.req.query('limit')) || undefined`, which silently treats `limit=0` and non-numeric input the same as 'unset' — harmless given the downstream clamp to 1, but slightly opaque; a short comment would help.
  • src/openapi/spec.ts:1033's `200` status code is a standard HTTP code, not really a magic number needing a named constant.
  • Consider whether `backend_ref`'s Rekor shape (shardBaseUrl/logIndex/logIdKeyId/uuid) should get a typed schema instead of `unknown` in `PublicLedgerAnchor`, now that the PR description calls out this exact full-reference requirement — a discriminated union keyed by `backend` would catch a future shape drift at compile time.
  • PR author also opened the linked issue — Link an issue that was opened by a different contributor, or provide a rationale for why this self-authored issue represents genuine discovery work.
  • Diff looks like trivial or whitespace-only churn — Reduce whitespace-only or formatting-only churn and keep the diff focused on substantive changes.

Decision drivers

  • ✅ Code review — No blockers (1 reviewer)
  • ✅ Gate result — Passing (No configured blocker found.)
Context & advisory signals — never blocks the verdict
Signal Result Evidence
Linked issue ✅ Linked #9271
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (1 linked issue).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 13 registered-repo PR(s), 13 merged, 345 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 13 PR(s), 345 issue(s).
Improvement ✅ Minor risk: low · value: minor · LLM: moderate
Linked issue satisfaction

Addressed
The PR adds migrations/0195 with the exact decision_ledger_anchors schema (seq, row_hash, payload_json, signature, backend enum, backend_ref, proof_r2_key, status, error, created_at), a single recordLedgerAnchorAttempt helper used uniformly for both success and failure, a Rekor backend_ref storing the full {shardBaseUrl, logIndex, logIdKeyId, uuid} reference, and a paginated public GET /v1/public/

Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository is configured for direct PR review.
  • Public profile languages: Python, TypeScript, Ruby, Go, MDX, Shell, Solidity, JavaScript
  • Official Gittensor activity: 13 PR(s), 345 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Start here: Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • Then work through the remaining 1 step in the Signals table above.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.
🧪 Chat with LoopOver

Ask LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.

  • @loopover ask <question> answers contribution-quality Q&A with source citations and freshness.
  • @loopover chat <question> answers in natural prose from cached decision-pack facts via local inference (maintainer/collaborator; read-only).
  • A plain-language @loopover mention with a real question is routed to the closest matching read-only command automatically — no exact syntax required.

Full command reference: https://loopover.ai/docs/loopover-commands

🧪 Experimental — new and may change.

Visual preview
Route Viewport Before (production) After (this PR's preview) Diff
/ desktop before /
before /
after /
after /
/ mobile before / (mobile)
before / (mobile)
after / (mobile)
after / (mobile)

Click any thumbnail to open the full-size screenshot. Before = production · After = this PR's preview deploy.

Scroll preview
Route Before (production) After (this PR's preview)
/ before / (scroll)
before / (scroll)
after / (scroll)
after / (scroll)

A short scroll-through clip (desktop) — click either thumbnail to open the full animation. Evidence for scroll-linked behavior a single screenshot can't show.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.

  • Re-run LoopOver review

…ilure alike (#9271)

Third sub-issue of #9267. migrations/0195 adds decision_ledger_anchors: every
backend calls the same recordLedgerAnchorAttempt whether it succeeded or
failed — there is no separate failure path a backend could simply not call.

Per the mechanism research on #9267: anchoring is best-effort (it must never
block a review), which means a failure could otherwise be silent. An operator
whose anchoring quietly fails could regress the ledger back to
tamper-evident-only with no visible signal. Recording every attempt on the same
public listing, in the same shape regardless of outcome, is what makes
anchoring's own health a fact anyone can observe rather than something only
the operator's own logs show.

GET /v1/public/decision-ledger/anchors serves the log, paginated newest-first,
filterable by backend. backend_ref stores the full resolvable reference (for
Rekor: shard URL + log index + key id + uuid, not a bare index that becomes
unresolvable after the annual shard rotation).
check-schema-drift caught this on push: the migration 0195 table was never
declared in src/db/schema.ts and wasn't in the raw-SQL-only allowlist either,
matching decision_ledger's own existing entry (the table this one is the
anchoring companion to).
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Bundle Report

Changes will increase total bundle size by 2.11kB (0.03%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
loopover-ui 7.49MB 2.11kB (0.03%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: loopover-ui

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/add-scalar-classes-BCf_aDPe.js (New) 2.16MB 2.16MB 100.0% 🚀
assets/tanstack-vendor-BIOn6cTE.js (New) 819.0kB 819.0kB 100.0% 🚀
openapi.json 1.36kB 536.57kB 0.25%
assets/docs.fumadocs-spike-api-reference-kW9u7muo.js (New) 443.45kB 443.45kB 100.0% 🚀
assets/AgentScalarChatInterface.vue-B0akRfmX.js (New) 201.7kB 201.7kB 100.0% 🚀
assets/modal-BoRhkA3-.js (New) 184.5kB 184.5kB 100.0% 🚀
assets/client-BLpLYhIP.js (New) 151.47kB 151.47kB 100.0% 🚀
assets/maintainer-panel-BWp7tVCW.js (New) 78.99kB 78.99kB 100.0% 🚀
assets/routes-BG0bXX0-.js (New) 35.96kB 35.96kB 100.0% 🚀
assets/owner-panel-CAcT_Owx.js (New) 27.92kB 27.92kB 100.0% 🚀
assets/app-DNaYzwzR.js (New) 25.78kB 25.78kB 100.0% 🚀
assets/ui-vendor-Bj6G_PLj.js (New) 24.57kB 24.57kB 100.0% 🚀
assets/miner-panel-CL_JIL1c.js (New) 20.24kB 20.24kB 100.0% 🚀
assets/app.runs-CaMsv4QD.js (New) 20.22kB 20.22kB 100.0% 🚀
assets/api._op-CXhUWBKT.js (New) 17.57kB 17.57kB 100.0% 🚀
assets/self-hosting-docs-audit-IapkYHDM.js (New) 16.6kB 16.6kB 100.0% 🚀
assets/docs._slug-BLWaE6yc.js (New) 15.52kB 15.52kB 100.0% 🚀
assets/playground-panel-DluYqd5e.js (New) 14.42kB 14.42kB 100.0% 🚀
assets/fairness-Dkl0Nmh9.js (New) 10.73kB 10.73kB 100.0% 🚀
assets/app.audit-DgTETJsG.js (New) 10.08kB 10.08kB 100.0% 🚀
assets/app.config-generator-Dnx9Yi01.js (New) 10.06kB 10.06kB 100.0% 🚀
assets/maintainers-lz9FPSWJ.js (New) 8.06kB 8.06kB 100.0% 🚀
assets/miners-Dm4oeAYO.js (New) 7.91kB 7.91kB 100.0% 🚀
assets/agents-BNc5FLXK.js (New) 7.74kB 7.74kB 100.0% 🚀
assets/commands-panel-BOHU6EnE.js (New) 6.65kB 6.65kB 100.0% 🚀
assets/maintainer-workflow-DVEMZFg6.js (New) 6.52kB 6.52kB 100.0% 🚀
assets/digest-panel-BNGeS1OD.js (New) 6.15kB 6.15kB 100.0% 🚀
assets/repos._owner._repo.quality-BM1jh1QK.js (New) 6.14kB 6.14kB 100.0% 🚀
assets/docs-nav-CA4KOcp3.js (New) 6.01kB 6.01kB 100.0% 🚀
assets/docs.index-BF-mzXRa.js (New) 5.95kB 5.95kB 100.0% 🚀
assets/api.index-DjHVzwZI.js (New) 4.7kB 4.7kB 100.0% 🚀
assets/docs-B0jCA4SP.js (New) 2.7kB 2.7kB 100.0% 🚀
assets/api-DPwUObq6.js (New) 2.69kB 2.69kB 100.0% 🚀
assets/docs-page-Dl7dcFvS.js (New) 2.1kB 2.1kB 100.0% 🚀
assets/table-BrynWu2F.js (New) 1.75kB 1.75kB 100.0% 🚀
assets/app.workbench-Ce7ifkjE.js (New) 1.58kB 1.58kB 100.0% 🚀
assets/tabs-DQ9N4krb.js (New) 1.39kB 1.39kB 100.0% 🚀
assets/app.repos-EHvRBkVN.js (New) 1.07kB 1.07kB 100.0% 🚀
assets/input-BebdtE4E.js (New) 796 bytes 796 bytes 100.0% 🚀
assets/file-cog-DfkGWZUJ.js (New) 758 bytes 758 bytes 100.0% 🚀
assets/app.maintainer-BrLMbU6e.js (New) 502 bytes 502 bytes 100.0% 🚀
assets/app.owner-DmmzeVuy.js (New) 474 bytes 474 bytes 100.0% 🚀
assets/app.commands-8kn2Qef9.js (New) 455 bytes 455 bytes 100.0% 🚀
assets/app.playground-Bde1dHe5.js (New) 442 bytes 442 bytes 100.0% 🚀
assets/index-DjizPwog.js (New) 438 bytes 438 bytes 100.0% 🚀
assets/app.digest-DPqeJZ6B.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/eye-off-Ba9N072B.js (New) 430 bytes 430 bytes 100.0% 🚀
assets/app.miner-C991ZAer.js (New) 422 bytes 422 bytes 100.0% 🚀
assets/key-round-C9R7ijE6.js (New) 355 bytes 355 bytes 100.0% 🚀
assets/bot-BpF6pznL.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/trash-2-DYB8q5ky.js (New) 328 bytes 328 bytes 100.0% 🚀
assets/save-DbKrp0--.js (New) 327 bytes 327 bytes 100.0% 🚀
assets/git-pull-request-arrow-CZFVouPq.js (New) 321 bytes 321 bytes 100.0% 🚀
assets/list-checks-ERFeW2tk.js (New) 279 bytes 279 bytes 100.0% 🚀
assets/compass-BDGbbA_w.js (New) 251 bytes 251 bytes 100.0% 🚀
assets/history-xbm3z6OY.js (New) 237 bytes 237 bytes 100.0% 🚀
assets/message-square-B0l045b0.js (New) 233 bytes 233 bytes 100.0% 🚀
assets/lock-DMcIvEEe.js (New) 206 bytes 206 bytes 100.0% 🚀
assets/rotate-cw-BZJFzApn.js (New) 201 bytes 201 bytes 100.0% 🚀
assets/play-CmxIsM9k.js (New) 190 bytes 190 bytes 100.0% 🚀
assets/circle-check-DO8aLIId.js (New) 178 bytes 178 bytes 100.0% 🚀
assets/search-paxvfzxt.js (New) 174 bytes 174 bytes 100.0% 🚀
assets/add-scalar-classes-D_iRqUX6.js (Deleted) -2.16MB 0 bytes -100.0% 🗑️
assets/tanstack-vendor-8C5a8T_K.js (Deleted) -818.25kB 0 bytes -100.0% 🗑️
assets/docs.fumadocs-spike-api-reference-DHCVATSE.js (Deleted) -443.45kB 0 bytes -100.0% 🗑️
assets/AgentScalarChatInterface.vue-DtzLOzPu.js (Deleted) -201.7kB 0 bytes -100.0% 🗑️
assets/modal-CLv8rwJ3.js (Deleted) -184.5kB 0 bytes -100.0% 🗑️
assets/client-BKJqSiAM.js (Deleted) -151.47kB 0 bytes -100.0% 🗑️
assets/maintainer-panel--3vZ7D5K.js (Deleted) -78.99kB 0 bytes -100.0% 🗑️
assets/routes-B0kJmpBk.js (Deleted) -35.96kB 0 bytes -100.0% 🗑️
assets/owner-panel-MX-VlonC.js (Deleted) -27.92kB 0 bytes -100.0% 🗑️
assets/app-DdUJBKjL.js (Deleted) -25.78kB 0 bytes -100.0% 🗑️
assets/ui-vendor-CcU-isYI.js (Deleted) -24.57kB 0 bytes -100.0% 🗑️
assets/miner-panel-DWec4ugQ.js (Deleted) -20.24kB 0 bytes -100.0% 🗑️
assets/app.runs-b-OEbzCL.js (Deleted) -20.22kB 0 bytes -100.0% 🗑️
assets/api._op-Bo89QKY1.js (Deleted) -17.57kB 0 bytes -100.0% 🗑️
assets/self-hosting-docs-audit-Cn-tmOjv.js (Deleted) -16.6kB 0 bytes -100.0% 🗑️
assets/docs._slug-BfkMErMm.js (Deleted) -15.52kB 0 bytes -100.0% 🗑️
assets/playground-panel-BQm5Ih8W.js (Deleted) -14.42kB 0 bytes -100.0% 🗑️
assets/fairness-N--K7TX7.js (Deleted) -10.73kB 0 bytes -100.0% 🗑️
assets/app.audit-CEqeXAOC.js (Deleted) -10.08kB 0 bytes -100.0% 🗑️
assets/app.config-generator-BGs7ddWD.js (Deleted) -10.06kB 0 bytes -100.0% 🗑️
assets/maintainers-DOrPfd0T.js (Deleted) -8.06kB 0 bytes -100.0% 🗑️
assets/miners-uLWFx-VZ.js (Deleted) -7.91kB 0 bytes -100.0% 🗑️
assets/agents-DG-F7Vcv.js (Deleted) -7.74kB 0 bytes -100.0% 🗑️
assets/commands-panel-7eG1Ft4-.js (Deleted) -6.65kB 0 bytes -100.0% 🗑️
assets/maintainer-workflow-wZFp0ELa.js (Deleted) -6.52kB 0 bytes -100.0% 🗑️
assets/digest-panel-7HsQM5ij.js (Deleted) -6.15kB 0 bytes -100.0% 🗑️
assets/repos._owner._repo.quality-B4F_zX9X.js (Deleted) -6.14kB 0 bytes -100.0% 🗑️
assets/docs-nav-B8EwJdOi.js (Deleted) -6.01kB 0 bytes -100.0% 🗑️
assets/docs.index-CaazW2mk.js (Deleted) -5.95kB 0 bytes -100.0% 🗑️
assets/api.index-Wb5jrwqV.js (Deleted) -4.7kB 0 bytes -100.0% 🗑️
assets/docs-RbyfZx9M.js (Deleted) -2.7kB 0 bytes -100.0% 🗑️
assets/api-Dvz1Mr64.js (Deleted) -2.69kB 0 bytes -100.0% 🗑️
assets/docs-page-BzFLgZrl.js (Deleted) -2.1kB 0 bytes -100.0% 🗑️
assets/table-DOgxDRuH.js (Deleted) -1.75kB 0 bytes -100.0% 🗑️
assets/app.workbench-DPLXkjLe.js (Deleted) -1.58kB 0 bytes -100.0% 🗑️
assets/tabs-Cn9B6DZu.js (Deleted) -1.39kB 0 bytes -100.0% 🗑️
assets/app.repos-D6fZiBek.js (Deleted) -1.07kB 0 bytes -100.0% 🗑️
assets/input-eCOazJZd.js (Deleted) -796 bytes 0 bytes -100.0% 🗑️
assets/file-cog-BDqu4Ioy.js (Deleted) -758 bytes 0 bytes -100.0% 🗑️
assets/app.maintainer-CZ4l6AYJ.js (Deleted) -502 bytes 0 bytes -100.0% 🗑️
assets/app.owner-BUROoLgH.js (Deleted) -474 bytes 0 bytes -100.0% 🗑️
assets/app.commands-VKRkpgL5.js (Deleted) -455 bytes 0 bytes -100.0% 🗑️
assets/app.playground-DzBFb2c-.js (Deleted) -442 bytes 0 bytes -100.0% 🗑️
assets/index-MGQgWNTP.js (Deleted) -438 bytes 0 bytes -100.0% 🗑️
assets/app.digest-Bpjj88BK.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/eye-off-BBdMdTRA.js (Deleted) -430 bytes 0 bytes -100.0% 🗑️
assets/app.miner-D9-SFOXs.js (Deleted) -422 bytes 0 bytes -100.0% 🗑️
assets/key-round-D9MidLIL.js (Deleted) -355 bytes 0 bytes -100.0% 🗑️
assets/bot-BBK5VSxW.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/trash-2-BFHhsFD7.js (Deleted) -328 bytes 0 bytes -100.0% 🗑️
assets/save-CpWoE1Uz.js (Deleted) -327 bytes 0 bytes -100.0% 🗑️
assets/git-pull-request-arrow-D8LN6KGz.js (Deleted) -321 bytes 0 bytes -100.0% 🗑️
assets/list-checks-Dxfm_9Z6.js (Deleted) -279 bytes 0 bytes -100.0% 🗑️
assets/compass-CZafoRIs.js (Deleted) -251 bytes 0 bytes -100.0% 🗑️
assets/history-B8EfbT9o.js (Deleted) -237 bytes 0 bytes -100.0% 🗑️
assets/message-square-CNz9Z10b.js (Deleted) -233 bytes 0 bytes -100.0% 🗑️
assets/lock-4RJ7foQM.js (Deleted) -206 bytes 0 bytes -100.0% 🗑️
assets/rotate-cw-DH_Od3S7.js (Deleted) -201 bytes 0 bytes -100.0% 🗑️
assets/play-BtpYzKmh.js (Deleted) -190 bytes 0 bytes -100.0% 🗑️
assets/circle-check-DK2c8BxU.js (Deleted) -178 bytes 0 bytes -100.0% 🗑️
assets/search-OTncJrg4.js (Deleted) -174 bytes 0 bytes -100.0% 🗑️

@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.50%. Comparing base (87b8f1a) to head (67ea1fe).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@                      Coverage Diff                       @@
##           feat/ledger-anchor-payload    #9395      +/-   ##
==============================================================
+ Coverage                       88.59%   89.50%   +0.90%     
==============================================================
  Files                             839      840       +1     
  Lines                          109729   109770      +41     
  Branches                        26127    26141      +14     
==============================================================
+ Hits                            97216    98248    +1032     
+ Misses                          11541    10259    -1282     
- Partials                          972     1263     +291     
Flag Coverage Δ
backend 95.23% <100.00%> (+1.65%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/api/routes.ts 95.55% <100.00%> (+0.02%) ⬆️
src/openapi/spec.ts 99.49% <100.00%> (+<0.01%) ⬆️
src/review/ledger-anchor-persistence.ts 100.00% <100.00%> (ø)

... and 3 files with indirect coverage changes

@loopover-orb loopover-orb Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LoopOver approves — the gate is satisfied and CI is green.

@loopover-orb
loopover-orb Bot merged commit fa8c60e into feat/ledger-anchor-payload Jul 27, 2026
11 checks passed
@loopover-orb
loopover-orb Bot deleted the feat/ledger-anchor-persistence branch July 27, 2026 16:44
JSONbored added a commit that referenced this pull request Jul 27, 2026
…on route (#9392)

* feat(ledger): versioned + signed anchor payload, published key rotation route (#9270)

Extends the unused {seq, rowHash, at} stub into a self-describing, signed payload,
and publishes the verifying keys.

Self-describing: an anchor read cold in two years, out of whatever log it landed
in, must say what it is and what chain it commits to — hence an explicit schema
version and ledger id. totalCount rides alongside seq so a truncate-and-rechain to
the same length still has to match both.

Signed: without a signature anyone could publish a plausible anchor for this ledger
and then "prove" a rewrite that never happened — a denial-of-integrity attack
against our own record. ECDSA P-256/SHA-256 via WebCrypto: no new dependency, and
the same keypair is what Rekor's hashedrekord takes as a self-managed verifier key
(#9272).

Key rotation is first-class: the published list is the full history, since an
anchor signed under a retired key must stay verifiable forever. keyId is derived
from the key (sha256 of SPKI) so it cannot drift from what it names. An ambiguous
rotation state (zero or several open-ended keys) fails closed rather than guessing.

The private half is a Worker secret, never served by any route and never in the
repo; unset simply means anchoring does not run, degrading to the honest
pre-#9267 tamper-evident posture rather than failing.

* fix(ledger): re-export canonicalJson from ledger-anchor.ts (#9270)

Lost during editing -- the re-export line only carried sha256Hex, so any
consumer importing canonicalJson from this module got undefined. Caught while
building #9273's git-commit backend, which needs it to commit the same
canonicalized payload Rekor anchors.

* feat(ledger): anchor persistence + public attempt log, success and failure alike (#9395)

* feat(ledger): anchor persistence + public attempt log, success and failure alike (#9271)

Third sub-issue of #9267. migrations/0195 adds decision_ledger_anchors: every
backend calls the same recordLedgerAnchorAttempt whether it succeeded or
failed — there is no separate failure path a backend could simply not call.

Per the mechanism research on #9267: anchoring is best-effort (it must never
block a review), which means a failure could otherwise be silent. An operator
whose anchoring quietly fails could regress the ledger back to
tamper-evident-only with no visible signal. Recording every attempt on the same
public listing, in the same shape regardless of outcome, is what makes
anchoring's own health a fact anyone can observe rather than something only
the operator's own logs show.

GET /v1/public/decision-ledger/anchors serves the log, paginated newest-first,
filterable by backend. backend_ref stores the full resolvable reference (for
Rekor: shard URL + log index + key id + uuid, not a bare index that becomes
unresolvable after the annual shard rotation).

* fix(db): add decision_ledger_anchors to RAW_SQL_ONLY_TABLES (#9271)

check-schema-drift caught this on push: the migration 0195 table was never
declared in src/db/schema.ts and wasn't in the raw-SQL-only allowlist either,
matching decision_ledger's own existing entry (the table this one is the
anchoring companion to).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ledger: anchor persistence + public attempt log (success and failure both visible)

1 participant