Validation build: the evidence log is live; the pricing example is simulated and places no cover. What that means

Specification · version 1.0 · 23 September 2026

Open evidence specification

Every byte RiskRouter’s evidence commits to, written down so that anyone can check it without us, and anyone can build a second implementation. Two already exist that share no code: the JavaScript verifier and a Python one, held to identical verdicts by the repository’s tests. Anyone may implement this specification without asking us: the specification, its test vectors, both verifiers and the conformance suite are published under the Apache License 2.0.

The completeness, disclosure and sampling constructions, and the SCITT profile, are also written up for the IETF as an individual Internet-Draft, draft-jangra-scitt-digest-evidence, posted on 8 October 2026. No working group has adopted it. Where the draft and this page differ, this page and spec-vectors.json are what was published.

0 · Conventions

Conventions

1 · v1 quote ledger

The quote ledger: a hash chain

1.1 Canonical form (frozen)

chain_index|id|created_at|active_vertical|components|premium|compliance_status|matrix_version|distributor_id|prev_hash

1.2 Attestation (frozen payload)

riskrouter-ledger-attestation|v1|as_of|entries|head_hash

The signed statement that the ledger had entries entries and that head at as_of. A verifier rebuilds this payload from the attestation’s own fields; it never trusts a signed_payload field alone.

1.3 Export and single-entry proof

An export (riskrouter-ledger-export|v1) carries the holder’s entries in full, the (chain_index, row_hash, prev_hash) of every entry, and the signed attestation. A single-entry proof (riskrouter-entry-proof|v1) carries one entry, the links from it to the head, and the attestation. What these prove, precisely: the holder’s entries are rebuilt from content; other entries are followed by digest only and bind because anyone recomputing the full ledger must reach the signed head. A rebuilt ledger is caught by comparing with a head recorded earlier, which is why heads are anchored (section 4).

2 · v2 evidence log

The evidence log: digests of any regulated decision, in a Merkle tree

2.1 What the client sends

The client hashes its own record and sends only record_digest and a kind tag (^[a-z0-9][a-z0-9.-]{0,39}$). The record never leaves the client.

The client must salt the record. Hash a random value of at least 128 bits together with the record, and keep both. A digest of a guessable record, such as “approved”, can be confirmed by anyone who guesses it. The salted construction this specification recommends is SHA-256(salt || record), but any construction the client can reproduce is valid: the log commits to the digest, not to how it was made.

2.1b Canonical record and bundle (frozen)

How the record is hashed is the client’s choice, but a record another party must be able to re-hash, such as one written to a regime pack, uses this construction:

record         = a JSON object; keys ^[a-z][a-z0-9_]{0,63}$ at every level; values are strings (valid
                 Unicode), integers within ±(2^53 − 1), true, false, null, arrays and objects,
                 nested at most 32 deep; no fractions (money is integer cents)
canonical      = RFC 8785 (JSON Canonicalization Scheme) of the record
record_digest  = SHA-256(salt || UTF-8(canonical)), salt 16 to 64 random bytes as lowercase hex

bundle         = { "format": "riskrouter-record-bundle|1", "record", "salt_hex", "proof" }

A record outside the profile is refused rather than coerced. A bundle is intact when the record and salt produce the entry’s record_digest, the record’s kind is the entry’s, and the proof verifies as below. The vectors carry records chosen for the places two languages differ (escapes, astral characters, key order, edge integers) and records that must be refused.

2.2 Leaf (frozen)

leaf string = riskrouter-evidence-leaf|v2|leaf_index|created_at|distributor_id|kind|record_digest
leaf hash   = SHA-256(0x00 || leaf string)

leaf_index counts from 0 with no gaps. created_at is set by the log, never by the client. Every entry carries leaf_version, 2 or 3; a verifier rebuilds the leaf string for the version it sees.

2.2b Signed leaf (frozen, v3)

A v2 leaf proves that the log sealed a digest at a time. It does not prove that the distributor asserted anything: for that, the distributor signs its own claim, and the signature goes inside the leaf, so it cannot be swapped after the fact. Optional per entry; an unsigned entry is a v2 leaf exactly as above. Both versions sit in the same tree under the same head.

