Skip to content

Commit 88db104

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix/publish-contract-9749
# Conflicts: # src/openapi/spec.ts
2 parents 1c6052f + 03909f6 commit 88db104

14 files changed

Lines changed: 968 additions & 286 deletions

apps/loopover-ui/content/docs/self-hosting-configuration.mdx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,68 @@ Lets LoopOver open a pull request that refreshes this repo's own `AGENTS.md`/`CL
513513
]}
514514
/>
515515

516+
## Decision-ledger anchoring (optional)
517+
518+
Your instance keeps a hash-chained ledger of every verdict it persists. That chain is **tamper-evident**
519+
on its own — anyone can walk it and locate an edit. Anchoring makes it **tamper-proof against you**: a
520+
scheduled job publishes a signed checkpoint of the chain's tip to places you do not control (a Sigstore
521+
Rekor transparency log, and a git commit that GH Archive and Software Heritage mirror), so rewriting
522+
history back past a published checkpoint would also require forging a signature or fabricating matching
523+
evidence at an external mirror.
524+
525+
**This is a self-host capability, and only a self-host one.** Hosted review execution is retired, so
526+
`api.loopover.ai` never decides anything and has no chain of its own to anchor — its ledger endpoints
527+
answer for an empty chain and say so (`status: "empty_ledger"`). The chain that matters is the one *your*
528+
instance wrote, so anchoring runs where the decisions were made.
529+
530+
Anchoring is **off until you provision a signing key**, and stays silent-but-visible until then: the public
531+
anchors endpoint reports `status: "unconfigured"` rather than an empty list that could be mistaken for a
532+
healthy idle instance.
533+
534+
### 1. Generate the keypair
535+
536+
```bash
537+
npm run ledger:anchor-keygen
538+
```
539+
540+
Prints both halves already in the encodings the runtime expects, with the key id derived from the public
541+
half so the two cannot drift apart. It writes nothing to disk — the private key exists only in that output,
542+
so run it on a machine you trust and paste straight into your secret store.
543+
544+
### 2. Provision both halves
545+
546+
| Variable | Secret? | What it is |
547+
| --- | --- | --- |
548+
| `LOOPOVER_LEDGER_ANCHOR_KEYS` | No — publish it | The JSON array of published public keys. Served verbatim by `/v1/public/decision-ledger/anchor-key`, which is how a third party verifies your anchors. |
549+
| `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY` | **Yes** | PKCS8 PEM. Treat it like any other credential: `wrangler secret put`, a compose secret, or a vault entry — never a committed file. |
550+
551+
Optional, for the git-commit backend (Rekor needs no credentials and uses its public shard by default):
552+
553+
| Variable | What it is |
554+
| --- | --- |
555+
| `LOOPOVER_LEDGER_ANCHOR_GIT_OWNER` / `_REPO` | The repo checkpoints are appended to. A dedicated, public, otherwise-empty repo is the intended shape. |
556+
| `LOOPOVER_LEDGER_ANCHOR_GIT_BRANCH` / `_PATH` | Default `main` / `anchors.jsonl`. |
557+
| `LOOPOVER_LEDGER_ANCHOR_GIT_INSTALLATION_ID` | The GitHub App installation used to commit. Unset means the git backend simply does not run; Rekor is unaffected. |
558+
559+
### 3. Confirm it is live
560+
561+
```bash
562+
curl -s "$ORB/v1/public/decision-ledger/anchors?limit=1" | jq '{status, anchor: .anchors[0]}'
563+
```
564+
565+
`status` distinguishes the states that used to be indistinguishable:
566+
567+
- `anchored` — checkpoints are publishing; the newest one is in `anchor`.
568+
- `pending` — key provisioned and a ledger exists; the first checkpoint is due (hourly, or every 256 rows).
569+
- `empty_ledger` — nothing has been decided yet. Expected on a fresh instance.
570+
- `unconfigured` — no signing key. Anchoring is not running.
571+
572+
### Rotating the key
573+
574+
Keep the retired entry in `LOOPOVER_LEDGER_ANCHOR_KEYS` with its `notAfter` set to the rotation instant,
575+
and append the new one. **A retired key is never removed, only closed** — anchors signed under it must stay
576+
verifiable forever, and a verifier checking a two-year-old checkpoint needs the key it was signed with.
577+
516578
## Next steps
517579

