docs

Daily Merkle roots

Every evidence record is hash-chained and signed (SPEC §2.5). Once a day the server additionally publishes a **signed Merkle root** over that day's records. A published root is a public commitment: once it is out, the operator cannot rewrite, drop or reorder any record of that day without the change being detectable by anyone who kept the root.

What is published

`GET /v1/roots/{date}` (also `GET /v1/evidence/roots/{date}`) returns, without authentication:

{
  "format": "merkle-root/v1",
  "date": "2026-11-09",
  "treeSize": 6,
  "root": "0aa1c42d…",
  "firstRecordId": "01M70…",
  "lastRecordId": "01M70…",
  "publishedAt": "2026-11-10T00:05:00.000Z",
  "server": "did:key:z6Mk…",
  "signature": "…"
}

- **Day:** records whose `createdAt` falls in `[date 00:00 UTC, date+1 00:00 UTC)`, in chain order. - **Leaves:** each record's 32-byte `hash` (the hex value in the record), hashed as an RFC 9162 leaf: `SHA-256(0x00 || hash)`. - **Tree:** RFC 9162 §2.1 (Certificate Transparency v2): `node = SHA-256(0x01 || left || right)`, split at the largest power of two below the size. An empty day has root `SHA-256("")`. - **Signature:** Ed25519 by the `server` did:key over the RFC 8785 canonical JSON of the document without `signature`.

Roots are stored append-only (a database trigger rejects changes) and only for finished UTC days.

Publishing

pnpm roots:publish                         # yesterday (UTC)
pnpm roots:publish --date 2026-11-09       # a finished day
pnpm roots:publish --date today --preview  # partial root for today, not stored

Run it daily after midnight UTC (cron, a systemd timer or your scheduler). With the docker compose stack, run it in the API container: `docker compose exec api node --import tsx apps/api/src/jobs/publish-roots.ts`. `pnpm roots:publish` on the host cannot reach the stack's Postgres (it is not published to the host, so it fails with `ECONNREFUSED`), and it would sign with the host's dev key instead of the API's evidence key. Re-running for a published day re-computes the root and fails loudly if the chain no longer matches it.

To make roots useful as commitments, copy each one somewhere the operator does not control (a partner's inbox, a public repository, a transparency log).

Verifying an inclusion proof (third party)

The record owner fetches `GET /v1/evidence/{id}/proof`:

{
  "format": "merkle-proof/v1",
  "recordId": "01M70…",
  "recordHash": "75cd1bfc…",
  "leafIndex": 0,
  "auditPath": ["8fc0ec53…", "…"],
  "root": { "format": "merkle-root/v1", "…": "…" }
}

With this repository:

pnpm verify proof.json --server-key did:key:z6Mk…

Without it, any language will do:

1. **Root signature.** Remove `signature` from `root`, serialise it with RFC 8785 (JCS), and verify the base64url Ed25519 signature with the public key inside `root.server` (did:key, multicodec `0xed01`, base58btc). Compare `root.server` and `root.root` with the values you obtained independently (for example from `/v1/roots/{date}` at publication time, or from your own archive). 2. **Record.** Check that `recordHash` is the `hash` of the record in your Nachweis, and that the Nachweis itself verifies (`pnpm verify nachweis.json`). 3. **Inclusion.** Compute `r = SHA-256(0x00 || bytes(recordHash))`, then run the RFC 9162 §2.1.3.2 algorithm with `fn = leafIndex`, `sn = treeSize − 1` over `auditPath`:

``` for p in auditPath: if sn == 0: fail if fn is odd or fn == sn: r = SHA-256(0x01 || p || r) if fn is even: while fn is even and fn != 0: fn >>= 1; sn >>= 1 else: r = SHA-256(0x01 || r || p) fn >>= 1; sn >>= 1 accept iff sn == 0 and r == root.root ```

The tree size is authenticated by the signed root, not by the proof alone; always take `treeSize` from the signed root.

Hourly checkpoints

Daily roots exist only after the UTC day ends, so a record written at 10:00 cannot be proven until the next morning. For the live demo the API also publishes a **signed hourly checkpoint**. It keeps the daily root unchanged and only adds the checkpoint alongside it.

`GET /v1/roots/checkpoints/latest` and `GET /v1/roots/checkpoints/{YYYY-MM-DDTHH}` return, without authentication:

{
  "format": "merkle-checkpoint/v1",
  "id": "2026-11-11T13",
  "date": "2026-11-11",
  "from": "2026-11-11T00:00:00.000Z",
  "to": "2026-11-11T14:00:00.000Z",
  "treeSize": 42,
  "root": "…",
  "firstRecordId": "01M…",
  "lastRecordId": "01M…",
  "publishedAt": "2026-11-11T14:00:31.000Z",
  "server": "did:key:z6Mk…",
  "signature": "…"
}

How a checkpoint relates to the daily root:

|                  | Daily root                                               | Hourly checkpoint                                                   |
| ---------------- | -------------------------------------------------------- | ------------------------------------------------------------------- |
| Covers           | `[date 00:00, date+1 00:00)`                             | `[date 00:00, end of hour HH)`, cumulative within the day           |
| Published        | after the day ends (`pnpm roots:publish`, or by the API) | after each hour ends, by the API every minute (`ROOTS_AUTOPUBLISH`) |
| Leaves and nodes | `SHA-256(0x00 ‖ hash)`, `SHA-256(0x01 ‖ l ‖ r)`          | identical                                                           |
| Signed document  | `merkle-root/v1`                                         | `merkle-checkpoint/v1` (same signing rule)                          |
| Proof format     | `merkle-proof/v1` with `root`                            | `merkle-checkpoint-proof/v1` with `checkpoint`                      |

Because checkpoints are cumulative and use the same tree, the checkpoint for `T23` covers the whole day and has exactly the daily root's value. Earlier checkpoints of a day are prefixes of the day's tree, so a reader who kept them can check that the day only grew. Checkpoints are stored append-only, like roots.

`GET /v1/evidence/{id}/proof` (and `/v1/session/evidence/{id}/proof`) returns the daily-root proof once the day is published. Before that, it returns the proof against the latest checkpoint of the record's day. `pnpm verify proof.json --server-key …` and `verifyInclusionProof` accept both formats. The checking algorithm is the one above, with `checkpoint` in place of `root`.