KV Ledgers
A ledger is a collection of a format 2 store that only accepts new entries. Nobody can change or delete an entry once it is written, including through the dashboard or the management API. Each entry is hash-linked to the entry before it, copied within a few minutes to an archive in write-once storage with a retention lock, and covered by a signed digest. You, or an auditor you appoint, can then prove what the ledger held at a point in time without having to trust Quant.
Use a ledger for records that must stay as they were written: audit trails, a register of AI use, credit and billing ledgers, records of administrative actions, and consent or approval logs. Keep data that you need to edit in a normal collection.
A ledger has a number of chains (1 to 64). Each concrete collection of the ledger is one ledger instance, and each instance has that many independent hash chains, called shards. An append goes to a randomly chosen shard. Within a shard every entry has a strict sequence number (seq). Across shards, entries are ordered by server time to the millisecond, which is not one global sequence. If you need a strict total order, declare the ledger with chains: 1, and accept the lower write rate of a single chain.
Declare a Ledger
Section titled “Declare a Ledger”You declare a ledger through the management API or the Ledgers tab. The edge runtime cannot declare, change or drop one.
POST /applications/{org}/kvstores/{store}/ledgers{ "name": "aiUse", "collection": "ai-use:*", "chains": 8, "retentionDays": 2555, "lockMode": "compliance", "personalFields": ["requesterName", "requesterEmail"], "subjectField": "requesterId", "anchor": { "type": "rfc3161", "tsaUrl": "http://timestamp.digicert.com" }}| Field | Rule |
|---|---|
name |
Matches ^[A-Za-z][A-Za-z0-9_]{0,63}$ and is unique in the store |
collection |
A concrete collection, or a pattern where * stands for one whole segment, as for indexes. Each concrete collection that matches is its own ledger instance. A collection can belong to only one ledger. At most 512 bytes (UTF-8), and every concrete collection you append to is at most 512 bytes too: a longer one is VALIDATION |
chains |
1 to 64 shards per instance. Can be raised later, never lowered |
retentionDays |
1 to 36,500 days of write-once retention for each archive object. Can be raised later, never lowered |
lockMode |
governance or compliance (see Archive, Digests and Lock Modes). Governance can become compliance later. Compliance can never become governance |
personalFields |
Optional. Up to 20 top-level field names that are encrypted per data subject so that they can be shredded. Any name except at |
subjectField |
Required when personalFields is set. The top-level plaintext field that holds the data subject id. It cannot be a personal field |
anchor |
Optional. { "type": "none" } (the default), customer-bucket or rfc3161 (see Anchoring) |
The rules that only go one way are deliberate. A ledger whose retention or lock mode could be lowered would not protect anything, so the API refuses a lower value with LEDGER_DIRECTION. Raise values with PATCH /applications/{org}/kvstores/{store}/ledgers/{name}, which takes any of chains, retentionDays and lockMode. A raise of retentionDays also extends the retention of the archive objects that already exist. New shards from a raise of chains start at seq 1.
The collection must be empty when you declare the ledger, with no normal keys and no ledger entries. The API checks this and refuses with 409 LEDGER_COLLECTION_NOT_EMPTY and reason: "not_empty". For a pattern, the check is a bounded scan (20 requests or 4 seconds). If the scan cannot finish, the refusal has reason: "check_incomplete", and you should declare an exact collection or a narrower pattern.
The call returns 202 with the ledger in the state PENDING and an activeAfter time 75 seconds after the declaration. During that time, appends fail with LEDGER_NOT_READY (retryable, the error body carries activeAfter). The wait gives every running writer time to see the declaration. At activeAfter the emptiness check runs again. The ledger becomes ACTIVE, or FAILED with an error if a normal key was written into the collection in that window. Declaring the same name again replaces a FAILED ledger.
A store can hold up to 20 ledgers. GET /applications/{org}/kvstores/{store}/ledgers lists them with their state (PENDING, ACTIVE, FAILED or DROPPED) and their definitionHash.
Each ledger in the list also has a summary, so you do not need a status call per ledger:
"summary": { "archiveLagSeconds": 42, "broken": false, "retainUntil": "2027-01-01T00:00:00.000Z", "anchorFailingSince": "2026-10-14T00:10:00.000Z", "lastVerify": { "id": "01M5104A00ER2CRR3DHK1FVYH0", "at": "2026-10-16T16:20:00.000Z", "ok": true, "complete": true, "…": "…" }, "at": "2026-10-16T16:25:00.000Z"}archiveLagSeconds (the age of the oldest entry not archived yet) and broken (the archive job stopped a shard of the ledger because of a chain fault) are the values of the archive job’s last run, at the time at. The job runs about every 5 minutes for a store with entries. A run on a forked store, or a run that stops early, keeps the older summary, so compare at with the current time. A ledger the job has not run for yet has archiveLagSeconds: 0 and broken: false without at. retainUntil, anchorFailingSince and lastVerify are as in the ledger status (see below), and are absent until they exist. The ledger status has the detail per shard and the current lag. If the summaries cannot be read, the list fails with 503 UNAVAILABLE; it never returns ledgers without them.
You can declare normal indexes on a ledger collection, but only plain indexes. A unique or count index on a ledger collection is refused with LEDGER_INDEX_KIND, in either order of declaration, and an index field or sortBy field cannot be a personal field (INDEX_FIELD_ENCRYPTED). Each entry has the server time in a top-level at field for indexing, so sortBy: "at" gives a time-ordered list across all shards without a merge:
{ "name": "aiUseByModel", "collection": "ai-use:*", "fields": ["model"], "sortBy": "at" }DELETE /applications/{org}/kvstores/{store}/ledgers/{name} removes a PENDING or FAILED ledger. An ACTIVE ledger can be dropped only when every shard is fully archived and its retention has ended. Before then the call returns 409 LEDGER_RETAINED with retainUntil, or with reason: "unarchived" (and the collection and shard) while entries still wait for the archive. A dropped ledger takes no new entries. Its entries stay readable and immutable, and the archive objects expire on their own. A drop is idempotent: a drop of a DROPPED ledger returns 200 and changes nothing. If a drop times out or the response is lost, send it again until it returns 200.
Append and Read
Section titled “Append and Read”kvStore.append(storeId, collection, value, { idempotencyKey }) writes one entry and returns its receipt. It works in the sandboxed and the legacy edge runtime. collection is a concrete collection that a ledger covers (no *), and value must be a JSON object. append takes one option, idempotencyKey. Any other option throws VALIDATION.
// session comes from your verified login, not from the request body.const receipt = await kvStore.append(AUDIT_STORE, `ai-use:${agencyId}`, { requesterId: session.userId, requesterName: session.displayName, // a personal field: stored encrypted model: 'claude-opus-5-5', purpose: 'triage', requestId, // your own id, so you can find the entry again});// {// key: 'ai-use:agency-42:01M5104A000000000000000000-03',// id: '01M5104A000000000000000000-03',// shard: '03', seq: 1, at: 1792108800000,// hash: '13af...7bad', prevHash: '90e1...71db',// ledger: 'aiUse',// source: { type: 'edge', principal: '<org>/<project>/<function id>' }// }The server chooses the shard and sets id, seq, at and the hashes. The id is a ULID followed by the shard number, and it always increases within a shard, even if a server clock runs backwards. at is the time of the ULID in epoch milliseconds. The key of the entry is <collection>:<id>, so get() finds the entry without a lookup.
Some value fields are reserved. A top-level at is refused because the server sets it, and $event is reserved for shred entries. A top-level field whose value is an object with a $enc or $shredded key is refused with VALIDATION. The stored value, with its source, can be at most 350,000 bytes (LIMIT_EXCEEDED). If the ledger has personal fields and the value holds any of them, the subject field must be a non-empty string of at most 256 bytes, or the append fails with LEDGER_SUBJECT_REQUIRED.
Every new entry also records who wrote it, as source. See Provenance.
Keep the receipts if you want independent evidence. A later read of the same key with a different hash proves that the entry was changed, and the offline verifier can check a file of receipts against the archive.
Pass an idempotencyKey (1 to 64 letters, digits, hyphens or underscores) to make retries safe. A retry with the same key, collection and value returns the first receipt with replayed: true and adds no entry, for 24 hours after the first append. The same key with another collection or value throws VALIDATION. A key is unique within the store, across all ledgers and collections, so derive one key per append, for example ${requestId}-audit (a colon is not allowed). If you erase a subject within 24 hours of the first append, a retry with that key fails with a dedicated VALIDATION. Use a new key then. A retry with the key of an append that committed before its store was forked still returns the first receipt; a new append is refused with LEDGER_FORKED.
Errors that are safe to retry with the same key: CONTENTION, LEDGER_NOT_READY, and UNAVAILABLE with retryable: true. With a key, an unknown outcome (a timeout or a lost response) is UNAVAILABLE with retryable: true, because the retry returns the first receipt if the entry was written.
async function appendOnce(storeId, collection, value, idempotencyKey) { for (let attempt = 1; ; attempt++) { try { return await kvStore.append(storeId, collection, value, { idempotencyKey }); } catch (err) { const safe = err.code === 'CONTENTION' || err.code === 'LEDGER_NOT_READY' || (err.code === 'UNAVAILABLE' && err.retryable === true); if (!safe || attempt === 5) throw err; let delay = Math.min(200 * 2 ** attempt, 5000) * (0.5 + Math.random() / 2); // exponential backoff with jitter if (err.code === 'LEDGER_NOT_READY' && err.activeAfter) delay = Math.max(delay, new Date(err.activeAfter) - Date.now()); // wait for the ledger to become active await new Promise((r) => setTimeout(r, delay)); } }}const receipt = await appendOnce(AUDIT_STORE, `ai-use:${agencyId}`, entry, `${requestId}-audit`);When the database throttles a read or cancels the transaction, the server backs off and retries on another chain where it can. When the database throttles the transaction request or reports it in progress, the server backs off and resends the same transaction (same chain, same request token), so it is never written twice. Both stop after about 8 seconds. An outcome the server cannot see is settled by reading the entry: if it was written, the receipt is returned; if it provably was not, the answer is UNAVAILABLE with retryable: true; only if neither can be shown is it UNAVAILABLE with retryable: false (true with an idempotencyKey).
Without a key, an append is not idempotent: an outcome that stays unknown is UNAVAILABLE with retryable: false, because the entry may already be on the record. Look for the entry before you append again.
To read a time window, pass since (inclusive) and until (exclusive) with the collection. Each is epoch milliseconds or an ISO 8601 time with Z or an offset. A time without a zone throws VALIDATION.
const page = await kvStore.list(AUDIT_STORE, { collection: `ai-use:${agencyId}`, since: '2026-10-16T00:00:00Z', until: Date.now(), includeValues: true, limit: 100,});// page.items[i]: { key, value, encrypted: false, createdAt, updatedAt,// ledger: { name, id, shard, seq, at, hash, prevHash, source } }ledger.source is present when the value is read (includeValues: true, get(), getMany()) and the entry was written after Quant started to record provenance.
A ledger list reads every shard with a strongly consistent query and merges them by server time, then by shard number, then by id. reverse: true gives the exact reverse order. The cursor remembers the last id of each shard, so entries that share a millisecond on different shards are never skipped or repeated. A ledger list takes collection, since, until, reverse, limit (1 to 100), includeValues, type (all or plaintext) and cursor. where, prefix, pattern, startsWith, after, before and index are refused, and so are since and until without a ledger collection. Through the HTTP API the same list is GET /applications/{org}/kvstores/{store}/keys?pagination=cursor&collection=<collection>&since=<time>&until=<time>.
get(), getEntry() and getMany() work on ledger keys as usual. getEntry() adds the ledger block and has no version, because a ledger entry never changes. createdAt and updatedAt are both the time of at.
const entry = await kvStore.getEntry(AUDIT_STORE, receipt.key);// { key, value, encrypted: false, createdAt, updatedAt,// ledger: { name: 'aiUse', id, shard: '03', seq: 1, at, hash, prevHash, source } }Every write that would change a ledger is refused with LEDGER_IMMUTABLE (HTTP 400): set() and delete() on a ledger key, increment(), any atomic() operation on a ledger key, a ttl, and a clear (purge) whose prefix starts inside a ledger collection. An append to a dropped ledger is refused the same way. On a forked store (see Server Verify), appends and set(), delete(), increment(), atomic() and ttl on a ledger key return 409 LEDGER_FORKED instead. A clear (purge) inside a ledger collection keeps 400 LEDGER_IMMUTABLE. A store that has a ledger in any state except FAILED cannot be rolled back to format 1 (LEDGER_PRESENT). A store can be deleted when every ledger is FAILED or DROPPED, and the archive job has finished each dropped ledger. The job finishes a dropped ledger a few minutes after the drop, when it has archived the last entries. Until then the delete returns 409 LEDGER_PRESENT, and the message names the ledgers that block it. A store delete removes the live table. The archive objects, the digests and the anchors stay in place. Their lock ends with their retention, but they are not removed automatically. While they exist, a new store with the same name cannot be created: the create returns 409 STORE_NAME_RETAINED, so choose another name.
Provenance: Who Wrote an Entry
Section titled “Provenance: Who Wrote an Entry”Every new ledger entry carries source: { type, principal }. Quant sets it from the credential of the caller that made the append. It never comes from your request: an append body with a source field fails with VALIDATION. A field called source inside value is your own data and has no special meaning.
type |
Who | principal |
|---|---|---|
edge |
An edge function, through the Quant edge runtime | <org>/<project>/<function id> (<org>/<project>/content-filter for a content filter). The org is always the org of the store |
dashboard |
A person in the Quant dashboard | The portal user id |
api |
A Quant API token, through the portal API | token:<token id> |
api |
A caller that holds only the platform API secret | api-secret |
platform |
Quant itself | The service name. Shred entries are quant-ledger |
source is part of the entry that is hashed. The stored value is {"type":"plaintext","value":{...},"source":{"type":"...","principal":"..."}}, so the hash chain, the archive, the inclusion proof and the signed digests all cover it. If anyone changes, adds or removes a source after the append, verify and the offline verifier report hash_mismatch at that entry. Entries written before Quant recorded provenance have no source. They verify exactly as before.
You see source on the append receipt, on every read of the entry (ledger.source), in the inclusion proof (source beside entry, read from the proven bytes), in an export line, and in the archive line’s value.
How Quant Decides the Source
Section titled “How Quant Decides the Source”Quant does not believe a writer that only says who it is. A caller must prove the claim with a credential that it alone holds:
- Every caller must first send the platform API secret. A caller that sends nothing more is recorded as
api/api-secret. This is the most conservative label: it says only that the caller held the secret. - The Quant portal and the Quant edge runtime each hold their own actor key. The keys are different from each other and from the API secret. With its key, a proxy names the person, token or function that it acts for, in three headers:
Quant-KV-Actor-Key,Quant-KV-Actor-TypeandQuant-KV-Actor-Principal. - Quant accepts the claim only when the key matches, and only for the types that the key may claim. The portal key may claim
dashboardorapi. The edge runtime key may claimedge, and only for a store of the function’s own org: anedgeprincipal of another org is refused. No key may claimplatformover the API. Only Quant’s own services, inside the platform, writeplatformentries. - A claim that the key does not prove fails with
403 FORBIDDEN, and nothing is written. Quant never lowers a failed claim toapiand writes the entry anyway.
What this means for an auditor:
- A
dashboardorapi/token:<id>entry was made through the Quant portal, which authenticated that user or token. - An
edgeentry was made by the Quant edge runtime for the named edge function of the store’s own org. The runtime sets the principal from its own request context, outside the function’s sandbox, and function code cannot read the runtime’s key. The provenance of edge entries therefore relies on the sandboxed edge runtime. The edge runtime cannot claimdashboard,apiwith a token, orplatform. - An
api/api-secretentry was made by a holder of the platform API secret that did not name an actor. - The principal is an id, not a name. Look up the user, token or function in the portal to identify it.
// An edge function needs no change: the edge runtime names the function for you.const receipt = await kvStore.append(AUDIT_STORE, `ai-use:${agencyId}`, entry, { idempotencyKey: `${requestId}-audit` });console.log(receipt.source); // { type: 'edge', principal: 'acme/gov-ai/0b6f5c1e-...' }An idempotencyKey belongs to the first writer. A retry with the same key from another source fails with VALIDATION, so one caller never receives another caller’s receipt.
Personal Fields and Shredding
Section titled “Personal Fields and Shredding”Ledgers keep records for a long time, and records about people can be subject to a lawful erasure request. A ledger handles this by crypto-shredding: each data subject has their own encryption key, and the fields you list in personalFields are encrypted with that key before the entry is hashed. Destroying the key makes those fields unreadable for good, while every hash, the archive and the digests still verify, because they cover the encrypted form.
When an entry holds a personal field, the server encrypts it with AES-256-GCM under the subject’s key (named by subjectField) and stores { "$enc": "...", "iv": "...", "tag": "..." } in its place. The additional authenticated data is <org>/<store>/<collection>/<id>/<field>, so a ciphertext cannot be moved to another entry or field. The subject keys are encrypted at rest with platform-managed keys and live in a separate table with point-in-time recovery on. Fields that are not listed stay plaintext, so do not put personal data in an unlisted field.
Reads through get(), getEntry(), getMany(), a ledger list, a list by index and getBy() return personal fields decrypted. After a shred, each personal field of that subject reads as { "$shredded": true }. Scan, prefix and pattern lists and the store export never decrypt: they show each personal field as "[PERSONAL]" and list the field names in personal, in the same way that secrets show as "[ENCRYPTED]". To list a ledger collection with values but without decrypting, add personal=sealed to the ledger list (pagination=cursor, collection, includeValues=true): each personal field shows as "[PERSONAL]", the row lists the names in personal, and no subject key is read. A shredded subject then reads "[PERSONAL]" too. personal=decrypted is the default. A nextToken works only with the personal setting it was made with.
Before you shred, you can see what the shred will touch:
POST /applications/{org}/kvstores/{store}/ledgers/{name}/subject-preview{ "subjectId": "u-17" }{ "ledger": "aiUse", "subjectId": "u-17", "subjectRef": "7604cc606d8cc2fdfa20af770a374f89", "found": true, "keyState": "live", "complete": true, "entries": 14, "collections": [ { "collection": "ai-use:agency-42", "entries": 14, "complete": true, "firstAt": "2026-03-02T01:10:00.000Z", "lastAt": "2026-10-15T23:58:00.000Z" } ]}The preview counts the subject’s entries in each collection that the shred will append to, with the time of the first and the last entry, and says whether the subject key is still live or already shredded (then with shreddedAt). It decrypts nothing, returns no field value, writes nothing, and does not count shred entries (an entry with a top-level $event). Read every count as “at least N”. The preview uses eventually consistent reads, so an entry appended in the last second or so can be missing. It also reads every shard of each collection, so one call stops after about 20 seconds: a collection with complete: false was not counted to its end, and the top-level complete is true only when every collection was. subjectRef is left out when no shred has been made in the store yet. An unknown subject returns 404 SUBJECT_NOT_FOUND, as the shred does.
Send the subject id in the body, as above. The older path forms GET …/ledgers/{name}/subjects/{subjectId} and POST …/ledgers/{name}/subjects/{subjectId}/shred still work but are deprecated: they put the subject id in the request path, and request paths appear in Quant’s API access logs. Use a pseudonymous subject id in any case (an opaque id, not a name or an email address).
To shred a subject, call the shred route. It needs the delete_content permission in the dashboard.
POST /applications/{org}/kvstores/{store}/ledgers/{name}/shred{ "subjectId": "u-17", "reason": "Erasure request 2026-117", "authority": "Records authority 2023/00456 class 12", "requestedBy": "Privacy officer"}subjectId is the value of the ledger’s subject field. The other three fields are required strings of 1 to 1,000 characters (not only spaces), and no other field is accepted. The route first appends a shred entry to every collection of the ledger that holds the subject, then destroys the subject key:
{ "$event": "shred", "subjectId": "u-17", "reason": "Erasure request 2026-117", "authority": "Records authority 2023/00456 class 12", "requestedBy": "Privacy officer" }Shred entries are ordinary ledger entries. They are hash-chained, archived and covered by digests, so the erasure itself is on the record. Their source is { "type": "platform", "principal": "quant-ledger" }: the ledger records the erasure, whoever asked for it (requestedBy names the person). They are stored as plaintext even when one of these field names is a personal field, because they are the permanent audit record of the erasure. Do not put personal data in reason, authority or requestedBy. The subjectId is part of the entry, so use an opaque id as the subject field, not a name or an email address.
{ "ledger": "aiUse", "subjectId": "u-17", "entries": [ { "key": "ai-use:agency-42:01M5...-05", "seq": 912, "hash": "...", "...": "..." } ], "shreddedAt": "2026-10-16T03:00:00.000Z", "fullyErasedAfter": "2026-11-20T03:00:00.000Z", "shredId": "01M5104A00ER2CRR3DHK1FVYH0", "subjectRef": "7604cc606d8cc2fdfa20af770a374f89", "receipt": { "json": "{\"authority\":...}", "signature": "MEUCIQ...", "keyId": "<signing key id>", "algorithm": "ECDSA_SHA_256" }}The response carries a receipt signed with the ledger key; check it with verify.mjs verify-signed. receipt.json is the signed text (RFC 8785 JSON) of { v, type: "kv-ledger-shred-receipt", id, org, store, ledger, subjectRef, shreddedAt, fullyErasedAfter, reason, authority, requestedBy, entryCount, entriesSha256, entries: [{ key, shard, seq, hash }] }, plus ledgerForked: true for a shred on a forked store and supersedes for the re-shred that replaces it. The entries are sorted by key, and the collection of each is the text of key before the last colon. entriesSha256 is the SHA-256 of the JCS of the entries. If the receipt would be larger than 300 KB, entries is left out: entryCount and entriesSha256 still bind them, and GET …/ledgers/{name}/shreds/{shredId}/entries returns them in pages (save the pages as a JSON array and pass them with --entries). The receipt names the subject by subjectRef, not by the id, so you can keep it or give it to an auditor. The receipt is signed once and never again: a repeat call and GET /applications/{org}/kvstores/{store}/ledgers/{name}/shreds/{shredId}/receipt return the same receipt. Save the receipt object (or the whole answer of the receipt route) as receipt.json and run node verify.mjs verify-signed --doc receipt.json --public-key key.pem with the key from GET …/ledgers/public-key. RECORDED means that Quant signed that the shred entries of this erasure are on the ledger record; prove each entry with the proof route or with verify. FORKED-NOT-RECORDED is a shred on a forked store, and NOT-RECORDED means that no entry could be appended (a DROPPED ledger). A receipt that is signed correctly but malformed is refused (signature invalid, exit code 4). The receipt can only be signed after the key is destroyed: if signing fails, the call answers 503 (retryable), and the same call again returns the signed receipt. A 202 has no receipt.
One call runs for at most about 20 seconds, so that it finishes inside the API request limit. If the subject is in many collections, the call can stop before it is complete. It then returns 202 with "status": "in_progress" and the entries recorded so far. The subject key may or may not be destroyed at that point, so treat the shred as incomplete. Send the same call again: it continues from where it stopped and does not duplicate entries. Only a 200 response is final. In the dashboard, press Shred again when the message says that the shred is in progress.
The fields read as $shredded from the moment the final call returns, in every region and every running function. A copy of the destroyed key remains in the point-in-time recovery history of the subject-key table for 35 days. fullyErasedAfter is shreddedAt plus 35 days, the time after which no backup of the key exists. Use that date when you report the erasure as complete.
A shred cannot be undone. A few related cases:
- A repeat shred of a shredded subject writes nothing and returns
alreadyShredded: truewith the firstshreddedAt. If an earlier call failed part way, a repeat appends only the shred entries that are missing, so you can send a failed call again without duplicating entries. - An append for the subject in the 60 seconds after a shred fails with
CONTENTION(retryable). After that, a new key is created, so a later record about the same person is readable while the old entries stay shredded. An append that read the old key before the shred and committed after it is sealed with the destroyed key, so its personal fields also read as shredded. - A subject that the ledger holds no personal data for returns
404 SUBJECT_NOT_FOUND. A ledger without personal fields returnsVALIDATION. - On a
DROPPEDledger the key is destroyed, but no shred entry can be appended, soentriesis empty. - On a forked store a shred still destroys the subject key, but records no shred entry (
ledgerForked: true, andentriesis empty). After Quant resolves the fork, send the same shred call again: it appends the missing shred entries. That re-shred is a new erasure record with a newshredId, whosesupersedesnames the forked record. The forked record and its receipt stay as they were signed.
In the dashboard, the Shred a data subject action on the ledger page asks for the subject id twice and requires the reason, the authority and the requester.
The dashboard and the API list past erasure requests: GET /applications/{org}/kvstores/{store}/ledgers/{name}/shreds returns one record per shred, newest first, 50 per page (pass nextCursor back as cursor). Each record has the id (the shredId of the shred response), subjectRef, shreddedAt, fullyErasedAfter, the number of shred entries, and the reason, authority and requestedBy of the request (the same text the shred entries hold), plus ledgerForked: true for a shred made on a forked store and supersedes for the re-shred after the fork. A record never holds the subject id. subjectRef is a keyed hash of the subject id under a key that Quant keeps for each store. Without API access to the store it cannot be worked out from a guessed id. It is not a confidentiality control: anyone who can call the preview for the store can match a guessed id, so treat it as a pseudonym. Use it to match a record with the subjectRef of the shred response that you kept. A repeated shred call adds no second record. An unknown shredId is 404 SHRED_NOT_FOUND. Records of shreds made before the list existed were added by a one-time backfill and have backfilled: true. If the subject key was re-issued after such a shred, the record also has keyReissued: true: its shreddedAt is then the time of the earliest shred entry (the key’s own destruction time is no longer stored), and fullyErasedAfter is computed from it.
{ "ledger": "aiUse", "shreds": [ { "id": "01M5104A00ER2CRR3DHK1FVYH0", "subjectRef": "7604cc606d8cc2fdfa20af770a374f89", "shreddedAt": "2026-10-16T03:00:00.000Z", "fullyErasedAfter": "2026-11-20T03:00:00.000Z", "entries": 2, "reason": "Erasure request 2026-117", "authority": "Records authority 2023/00456 class 12", "requestedBy": "Privacy officer" } ], "nextCursor": "..."}Archive, Digests and Lock Modes
Section titled “Archive, Digests and Lock Modes”Every 5 minutes, a scheduled job copies new entries to archive storage in the platform’s Australian region. Periods are 5-minute UTC windows, and a period is archived from 60 seconds after it closes, so an entry reaches the archive at most about 6 minutes after it is written, plus the run time of the job. The job reads each shard in seq order, checks every hash while it reads, and writes one gzip JSON lines object per shard per period (at most 10,000 entries per object). If a hash does not match, the job stops archiving that shard and raises an alarm for Quant, and the ledger status shows the shard as broken.
A broken shard stays stopped until a Quant operator investigates and clears it. You do not need to do anything. New appends to the ledger still succeed, and verify reports the break until the operator clears it.
Each archive object is written to write-once storage in the ledger’s lock mode, with a retention date of the time it is written plus retentionDays. In plain words:
- Governance mode. Nobody can change or delete an archive copy before its retention date through normal access, including Quant’s own services. The only exception is a break-glass role in Quant’s cloud account. That role has no users. It can be assumed only with multi-factor authentication, through the regional sign-in service, and every use sends an alert to Quant’s operations team. Choose governance when you want strong protection with a documented way to remove data in an emergency, such as a court order.
- Compliance mode. Nobody can change or delete an archive copy before its retention date, including Quant, the break-glass role and the root user of the cloud account. The retention date can be extended, never shortened. Choose compliance when records law or an auditor requires that nobody can remove the record.
A raise of retentionDays extends the existing archive objects of the ledger to their write time plus the new number of days. A raise from governance to compliance applies to every new archive object at once, and the job also asks the storage service to move the existing objects to compliance. The storage service refuses that change on an existing governance object (it would need a governance bypass, which only the break-glass role has). So after a raise, the existing archive objects and digests keep governance mode, with their retention date kept or extended. They are counted in modeRefused and digestModeRefused in the ledger status, raise an alarm for Quant, and are tried again after 24 hours. Every archive object and digest written after the raise is in compliance mode. This was measured on staging on 2026-10-16.
After a period is archived, the job writes one digest for the store and period, signed with a platform-managed key (ECDSA P-256, SHA-256) that is used only for ledgers. A digest lists, for every archive object of the period, the Merkle root and the head hash of the entries in it, together with each ledger’s definition. Each digest also holds the hash of the previous digest, so the digests form their own chain, and removing or changing one breaks every later link. A period with no new entries writes no digest. An entry that arrives for a period that already has a digest gets a supplementary digest (<period>_000001), which also joins the chain.
Digests take the strongest lock of the store: compliance if any ledger of the store is in compliance mode, and the longest retention of the store’s ledgers.
GET /applications/{org}/kvstores/{store}/ledgers/public-key returns the public key that checks the digest signatures, as { keyId, algorithm: "ECDSA_SHA_256", curve: "P-256", publicKeyPem }. Keep a copy of the PEM with your audit records.
GET /applications/{org}/kvstores/{store}/ledgers/{name} returns the ledger status: each instance and shard with headSeq, archivedSeq and archiveLagSeconds, the last digest time, retainUntil, the anchor state, the last verify result (lastVerify, with the id of its signed report, see Server Verify) and forked when the store table no longer matches its archive. Instances are paged 50 at a time with cursor.
Anchoring
Section titled “Anchoring”The archive and the digests protect against changes inside the live table, but they are both held by Quant. An anchor sends evidence of the digest chain, once a day, to a witness outside Quant. The anchor runs on the first archive run after 00:10 UTC for the previous UTC day. It binds the latest digest of the store at that time: the binding file digests/<org>/<store>/anchors/<YYYYMMDD>.anchor.json records the digest’s key and SHA-256 hash, and because every digest holds the hash of the one before it, the binding covers every earlier digest too. A day with no new digest needs no anchor. A failed anchor is retried on the next run, and Quant is alerted when a failure is 24 hours old. The ledger status shows the last anchored day and any error.
RFC 3161 Timestamp
Section titled “RFC 3161 Timestamp”"anchor": { "type": "rfc3161", "tsaUrl": "http://timestamp.digicert.com" }The job asks a timestamp authority (TSA) to sign the SHA-256 hash of the bound digest, and stores the token as digests/<org>/<store>/anchors/<YYYYMMDD>.tsr beside the binding. The token proves that the digest, and so the whole chain before it, existed at that time, signed by a party that Quant does not control. Only TSAs on Quant’s allow list are accepted. The allow list is DigiCert (http://timestamp.digicert.com), because we found no Australian-operated public TSA to add.
DigiCert’s TSA answers over plain HTTP, so the transport gives no integrity. Before a token is stored, the job checks that the signer certificate is a DigiCert timestamping certificate that chains to the pinned DigiCert Trusted Root G4, that the signature over the token verifies, and that the token covers the right hash. The offline verifier repeats these checks. Neither checks certificate revocation.
Your Own Bucket
Section titled “Your Own Bucket”"anchor": { "type": "customer-bucket", "bucketArn": "<your bucket identifier>", "prefix": "quant/", "accountId": "<the account that owns the bucket>"}The job copies every new digest, its .sig file and the day’s binding into a bucket in your own cloud storage account, under <prefix><org>/<store>/:
<prefix><org>/<store>/<period>.json<prefix><org>/<store>/<period>.json.sig<prefix><org>/<store>/anchors/<YYYYMMDD>.anchor.jsonbucketArn (the identifier of your bucket) and accountId (the account that owns it) are required. prefix is optional. It can be empty, which is the default, and it can hold up to 256 letters, digits and the characters ! _ . * ' ( ) / -.
Each object is written so that your account owns it. Quant never reads, lists or deletes anything in your bucket. Every write names accountId as the expected owner, and the write is refused if the bucket belongs to any other account, for example if the bucket name was deleted and taken by someone else.
Your bucket policy must allow Quant’s ledger role to write objects on your organisation’s path only. Do not grant the whole bucket or the whole prefix: Quant writes for many organisations with the same role, and a grant limited to your organisation’s path is what stops another tenant’s ledger from writing into your bucket. The ledger page shows the exact policy for your anchor with a Copy button (also anchor.bucketPolicy in the ledger status). Add it as shown.
Turn on versioning on the bucket, and write-once retention if your storage supports it. The policy lets Quant write but not delete, and a later write to the same key would replace an object in a bucket without versioning, so versioning keeps every original copy.
What Quant Can Change After an Anchor
Section titled “What Quant Can Change After an Anchor”After a day is anchored, Quant cannot change any entry, archive object or digest that the anchor covers without the change showing. A changed digest no longer matches the hash in the timestamp token or the copy in your bucket, and a changed entry no longer matches its digest. Quant could still stop writing new digests or new anchors, or refuse new appends, and you would notice that as anchors that stop arriving. The newest digests, written after the last anchor, are not yet witnessed until the next daily anchor.
Proofs and Verification
Section titled “Proofs and Verification”Three tools prove what a ledger holds, at different costs.
Receipts
Section titled “Receipts”The receipt from append() holds the entry’s hash and prevHash. Compare it with a later read, or give a file of receipts to the offline verifier.
Inclusion Proof
Section titled “Inclusion Proof”GET /applications/{org}/kvstores/{store}/ledgers/{name}/proof?key=<key> proves that one entry is in the signed archive without the rest of the chain. Until the entry is archived and its digest exists, the answer is { "status": "pending" }. After that it is:
{ "status": "archived", "entry": { "key": "...", "collection": "...", "shard": "03", "id": "...", "seq": 1, "at": 1792108800000, "value": "<stored string>", "prevHash": "...", "hash": "..." }, "hash": "13af...7bad", "period": "20261016T0300Z", "archiveKey": "ledgers/acme/audit/aiUse/.../03/20261016T0300Z-000000000001-000000000005.jsonl.gz", "leafIndex": 0, "treeSize": 5, "merkleRoot": "7a31...22ed", "auditPath": ["4cf7...9bb5", "d2e5...8575", "2826...fa98"], "digest": { "v": 1, "...": "..." }, "digestKey": "digests/acme/audit/20261016T0300Z.json", "signature": "<base64 DER>", "keyId": "<signing key id>", "digestSignedAt": "2026-10-16T03:06:12.000Z", "anchor": { "type": "rfc3161", "day": "20261016", "bindingKey": "digests/acme/audit/anchors/20261016.anchor.json", "tokenKey": "digests/acme/audit/anchors/20261016.tsr" }}digestSignedAt is when Quant signed the digest (its signedAt). anchor is the daily anchor that covers the digest: the earliest binding whose bound digest is this digest or a later one (the digest chain links each earlier digest to it). tokenKey is present when an RFC 3161 token exists for that day, and type is the ledger’s own anchor type when the binding lists it. Until the next daily anchor runs, or when the store has no anchor, the proof has no anchor. If the anchor lookup fails (a binding that cannot be read, is missing or names this digest with another hash, or a storage error), the proof is still returned, without anchor and with anchorUnavailable: true, and Quant is alerted. The proof does not check the token or the chain from this digest to the bound one: download the archive and run the offline verifier with --anchors (see Verify Offline).
To check a proof yourself:
- Recompute the entry hash from the
entryfields (see Byte Layout) and compare it withhash. - Walk
auditPathfrom the leafhashatleafIndexin a tree oftreeSizeleaves (RFC 9162) and compare the result withmerkleRoot. - Find the instance in
digest.ledgers[].instances[]whosearchiveKeyequals the proof’sarchiveKey, and check that itsmerkleRootmatches. - Serialise
digestwith JCS (RFC 8785), and verifysignatureover the SHA-256 of those bytes with the public key.
The entry is returned in its stored form, so personal fields stay encrypted and the proof needs no subject key. When the entry records its writer, the response also has source, read from entry.value, the bytes that the proof covers. If the live entry differs from its archived copy, the route returns UNAVAILABLE (not retryable) and no proof, and you should run a verify. The dashboard’s Proof by key shows the result and has Download proof (JSON).
Server Verify
Section titled “Server Verify”POST /applications/{org}/kvstores/{store}/ledgers/{name}/verify recomputes the chains from the live table and checks them against the archive objects and the signed digests.
{ "collection": "ai-use:agency-42", "since": "2026-10-01T00:00:00Z", "until": "2026-10-16T00:00:00Z" }Every field is optional: collection (one concrete collection, otherwise every collection), since (inclusive), until (exclusive), cursor, maxEntries (1 to 10,000) and trigger (api, the default, or dashboard; it is recorded, and is not part of the cursor’s range). trigger is what the caller says; the server does not prove it. The answer is:
{ "ok": false, "entries": 812, "shards": [ { "collection": "ai-use:agency-42", "shard": "00", "fromSeq": 1, "toSeq": 406, "ok": true }, { "collection": "ai-use:agency-42", "shard": "01", "fromSeq": 1, "toSeq": 406, "ok": false, "firstBrokenSeq": 97, "reason": "hash_mismatch" } ], "cursor": "...", "verificationId": "01M52R6QW07CCAW8KHYZM9210J"}| Reason | Meaning |
|---|---|
hash_mismatch |
A recomputed entry hash, or the link to the previous entry, differs from the stored value |
missing_entry |
A sequence number is missing, an archived entry is not in the live table, an archive object is missing (a gap between objects, also when the table still names the removed object), or a digest lists an object that the archive no longer holds |
archive_mismatch |
An archived entry differs from the live entry, a live entry up to the archive point has no archive object, the table’s archive records disagree with the archive bucket, two archive objects hold the same sequence number (a fork), or the archive holds a name that the archive job never writes |
digest_mismatch |
The Merkle root or head hash of an archive object differs from its digest, or no digest lists it. The result adds objectFromSeq and objectToSeq, and the whole object range is untrusted |
bad_signature |
A digest signature, the digest chain or a definition hash is wrong |
Server verify reads the archive side from the archive bucket and the signed digests, not from the store table, and gives the same result as the offline verifier for the same archive. So a change that rewrites the live table and also removes its archive bookkeeping still fails against the locked objects. The shards come from the table, the bucket and the digests:
- A fork is two archive objects that hold the same sequence number. In listing order, the later object is reported:
archive_mismatchat the first sequence number they share, with itsobjectFromSeqandobjectToSeq. - A gap is a sequence number that no object holds, below the highest archived one. It is
missing_entryat its first sequence number, also when the table still names the removed object. - Each object that a digest lists must still be in the bucket. A missing one is
missing_entryat its first sequence number. So a shard whose objects and table rows were all removed fails withmissing_entryat 1. - A name under the ledger’s archive prefix that the archive job never writes is
archive_mismatchat 1. Inside a shard folder it fails that shard. Elsewhere it is a result of its own, withshard''for a folder that is not a collection, else the bad folder or key name. - A shard that has archive objects but is no longer listed in the table is
archive_mismatchat 1, ormissing_entryat 1 when its entries were deleted too.
Every listing shares the call’s time and object limits and continues from the cursor. If the archive folders cannot be listed within the time limit before any shard is checked, the call fails with 503. The digests are read only by a full verify (no since and no until), after the shards.
One call is bounded: it checks at most 10,000 entries and 200 archive objects, and stops after about 22 seconds so that it finishes inside the API request limit. When it stops early, the answer has a cursor. Send the same collection, since and until with the cursor to continue. The cursor is signed, bound to that range, and valid for 24 hours. ok covers the shards of this call only, so follow the cursor to the end before you treat a range as verified.
The range matters. Without since, each shard is checked from seq 1 against its genesis hash. With since, the first entry in the range is taken as the starting point, so a since range cannot detect the deletion or rewriting of entries before its first entry. A since or until range also does not detect a fork under an earlier period label, a gap before its first archive object, or a digest-listed object that is missing. A full verify detects all of these, and the scheduled daily verify is a full verify. Without until, the last entry of each shard must equal its chain head, and no archived entry may follow it. Use a full verify, or the offline verifier, for an audit.
The server verify does not check anchors. Check the timestamp tokens and your bucket copies with the offline verifier (--anchors and --anchor-copies, see Verify Offline). The ledger status shows only whether the anchor writes succeed.
An archive object whose digest is not written yet counts as pending, not as a failure, until 1 hour after its period ends. The dashboard’s Verify section runs the same check over a UTC range and has Continue for the next page.
Quant also runs a full server verify of every ACTIVE and DROPPED ledger once a day, following the cursor to the end, and checks that every archive object each new digest lists exists under its own shard and that the table still lists that shard. Only one verify of a ledger runs at a time. A failure alerts Quant operations. The ledger status shows the last result as lastVerify:
{ "lastVerify": { "at": "2026-10-16T16:20:00.000Z", "startedAt": "2026-10-16T16:17:00.000Z", "durationMs": 180000, "ok": false, "entries": 15, "shardsFailed": 1, "complete": true, "trigger": "scheduled", "scope": {}, "firstFailure": { "collection": "ai-use:agency-42", "shard": "01", "seq": 3, "reason": "archive_mismatch" } }}ok is true only when the run reached the end of the ledger (complete: true) and every shard passed. shardsFailed counts each failing shard once.
The daily verify is rolling. One day’s run checks roughly 680,000 archive objects. A ledger accumulates up to 288 objects per shard per day that it is written, and keeps them for retentionDays, so a ledger with more than roughly shards × 288 × retentionDays = 680,000 objects needs more than one day. For example, 64 busy shards with 365 days of retention hold about 6.7 million objects, and one full run takes about 10 days. When a day’s run ends before the end of the ledger, it pauses, and the next daily run continues from where it stopped. While a run is in progress, the status shows verifyProgress (startedAt, updatedAt, entries, shardsFailed, checkedThrough). A failure found on the way is recorded in lastVerify straight away, with complete: false and ok: false. Quant is alerted if a run stops making progress.
A result is replaced only by a newer one: a success replaces a result only when it is complete, covers the whole ledger and did not start earlier, and a failure is never replaced by a success that started earlier. A failure replaces any result without failures, but never a failure that started later. A result for a collection, since or until range without failures becomes lastVerify only when the ledger has no result yet. A paged verify becomes lastVerify only when its last page is checked.
Verification Records and Signed Reports
Section titled “Verification Records and Signed Reports”Each verify is recorded, and so is each scheduled run (each failure found on the way, and its end). A record is kept for 400 days, and its id is the verificationId of the answer and the id of lastVerify. The ledger status shows the newest record as lastVerify, and GET …/verifications/{id}/report returns that result signed with the ledger key: check it offline with verify.mjs verify-signed --doc report.json --public-key key.pem.
GET /applications/{org}/kvstores/{store}/ledgers/{name}/verifications/{verificationId}/report{ "report": { "v": 1, "type": "kv-ledger-verification-report", "org": "acme", "store": "audit", "ledger": "aiUse", "id": "01M52R6QW07CCAW8KHYZM9210J", "at": "2026-10-16T16:20:00.000Z", "startedAt": "2026-10-16T16:17:00.000Z", "durationMs": 180000, "trigger": "api", "scope": { "collection": "ai-use:agency-42" }, "pages": 1, "complete": true, "ok": false, "entries": 812, "shardsFailed": 1, "firstFailure": { "collection": "ai-use:agency-42", "shard": "01", "seq": 97, "reason": "hash_mismatch" }, "failures": [ { "collection": "ai-use:agency-42", "shard": "01", "fromSeq": 1, "toSeq": 406, "ok": false, "firstBrokenSeq": 97, "reason": "hash_mismatch" } ] }, "json": "{\"at\":\"2026-10-16T16:20:00.000Z\",...}", "signature": "MEUCIQ...", "keyId": "<signing key id>", "algorithm": "ECDSA_SHA_256"}json is the canonical (RFC 8785) form of report, and signature is the base64 DER ECDSA P-256 signature over the SHA-256 of its bytes, made with the same key and scheme as the digests. Trust the signed json; the verifier also checks that report is the same. A record holds counts, collection and shard names, sequence numbers and reason codes. It holds no values and no personal data, so do not put personal data in collection names. failures lists at most 200 failing shards; failuresTruncated: true says that more failed (see shardsFailed).
A paged verify is one run with one record. The call without a cursor starts the run, the cursor carries the run’s id and page number, and every page answers the same verificationId. Each page adds its entries and failing shards to the record (pages counts the calls). The record is complete when the page that has no next cursor is checked; until then a report of it is complete: false. startedAt and trigger come from the first page. A repeated cursor is a safe retry, for example after a lost answer: the page is checked again and answered with the same verificationId and the cursor of the position the record holds (not where the retry stopped), so the walk never has a gap. The retried page is not counted again, but a failing shard it finds that the record lacks is added to the record: a failure is never dropped. If the run is already complete, that failure becomes a record of its own (retryOf names the run). The entries count is approximate when a walk is retried, because a retry may stop at another point than the page it repeats. A page that finds a failure updates lastVerify at once, as the daily run does. A cursor whose page is ahead of its run’s record, or whose run has no record, is 400 CURSOR_INVALID. An abandoned run stays an incomplete record. A cursor issued before runs existed gives a record of its own with resumed: true, which is never complete. ok is true only when complete is true and no shard failed, so a report with complete: false never says that the ledger is intact. A complete record is signed once: every later request returns the same signed copy. trigger is scheduled for Quant’s daily run; api and dashboard are what the caller said. verify-signed prints signature valid, then INTACT (complete and ok, exit code 0), FAILED (a shard failed, exit code 1) or INCOMPLETE (part of the scope only, exit code 3), then the signed json. An invalid signature prints signature invalid (exit code 4); exit code 2 means the input was bad. An unknown id, or a record older than 400 days, is 404 VERIFICATION_NOT_FOUND.
If the status shows forked (reason, at, detail, and tableCreatedAt, objectKey or digestKey), the store table no longer matches its archive, for example after a restore. Appends are refused with LEDGER_FORKED and nothing is archived or digested until Quant resolves it. Entries appended in the minutes before Quant detected the fork stay readable but are not archived. Reads, lists, verify, proofs, archive downloads and export keep working, so you can still check and copy out every entry. Contact Quant support.
Archive Download
Section titled “Archive Download”GET /applications/{org}/kvstores/{store}/ledgers/{name}/archive returns presigned download URLs for the ledger’s archive objects, 100 per page, and on the first page the store’s digests, signatures, timestamp tokens and anchor bindings. Each URL is valid for up to one hour, so download the files straight away and request the next page with cursor (the nextCursor of the previous page). If there are more than 2,000 digest files, digestsTruncated is true, and you continue the digest list with digestCursor. Send cursor or digestCursor in one request, not both. Keep the file paths when you save the files, because the offline verifier reads them.
Verify Offline
Section titled “Verify Offline”The open-source verifier in tools/kv-ledger-verify checks a downloaded archive with no Quant service involved. It needs Node.js 20 or later and uses only the standard library, so an auditor can read every line of it before running it.
- Download every URL of the archive route into one directory, keeping the paths (
ledgers/...anddigests/...). - Save
publicKeyPemfrom the public-key route tokey.pem. - Optionally, save your receipts as JSON lines (
key,seq,hash,prevHash), and download your bucket’s anchor prefix (<prefix><org>/<store>/) if you use a customer-bucket anchor. - Run the verifier for one store:
node verify.mjs --dir ./archive --store acme/audit --public-key ./key.pem \ --receipts ./receipts.jsonl \ --anchors ./archive/digests/acme/audit/anchors \ --anchor-copies ./witness/quant/acme/auditThe output ends with a summary of who wrote the entries: the number of entries for each source (type and principal), and the entries written before Quant recorded a source. Only entries whose hash verifies are counted, so a forged source never appears in it. Add --list-entries to print every entry with its collection, shard, seq, key, hash and source. With --json, the result has sources and, with --list-entries, entryList.
Exit code 0 means everything verified, 1 means a check failed, 2 means the input was bad (a missing option, an unreadable file or an --as-of that is not an ISO time), and 3 means incomplete: nothing failed, but some objects are pending. Pending is not a pass. For a final audit, use --strict, which turns pending objects into a failure (exit code 1). A renamed object whose period label is in the future, or earlier than the object before it, is archive_mismatch, never pending. Add --json for the full result, which has the same shard shape and reasons as the server verify. Offline, archive_mismatch means a fork (two archive objects that hold the same sequence number) or a name that the archive job never writes. The other archive_mismatch cases need the live table, so they do not occur offline.
An object written in the last hour may have no digest yet. The verifier lists it as pending, not as a failure, until 1 hour after its 5-minute period ends. Use --strict to treat pending objects as failures, and --as-of to verify as of a past time. The human output prints pending (digest not written yet): N objects, and --json lists them in pending.
The verifier recomputes every entry hash from the genesis hash, checks sequence continuity and links across all objects of each shard, checks each object’s Merkle root and head hash against its digest, and checks each digest’s signature, the digest chain and the definition hashes. With --anchors, every RFC 3161 token must verify fully: the CMS signature, a signer certificate that is a DigiCert timestamping certificate, and a chain to the pinned DigiCert Trusted Root G4, with certificate validity checked at the time in the token, so an old archive still verifies after the certificates expire. The token must cover the hash of the digest its binding names. With --anchor-copies, every copy in your bucket must be byte-identical to the archive, and a copy that the archive lacks is a failure, because it shows a digest that was removed from the archive. A customer-bucket binding that you cannot back with your own copy does not count as external evidence.
Without receipts or anchors, an offline check cannot detect the removal of the newest digests together with their objects, because the archive then looks complete up to an earlier period. Keep receipts, or use an anchor.
--tsa-root <pem> replaces the pinned DigiCert root with another root certificate. It exists only for the verifier’s own tests with a test TSA. Do not use it to check a real archive, because a token from any TSA under that root would then pass.
For a second, independent check of a timestamp token, use OpenSSL with the DigiCert certificate chain:
openssl ts -verify -in 20261015.tsr \ -data digests/acme/audit/<digest named in 20261015.anchor.json> \ -CAfile digicert-tsa-chain.pemByte Layout (Version 1)
Section titled “Byte Layout (Version 1)”This section is enough to write a verifier in any language. All strings are UTF-8, all hashes are lowercase hex SHA-256, and integers are decimal with no padding. \n is a single line feed (0x0A).
Definition Hash
Section titled “Definition Hash”At declaration, the definition { v: 1, name, collection, chains, retentionDays, lockMode, personalFields, subjectField?, anchor, createdAt } with the declared values is serialised with JCS (RFC 8785). definitionHash is the SHA-256 of those bytes. It never changes, and new shards from a raise use the same value.
Genesis Hash
Section titled “Genesis Hash”The genesis hash is the prevHash of seq 1 on a shard:
SHA-256( "qkv-ledger-v1 genesis\n" + org + "/" + store + "/" + collection + "#" + shard + "\n" + definitionHash )Entry Hash
Section titled “Entry Hash”valueHash = SHA-256( value ) // the exact stored value stringhash = SHA-256( "qkv-ledger-v1\n" + prevHash + "\n" + org + "/" + store + "/" + collection + "#" + shard + "\n" + seq + "\n" + id + "\n" + at + "\n" + valueHash )collection is the concrete collection, and shard is the two-digit shard (00 to 63). The stored value string is {"type":"plaintext","value":<the entry object>,"source":{"type":<type>,"principal":<principal>}} as written (JSON.stringify order; an entry written before provenance has no source field), with personal fields as {"$enc","iv","tag"} envelopes (base64 AES-256-GCM ciphertext, 12-byte IV, 16-byte tag).
Merkle Tree
Section titled “Merkle Tree”The Merkle tree (RFC 6962) is built over the entry hashes of one archive object in seq order. A leaf is SHA-256(0x00 ‖ h) and a node is SHA-256(0x01 ‖ left ‖ right), where h is the 32 raw bytes of the entry hash (hex-decoded), not the hex text. The split point is the largest power of two less than the number of leaves.
Archive Objects
Section titled “Archive Objects”The key is ledgers/<org>/<store>/<ledger>/<collection base64url>/<shard>/<period>-<fromSeq>-<toSeq>.jsonl.gz, where period is the period start as YYYYMMDDTHHmmZ and both seq numbers are padded to 12 digits. The object is gzip JSON lines, one entry per line, with only the hashed fields:
{"key":"ai-use:agency-42:01M5104A000000000000000000-03","collection":"ai-use:agency-42","shard":"03","id":"01M5104A000000000000000000-03","seq":1,"at":1792108800000,"value":"{\"type\":\"plaintext\",\"value\":{...}}","prevHash":"90e1...","hash":"13af..."}Digest
Section titled “Digest”The key is digests/<org>/<store>/<name>.json, where name is the period label, or <period>_<n> with a six-digit n for a supplementary digest. The file bytes are the JCS serialisation of:
{ "v": 1, "org": "acme", "store": "audit", "period": { "start": "2026-10-16T03:00:00.000Z", "end": "2026-10-16T03:05:00.000Z" }, "prevDigestHash": "<SHA-256 of the previous digest file's bytes>", "ledgers": [ { "name": "aiUse", "definitionHash": "c4a5...14b3", "definition": { "v": 1, "name": "aiUse", "...": "..." }, "instances": [ { "collection": "ai-use:agency-42", "shard": "03", "fromSeq": 1, "toSeq": 5, "headHash": "3791...86af", "merkleRoot": "7a31...22ed", "archiveKey": "ledgers/..." } ] } ], "signedAt": "2026-10-16T03:06:12.000Z"}Digests form one chain in name order. The first digest of a store has prevDigestHash = SHA-256("qkv-digest-v1 genesis\n" + org + "/" + store). headHash is the hash of the entry at toSeq.
Signature
Section titled “Signature”<digest>.json.sig is JSON:
{ "v": 1, "keyId": "<signing key id>", "algorithm": "ECDSA_SHA_256", "signature": "<base64 DER>" }The signature is ECDSA P-256 over the SHA-256 of the digest file bytes, checked with the public key from the public-key route.
Anchor Binding
Section titled “Anchor Binding”digests/<org>/<store>/anchors/<YYYYMMDD>.anchor.json is the JCS serialisation of { "anchoredAt", "digestHash", "digestKey", "types", "v": 1 }, where types is the sorted list of anchor types of the store’s ledgers (customer-bucket, rfc3161). An RFC 3161 token’s message imprint is the SHA-256 of the bound digest’s bytes, which equals digestHash.
Test Vectors
Section titled “Test Vectors”The full set of test vectors, with every intermediate value, is published at /kv/ledger-vectors.json. It uses org acme, store audit, collection ai-use:agency-42, shard 03 and a five-entry chain. The key values are:
| Value | Hex |
|---|---|
definitionHash |
c4a505c8f93d5d6da23dc30d3a9c4274cd7f3a9135f756b5a3317c50109514b3 |
genesisPrevHash |
90e1d5e16e174ad7ad87dbf0409a6b7f558866a2da8fcc8c55eb57b4b6d571db |
Entry 1 hash |
13af6943007a581b06e21c07201d8619cda0936ffed496ca8801509666867bad |
Merkle root of the 5 entries |
7a315c92bfbec26ff29ea7df42393a2f3c4be51229c571b9c0464b6604b822ed |
sourceChain in the same file is a four-entry chain on collection ai-use:agency-43, shard 00, for provenance: entry 1 has no source, entries 2 to 4 have dashboard, edge and platform sources. Its Merkle root is c5399fc84db6d89f986b9e924b33b61b9b77ee56b563a055ff0e3bfb540b8de3.
How This Compares to a Blockchain
Section titled “How This Compares to a Blockchain”A blockchain spreads trust across many independent parties, which must agree on every record. A ledger keeps one accountable operator, Quant, and adds layered checks instead: a hash chain, a locked archive, signed digests, external anchors and offline verification. The table shows which check covers which risk.
| If someone… | What detects or stops it |
|---|---|
| Changes an entry in the live table | The hash chain. Verify fails from that entry on, and the entry no longer matches your receipt |
| Rewrites the live table and the chain together | The locked archive copy and the signed digests disagree with the live table |
| Rewrites the live table, the archive and the digests (only Quant could) | In compliance mode, the storage refuses any change to the archive, even from the root user of the cloud account. Your receipts and any digests you hold disagree |
| Rewrites everything and signs new digests with Quant’s key | The anchor: the timestamp token or the copy in your bucket holds the hash of the original digest chain |
| Needs to prove one entry to an auditor | An inclusion proof against a signed digest, with no need for the full chain |
These are the trust assumptions that remain, stated plainly:
- Without an anchor, you trust Quant not to rewrite the archive and re-sign the digests, because the signing key and the archive are both in Quant’s cloud account. Compliance mode makes the archive rewrite impossible during retention, and an anchor makes a rewrite of the digests detectable by a party outside Quant.
- In governance mode, Quant’s break-glass role can remove archive copies before their retention ends. Every use needs multi-factor authentication and alerts Quant’s operations team, but it remains possible, so choose compliance where that matters.
- A ledger detects changes after the fact. It cannot force Quant to accept an append, and it cannot prove that an entry you never received a receipt for was submitted. Keep receipts for records that matter.
- Entries from the last day are witnessed only when the next daily anchor runs.
- The
sourceof an entry is what Quant’s portal, edge runtime or service proved to the ledger with its own key. The hash makes it tamper-evident after the append; it cannot be stronger than the proxy that authenticated the writer. - Order across shards comes from Quant’s server clock. Within a shard the order is strict and covered by the hashes.
- The RFC 3161 check trusts DigiCert as the timestamp authority, and does not check certificate revocation.
The offline verifier lets you check every one of these properties from downloaded files, with code you can read, and without calling Quant.
Ledger Errors
Section titled “Ledger Errors”Ledger errors use the same KVError shape as other format 2 errors (code, retryable, status).
| Code | HTTP | Retry | Meaning |
|---|---|---|---|
LEDGER_IMMUTABLE |
400 | No | A write that would change a ledger: set(), delete(), increment(), atomic(), ttl or a clear inside a ledger collection, or an append to a dropped ledger |
LEDGER_COLLECTION_NOT_EMPTY |
409 | No | The declared collection holds keys (reason: "not_empty"), or a pattern check could not finish (reason: "check_incomplete") |
LEDGER_INDEX_KIND |
400 | No | A unique or count index on a ledger collection |
LEDGER_SUBJECT_REQUIRED |
400 | No | Personal fields without subjectField at declaration, or an entry with a personal field and no subject field value |
LEDGER_RETAINED |
409 | No | A drop before the retention ends (retainUntil), while entries are unarchived (reason: "unarchived", with collection and shard), or before the retention date is recorded (reason: "retention_unknown") |
LEDGER_PRESENT |
409 | No | A format 1 rollback of a store that has a ledger, or a store delete while a ledger is PENDING or ACTIVE, or DROPPED but not yet finished by the archive job. The message names the blocking ledgers |
STORE_NAME_RETAINED |
409 | No | A store create with the name of a deleted store whose ledger archive still exists. Choose another name |
LEDGER_DIRECTION |
400 | No | A raise that would lower chains or retentionDays, or change compliance to governance |
LEDGER_NOT_READY |
409 | Yes | The ledger is PENDING. The body has activeAfter |
LEDGER_FORKED |
409 | No | The store table no longer matches its ledger archive, for example after a restore. Appends and writes to ledger collections are refused until Quant resolves it; contact Quant support. The body carries reason |
LEDGER_NOT_FOUND |
404 | No | No ledger has that name, or no ledger covers the collection of an append |
SUBJECT_NOT_FOUND |
404 | No | The ledger holds no personal data for that subject |
VERIFICATION_NOT_FOUND |
404 | No | No verification record with that id for the ledger, or it is older than 400 days |
SHRED_NOT_FOUND |
404 | No | No erasure record with that shredId for the ledger (receipt and entries routes) |
FORBIDDEN |
403 | No | An append whose actor claim its key does not prove: actor headers without Quant-KV-Actor-Key, a key that is not an actor key, or a type that the key may not claim. Nothing is written |
CONTENTION |
409 | Yes | Concurrent appends took the chain head on every attempt, the subject was shredded less than 60 seconds ago, or the definition changed during a declare or raise. Nothing was written |
INDEX_FIELD_ENCRYPTED |
400 | No | An index field or sortBy is a personal field |
REQUIRES_FORMAT_2 |
400 | No | A ledger on a format 1 store |
LIMIT_EXCEEDED |
400 | No | A stored value over 350,000 bytes (the source counts), or more than 20 ledgers in a store |
VALIDATION |
400 | No | A bad definition, a reserved field, a source field in the append body, an actor type or principal that is not well formed, an idempotencyKey reused by another source, a non-object value, a bad idempotencyKey or an option on append() other than idempotencyKey, an idempotencyKey reused for a different entry, an idempotencyKey used before its subject was erased (“This idempotency key was used before the subject was erased; use a new key.”), a list option that a ledger list does not take, a verify trigger other than api or dashboard, a malformed verificationId, or a bad shred body |
CURSOR_INVALID |
400 | No | A verify, status or archive cursor that is altered, expired or from another range |
UNAVAILABLE |
503 | Check retryable |
The service did not answer. For append() with an unknown outcome, retryable is true with an idempotencyKey (retry with the same key) and false without one (check for the entry first) |
Limits and Cost
Section titled “Limits and Cost”A ledger collection (the declared pattern and every concrete collection) is at most 512 bytes in UTF-8. Quant derives several internal keys from the collection name, and at 512 bytes the longest of them stays well below the database key limits.
Each shard of each ledger instance has its own database partition, so the write rate of a ledger grows with chains. An append retries up to 3 times on a newly chosen shard when another append takes its chain head, and then fails with CONTENTION. When the database throttles, the append backs off and tries again, on another shard where it can, for up to about 8 seconds.
Measured on staging (2026-10-16): one shard sustained about 55 appends per second (p50 about 150 ms, p95 0.75–1 s), whatever the client concurrency. A ledger with chains: 8 sustained 217–286 appends per second, which is 4–5 times one shard, not 8. Size chains at about 30–50 appends per second per shard: for example, chains: 8 for a steady 300 appends per second.
Usage that a ledger adds:
| Item | Usage |
|---|---|
| Append (an entry up to 1 KB) | 4 write units. The first entry on a shard adds 2 write units. Each append also makes one strongly consistent read of the chain head |
| Each plain index on the collection | +1 write unit per append |
| Personal fields | One key decrypt only when the subject key is not in the 5-minute key cache; a new subject also needs one new key. An append with a personal field also writes the subject listing (1 write unit) and reads the subject key (1 read unit) |
| Digest signature | One per store per 5-minute period with new entries, at most 288 a day |
| Archive storage | The compressed archive, for the whole retention period |
| Archive writes | One per shard per period with new entries |
A ledger list reads every shard of the instance. The list of ledgers (GET …/ledgers) reads two small items per ledger in one batch, at most 40 read units for 20 ledgers, and never reads entries or shards.
Ledgers in the Dashboard
Section titled “Ledgers in the Dashboard”The Ledgers tab of a store page lists the store’s ledgers with their collection, state, chains, retention and lock mode.
- Declare a ledger opens a form with the fields of the declaration, including the anchor (none, your own bucket or an RFC 3161 timestamp). Before the ledger is declared, a review step lists the one-way rules in plain words: entries can never change, chains and retention can only go up, and what the chosen lock mode means. The ledger shows
PENDINGand the tab refreshes until it becomesACTIVE. - Raise offers only the allowed directions for an
ACTIVEledger. - Drop explains what happens for the ledger’s state and shows the retention date if the API refuses.
- Details opens the ledger page. It shows each instance and shard with its head and archived sequence and the archive lag, the last digest time, the retention date and the anchor state. For a customer-bucket anchor it shows the bucket policy to add to your bucket, with a Copy button (see Your Own Bucket). It also has Download public key (PEM), Verify over a UTC range, Proof by key with Download proof (JSON), and, for users with the
delete_contentpermission, Shred a data subject.
Archive downloads for the offline verifier are available through the API only.
