Audit & Attestation
Every event you write to j17 is hash-chained: each event's SHA-256 hash includes the hash before it, per stream, so any tampering breaks the chain from that point forward. On top of the chains sit Merkle trees — trees of hashes whose single root commits to every leaf — with per-stream roots, an instance root over your stream roots, and a fleet-wide platform root. The instance and platform roots are Ed25519-signed. These endpoints let you retrieve proofs and signed anchors so you can verify your own data offline, with the j17-verify CLI or ~50 lines of any language's standard crypto library.
The chain a proof walks:
event
│ inclusion proof
▼
stream root
│ streams map
▼
instance root ── Ed25519 signature (check against your pinned key)
│ platform proof
▼
platform root ── OTS / RFC 3161 external timestamp
All endpoints below use your instance host and API key. Per-stream proofs and anchors need read scope; creating an anchor on demand needs write.
Per-stream proofs
Prove a single event is in a stream without exposing the rest of the stream.
GET /{aggregate_type}/{aggregate_id}/audit/merkle-root
GET /{aggregate_type}/{aggregate_id}/audit/merkle-proof/{index}
GET /{aggregate_type}/{aggregate_id}/audit/verify-chain
The index is the event's 0-based position in the stream — the same order /events returns.
curl https://myapp.j17.dev/order/ord_123/audit/merkle-proof/2 \
-H "Authorization: Bearer $API_KEY"
{
"ok": true,
"root": "9f2c…",
"event_hash": "1a2b…",
"siblings": ["c3d4…", "e5f6…"],
"directions": ["right", "left"],
"index": 2,
"event_count": 7
}
To check it: leaf-hash event_hash (SHA-256(0x00 || bytes)), then hash your way up — combine with each sibling on the given side as SHA-256(0x01 || left || right) — and compare the result to root. The 0x00/0x01 prefixes are RFC 6962 domain separation, present since audit format 2 (platform 0.16.3); responses carry "format": 2, and artifacts without a format field are format 1 (no prefixes, no leaf-hash step). Two further mechanics matter if you're implementing this yourself: hashes combine over the raw bytes (decode the hex before concatenating), and when a tree level has an odd count, the last node is duplicated. The complete normative rules — including the canonical signed anchor payload that binds an anchor's instance, environment, and timestamp — are in the audit format specification, written to be implementable without reference to j17's source. event_hash itself is the event's chain hash — SHA-256 over the previous event's hash concatenated with the event's canonical JSON (a stream's first event has no predecessor, so its hash is SHA-256 of the event JSON alone) — so it can be recomputed from exported event data, not just taken on faith. GET /{type}/{id}/events?raw=true supplies both ingredients per event: the chain hash and the stored bytes it was computed over.
merkle-root returns the stream's current root and event count; verify-chain re-walks the stream's hash chain server-side and reports whether it's intact.
A per-stream root proves inclusion, but on its own it's unsigned. To make it attestable, chain it to a signed root anchor.
Root anchors
A root anchor is a signed snapshot of your entire environment: every stream's Merkle root, combined into one instance-level root, signed with your instance's Ed25519 key.
GET /_audit/root-anchors # all anchors, newest first
GET /_audit/root-anchors/latest # the most recent one
GET /_audit/root-anchors/{id} # a specific one
curl https://myapp.j17.dev/_audit/root-anchors/latest \
-H "Authorization: Bearer $API_KEY"
{
"ok": true,
"root_anchor": {
"id": "8e86…",
"environment": "prod",
"merkle_root": "c005…",
"signature": "a5aa…",
"public_key": "a5fe…",
"event_count": 41230,
"stream_count": 318,
"streams": {
"order:ord_123": { "merkle_root": "9f2c…", "event_count": 7 }
},
"created_at": "2026-07-27T21:55:17Z"
}
}
This is exactly the shape j17-verify consumes as verification.json. To verify one event end to end:
- Check the event's inclusion proof against its stream root.
- Find that stream root in the anchor's
streamsmap. - Rebuild the instance root: take the stream roots sorted by stream key and build the Merkle tree — it must equal
merkle_root. - Verify
signaturewithpublic_key(Ed25519). Format 2 anchors sign the canonical anchor payload — rebuilt from the anchor's own fields, so its instance, environment, counts, andcreated_atare all bound; substituting any of them fails. (Format 1 anchors, minted before 0.16.3, signed only the baremerkle_rootstring.)
Anchors are created on a schedule you control from the dashboard (your instance's Snapshots tab) — and on demand:
Anchoring on demand
POST /_audit/root-anchors
curl -X POST https://myapp.j17.dev/_audit/root-anchors \
-H "Authorization: Bearer $API_KEY"
Returns 201 with the new anchor. Mint one right after a critical batch, before an export, or on your own compliance cadence — then submit the anchor's merkle_root to OpenTimestamps or any RFC 3161 timestamping authority yourself. That gives you third-party-verifiable timestamps at whatever frequency you need, without waiting on the platform schedule.
Anchor creation scans every stream in the environment, so it's guarded: one creation at a time per environment (409 while one is running), a short cooldown between anchors (429 with a Retry-After header), and a 413 past 100,000 streams. 422 means there are no events to anchor yet.
Your signing key
GET /_audit/signing-key
{
"ok": true,
"public_key": "a5fe…",
"history": [
{ "event": "was_created", "public_key": "a5fe…", "backfilled": 0, "created_at": "2026-07-01T09:12:44Z" }
]
}
Fetch this once, pin it, and check every anchor's public_key against it. The endpoint works before your first anchor exists — the key is created on first read — so you can pin before you have anything to verify.
Third parties don't need your API key to check the pin: your instance also publishes its keys unauthenticated at GET /.well-known/j17-attestation (instance key + platform key, each with history). Point a verifier there — keys they fetch from your instance's own address never depend on the evidence bundle they're checking. The public endpoint is strictly read-only and returns 404 until the authenticated read above has created the key.
The history list is the key's lifecycle, recorded as events in the same tamper-evident log the key protects: a key that changes is a key that left a record. A backfilled: 1 entry means the record was reconstructed after the fact; its timestamp reads "this key existed since at least then," not the key's birth time.
The key never changes on its own. A damaged key file halts anchor creation rather than silently generating a replacement identity — a signature that can be quietly re-keyed isn't worth verifying.
Platform proofs
Daily, j17 combines the latest production anchor from every instance into one fleet-wide platform root, signs it, and submits it for external timestamping (OpenTimestamps and RFC 3161). Your instance can prove its inclusion:
GET /_audit/platform-proof
{
"ok": true,
"platform_anchor": {
"merkle_root": "77aa…",
"signature": "31c9…",
"public_key": "0be2…",
"instance_count": 214,
"cutoff_at": "2026-07-27T00:00:12Z",
"ots_proof": "AE9w…",
"ots_status": "submitted",
"rfc3161_response": "MIIC…",
"rfc3161_status": "submitted",
"created_at": "2026-07-27T00:00:42Z"
},
"instance_root": "c005…",
"proof": { "index": 12, "siblings": ["4d1e…"], "directions": ["right"] }
}
Verify proof from your instance_root up to the platform merkle_root, then the platform signature — and you've chained a single event in your instance to an externally timestamped, fleet-wide root. The response contains nothing about other tenants: a Merkle proof needs only sibling hashes, so that's all you get.
Per-event attestation bundles
GET /{type}/{id}/audit/attestation/{index}?anchor={anchor_id} assembles the whole story for one event in one round trip: the event's stored bytes and chain hash, its proof into the stream root, the stream root's proof into the signed instance anchor, and the anchor itself — redacted to exactly the fields its signature covers, never the environment-wide streams map. A few kilobytes, verifiable offline, safe to hand to a third party. Pin ?anchor= when attesting a batch so every document proves against the same anchor; the format spec §6 defines the shape, the verification recipe, and the error contract (notably beyond_anchor_coverage, which means "wait for the next anchor", not "failure").
ots_status moves pending → submitted → confirmed: a fresh submission is a calendar receipt, and within a day it's upgraded automatically to a complete .ots file (base64 in ots_proof) that the standard OpenTimestamps client verifies against Bitcoin as-is. For court-ready evidence, use a confirmed anchor. RFC 3161 tokens are complete at issuance — submitted is final there.
Platform anchors cover production anchors only. Before your first daily inclusion — or if you have no production anchors at all — this returns 404. For test and staging evidence, anchor on demand and timestamp the roots yourself.
Verifying offline
Everything above can be verified with no j17 access at all. You need three things:
- Your events —
GET /{type}/{id}/eventsfor a stream, or a verifiable export (a backup bundle that includes every event with its chain hash, plus averification.jsonin the root-anchor shape shown above). - A signed anchor — from
/_audit/root-anchors/latest, stored wherever you keep evidence. - Your pinned public key — from
/_audit/signing-key, fetched once over a channel you trust.
Then it's math anyone can re-run: recompute the hash chain, rebuild the Merkle roots (domain-separated per the format spec, odd counts duplicate the last node, stream roots sorted by stream key), and check the Ed25519 signature over the canonical anchor payload. The j17-verify CLI does all three against an export directory in one command; the same checks are a short afternoon in any language with SHA-256 and Ed25519 in the standard library.
See also
- Reading Aggregates — the
/eventsendpoint that returns a stream's raw events - Authentication — key scopes and environment locking
- Schema Deployment — the other self-serve key scope, for shipping your spec from CI