claim payload  = riskrouter-evidence-claim|v3|kind|record_digest|signer_key_id|claimed_at
                 signed by the client: ECDSA P-256 over SHA-256, IEEE P1363 (r || s), base64

leaf string    = riskrouter-evidence-leaf|v3|leaf_index|created_at|distributor_id|kind|record_digest|signer_key_id|claimed_at|client_signature
leaf hash      = SHA-256(0x00 || leaf string)

signer_key_id  = first 16 hex characters of SHA-256(JSON [x, y] of the public JWK), as a witness id is derived
claimed_at     = the client's own timestamp, YYYY-MM-DDTHH:MM:SS.ffffffZ, exactly as it was signed

The client signs only what it knows before sending; leaf_index and created_at remain the log’s. The log verifies the signature before recording and refuses a claim that does not verify, with nothing written; the database then commits to the signature as presented, and every verifier checks it again. The public key is registered once (POST /api/v2/evidence/keys) and served to anyone (GET /api/v2/evidence/keys?key_id=); a proof of a v3 leaf carries it as signer_public_key. The signer should publish the same key under its own control, exactly as a witness does, so that “the client signed it” is not the log’s word alone. A key id belongs to one distributor forever; registering a new key never removes an old one, so leaves signed before a rotation stay verifiable.

What a v3 leaf proves: this distributor, holding this key, asserted this digest and this kind at the time it claimed, and the log sealed that assertion at created_at in a tree whose head is signed. What it does not prove: that the record behind the digest is true, or that the key was not stolen.

2.3 Tree

RFC 6962 / RFC 9162, unchanged: node hash = SHA-256(0x01 || left || right), with the split at the largest power of two smaller than the number of leaves; the root of the empty tree is SHA-256 of the empty string. Inclusion and consistency proofs are the RFC 9162 section 2.1.3 and 2.1.4 constructions, verified by the algorithms given there. The implementation is checked against RFC 6962’s own reference roots.

2.4 Signed tree head (frozen payload)

riskrouter-evidence-head|v2|tree_size|root_hash|timestamp

Signed with the same key as v1 attestations. A verifier must take tree_size and root_hash from the same signed head: an inclusion proof does not bind the tree size on its own. A head with no key bound is returned unsigned and says so; it is never given a signature it does not have.

2.5 Endpoints

POST /api/v2/evidence, POST /api/v2/evidence/batch, GET /api/v2/evidence/head, GET /api/v2/evidence/proof, GET /api/v2/evidence/consistency, GET /api/v2/evidence/chain (2b), and for signed leaves POST and GET /api/v2/evidence/keys, described in the API reference and openapi.json. A consistency proof is checked against the root of a head the verifier saved, never a root the log supplies.

2b · Completeness

Chains, spot checks, disclosure and co-sealing

A proof of one record answers “was this changed?”. An auditor’s harder questions are “is this all of it?”, “did you keep it?”, “what did this one field say?” and “did both of you agree to it?”. The four constructions below answer them. Each is optional per record, none changes a leaf, and all four are frozen from their first published vectors (settled 7 October 2026).

2b.1 Completeness chains (frozen)

A firm puts records in numbered chains. The record carries three fields inside itself, and the entry carries two of them beside the digest, outside the leaf:

record fields  chain_tag   64 hex characters; recommended: HMAC-SHA256(secret, "riskrouter-chain-tag|1|" || name)
               chain_seq   1, 2, 3, ... with no gap
               chain_prev  the record_digest of the record at chain_seq - 1; 64 zeros at position 1
entry fields   chain_tag, chain_seq        sent with record_digest; never part of the leaf string

link line      chain_seq|leaf_index|created_at|record_digest\n
links_digest   SHA-256(UTF-8(the link lines from `from` to `to`, concatenated))
chain payload  riskrouter-evidence-chain|v1|chain_tag|length|tree_size|root_hash|timestamp|from|to|links_digest

