Skip to content

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.

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.

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.

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.

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:

  1. 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.
  2. 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-Type and Quant-KV-Actor-Principal.
  3. Quant accepts the claim only when the key matches, and only for the types that the key may claim. The portal key may claim dashboard or api. The edge runtime key may claim edge, and only for a store of the function’s own org: an edge principal of another org is refused. No key may claim platform over the API. Only Quant’s own services, inside the platform, write platform entries.
  4. A claim that the key does not prove fails with 403 FORBIDDEN, and nothing is written. Quant never lowers a failed claim to api and writes the entry anyway.

What this means for an auditor:

  • A dashboard or api / token:<id> entry was made through the Quant portal, which authenticated that user or token.
  • An edge entry 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 claim dashboard, api with a token, or platform.
  • An api / api-secret entry 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.

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: true with the first shreddedAt. 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 returns VALIDATION.
  • On a DROPPED ledger the key is destroyed, but no shred entry can be appended, so entries is empty.
  • On a forked store a shred still destroys the subject key, but records no shred entry (ledgerForked: true, and entries is 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 new shredId, whose supersedes names 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": "..."
}

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.

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.

"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.

"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.json

bucketArn (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.

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.

Three tools prove what a ledger holds, at different costs.

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.

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:

  1. Recompute the entry hash from the entry fields (see Byte Layout) and compare it with hash.
  2. Walk auditPath from the leaf hash at leafIndex in a tree of treeSize leaves (RFC 9162) and compare the result with merkleRoot.
  3. Find the instance in digest.ledgers[].instances[] whose archiveKey equals the proof’s archiveKey, and check that its merkleRoot matches.
  4. Serialise digest with JCS (RFC 8785), and verify signature over 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).

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_mismatch at the first sequence number they share, with its objectFromSeq and objectToSeq.
  • A gap is a sequence number that no object holds, below the highest archived one. It is missing_entry at 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_entry at its first sequence number. So a shard whose objects and table rows were all removed fails with missing_entry at 1.
  • A name under the ledger’s archive prefix that the archive job never writes is archive_mismatch at 1. Inside a shard folder it fails that shard. Elsewhere it is a result of its own, with shard '' 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_mismatch at 1, or missing_entry at 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.

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.

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.

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.

  1. Download every URL of the archive route into one directory, keeping the paths (ledgers/... and digests/...).
  2. Save publicKeyPem from the public-key route to key.pem.
  3. 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.
  4. Run the verifier for one store:
Terminal window
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/audit

The 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:

Terminal window
openssl ts -verify -in 20261015.tsr \
-data digests/acme/audit/<digest named in 20261015.anchor.json> \
-CAfile digicert-tsa-chain.pem

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).

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.

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 )
valueHash = SHA-256( value ) // the exact stored value string
hash = 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).

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.

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..."}

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.

<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.

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.

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.

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 source of 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 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)

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.

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 PENDING and the tab refreshes until it becomes ACTIVE.
  • Raise offers only the allowed directions for an ACTIVE ledger.
  • 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_content permission, Shred a data subject.

Archive downloads for the offline verifier are available through the API only.