Merkle-row schemes (merkle-row-v1, merkle-row-sealed-v1)
Terminology. The canonical vocabulary on every surface is proof (grouped in folders); API responses emit the canonical names only (proof_id, proof_url, folder_slug). Where this spec shows receipt / matter / bundle_id, it is documenting a frozen on-disk/on-chain format, a stable filename (RECEIPTS.md), or a legacy route that remains accepted inbound — proof and receipt denote the same record. Full alias map: the compatibility map.
In plain words: sometimes you want to anchor a whole set of records (bids, line items, log rows) at one moment, then much later prove one of them — to an auditor or counterparty — without exposing the rest. That is commitment and reveal: one chain anchor binds the whole set; a small "reveal" payload later lets anyone check a single row against it while the other rows stay hidden. This page is the implementer specification — the byte-level scheme any verifier needs to check a reveal without trusting Satsignal. For the user-facing explanation — when and why to use selective row disclosure, and the trade-offs — see Selective row disclosure in the docs. What an anchor does and does not prove is stated canonically in the bundle spec.
Selective-disclosure schemes for tabular data: anchor a whole table on chain, later reveal one row to an auditor without revealing any of the other rows.
Commitment and reveal, in plain words
A brief orientation so the numbered byte-level sections have shared vocabulary. The user-facing explanation of the commit / reveal idea — when to use it and the trade-offs — lives in the docs.
- Commit. You build a Merkle tree over your rows and anchor only the root — one short value — in a single BSV transaction. The anchor proves each later-revealed row was part of the set committed by the block time; it reveals nothing about any individual row's contents. (It does not prove that committed set was complete relative to some external universe — see the threat model in §4 — unless the table's scope was separately committed.) (
merkle-row-v1anchors the root via manifest mode;merkle-row-sealed-v1additionally HMAC-salts each leaf so even the number and shape of rows leaks nothing to a chain observer.) - Reveal (later, selectively). To prove one row, you hand the recipient a small reveal payload: that row's canonical bytes, its Merkle path to the anchored root, and (for the sealed scheme) its per-leaf salt. They recompute the path and confirm it lands on the on-chain root. One row is now checkable; every other row remains a salted hash with no candidate set.
- Verify without us. The recipient needs only the reveal payload, SHA-256, and a way to read the BSV transaction — no Satsignal account, no API call (the one Satsignal-hosted convenience is
/lookup_hashresolving amerkle-row-sealed-v1commit doc; the root itself is verifiable at any explorer).
This is the same commit-then-selectively-reveal pattern used by the sealed-bid demo and by agent runs that anchor a decision before disclosing it. Whole-file sealed mode (one salted HMAC over a whole file, no per-row tree) is the related but distinct construction in the sealed spec; the operator how-to for disclosing a sealed proof is the unseal guide. The byte-level scheme follows.
These are client-side schemes layered on Satsignal's existing API. The server does not need to be aware of them. merkle-row-v1 rides on top of manifest mode (POST /api/v1/anchors {items: [...]}); merkle-row-sealed-v1 rides on top of plain commitment anchors (POST /api/v1/anchors {category: "commitment"}). Both schemes are specified here so third-party verifiers can interop without reading helper source.
Reference helpers (stdlib-only, byte-identical between runtimes):
- Python:
/merkle_row.py - Browser + Node 18+:
/merkle-row.js
Public-domain primitives. Pin the sha256 if supply chain is a concern.
1. Common substrate
1.1 Canonical bytes
Each row is canonicalized with Satsignal Canonical JSON v1 (SCJ-v1) before hashing or HMACing — identical to notary.canonicalize and the canonicalize() in /commit-reveal.js:
- NFC normalize all string keys and string values (recursive).
- Sort all dict keys at every level by Unicode code point.
- Emit minimal JSON (no whitespace, no trailing commas, no NaN/Infinity).
- Floats are forbidden. Pre-quantize integers (e.g. cents) or pass decimals as strings. Cross-runtime float encoding diverges on integer-valued floats (
1.0vs1); refusing them is the only way to guarantee byte-identical canonical bytes between Python and JavaScript.
SCJ-v1 is not RFC 8785 (JCS). The historical "JCS" shorthand here means the
notary.canonicalizerule above — code-point key sort + NFC — not RFC 8785, which sorts by UTF-16 code unit and does not NFC. Do not verify rows with an RFC 8785 library. (SeeSPEC_mbnt.md §Canonicalization.)
1.2 Merkle tree
Standard binary Merkle, SHA-256 throughout:
merkle_root([leaf_0, ..., leaf_{N-1}]):
level = leaves
while len(level) > 1:
next = []
for i in 0..len(level) step 2:
left = level[i]
right = level[i+1] if i+1 < len(level) else level[i] // last-node-dup
next.append(sha256(left || right))
level = next
return level[0]
Inclusion-proof entries are {"sib": "<64-hex>", "side": "L"|"R"}, ordered from leaf level upward. To verify, start with the leaf bytes; for each entry, if side="L" then carry = sha256(sib || carry) else carry = sha256(carry || sib). The final carry must equal the root.
This matches the existing manifest-mode implementation in notary/manifest.py and the verifier-page implementation in docs/notary_spec/verifier.html (merkleRootFromHexLeaves).
1.3 Odd-node rule and salt derivation, by scheme
There are two odd-level Merkle rules in the Satsignal ecosystem, and they are not interchangeable: for any leaf-set that is odd at some level they produce different roots. Both schemes in this spec use duplicate-last (the §1.2 rule). The related salted disclosure-v1 corpus instead uses promote-unchanged. A third-party verifier MUST apply the rule that matches the scheme string it is checking — pick the wrong one and a valid odd-sized reveal fails (or a hand-rolled verifier silently accepts a tree it should reject). The inner-node hash is always SHA-256(left || right) over the raw 64-byte buffer, and the proof walk (verifyProofPath) is structure-agnostic — only root construction and proof-path construction differ between the two rules.
| Scheme string | Leaf value | Odd-node rule | HKDF salt · info | ||
|---|---|---|---|---|---|
satsignal-merkle-row-v1 | sha256(SCJ-v1({"label": …, "sha256_hex": sha256(SCJ-v1(row))})) | duplicate-last | — (unsalted) | ||
satsignal-merkle-row-sealed-v1 | HMAC-SHA256(salt_i, SCJ-v1(row)) | duplicate-last | satsignal-merkle-row-sealed-v1/per-leaf · `b"row/" \ | \ | u32_be(i)` |
satsignal-sealed-v1 chunk_merkle — incl. native csv-row-v1, csv-column-v1 | HMAC-SHA256(salt_i, canonical chunk/row) | duplicate-last | satsignal-sealed-v1/per-leaf · `b"chunk/" \ | \ | u32_be(i)` |
salted disclosure-v1 corpus (disclosure-v1 §3.4) | per-profile leaf hash | promote-unchanged | per-profile (see the profile spec) |
Auditable source of truth. Duplicate-last: §1.2 above (right = level[i+1] if i+1 < len else level[i]); the reference helpers /merkle_row.py and /merkle-row.js ("last-node-duplicated on odd levels"); disclosure-builder/merkle.mjs merkleRootDuplicateLast / buildProofPathDuplicateLast (an odd last node self-pairs, so its proof path carries a self-sibling {"side": "R", "hash": <its own hash>}); and SPEC_v2_sealed.md §3.3 ("duplicate-last on odd levels"). Promote-unchanged: disclosure-builder/merkle.mjs merkleRoot and satsignal_notary/disclosure/merkle.py merkle_root ("odd remainder: promote unchanged — NEVER duplicate-and-rehash"), which pin disclosure-v1.md §3.4 invariant 3.
2. merkle-row-v1 — standard scheme
2.1 When to use it
Use merkle-row-v1 when each row's contents are high-entropy (UUIDs, free-text bodies, large structured payloads) so that a plain SHA-256 leaf cannot be brute-forced from a small candidate space.
2.2 Leaf and root
row_canonical_i = canonicalize(rows[i])
row_sha_i = sha256(row_canonical_i) // hex
leaf_i = sha256(canonicalize({ // bytes
"label": <row label>,
"sha256_hex": row_sha_i
}))
root = merkle_root([leaf_0, ..., leaf_{N-1}])
The leaf shape is manifest-items-v1 exactly — merkle-row-v1 is a documented use of manifest mode where each "item" is a JCS-canonicalized row. The label is conventionally "row-{i}" but callers may supply human-readable labels (e.g. "q1", "customer-42").
2.3 Anchor
POST https://app.satsignal.cloud/api/v1/anchors
Authorization: Bearer sk_...
Content-Type: application/json
{
"folder_slug": "<your folder>",
"items": [
{"label": "row-0", "sha256_hex": "<row_sha_0>"},
...
],
"category": "evidence_bundle",
"label": "<optional anchor-level label>"
}
The server computes the Merkle root, anchors a transaction whose OP_RETURN binds to it, and ships a .mbnt bundle whose proofs.json carries the leaves.
2.4 Reveal payload
{
"version": "satsignal-merkle-row-v1",
"leaf_index": 7,
"label": "row-7",
"row": { ... the original row JSON ... },
"row_canonical_b64": "<base64 of canonicalize(row)>",
"row_sha256_hex": "<64-hex>"
}
Plus the .mbnt bundle (<bundle_id>.mbnt — the filename stem is the proof id) — which already carries the leaves and lets the verifier walk the inclusion path.
2.5 Verification
The existing https://proof.satsignal.cloud/verify page handles merkle-row-v1 reveals end-to-end as manifest leaf reveals — the user drops the bundle plus the row's canonical bytes (decode row_canonical_b64) into the verifier; it hashes the bytes, looks up the matching leaf in proofs.json, walks the inclusion path to the root, and checks the root against the on-chain transaction.
A verifier MUST ALSO confirm that canonicalize(reveal.row) equals the supplied row_canonical_b64 bytes. Without this binding check, an attacker could mutate the human-readable row field while leaving the canonical bytes intact, producing a verifier-clean reveal that lies about what was committed.
3. merkle-row-sealed-v1 — sealed-style scheme
3.1 When to use it
Use merkle-row-sealed-v1 when row contents are low-entropy ({"vote": "yes"}, {"score": 73}, single bids on a small set of bidders), where a plain SHA-256 leaf would be brute-forceable from the published commitment. The HMAC layer makes leaves opaque to anyone without the per-leaf salt.
3.2 Per-leaf salts (HKDF derivation)
For i in 0..N-1:
salt_i = HKDF-SHA256(
ikm = master_salt, // 32 random bytes
salt = b"satsignal-merkle-row-sealed-v1/per-leaf",
info = b"row/" || encode_u32_be(i), // 4 BE bytes
length = 32
)
This matches the structure of chunk_merkle in SPEC_v2_sealed.md §3.3 — different namespace (row/... vs chunk/...), different HKDF salt string. Revealing salt_i does not permit deriving any other salt_j or recovering master_salt; see SPEC_v2_sealed.md §5.3 for the formal argument.
Scope note (2026-05-29).
merkle-row-sealed-v1is a distinct, client-side, JSON-row commit/reveal scheme — it is not the per-rowchunk_merklerule a CSV file anchor commits. The CSVcsv-row-v1sealed disclosure rule lives inprofiles/csv-row-v1.md§5b and pins the anchor's actualchunk_merkleHKDF derivation —salt = "satsignal-sealed-v1/per-leaf",info = "chunk/" || u32_be(i), over RFC-4180 CSV canonical rows (authoritative:SPEC_v2_sealed.md §3.3). This scheme's"…merkle-row-sealed-v1/per-leaf"+"row/"namespace over JCS-canonicalized JSON rows is deliberately different and is correct for this scheme; do not cross the two namespaces or treat one spec as superseding the other.
3.3 Leaf, commitment, and root
row_canonical_i = canonicalize(rows[i])
commitment_i = HMAC-SHA256(salt_i, row_canonical_i) // 32 bytes
root = merkle_root([commitment_0, ..., commitment_{N-1}])
The Merkle tree's inner-node hashing is plain SHA-256; the per-leaf HMAC is the only salting point.
3.4 Commit doc
What gets anchored on chain is the SHA-256 of the commit doc's canonical bytes:
{
"scheme": "satsignal-merkle-row-sealed-v1",
"root": "<64-hex>",
"leaf_count": <N>
}
Anchor:
POST https://app.satsignal.cloud/api/v1/anchors
Authorization: Bearer sk_...
Content-Type: application/json
{
"folder_slug": "<your folder>",
"sha256_hex": "<sha256 of canonicalize(commit_doc)>",
"file_size": <bytes of canonicalize(commit_doc)>,
"category": "commitment",
"label": "merkle-row-sealed-v1 root"
}
The on-chain commitment binds the root + leaf_count together. Holder keeps master_salt, the per-leaf salts (or just regenerates them via HKDF from master_salt), and the row-canonical bytes private until disclosure.
3.5 Reveal payload
To disclose a single row:
{
"version": "satsignal-merkle-row-sealed-v1",
"leaf_index": 7,
"leaf_count": 50,
"label": "row-7",
"row": { ... original row JSON ... },
"row_canonical_b64": "<base64 of canonicalize(row)>",
"salt_b64": "<base64 of salt_i (32 bytes)>",
"commitment_hex": "<64-hex of HMAC(salt_i, row_canonical)>",
"proof": [
{"sib": "<64-hex>", "side": "L" | "R"},
...
],
"root_hex": "<64-hex>"
}
The reveal does not contain master_salt. Per §3.2, revealing salt_i does not permit deriving any salt_j for j ≠ i.
3.6 Verification
A standalone verifier (no Satsignal repo dependency) checks, in order:
reveal.version == "satsignal-merkle-row-sealed-v1".commit_doc.scheme == "satsignal-merkle-row-sealed-v1".commit_doc.root == reveal.root_hex.- Row binding:
canonicalize(reveal.row)equalsdecode_b64(reveal.row_canonical_b64). Without this, a tamperedrowfield passes the HMAC check and lies to the auditor. HMAC-SHA256(decode_b64(salt_b64), row_canonical) == from_hex(commitment_hex).- The Merkle path reconstructs to
from_hex(root_hex). sha256(canonicalize(commit_doc))matches the on-chain transaction's committed hash (resolve via/lookup_hash?sha=<commit_doc_sha>againsthttps://proof.satsignal.cloud).
All seven must hold for the reveal to verify.
4. Threat model deltas
4.1 What merkle-row-sealed-v1 adds over merkle-row-v1
The standard scheme's leaves are pure SHA-256 of canonical row bytes. For low-entropy rows (e.g., a column with only "yes"/"no" values across N bidders), an adversary who sees a leaf can brute-force the row by trying every candidate. The sealed scheme defends against this: HMAC under a per-leaf salt makes the commitment opaque without the salt.
4.2 What both schemes preserve
Selective disclosure: revealing one row's contents (and, for sealed, its salt) does not let an adversary recover any other row's contents or test candidate values against any other commitment.
Tamper evidence: the on-chain timestamp predates any reveal. If the row's bytes are mutated after disclosure, the leaf hash / commitment no longer matches and verification fails at step 5 or 6.
4.3 What neither scheme protects against
Holder dishonesty about row order or labels: if the holder constructs the table with rows the holder has chosen before anchoring, an auditor cannot tell whether the holder dropped or re-arranged unrevealed rows. Adversaries who anticipate this should use commit-reveal (/commit_reveal.py) to commit to the table's intended scope (e.g., a list of bidder IDs) before anchoring, then verify the disclosed row's place against that scope.
Leaf-level metadata leaks for merkle-row-v1: the schema label ("q1", "customer-42") is recorded on the .mbnt bundle's leaves, not the row contents. Choose labels that don't themselves disclose row content ("row-7" rather than "row-7-customer-acme-corp").
4.4 Salt provenance for merkle-row-sealed-v1
A skeptical auditor might worry that the holder picked the salt after the fact in a way that lets them deny a row or fit a self-serving disclosure. The cryptographic answer is preimage resistance: once a leaf commitment HMAC(salt_i, canonical_row_i) is folded into a Merkle root that is on chain, no different (salt_i', canonical_row_i') pair satisfies it. The holder cannot revise the binding after anchor.
For audit contexts that require explicit proof the salt was fixed before any data was known, anchor a commit-reveal of sha256(canonicalize({nonce_hex, payload: {master_salt_hex, ...}})) before the table anchor; chain-order then enforces salt-first discipline. The full protocol, including what it does and does not defend against, lives in SPEC_v2_sealed.md §12.
5. Live samples (anchored 2026-05-08)
Both schemes have a real on-chain anchor with all artifacts (rows, commit doc, single-row reveal, API response) hosted publicly.
merkle-row-v1 (5 eval-result rows, evidence_bundle category):
- txid
4fc609d83c59fb173848f879ff78148dea3f797ddc2bf65a8c670ba84a8d383d - root
46788ed6fed1498de0b8af5129479afc08b76f67f812702db416501eb358f216 - proof id
de04a33002354793 - artifacts at
/samples/merkle-row/
merkle-row-sealed-v1 (5 sealed bids, commitment category):
- txid
d623cb94d09c6d02b11fcd239667e12fab0929ce281b79b7e0964053a6c4dcc8 - commit-doc sha (the on-chain commitment)
f3dd742039a4c287d23f735cb59833f669d1ff96dc50fab0c173a24608ef3584 - root
a67956a2d0ca871b665da9d99fb1ac2dc3690d9484bcac23dd09c2c5ae477bf7 - proof id
007dff5816814594 - public lookup
/lookup_hash?sha=f3dd7420…608ef3584resolves to the proof id + txid without auth
For both samples, the client-side helper computes a Merkle root that matches the on-chain anchor exactly. Drop a *-reveal-row-2.json and the matching commit doc into merkle_row.py verify-sealed (or the JS module's verifySealedReveal) and all four binding checks pass: root_match, row_binding, leaf_commitment, merkle_path.
5.1 Chain confirmation (default on)
Chain confirmation is on by default. A sealed-row reveal is not
verified: trueunless the commit document's hash is confirmed against an on-chain anchor. Crypto-only verification is available only as an explicit offline mode (--no-chain-confirm); it is safe only when you have independent confirmation of the anchor, because cryptographic checks alone cannot distinguish a real anchor from a locally-fabricated commit-doc + reveal pair.
merkle_row.py verify-sealed defaults to confirming the commit doc's sha256 is actually anchored on chain — it hashes the canonicalized commit doc, calls lookup_hash, and reports the result under chain_check:
chain_check.state = "confirmed"(withtxid): the commit doc is on chain.verified: true.chain_check.state = "missing": cryptographic checks pass, but no on-chain anchor exists for this commit doc. The result isverified: falsewithforgery_suspected: true— either the anchor has not yet been broadcast, or the bundle was fabricated locally. Cryptographic verification alone CANNOT distinguish these two cases; only the chain check can.chain_check.state = "error": network-level failure reachinglookup_hash. Verifier fails closed by default; re-run when network is available, or pass--no-chain-confirmto accept the crypto-only result deliberately.
Pass --no-chain-confirm for offline use (e.g. CI without network access). The helper then verifies cryptographic checks only and emits a loud stderr warning: a locally-fabricated commit doc + reveal pair would also pass crypto, so this mode is safe only when you have out-of-band confirmation of the on-chain anchor.
The browser verifier at proof.satsignal.cloud/verify performs the equivalent chain-resolution step automatically (it hits lookup_hash during the merkle-row card flow). The CLI helper now mirrors that discipline rather than diverging.
6. Out of scope
- Server-side scheme enforcement (out of scope). These are client-side interoperability schemes layered on the core anchor primitive: the API anchors the submitted commitment, and the reference helper and the verifier page enforce the row-reveal scheme rules. The schemes are interop conventions, not protocol upgrades; third-party implementations MUST follow the registered scheme strings and the §3.6 verification procedure exactly.
- Whole-table reveal. To unseal the entire table publicly, publish the master record (master_salt + all row canonicals). Anyone can recompute every commitment, walk the tree, and confirm the root. No special endpoint or path needed.
- Schema evolution. A
merkle-row-v2would require a new scheme string. Verifiers MUST refuse mismatched scheme strings — do not silently accept future versions.
7. Related specs
/spec-mbnt— the on-chain MBNT OP_RETURN wire format (28-byte header + TLV section). Read this if you're walking from a txid back to adoc_hash./spec— sealed-mode bundle format, HKDF-derived salt protocol, and threat model.
8. Registered schemes
The two scheme strings specified above — satsignal-merkle-row-v1 and satsignal-merkle-row-sealed-v1 — are listed in the public scheme registry at https://satsignal.cloud/schemes.json. The registry carries each scheme's spec link, reference-helper URLs, and the txid of the first on-chain anchor. New schemes are added once they have a stable spec, a published helper, and a real on-chain anchor.
A bundle that declares an unlisted scheme is not automatically invalid — the cryptographic checks in the helper still run — but auditors should treat it the same way they would treat any other operator-supplied verification code: ask for re-issuance under a registered scheme, or for independent reproduction of the verification logic, before trusting the result.
Questions about this specification? Email hello@satsignal.cloud.