518580
Configure the GitHub integration in [GitHub App and Orb](/docs/self-hosting-github-app), then add optional context through [AI providers](/docs/self-hosting-ai-providers), [REES](/docs/self-hosting-rees), or [RAG](/docs/self-hosting-rag). For the full gate-mode and per-repo settings reference — including the AI-review combine modes and a complete worked manifest — see [Tuning your reviews](/docs/tuning).

apps/loopover-ui/content/docs/what-you-can-verify.mdx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,14 @@ only tampering within it.
2525
Every persisted verdict appends to a hash-chained ledger — each row's hash covers the previous row's
2626
hash, so any edit to history breaks the chain at a point you can locate.
2727

28+
Run this against **the instance that reviewed the PR** — the host in the review comment's own links.
29+
A ledger belongs to the runtime that made the decisions, so `api.loopover.ai` is the wrong host to
30+
ask (see "Which host you ask matters" below):
31+
2832
```bash
29-
curl -s "https://api.loopover.ai/v1/public/decision-ledger/verify" | jq
33+
ORB=https://orb.example.org # the instance that reviewed the PR
34+
35+
curl -s "$ORB/v1/public/decision-ledger/verify" | jq
3036
```
3137

3238
Returns `{ ok, checked, nextAfterSeq, tipSeq, tipHash, totalCount, prunedRecords }`, and a `break`
@@ -63,13 +69,33 @@ Two response fields encode deliberate, published semantics rather than tolerance
6369
every row individually external-checkable in real time.
6470
</Callout>
6571

72+
<Callout variant="warn">
73+
**Which host you ask matters.** A decision ledger belongs to the runtime that *made* the decisions,
74+
and hosted review execution is retired — only self-host runtimes execute reviews, so each one
75+
writes and anchors **its own** chain in its own database. `api.loopover.ai` therefore has no ledger
76+
of its own to anchor and never will: its `/v1/public/decision-ledger/*` endpoints answer for an
77+
empty chain, and its `anchors` response says so explicitly via `status: "empty_ledger"` rather
78+
than an ambiguous empty list.
79+
80+
So every command on this page runs against `$ORB`, the instance whose reviews you are checking —
81+
never against `api.loopover.ai`.
82+
83+
This is a deliberate boundary, not a gap. Aggregating every instance's records into one central
84+
chain would produce an anchored artifact that looks *more* authoritative while proving *less*: the
85+
central chain would be a re-chained aggregate nobody's verifier cares about, and each instance's
86+
real chain would still be unanchored.
87+
</Callout>
88+
6689
**Verify an anchor end to end**
6790

68-
a. Fetch the most recent anchor and the currently-published signing key:
91+
a. Fetch the most recent anchor and the currently-published signing key. `status` tells you which
92+
state you are in before you read the list — `anchored`, `empty_ledger` (nothing decided yet),
93+
`unconfigured` (no signing key provisioned), or `pending` (a ledger exists and is due its first
94+
anchor):
6995

7096
```bash
71-
curl -s "https://api.loopover.ai/v1/public/decision-ledger/anchors?limit=1" | jq '.anchors[0]' > anchor.json
72-
curl -s "https://api.loopover.ai/v1/public/decision-ledger/anchor-key" | jq -c '.keys' > anchor-keys.json
97+
curl -s "$ORB/v1/public/decision-ledger/anchors?limit=1" | jq '{status, anchor: .anchors[0]}' > anchor.json
98+
curl -s "$ORB/v1/public/decision-ledger/anchor-key" | jq -c '.keys' > anchor-keys.json
7399
```
74100

75101
b. Fetch the actual signed payload the anchor committed to. For the git-commit backend,
@@ -108,7 +134,7 @@ hash yourself:
108134

109135
```bash
110136
SEQ=$(jq -r '.payload.seq' anchor-signed.json)
111-
curl -s "https://api.loopover.ai/v1/public/decision-ledger/row/$SEQ" | jq > row.json
137+
curl -s "$ORB/v1/public/decision-ledger/row/$SEQ" | jq > row.json
112138
npx tsx -e '
113139
import { readFileSync } from "node:fs";
114140
import { ledgerRowHash } from "./src/review/decision-record.ts";

0 commit comments

Comments
 (0)