The log refuses a chain_seq that is not the next position, and a chain_tag another distributor uses, with nothing written. GET /api/v2/evidence/chain?chain_tag= answers what the log holds in that chain as of the current signed head: its length and, for each position, the leaf, the time and the digest, signed over the chain payload with the head-signing key. A holder checks the signature, recomputes links_digest, and then that each record shown produces the digest at its position, names that position, and points at the one before. A position the statement lists and nobody showed is a record left out. A firm alone cannot hide one; doing so would need the log to sign a statement its own tree contradicts. On a log the firm runs itself, this guarantee is the firm’s own word.

completeness bundle = { "format": "riskrouter-completeness-bundle|1", "chain": [statement, ...], "records": [{ record, salt_hex }, ...], "as_of"? }

With as_of, only the positions sealed by that time must be shown.

2b.2 Spot checks (frozen)

An auditor announces, and seals in the log, a future Bitcoin block height and a sample size. When the block is mined its hash is the seed, and neither side could have chosen it:

for counter = 0, 1, 2, ...
  h = SHA-256(UTF-8("riskrouter-spot-check|v1|" seed "|" population "|" counter))
  x = first 8 bytes of h, big-endian unsigned
  skip if x >= 2^64 - (2^64 mod population)
  position = 1 + (x mod population), kept unless already drawn
until min(sample, population) positions are drawn

spot-check bundle = { "format": "riskrouter-spot-check-bundle|1", "chain", "seed", "bitcoin_height", "sample_size", "records" }

population is the chain’s length in the signed statement. The firm shows the records at the drawn positions; nobody sends anyone a bulk copy. The verifier recomputes the draw; that the block at that height has that hash, and was mined after the plan was sealed, the auditor checks on any Bitcoin node.

2b.3 Selective disclosure (frozen)

