Merkle Proof Verification
For: Application developers and compliance teams who need to independently verify that execution records have not been tampered with after the fact.

Developer Integration | Delegation Tokens | Merkle Proof Verification
Every action executed through Axemere Gateway produces an execution record. Records are hashed, appended to an ordered ledger, and periodically batched into a SHA-256 Merkle tree. The Merkle root is signed by the control plane and anchored to an external timestamp authority (RFC 3161 TSA). You can use the inclusion proof to independently verify that a record existed at a specific point in time and has not been altered.
Table of Contents
- How It Works
- Execution Record Hash
- Ledger and Batching
- Merkle Tree Construction
- Inclusion Proof Format
- Retrieving a Proof
- Verification Algorithm
- External Anchoring (RFC 3161 TSA)
- Root Metadata Schema
- Proof Availability Timing
- Security Properties
- Troubleshooting
- See Also
How It Works
Execution Record Hash
Each execution record is hashed using:
- JCS canonicalization (RFC 8785) of the full execution record JSON document: deterministic key order, no insignificant whitespace.
- SHA-256 of the canonical bytes.
The result is the record_hash, a 64-character hex string returned in every action response as X-MVGC-Record-Hash and in the response body.
You can independently compute the hash from a stored record copy to verify it matches what is on the ledger.
The record hash covers the signed execution record body, not the HTTP response envelope. Contact your Axemere representative for the full record schema.
Ledger and Batching
- Records are appended to the ledger in submission order with an auto-incrementing
ledger_seq. - The batch job collects records within a sequence range and builds one Merkle tree per batch.
- Batch size and frequency are configured on the control plane. Default: up to 10,000 records per batch, run every 5 minutes.
- A record that arrives after a batch closes is included in the next batch.
Merkle Tree Construction
The tree uses SHA-256 throughout with domain-separated prefixes to prevent second-preimage attacks:
Leaf hash:
leaf_hash = SHA-256( 0x00 || record_hash_bytes )
Internal node hash:
node_hash = SHA-256( 0x01 || left_hash_bytes || right_hash_bytes )
Odd leaf count: When the number of leaves is odd, the last leaf is duplicated for pairing. Leaves are ordered by ascending ledger_seq.
Inclusion Proof Format
The inclusion proof is returned by GET /v1/verify/{record_hash} when the record has been batched:
{ "schema": "mvgc.inclusion_proof.v1", "record_hash": "a1b2c3d4e5f6...", "batch_id": "01955f3e-0000-7abc-8def-000000000001", "leaf_index": 42, "leaf_count": 1000, "path": [ { "side": "right", "hash": "deadbeef..." }, { "side": "left", "hash": "cafebabe..." }, { "side": "right", "hash": "12345678..." } ], "root": { "schema": "mvgc.merkle_root.v1", "batch_id": "01955f3e-0000-7abc-8def-000000000001", "org_id": "01955f3e-0000-7abc-8def-000000000000", "created_at": "2026-03-12T10:05:00Z", "hash_alg": "sha256", "leaf_count": 1000, "ledger_seq_min": 5000, "ledger_seq_max": 5999, "root_hash": "abcdef01...", "anchor": { "type": "rfc3161", "ref": "<base64-encoded TSA token>", "published_at": "2026-03-12T10:05:03Z" }, "sig": { "alg": "ed25519", "kid": "kid_cp_1", "sig": "<base64url>" } } }
| Field | Description |
|---|---|
record_hash | The leaf value being proved |
batch_id | Identifies the Merkle batch |
leaf_index | 0-based position of this leaf in the ordered batch |
leaf_count | Total number of leaves in the batch |
path | Sibling hashes from leaf to root; side is the sibling's position |
root | Signed root metadata (see Root Metadata Schema) |
path[0] is the sibling of the leaf; path[last] is the sibling of the child that feeds the root.
Retrieving a Proof
GET /v1/verify/{record_hash}
Authorization: Bearer <api_key>
Response — verified (200 OK):
{ "status": "verified", "record_hash": "a1b2c3d4e5f6...", "inclusion_proof": { "...": "InclusionProof object" } }
Response — pending inclusion (200 OK):
{ "status": "pending_inclusion", "record_hash": "a1b2c3d4e5f6..." }
| Status | Meaning |
|---|---|
verified | Record has been included in a Merkle batch; full proof is available |
pending_inclusion | Record is in the ledger but not yet batched; retry after the next batch window |
404 is returned when the record_hash is not known to the gateway at all.
Verification Algorithm
Step-by-step
Given an inclusion proof, verify it as follows:
- Verify the
root.sigEd25519 signature against the CP's public key (kidfrom the signature envelope). This confirms the root was produced by the control plane. - Verify the
root.anchor.refif you require external time-binding (see External Anchoring). - Compute the leaf hash:
h = SHA-256( 0x00 || unhex(record_hash) ) - Walk
pathfrom index 0 to last:- If
step.side == "right":h = SHA-256( 0x01 || h || unhex(step.hash) ) - If
step.side == "left":h = SHA-256( 0x01 || unhex(step.hash) || h )
- If
- Assert
h == unhex(root.root_hash)
If step 5 holds and step 1 verified, the record was included in the signed batch and has not been altered.
Go example
import ( "crypto/sha256" "encoding/hex" "fmt" ) func VerifyInclusion(recordHash string, proof InclusionProof) error { recordBytes, err := hex.DecodeString(recordHash) if err != nil { return fmt.Errorf("decode record_hash: %w", err) } // Step 1: compute leaf hash h := leafHash(recordBytes) // Step 2: walk proof path for _, step := range proof.Path { sibling, err := hex.DecodeString(step.Hash) if err != nil { return fmt.Errorf("decode path hash: %w", err) } if step.Side == "right" { h = nodeHash(h, sibling) } else { h = nodeHash(sibling, h) } } // Step 3: compare to root rootBytes, err := hex.DecodeString(proof.Root.RootHash) if err != nil { return fmt.Errorf("decode root_hash: %w", err) } if !bytes.Equal(h, rootBytes) { return fmt.Errorf("proof verification failed: computed %x, want %x", h, rootBytes) } return nil } func leafHash(b []byte) []byte { h := sha256.New() h.Write([]byte{0x00}) h.Write(b) return h.Sum(nil) } func nodeHash(left, right []byte) []byte { h := sha256.New() h.Write([]byte{0x01}) h.Write(left) h.Write(right) return h.Sum(nil) }
Python example
import hashlib def leaf_hash(record_hash_hex: str) -> bytes: record_bytes = bytes.fromhex(record_hash_hex) return hashlib.sha256(b"\x00" + record_bytes).digest() def node_hash(left: bytes, right: bytes) -> bytes: return hashlib.sha256(b"\x01" + left + right).digest() def verify_inclusion(record_hash: str, path: list[dict], root_hash: str) -> bool: h = leaf_hash(record_hash) for step in path: sibling = bytes.fromhex(step["hash"]) if step["side"] == "right": h = node_hash(h, sibling) else: h = node_hash(sibling, h) return h == bytes.fromhex(root_hash)
External Anchoring (RFC 3161 TSA)
When anchoring is enabled, the control plane submits the root_hash to an external RFC 3161 timestamp authority (TSA) after building the Merkle batch. The TSA issues a signed timestamp token that cryptographically binds the root hash to a wall-clock time.
The anchor object in root metadata:
"anchor": { "type": "rfc3161", "ref": "<base64-encoded DER TimeStampToken>", "published_at": "2026-03-12T10:05:03Z" }
To verify the anchor independently:
- Base64-decode
anchor.refto get the DER-encodedTimeStampToken. - Verify the
TimeStampTokensignature against the TSA's public certificate. - Extract
messageImprintfrom the token and verify it equalsSHA-256(unhex(root_hash)). - The
genTimein the token is the TSA's assertion of when the root was submitted.
Every Merkle batch is anchored. If anchor is absent from a root metadata object, the batch was produced before anchoring was active and the proof is still valid for tamper detection.
Root Metadata Schema
The signed root object embedded in every inclusion proof:
| Field | Description |
|---|---|
schema | "mvgc.merkle_root.v1" |
batch_id | UUIDv7, unique batch identifier |
org_id | Organisation that owns this batch |
created_at | When the batch was finalized |
hash_alg | Always "sha256" in v1 |
leaf_count | Number of records in the batch |
ledger_seq_min | First ledger_seq in this batch |
ledger_seq_max | Last ledger_seq in this batch |
root_hash | Hex-encoded SHA-256 Merkle root |
anchor | Optional external anchor (RFC 3161) |
sig | Ed25519 signature over JCS-canonical root metadata |
Proof Availability Timing
Inclusion proofs are not available immediately after a request; the record must be batched first.
Typical timings (defaults, operator-configurable):
| State | Typical delay |
|---|---|
| Request → ledger | < 100ms |
| Ledger → batch (pending → verified) | Up to 5 minutes |
| Batch → anchor (verified → anchored) | Up to 30 seconds after batch |
Poll GET /v1/verify/{record_hash} until status == "verified" to retrieve the proof. There is no webhook or callback for batch completion.
Security Properties
| Property | Guarantee |
|---|---|
| Tamper detection | Any modification to a record changes its hash, invalidating the inclusion proof |
| Ordering | ledger_seq is monotonically increasing; records cannot be silently reordered within a batch |
| Non-repudiation | Signed root (sig) proves the CP produced this specific batch |
| External time-binding | RFC 3161 anchor proves the root existed at or before the TSA's genTime |
| Domain separation | 0x00 leaf prefix and 0x01 node prefix prevent second-preimage attacks between levels |
The Merkle ledger protects against post-hoc record alteration. It does not protect against a record being omitted from the ledger entirely; omission detection requires comparing the ledger_seq range against expected record counts.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
status: pending_inclusion persists | Batch job not running or batch interval is long | Check CP batch job health; wait for the next batch window |
404 on GET /v1/verify/{hash} | Record hash is wrong or record not yet submitted to ledger | Verify the hash from the original response; check network connectivity between gateway and CP |
| Verification step 5 fails (hash mismatch) | Proof path or root hash is corrupted | Re-fetch the proof; check for data corruption in storage |
Root sig verification fails | Wrong CP public key or root was produced by a different CP instance | Verify kid matches the CP's current keyring; check key rotation state |
| Anchor verification fails | TSA certificate not trusted or token corrupted | Verify the TSA cert chain independently; check anchor.published_at against TSA logs |
See Also
- Developer Integration Guide:
record_hashin action responses,GET /v1/verifyoverview - Security Overview: Merkle ledger in context of the full security model
- Glossary: execution_record
- Glossary: record_hash
- Glossary: inclusion_proof