Skip to main content
This page is for a developer who wants to read a bundle and understand what each value in it commits to. After reading it you will know how a field value becomes a leaf and how leaves become the root. You will also know which parts of that chain a proof covers and which parts a signature covers. The formulas below use for byte concatenation. Rescue is Rescue-Prime 64/256 over the Goldilocks field, the one hash used everywhere in the tool. Every call is prefixed with a distinct 32-byte domain tag; Security model lists all ten.

The batch date

The tool measures time in epochDays: whole days since 1970-01-01, UTC. A batch has one as-of date, chosen by security.epochDaysSource in config.json and recorded in every manifest as epochDays, asOfDate and epochDaysSource. That number is folded into every per-subject key, every commitment nonce and every leaf hash. It is also the currentEpochDays public input of every age_threshold proof and the from of every validity window. Pinning it with fixed:<n> or env:<VAR> is what makes a batch reproducible; utc_today is refused outside a development flag.

Leaves

Every field that carries a predicate becomes a leaf. A field can carry several predicate instances, for example an age threshold at 18 and another at 21, and then it becomes several leaves. The first instance keeps the bare field name. Later instances get a suffix derived from their parameters, so the name is stable across runs and safe as a file name. Examples: dateOfBirth@7665d for a 21-year threshold, investorType@PROFESSIONAL for a tier floor, nationality@country_allowlist for a list, firstName@max128 for a commitment-only instance. The leaf name is the proof file’s basename, proofs/<leafName>.bin, and the name a revocation refers to. Each leaf carries a witness (the field value), a circuit, public inputs, resolved parameters, a commitment, a proof, and in version 3 a binding block. The circuits are on Circuits.

Hiding commitments

The field value never appears in the bundle. What appears is a commitment to it:
witness_felts is the circuit’s canonical field-element encoding of the value: one element holding dob_days for age_threshold, and one holding the tier code for tier_threshold. The string circuits take the NFC-normalised UTF-8 bytes, append a 0x01 terminator, and chunk the result into 7-byte little-endian limbs. They then zero-pad to a fixed count: 12 elements for set_membership, 20 for preimage_knowledge and commitment_only. Unicode NFC normalisation happens first, so a composed and a decomposed spelling of the same name commit to the same value. The nonce is 32 bytes derived per leaf through HKDF from a per-subject key, never reused across leaves or subjects. It is what makes the commitment hiding. A date of birth has only a few tens of thousands of plausible values, and without the nonce none of them can be confirmed against the commitment. The nonce and the plaintext value are stored only in secrets.enc, encrypted, on the operator’s machine. The circuit id and the length prefix are part of the preimage. A commitment minted under one circuit therefore cannot be re-read as a commitment under another. A two-element witness cannot be confused with a one-element witness followed by a longer nonce.

Digests and the leaf hash

Two more digests summarise what the proof was made about and what the proof bytes are:
For a commitment_only leaf, proof_bytes is empty and proof_digest is the digest of the empty string. The leaf hash binds everything a verifier needs to hold constant:
The wallet, the batch date, the circuit id and the leaf name are inside the hash. A proof therefore cannot be presented for another subject, another date, another predicate or another field. Any such change moves the leaf hash, which moves the root, which breaks the signature and the on-chain anchor. The last component is present only on leaves that carry a binding block, which is every leaf a version 3 manifest produces. A leaf without one hashes over exactly the version 2 preimage, so bundles issued before version 3 keep their hashes and their signatures.

The binding digest

A version 3 leaf records, in binding, what it asserts and for how long: The whole block is hashed as compact JSON with keys sorted at every depth:
Editing a descriptor after the fact therefore changes the leaf hash, the root and the signature. A descriptor that could be swapped freely would be decoration; this one is evidence. Nothing in a binding block is proved by a circuit. A validity window is a signed assertion by the issuer about the period it stands behind a claim. basisAsOf is the day the underlying evidence was examined, which is deliberately separate from from. A passport checked in January and used for a March issuance records January in basisAsOf and March in from. The gap is the staleness a reviewer wants to see. The evidence classes are a labelled taxonomy, not a scale. Nothing in the tool compares two of them and no predicate takes an evidence-class threshold.

The Merkle root and the kycHash

The leaf hashes of one subject are folded into one root:
  1. Sort the leaf hashes by their 32-byte big-endian representation.
  2. Pad the sorted layer to the next power of two, minimum 2, with Rescue(MERKLE_PAD_TAG ‖ u64_le(leaf_count)).
  3. Pair adjacent nodes, smaller first, and merge each pair with Rescue(NODE_TAG ‖ a ‖ b). No layer is ever odd.
  4. root = Rescue(MERKLE_ROOT_TAG ‖ inner_root ‖ u64_le(leaf_count) ‖ arity_u8), with arity fixed at 2.
That root is the kycHash. It is written to kycHash.hex, recorded as manifest.kycHash, and printed in the batch index CSV; the three values are the same. Because the tree is sorted by hash, its shape depends only on the set of leaves, not on the order of fields in the config. The prover reorders manifest.leaves to match the tree. This is the merkleVersion: 2 construction. Bundles from before version 2.1 carry no merkleVersion and were built with a version 1 construction that the verifier still supports; Circuits has both.

Disclosure classes

Every leaf records how much of its value a reader of the bundle can learn, in manifest.leaves[].disclosure: The tool assigns plaintext to country and investorType whatever predicate they carry, because it always writes those two values to the index CSV and to the manifest’s passthrough block. country is mandatory in every record; investorType is optional but takes a configured default when the column is absent. For the config that init writes, a counterparty learns: softExpiryDays and hardExpiryDays are passthrough integers. No circuit witnesses them and no verifier evaluates them; only the manifest signature covers them.

List versions

An allowlist is committed as its own Rescue Merkle tree, with leaves Rescue(SET_LEAF_TAG ‖ value) and nodes under SET_NODE_TAG. Those tags are distinct from the subject tree’s, so a list entry can never be mistaken for a subject leaf. A set_membership proof carries the list’s root as a public input. Lists live in a signed, append-only registry that keeps every version that was ever authoritative, with its values. Each set_membership leaf records which version it was proved against, so a verifier holding the registry or an archive resolves the root back to the values it committed to. Rotating a list neither invalidates old bundles nor requires re-proving; Registries and archives has the procedure.

Two questions, kept apart

overall_pass says whether the bundle holds together cryptographically and is anchored to a key or a root. temporal.entitled says whether the subject could rely on it at a given instant, taking the root that was live then, each claim’s validity window and any revocation into account. A voided bundle still passes the first and fails the second, because the proof is a true record of what was asserted. Verification covers both.

In the repository