field salt     HMAC-SHA256(secret, UTF-8("riskrouter-disclosable|1|" || name))        32 bytes, one per top-level field
commitment     SHA-256(field salt || UTF-8(canonical({name: value})))
sealed record  { "format": "riskrouter-disclosable|1", "kind": <the record's kind>, "fields": { name: commitment, ... } }
record_digest  SHA-256(salt || UTF-8(canonical(sealed record)))                       as for any record (2.1b)

disclosure     { "format": "riskrouter-disclosure-bundle|1", "record": sealed record, "salt_hex", "proof",
                 "disclosed": { name: { "value", "field_salt_hex" }, ... } }

The secret never leaves the firm: a disclosure carries the salts of the fields it shows and nothing that derives the others, so a hidden low-entropy value (a score, an outcome) cannot be guessed. Field names are visible; values are not.

2b.4 Co-sealing

Two parties who share a record and its salt each record the same digest as a signed (v3) entry, under their own key and their own account. A co-seal (riskrouter-coseal|1: bundles, optionally expected_signers, the keys each party published) holds when every bundle verifies, the digests are equal, every entry is v3, and the signers and the distributors are all different. Nothing new is frozen: it is a rule over leaves that already exist.

Both verifiers and the conformance suite check all four; spec-vectors.json carries a chain statement and its payload, four spot-check draws (one seeded with Bitcoin’s genesis block hash), and a disclosure with its field salts, commitments and sealed record. Design notes: docs/completeness.md, docs/spot-checks.md, docs/selective-disclosure.md, docs/co-sealing.md.

3 · Witnesses

Independent witnesses

A witness is anyone who is not the operator. Each time it runs it fetches the current signed head, checks the operator’s signature, requests a consistency proof from the last head it saved, and checks that proof against the root it saved. Only if every check passes does it co-sign:

riskrouter-evidence-cosign|v2|witness_id|tree_size|root_hash|cosigned_at

If a check fails, the witness holds two heads, both signed by the operator, that cannot both be true. That pair is evidence of a rewritten history that the operator cannot disown, produced by someone the operator does not control. The reference witness is tools/witness.mjs; it saves the pair and exits with status 2.

The portable witness in witness/ of the verifier repository does the same for many logs at once, on Cloudflare Workers, Node or GitHub Actions. Its cosignature file is the same format, with one added member, log ({ id, api }), which a verifier ignores. It pins a log’s head-signing keys the first time it sees them and raises an alarm if a later list drops or changes one; an alarm is never overwritten, and the witness stops co-signing that log. It serves, read-only, /public-key.json, /logs.json, /cosignatures/<log>/latest.json and /alarms/<log>.json. Logs and witnesses are listed at /registry.json (riskrouter-witness-registry|1); a witness is listed only once it publishes its key under its own control. There are no independent witnesses yet running this tool; six staging witnesses of the public witness network cosign the same tree's C2SP checkpoints (below). How to become one: /witnesses.

The same log in the C2SP formats

The v2 tree is also published in the formats the public witness network speaks, so that witnesses who have never run our code can check it: c2sp.org/signed-note, tlog-checkpoint, tlog-cosignature and tlog-tiles. Two things are frozen, on the same terms as the forms above: the origin, api.riskrouter.eu/tlog/evidence-v2, and the checkpoint text, three lines and no extension lines:

api.riskrouter.eu/tlog/evidence-v2
<tree_size>
<base64 of the RFC 6962 root>

The root is the same 32 bytes as root_hash in the v2 head, in base64 instead of hex, and one read of the tree gives both. The note is signed with the log’s Ed25519 key (signature type 0x01), whose key name is the origin and whose key ID is the first four bytes of SHA-256(name || 0x0A || 0x01 || public key). That is a separate key from the P-256 head-signing key, published as a vkey in anchors/checkpoint/log.vkey. A checkpoint is served at GET /tlog/evidence-v2/checkpoint only when that key is bound; otherwise the answer is 503, never an unsigned note. Witnesses of the network return cosignatures of type 0x04 (cosignature/v1, Ed25519 over cosignature/v1\ntime <seconds>\n and the checkpoint text), and we keep each one that verifies in anchors/checkpoint/cosigned/.

Tiles of hashes are served at /tlog/evidence-v2/tile/<L>/<N>[.p/<W>], so anyone can mirror the whole tree and compute any proof without our API (node tools/checkpoint.mjs mirror). Entry bundles are not published: every leaf names a distributor and a kind, and a distributor holds its own entries with their proofs. Both verifiers check checkpoints and cosignatures; spec-vectors.json carries a checkpoint and a cosignature made with the RFC 8032 test keys, and the conformance suite derives cases from them.

3b · SCITT

Signed Statements and COSE Receipts

The log also serves as a Transparency Service in the sense of the IETF SCITT architecture (RFC 9943), returning receipts as defined by RFC 9942 (COSE Receipts). Registration, POST /api/v2/evidence/statement, applies the policy riskrouter-scitt-profile|1: a COSE_Sign1 signed ES256 with a key the caller registered (kid = its 16-hex id as UTF-8), CWT claims iss and sub (text, neither an email address), and a hash envelope (RFC 9995: protected 258 = -16, a 32-byte payload), so the artifact never reaches the log. What the log keeps is one v2 leaf of kind scitt.statement whose record_digest is:

SHA-256( 0xD2 0x84 || bstr(protected) || 0xA0 || bstr(payload) || bstr(signature) )

that is, the statement re-encoded with an empty unprotected header. A receipt (riskrouter-scitt-receipt|1) is a COSE_Sign1 signed ES256 with the head-signing key (kid = its key id), vds 395 = 1 (RFC9162_SHA256), vdp 396 holding one inclusion proof [tree_size, leaf_index, [hash, …]] at label -1, and a detached payload: the root. Its protected header carries CWT claims (iss, sub = the statement digest, iat) and riskrouter-leaf = [leaf_index, created_at, distributor_id, kind]. To check one: rebuild the v2 leaf string from those fields and sub, walk the proof to a root, and verify the signature over ["Signature1", protected, h'', root] with our published key. Both verifiers do this, and pycose, an independent COSE library, verifies our receipts in our tests. The statement itself stays with its issuer: we keep its digest only, which is where we depart from the architecture draft, deliberately (rule 15). spec-vectors.json carries a statement digest and a receipt’s signed bytes.

4 · Anchoring

Anchoring heads in time

Signed v1 heads are committed to OpenTimestamps calendars and upgraded to Bitcoin attestations; the 13 September 2026 head is in block 967830 and later. Since 18 October 2026 the newest v2 head of each day is anchored the same way: its payload alone, UTF-8 with no trailing newline, is kept as anchors/ots/evidence-head-N.payload, and the proofs beside it commit to its SHA-256, the same imprint as 4.1; ots verify checks one with no code of ours, and /age lists every head with its block. This bounds when a head existed, from above. It says nothing about the moment of each entry before it.

4.1 RFC 3161 time-stamp tokens (frozen imprint)

Every two hours, each new signed head (v2) and each new signed attestation (v1) is sent to the RFC 3161 time-stamping authorities listed in anchors/tsa/authorities.json. The token binds exactly the payload we signed:

message imprint = SHA-256(UTF-8(signed payload))       hashAlgorithm sha256
signed payload  = riskrouter-evidence-head|v2|tree_size|root_hash|timestamp
               or riskrouter-ledger-attestation|v1|as_of|entries|head_hash

file            = anchors/tsa/<evidence-head-N | ledger-N>.<authority>.json
                  { "format": "riskrouter-head-timestamp|1", subject, signed, signature, payload,
                    tsa: { id, name, url, qualified, trusted_list }, token (DER, base64), gen_time, requested_at }

A verifier rebuilds the payload from the head in the file, checks our signature on it, then the token: the imprint, the signed attributes (content type and message digest), the authority’s signature with the certificate the token carries, that the certificate is for time-stamping and valid at genTime, and the chain the token carries, whose root it compares with the root the authority publishes (openssl ts -verify -data payload -in token -token_in -CAfile root.pem does the same). Both verifiers do this offline. None of the authorities used today is a qualified eIDAS time-stamping service: a token is evidence of time from an independent authority, without the presumption of accuracy that a qualified time stamp carries under Article 41(2) of Regulation (EU) No 910/2014. A file calls a token qualified only when its authority is listed with its entry on the EU Trusted List.

5 · Test vectors and implementations

Check your implementation

spec-vectors.json holds deterministic inputs and expected outputs for every frozen form above: timestamp normalisation, a v1 canonical form and row hash, the attestation payload, eight v2 leaves and the roots of every prefix, an inclusion proof, a consistency proof, a head payload, a cosignature payload, a C2SP checkpoint and cosignature with their Ed25519 signatures, a chain statement, spot-check draws and a selective disclosure. It is regenerated from the reference code and both verifiers must agree with it before any change ships.

Run the conformance suite

The conformance suite turns those vectors into cases an implementation in any language can answer, adds proofs that have been deliberately broken and must be refused, and grades the answers. It ships with the reference code and the vectors in /kit/conformance/, needs only Node, and contacts nothing.

node conformance.mjs cases > cases.json          # one JSON object per frozen form, with the expected answer
# your implementation answers each case: { "<case id>": <answer> }
node conformance.mjs grade answers.json --name "Your product 12.4" > result.json
# or drive a command that reads one case on stdin and prints its answer:
node conformance.mjs run --command "python3 my_impl.py" --name "Your product 12.4" > result.json
# and check what your product produced: an export, an entry proof, an evidence proof, an evidence pack
node conformance.mjs artefacts ./evidence/ --key signing-key.json > artefacts.json

The result file is the implementer’s own statement: that this implementation, on this date, answered these cases as the vectors say, named by the vector set (riskrouter-spec-vectors|1) and the digest of the cases, so anyone can run the same cases again. RiskRouter certifies nothing and nobody. An implementation that passes needs no permission from us to say so, and no relationship with us to keep it true: the vectors and the suite are published, and both are free.

A defect in this specification, or a disagreement between it and any implementation, is a bug we want to hear about: security explains how to report one.