‖ 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 inepochDays: 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: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 binding digest
A version 3 leaf records, inbinding, what it asserts and for how long:
The whole block is hashed as compact JSON with keys sorted at every depth:
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:- Sort the leaf hashes by their 32-byte big-endian representation.
- Pad the sorted layer to the next power of two, minimum 2, with
Rescue(MERKLE_PAD_TAG ‖ u64_le(leaf_count)). - Pair adjacent nodes, smaller first, and merge each pair with
Rescue(NODE_TAG ‖ a ‖ b). No layer is ever odd. root = Rescue(MERKLE_ROOT_TAG ‖ inner_root ‖ u64_le(leaf_count) ‖ arity_u8), with arity fixed at 2.
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, inmanifest.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 leavesRescue(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
- docs/CIRCUITS.md, the leaf hash, binding digest and Merkle constructions with their tests
- bundle-format/src/leaf.rs, bundle-format/src/merkle.rs, bundle-format/src/claims.rs
