Bundle v1 — Satsignal .mbnt Receipt Spec
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: a Satsignal proof is a small
.mbntfile (a ZIP) that lets anyone prove a piece of data was anchored to the Bitcoin SV blockchain at a known time — without trusting Satsignal and without an account. This page is the precise spec for that file: what's inside it, and the exact steps a verifier runs to check it. If you just want the guarantee in one paragraph, jump to What Satsignal proves and does NOT prove; if you want the gist before the field tables, read How Standard proofs work. Everything after that is implementer-grade detail.
This is the canonical implementer specification for the .mbnt proof bundle. For the user-facing overview, see satsignal.cloud/docs.html.
Status: implementer-facing consolidation, v1. Audience: anyone writing a verifier, CLI, or SDK that consumes .mbnt bundles produced by Satsignal. Goal: a single self-contained document an implementer reads start-to-finish to write a conformant verifier. Older specs remain authoritative for protocol rationale; field-level shape is inline here.
Reference verifier: a standalone CLI reference verifier is planned but not yet published. The worked implementations you can run TODAY are the three verify paths: (1) the hosted verifier at proof.satsignal.cloud/verify — drag-drop the .mbnt, account-free; (2) by hand — unzip the bundle and run sha256sum bundle/canonical.json | cut -c1-40, compare to doc_hash_expected, then resolve the txid in any block explorer; (3) python scripts/agent_anchor.py verify-handoff (the stdlib helper at scripts/agent_anchor.py, for agent-session handoff.json packets).
1. Scope
A Satsignal .mbnt proof binds an off-chain canonical document to an on-chain BSV transaction. This document specifies:
- the
.mbntenvelope (Section 2), - the three JSON entries it carries —
manifest.json,canonical.json,proofs.json— and their field-level shape (Sections 3–5), - how the canonical document commits to an on-chain
doc_hash(Section 6), - the ordered procedure a conformant verifier MUST perform (Section 7),
- the error model and confirmation-depth semantics (Section 8),
- versioning and forward-compatibility rules (Section 9), including the additive
manifest.disclosureblock at §9.5 (specified fully at/spec-disclosure).
Relation to existing specs — for protocol rationale, cross-reference:
| topic | authoritative source | this doc inlines |
|---|---|---|
| OP_RETURN wire format, TLV registry, subtype codes | /spec-mbnt | header layout an implementer needs (§6.1) |
| Merkle tree construction, scheme tags | /spec-merkle-row | parser-facing field list (§4) |
| sealed-mode threat model, HKDF derivation, blind protocol | /spec | sealed-mode manifest deltas (§3.2) + verification (§7.3) |
row-reveal schemes merkle-row-v1, merkle-row-sealed-v1 | /spec-merkle-row | scheme-tag dispatch (§4.3) |
| selective-disclosure manifest, profile literals, binding chain | /spec-disclosure | additive manifest.disclosure cross-reference (§9.5) |
This document does not supersede those specs. It is the entry point for writing a verifier; they remain the entry point for understanding why the construction is what it is.
What Satsignal proves and does NOT prove
In one sentence: an anchor proves that whoever made it knew this exact fingerprint — and, if they later produce matching bytes, held data matching it — at or before a specific time — nothing more, nothing less. This section is the canonical, anchored statement of the guarantee. Other docs and the marketing pages link here rather than restate it, so the honesty model has exactly one source of truth.
What an anchor proves.
- Tamper-evidence. If the anchored data is changed by a single byte, its SHA-256 (or, in sealed mode, its salted HMAC) no longer matches the value committed on chain. A mismatch is detectable by anyone with the bundle; a match means the data is bit-for-bit what was anchored.
- Timing (an upper bound). The BSV transaction carrying the commitment has a block time. The anchorer demonstrably knew the hash by that time — they could not have produced a confirmed transaction committing to a value they did not yet have.
What an anchor does NOT prove.
- Not authorship or ownership. Anyone can anchor any data. The proof says "this hash was committed by this anchorer by time T", not "this anchorer is the author, owner, or originator of the underlying content".
- Not prior existence of the content. A naked-hash (standard) anchor proves the anchorer knew the hash by time T. It does not prove the underlying document existed before T — there is no preimage challenge in the protocol, so a hash can be committed without revealing (or even possessing a third party's copy of) the content. Adopters MUST NOT market any anchor as "proof of existence" of the content itself.
- Not a lower time bound. The block time is an upper bound on when the anchorer knew the value. It says nothing about how long before T they knew it.
- Not identity. The chain records a transaction, not a verified legal identity. Identity binding, if any, is layered on by the proof format or the workspace — not by the anchor.
This is true of every category — file proof, contract proof, agent-run proof, commitment / sealed-bid, policy snapshot, evidence bundle, webhook event, CI/CD provenance. Sealed mode narrows what a chain observer learns (see /spec); it does not widen what an anchor proves. Scheme-specific restatements: /spec-chain-anchor, /spec-provenance, /spec-mbnt §11, /whats-on-chain §6 all defer to this section.
How Standard proofs work
Plain-English version, for a reader arriving from a marketing page before the field-level spec below. Standard is the default mode — the one you get for an ordinary file proof, contract proof, or CI/CD provenance proof.
- You hash, locally. Your file's SHA-256 fingerprint is computed on your device (or by the client). For a standard file proof the file itself never leaves your machine — only the fingerprint does. (The webhook-ingest path is the one documented exception: there the raw body is hashed server-side by design.)
- Satsignal anchors a commitment. A short commitment to the canonical proof document — not the file, not the filename — goes into the OP_RETURN of one BSV transaction. That transaction is permanent and public.
- You get a bundle back. The
.mbntbundle (a ZIP — §2) carries the canonical document, the manifest with thetxid, and any proof material. The proof is the logical record; the bundle is the file that lets anyone re-derive and check it. - Anyone can verify with just the bundle and a way to read a BSV transaction — no Satsignal account, no API call to us (§7). If they also have the original file, verification additionally re-derives the file hash and confirms it matches what was anchored.
Standard mode commits to naked hashes: anyone who independently has the file's hash can confirm an anchor exists for it (this is what makes standard proofs hash-discoverable via /lookup_hash). If that property is itself sensitive, use Sealed mode instead, which anchors a salted HMAC so the chain reveals only that some file was anchored at time T — see /spec.
The exact manifest and canonical-document shapes for standard mode are §3.2 and §4 below.
2. The .mbnt envelope
A .mbnt file is a standard ZIP archive (PKZIP, deflate or store). The magic bytes 50 4B 03 04 identify it; any ZIP reader opens it.
A conformant bundle contains exactly these entries at the root:
| entry | required | content type | purpose |
|---|---|---|---|
manifest.json | yes | UTF-8 JSON | bundle metadata, mode, chain anchor reference (§3) |
canonical.json | yes | UTF-8 JSON, compact (no whitespace) | the document whose hash is committed on-chain (§4) |
proofs.json | when chunk_merkle is present in canonical.json | UTF-8 JSON | Merkle leaves + scheme re-derivation hints (§5) |
attachments/<name> | no | any bytes | optional original-file material; legacy bundles only — see §2.1 |
Bundles MAY contain additional entries beyond this list. Verifiers MUST tolerate unknown entries (skip and continue) and MUST NOT treat their presence as a failure. Verifiers MUST NOT treat any unknown entry as proof material.
canonical.json MUST be stored byte-identical to the canonicalized form whose SHA-256 was committed on-chain (Section 6). The SCJ-v1 encoding rule in Section 4.4 is what produces that form. A bundle that re-pretty-prints canonical.json will not verify.
2.1 Legacy entries
Bundles produced before mbnt_version 2.0 may include an attachments/ directory carrying the original file bytes the canonical doc commits to. Verifiers MUST NOT rely on its presence. Modern bundles never include it — the canonical doc commits to file hashes, and the original file is supplied separately at verification time (Section 7.2). A verifier reading a legacy bundle MAY use attachments/ to source the file for hash-recomputation, but the spec-defined input is the user-supplied file path.
2.2 Envelope hardening (MUST reject)
A conformant ZIP parser is permissive by design — multiple ZIP-format quirks let a tampered envelope appear well-formed to one verifier while resolving differently in another, defeating the spec-as-contract. Verifiers MUST reject the following malformations before extracting any entry. (Reference enforcement: verifier.html lines 1944–2111, shipped 2026-05-22 against M#36, H#17, and H#20.)
- Leading data before the first local file header. Verifiers MUST reject ZIP buffers whose first 4 bytes are not the local-file-header magic
50 4B 03 04(PK\x03\x04). Prepended bytes are a known smuggling vector — some parsers seek to the first LFH and ignore the prefix, others reject; a conformant verifier MUST reject. - Non-empty EOCD comment. Verifiers MUST reject ZIP buffers whose end-of-central-directory record reports a non-zero
commentLen. The trailing-bytes check at §7's structural step alone is not sufficient: an attacker may stash up to 64 KiB inside an EOCD comment that still satisfies the length math. The comment MUST be empty (commentLen == 0). - Multiple EOCD signatures in the buffer. Verifiers MUST scan the entire buffer for the 4-byte EOCD signature (
50 4B 05 06) and reject if it appears more than once. A multi-EOCD doppelganger lets one parser bind to the first EOCD and another to the rightmost, delivering divergent contents to different verifiers from a single bundle. - Duplicate filenames in the central directory. Verifiers MUST walk the central directory and reject if any filename appears more than once — most importantly a duplicate
manifest.json,canonical.json, orproofs.json. JSZip and CPythonzipfileresolve duplicate-filename conflicts in opposite directions (last-wins vs first-wins, per H#20), so a duplicate entry is a cross-implementation divergence vector even when the bytes parse. This check is on ZIP central-directory entries; it is independent of the §9.2 "tolerate unknown JSON keys" rule, which applies only to JSON object keys inside a successfully-extracted entry. - Path-traversal or absolute-path entries. Verifiers MUST reject any central-directory entry whose name contains a
..path segment, starts with/, or contains a backslash (\). Canonical.mbntnames aremanifest.json,canonical.json,proofs.json, andattachments/*— none of these legitimately contain traversal-style components. A verifier itself may not extract to disk, but downstream CLIs and adapters inherit any bundle that verifies green; a zip-slip entry MUST NOT pass conformance.
3. manifest.json
manifest.json is bundle metadata: which mode, which mbnt version, the txid that anchors it, optional ARC acceptance, optional category. It does not carry proof material — the cryptographic content lives in canonical.json (§4) and proofs.json (§5).
3.1 Common fields (every mode)
{
"mbnt_version": "2.1",
"txid": "<64 hex>",
"network": "bsv-mainnet",
"doc_hash_expected": "<40 hex>"
}
| field | type | required | meaning |
|---|---|---|---|
mbnt_version | string | yes | one of "1.1", "2.0", "2.1". Selects verifier codepath (§9). |
txid | string | yes | 64 lowercase hex chars. The BSV transaction whose OP_RETURN carries the on-chain commitment. |
network | string | yes | currently always "bsv-mainnet". Future deployments may add other values; verifiers MUST refuse unknown networks unless they explicitly support them. |
doc_hash_expected | string | yes | 40 lowercase hex chars = first 20 bytes of sha256(canonical_doc_canonical_bytes), hex-encoded. Used as a sanity check before the chain fetch (§7). |
Verifiers MUST also tolerate these optional informational fields:
| field | type | meaning |
|---|---|---|
category | string | off-chain bucket tag (e.g. "agent-run", "evidence"). Display-only. |
acceptance | object | Informational broadcast acceptance record (unsigned; display-only, not used by verification) when the broadcast went via ARC. Keys: endpoint, miner, status, accepted_at_utc. Absent on ElectrumX broadcasts, mock proofs, dry-runs. |
proof_mode | string | legacy hint carried by standard-mode bundles minted before 2026-06-11, value "private_receipt". The name predates sealed mode and means "proofs.json material is kept off-chain" — it does NOT indicate sealed mode. Sealed bundles never emitted this field; they use mode: "sealed" (§3.3). Standard bundles minted on/after 2026-06-11 omit it too. Verifiers MUST NOT switch on proof_mode (dispatch on mode instead) and MUST tolerate both its presence (older bundles, forever) and its absence. |
3.2 Standard mode (the default)
In standard mode the manifest has no mode field — its absence is the signal. The canonical doc carries the file's sha256 directly in subject (§4), so the manifest's job is purely chain-anchor metadata.
{
"mbnt_version": "2.0",
"txid": "<64 hex>",
"network": "bsv-mainnet",
"doc_hash_expected": "<40 hex>",
"filename": "report.pdf"
}
Additional optional fields:
| field | type | meaning |
|---|---|---|
filename | string | source filename for proof-page rendering. Display-only; does not affect verification. |
Correction to /spec §4.1: that document lists sha256 and file_size among public-mode manifest fields. The shipping implementation does not emit them at the manifest level (those values live in canonical.json.subject and canonical.json.subject.proofs.byte_exact). The standard-mode manifest is leaner than the sealed-spec narration implied. This document is authoritative.
3.3 Sealed mode
A sealed manifest carries the bearer secret (salt_b64) and an explicit mode field. Verifiers MUST treat the presence of mode == "sealed" as the dispatch signal.
{
"mbnt_version": "2.1",
"mode": "sealed",
"txid": "<64 hex>",
"network": "bsv-mainnet",
"doc_hash_expected": "<40 hex>",
"salt_version": "salt_v1",
"salt_b64": "<base64url, 32 bytes decoded>",
"bearer_secret": true,
"server_retain_until_utc": "2026-05-18T14:30:01Z"
}
| field | type | required | meaning |
|---|---|---|---|
mode | string | yes | exactly "sealed". |
salt_version | string | yes | currently "salt_v1". Names the salt-derivation scheme; future schemes will introduce new tags. |
salt_b64 | string | yes | base64url encoding of 32 random bytes (the master salt). Re-decoded as raw bytes for HMAC and HKDF. Bearer secret. |
bearer_secret | bool | yes | always true in sealed manifests. Renderers (verifiers, proof pages) MUST display a prominent warning that this bundle is privacy-bearing. |
server_retain_until_utc | string (ISO 8601 UTC) | no | when the server-side copy of the bundle is scheduled for deletion. Present only when the caller chose an explicit retain_days window. Display-only; does not affect verification. Absent in blind-submission bundles (no server-side copy exists). |
server_retention | string | no | exactly "indefinite" when present — the server-side mirror is kept until the proof owner deletes it (the default; mutually exclusive with server_retain_until_utc). Display-only; does not affect verification. |
Sealed manifests MUST NOT include sha256, file_size, or any other field that would leak the original file's identity. The omission is intentional — see /spec §4.1 for the threat model.
3.4 Manifest mode (Phase 8b)
Manifest-mode bundles bind multiple files via a Merkle root. The manifest layer carries the same fields as standard mode; the distinction lives in canonical.json (subject.kind == "manifest", scheme: "manifest-items-v1"). A verifier dispatches on the canonical-doc subject, not on the manifest. See /spec §7.2 for /lookup_hash behavior on manifest-mode bundles.
Required for client-assembled manifest bundles: the manifest layer MUST carry mbnt_version: "2.0" — the same value standard mode uses (§3.2), not the "2.1" sealed mode uses (§3.3). The mode signal lives in canonical.json (subject.kind == "manifest", scheme: "manifest-items-v1"), NOT in the manifest itself. A client that picks "2.1" at the manifest layer ships what verifiers will read as a sealed manifest missing its sealed-mode fields, and verification will fail at §9.1 dispatch.
4. canonical.json
The canonical document is the object whose hash is committed on-chain. Its shape depends on schema_version:
schema_version: 1— legacy single-proof shape (byte_exactonly).schema_version: 2— current multi-proof shape supporting any ofbyte_exact,content_canonical,chunk_merkle. Required fieldbyte_exact; the other two are optional, present when a Tier-1 canonicalizer ran.
A verifier MUST support both. Codepath selection is by canonical.json.schema_version.
4.1 v2 shape (default for current anchors)
{
"schema_version": 2,
"subtype": "generic",
"issuer": "did:web:satsignal.cloud",
"issued_at": "2026-05-11T14:30:01Z",
"nonce": "<16 bytes hex-encoded, 32 hex chars>",
"subject": {
"proofs": {
"byte_exact": {
"algo": "sha256",
"size": 12345,
"hash": "<64 hex>"
},
"content_canonical": {
"scheme": "text-norm-v1",
"algo": "sha256",
"hash": "<64 hex>"
},
"chunk_merkle": {
"scheme": "text-line-v1",
"algo": "sha256",
"leaf_count": 42,
"root": "<64 hex>"
}
}
},
"attestation": {
"method": "operator_attested",
"operator_id": "satsignal-cloud"
},
"attachments": []
}
Required fields: schema_version, subtype, issued_at, issuer, subject, attestation, attachments, and nonce (all eight top-level keys MUST be present), and within subject.proofs at least byte_exact. content_canonical and chunk_merkle are optional. attachments is always present and always empty (legacy field, retained for shape compatibility).
The published canonical.schema.json validates both versions: its top-level oneOf branches on schema_version + subtype, and for generic v2 selects the public / sealed (subject.kind == "file_anchor") / manifest (subject.kind == "manifest") shape by structural marker. schema.py is the runtime validator (it avoids a jsonschema dependency) and emits missing top-level keys: [...] on an omission. The JSON Schema and schema.py are kept byte-for-byte in lockstep by tests/test_canonical_schema_lockstep.py, which asserts they agree on accept/reject for every battery document — so either can be cited as the authority for the v2 required-field set.
session_commitmentis recorded, not verified. A sealed closing handoff may carry an optional fourth proof type,session_commitment(scheme: "merkle-session-v1", withroot,leaf_count; see/spec§4.5). It is not part of the §7.2 cryptographic check sequence, which covers onlybyte_exact,content_canonical, andchunk_merkle. The current reference verifier does not process or displaysession_commitmentat all — it neither verifies nor surfaces the field. The reason it cannot be verified: the session leaves needed to re-derive themerkle-session-v1root are not carried in the canonical doc, so an independent single-bundle verifier cannot recompute it. A future verifier MAY surfacescheme/leaf_count/root, but MUST NOT claim to have verified them and MUST label them "recorded on-chain, not independently verified". The server-side derivation (incl. the single-leaf ruleroot == sha256(leaf‖leaf)) is pinned bytests/vectors/sealed/session-commitment-v1/.
attestation.operator_id is a free-form string (max 255 chars) that no verification step branches on. Hosted-service anchors minted on/after 2026-06-11 carry "satsignal-cloud"; anchors minted before that date carry "satsignal-hosted-demo" — those canonical bytes are hash-committed on chain and immutable, so verifiers MUST accept both values indefinitely.
4.2 Sealed-mode subject.proofs
In sealed mode, each whole-file proof carries algo: "hmac-sha256", a salt_version, and a commitment (in place of hash). The chunk_merkle proof carries algo: "merkle-hmac-sha256", a salt_version, and keeps the root field name (the root itself is computed over HMAC-keyed leaves).
{
"schema_version": 2,
"subtype": "generic",
"issuer": "did:web:satsignal.cloud",
"issued_at": "2026-05-11T14:30:01Z",
"nonce": "<16 bytes hex-encoded, 32 hex chars>",
"subject": {
"kind": "file_anchor",
"proofs": {
"byte_exact": {
"algo": "hmac-sha256",
"salt_version": "salt_v1",
"commitment": "<64 hex>"
},
"content_canonical": {
"algo": "hmac-sha256",
"salt_version": "salt_v1",
"scheme": "text-norm-v1",
"commitment": "<64 hex>"
},
"chunk_merkle": {
"algo": "merkle-hmac-sha256",
"salt_version": "salt_v1",
"scheme": "text-line-v1",
"leaf_count": 17,
"root": "<64 hex>"
}
}
},
"attestation": {
"method": "operator_attested",
"operator_id": "satsignal-cloud"
},
"attachments": []
}
Field rules:
| field | sealed-mode meaning |
|---|---|
algo | "hmac-sha256" for byte_exact and content_canonical; "merkle-hmac-sha256" for chunk_merkle. |
salt_version | required on every proof. Currently "salt_v1". Names which salt-derivation scheme produced the keys. Verifier dispatches on this to know how to re-derive per-leaf salts (§5). |
commitment | 64 lowercase hex chars = HMAC-SHA256(master_salt, target_bytes). Replaces the hash field used in standard mode. |
root (chunk_merkle only) | 64 lowercase hex chars = Merkle root computed over HMAC-keyed leaves (§5). The field name remains root, not commitment — only byte_exact and content_canonical use commitment. |
scheme, leaf_count | unchanged from standard mode. |
For protocol rationale (why HMAC + HKDF per-leaf rather than a single salt), see /spec §3. The construction above is the field-level shape a verifier consumes.
4.3 Scheme tags
The scheme tag is opaque from the chain's point of view but selects which canonicalizer / chunker the verifier must re-run to recompute the proof. Tier-1 schemes shipping today:
Merkle convention per scheme (normative). Every chunk_merkle scheme below — and manifest-items-v1 — builds its tree with the duplicate-last odd-node rule and the single-leaf = bare leaf rule (N=1 ⇒ root == leaf, no extra hashing). This is the §6 anchor tree; it is byte-identical across the Python builders (notary/manifest.py compute_root / chunk_merkle_root_from_hex_leaves) and the JS verifier (verifier/merkle.mjs merkleRootFromHexLeaves). Do not substitute the satsignal.disclosure.v1 tree (disclosure/merkle.py, promote-unchanged on odd levels) — it is a different shape and would false-reject valid odd-width anchors. The sealed session_commitment tree (merkle-session-v1) is duplicate-last too but uses a different single-leaf rule (root == sha256(leaf‖leaf)). The full normative scheme → {odd-node, single-leaf, builder/walk} table, with per-scheme single-leaf vectors, lives in /spec-mbnt §5.1.
text-norm-v1 (content_canonical) / text-line-v1 (chunk_merkle)
Applies to text/plain, text/markdown, source code, and the extracted-text canonicalization of PDFs (see PDF-specific note below).
Canonical-form construction:
- Decode source bytes as UTF-8. If a BOM (
U+FEFF) is present at the start, strip it. - Unicode NFC normalize the entire string.
- Split on
\n(after normalizing CRLF / CR to LF — i.e. replace\r\nand standalone\rwith\n). - For each line, strip trailing whitespace where "whitespace" means ASCII space (
\x20) and ASCII tab (\x09) only. Non-breaking space (), other Unicode spaces, and form-feed are NOT stripped. - Rejoin lines with
\n. - Apply
.trim()on the final string (strips leading/trailing ASCII whitespace and blank lines). - UTF-8 encode → canonical bytes.
content_canonical.hash (or commitment) is sha256 (or HMAC-SHA256) of those bytes.
Merkle leaves for text-line-v1: one leaf per non-empty line in the canonical form (empty lines after normalization are not leaves). Each leaf is sha256(line_bytes_utf8) in standard mode or HMAC-SHA256(salt_i, line_bytes_utf8) in sealed mode.
PDF note: for application/pdf, the canonicalizer extracts text page-by-page via PDF.js (operators) or equivalent (verifiers), applies steps 1–6 to each page's text, then joins pages with \n\n. Merkle leaves are per-page, not per-line. Reference: see recomputePdfProofs in verifier.html (live verifier; view source for the function).
json-jcs-v1 (content_canonical) / json-keypath-v1 (chunk_merkle)
Applies to application/json.
Canonical-form construction follows RFC 8785 (JSON Canonicalization Scheme):
- Parse the source as JSON (UTF-8).
- UTF-8 output.
- NFC-normalize all string values.
- Sort all object keys lexicographically (by UTF-16 code-unit order per RFC 8785 §3.2.3).
- No insignificant whitespace.
- Reject
NaN,+Inf,-Inf. - Canonical number encoding per RFC 8785 §3.2.2 (no trailing zeros after the decimal point, no positive sign, exponential form only when necessary).
Merkle leaves for json-keypath-v1: one leaf per top-level key in the canonical form, in sorted order. Each leaf is sha256(key_utf8_bytes ‖ canonical_value_bytes) (concatenation, no separator). canonical_value_bytes is the JCS encoding of that key's value as a standalone JSON value.
csv-norm-v1 (content_canonical) / csv-row-v1 (chunk_merkle)
Applies to text/csv.
Canonical-form construction:
- Parse the source as RFC 4180 CSV (UTF-8, double-quote escaping,
,delimiter). - Normalize line endings to
\n. - Preserve the header row.
- Preserve column order from the source.
- Re-emit each field with canonical quoting: quote a field iff it contains
,,",\n, or\r. Quoted fields escape"as"". - UTF-8 encode → canonical bytes.
Merkle leaves for csv-row-v1: one leaf per data row (header excluded), in source row order. Each leaf is sha256(row_canonical_bytes).
manifest-items-v1
Manifest-mode bundles (Phase 8b) — binds multiple files via a Merkle root over their hashes. The canonical doc has subject.kind == "manifest" and subject.scheme == "manifest-items-v1". See /spec-mbnt for the leaf-construction rule and /spec §7.2 for /lookup_hash behavior.
merkle-row-v1 / merkle-row-sealed-v1
Selective row-reveal schemes (per-row proof for tabular data). Full spec: /spec-merkle-row. A verifier that supports row-reveal MUST follow that spec for leaf construction and reveal-bundle parsing.
Reference implementation
The canonical byte-level behavior for every scheme above is fixed by the production verifier: verifier.html (live verifier; view source. Functions recomputeProofsFor, recomputePdfProofs, recomputeImageProofs, plus the text/JSON/CSV recompute paths). Where this spec is ambiguous or silent, the verifier's output is authoritative. Canonicalizer behavior is defined by this specification together with the published test vectors and verification helpers linked from the docs.
Unknown schemes
A verifier that does not implement a referenced scheme MUST report the on-chain commitment + the txid + the unsupported scheme name, and MUST NOT claim to have validated that proof. (Cross-ref: SPEC_mbnt.md §4 — same "unknown subtype" rule applies at the canonical-doc layer.)
4.4 Canonicalization for doc_hash
The 20-byte doc_hash is computed from canonical.json via:
canonical_bytes = SCJ_v1(canonical.json)
doc_hash = sha256(canonical_bytes)[:20]
SCJ_v1 is Satsignal Canonical JSON v1 — the rule defined in SPEC_mbnt.md §Canonicalization. It means:
- UTF-8 output
- NFC-normalize all strings (keys and values)
- sort all object keys by Unicode code point
separators=(",",":")— no whitespace, no trailing newline- integers as bare decimal literals; floats forbidden (rejected, not rounded); no
NaN/Infinity - every integer MUST be within the JS-safe range
|n| ≤ 2^53−1(9007199254740991) — a JS verifier re-parses ints viaJSON.parse → Number, so an out-of-range int canonicalizes to different bytes than Python's arbitrary-precision ints and breaks cross-languagedoc_hashre-derivation (canonical.schema.jsonenforces this with"maximum"on every integer field)
Three non-interchangeable canonicalizers — do not substitute one for another. The product canonicalizes JSON with three distinct, non-equivalent rules; the same object hashes to three different forms depending on which scheme anchors it: - SCJ-v1 (this section) — the
canonical.jsonenvelope and themanifest-items-v1leaf preimage. Code-point key sort, NFC, floats forbidden.notary/canonical.py(Py) +verifier/canon.mjs(JS). -json-jcs-v1— thecontent_canonical/json-keypath-v1leaf scheme for JSON file payloads (§4.3). RFC 8785 key/number rules plus an NFC step (UTF-16 code-unit key sort, RFC 8785 §3.2.2 numbers). - RFC 8785 (JCS) proper — the selective-disclosure field profilesatsignal.json.field.v1only (no NFC step); see/spec-disclosure. So SCJ-v1 is NOT RFC 8785: it sorts keys by code point (RFC 8785 sorts by UTF-16 code unit — they differ for supplementary-plane keys) and forbids floats instead of applying RFC 8785 §3.2.2. A verifier MUST NOT use an RFC 8785 / JCS library forcanonical.json, and MUST NOT assume any two of the three rules are interchangeable. The full comparison table is in/spec-mbnt§5; the cross-language byte-parity corpus (tests/vectors/) is the canonical conformance reference.
A bundle's canonical.json entry MUST already be the canonical encoding — a verifier SHOULD re-encode and compare to defend against malformed bundles, but in well-formed bundles the bytes-on-disk equal the bytes-that-were-hashed.
4.5 v1 shape (legacy bundles)
{
"schema_version": 1,
"subtype": "generic",
"issuer": "did:web:satsignal.demo",
"issued_at": "...",
"nonce": "...",
"subject": {
"document_bytes": 51,
"document_name": "tier2-smoke.txt",
"document_sha256": "<64 hex>",
"memo": "...",
"submitter_label": "..."
},
"subtype": "generic"
}
Verifiers MUST accept v1 bundles. The single proof is subject.document_sha256 — verify by recomputing sha256(file_bytes) and comparing.
5. proofs.json
Present when canonical.json.subject.proofs.chunk_merkle is present. Carries the per-leaf material needed for the verifier to rebuild the Merkle root.
{
"scheme": "text-line-v1",
"merkle_leaves": ["<64 hex>", "<64 hex>", "..."],
"metadata": {
"canonical_scheme": "text-norm-v1",
"non_empty_lines": 42
}
}
In sealed mode, an additional field appears:
{
"scheme": "text-line-v1",
"salt_version": "salt_v1",
"merkle_leaves": ["<64 hex>", "..."],
"metadata": {...}
}
The per-leaf salts themselves are NOT stored — they are deterministically derivable from master_salt via HKDF (RFC 5869).
The full HKDF call for each leaf index i ∈ {0, 1, ..., leaf_count-1}:
salt_i = HKDF(
hash = SHA-256,
ikm = master_salt, // 32 bytes (decoded from manifest.salt_b64)
salt = b"satsignal-sealed-v1/per-leaf", // 28 ASCII bytes, literal
info = b"chunk/" || u32_be(i), // 10 bytes total: 6 (b"chunk/") + 4 (big-endian u32 of i)
length = 32, // bytes of output keying material
)
Standard HKDF-Extract then HKDF-Expand per RFC 5869. The salt parameter here is HKDF's "extract salt," not to be confused with the notary protocol's master_salt (which is HKDF's ikm). The literal string "satsignal-sealed-v1/per-leaf" provides domain separation — a future v2 sealed scheme would change this literal so derived keys under different scheme versions are independent.
The leaf commitment is then:
leaf_commitment_i = HMAC-SHA256(salt_i, chunk_i_canonical_bytes)
Merkle inner-node hashing is plain SHA-256 (no HMAC at inner nodes — the HMAC is the leaf-level salting point). See SPEC_v2_sealed.md §3.3 for the security argument (why per-leaf rather than per-tree salting).
Merkle tree construction is the plain concatenated-SHA-256 binary tree from /spec-merkle-row §1.2: pair adjacent nodes, duplicate the last node at odd levels, parent = sha256(left ‖ right). Root MUST equal canonical.json.subject.proofs.chunk_merkle.root (or commitment in sealed mode).
6. On-chain anchor
6.1 OP_RETURN payload layout (inline)
The MBNT payload appears inside the first OP_FALSE OP_RETURN output of the transaction at manifest.txid. The script is:
OP_FALSE OP_RETURN <push N> <payload bytes>
<push N> is either a 1-byte push opcode (0x01..0x4b) or OP_PUSHDATA1 (0x4c) followed by a 1-byte length. Payload is 28–220 bytes.
Payload byte layout:
offset size field encoding
------ ---- ----------- ------------------------------------
0 4 magic ASCII "MBNT" — 0x4D 0x42 0x4E 0x54
4 1 version currently 0x01
5 1 subtype 0x01 = generic (every public anchor)
6 2 tlv_len uint16 big-endian, bytes of TLV section
8 20 doc_hash first 20 bytes of sha256(canonical_doc_canonical_bytes)
28 N tlvs see /spec-mbnt §5–§7 for the TLV registry
A verifier needs only the 28-byte header to validate the binding. TLVs (currency, issuer_id, timestamp_unix, etc.) carry optional public metadata; a verifier MUST tolerate unknown TLV tags. See SPEC_mbnt.md §6–§7 for the full TLV registry.
6.2 Resolving txid → raw transaction (universal)
A verifier resolves manifest.txid to a raw transaction via any public BSV node. This is the standard verify-path; it requires no Satsignal-operated endpoint and works for bundles produced by any deployment.
Production verifiers use:
- WhatsOnChain
https://api.whatsonchain.com/v1/bsv/main/tx/hash/{txid}— returns JSON withvout[]and aconfirmationsfield. - Bitails
https://api.bitails.io/tx/{txid}— fallback when WoC is rate-limiting.
Any BSV node exposing raw-tx-by-hash works; the explorer choice is not normative. The verifier needs vout[].scriptPubKey (raw hex) and confirmations from whatever resource it queries.
6.3 Resolving file-hash → txid (Satsignal-issued bundles only)
This section applies only to a discovery workflow — a verifier that holds the file but not the bundle, and needs to look up the anchoring transaction by the file's SHA-256. It is not part of the verify procedure when a .mbnt is in hand (Section 6.2 already provides the txid via manifest.txid).
For Satsignal-issued bundles, the /lookup_hash?sha={byte_exact_sha256} endpoint on https://proof.satsignal.cloud is the canonical resolver. It returns {proof_id, created_utc, txid} keyed on the naked file SHA-256.
Sealed-mode and manifest-mode bundles are excluded from /lookup_hash by design (see /spec §7.2). The lookup endpoint only resolves standard-mode anchors; for sealed and manifest bundles, the verifier MUST already have the bundle (or be supplied the txid out-of-band) and use Section 6.2 directly.
/lookup_hash is a Satsignal-operator-run convenience and is not required for protocol conformance. A third-party deployment running its own notary would expose its own resolver or none at all; the core verify path in Section 6.2 + Section 7 stays unchanged.
6.4 Deriving doc_hash from the transaction
Walk the outputs; for each scriptPubKey, check for the 00 6a prefix (OP_FALSE OP_RETURN) and a single push; the pushed bytes whose first 4 bytes are MBNT is the payload. Slice doc_hash_on_chain = payload[8:28]. Constant-time compare to sha256(SCJ_v1(canonical.json))[:20]. Equal means the document was committed in this transaction; not equal means the document does not match this anchor — full stop.
7. Conformant verification procedure
A verifier MUST perform these checks in order. Failing any check is a verification failure with the error class given in Section 8.
7.1 Bundle structure (any mode)
- Open the
.mbntZIP archive. Readmanifest.jsonandcanonical.json. (Error class CRYPTO if either is missing or not parseable as UTF-8 JSON.) - If
canonical.json.subject.proofs.chunk_merkleis present, readproofs.json. (Error class CRYPTO if absent.) - Confirm
manifest.mbnt_versionis supported (one of"1.1","2.0","2.1"). Refuse unknown values. (Error class VERSION.)
7.2 Cryptographic checks
In standard mode (manifest has no mode field):
- Recompute
sha256(file_bytes)from the user-supplied file. Compare tocanonical.json.subject.proofs.byte_exact.hash(v2) orcanonical.json.subject.document_sha256(v1). MUST match. (Error class CRYPTO.) - If
content_canonicalis present, re-canonicalize the file according to itsscheme, recomputesha256(canonical_bytes), compare tocontent_canonical.hash. MUST match if computed. - If
chunk_merkleis present, chunk the file according to itsscheme, compute leaf hashes, build the Merkle root per §5, compare tochunk_merkle.root. MUST match. MUST also confirmlen(proofs.json.merkle_leaves) == chunk_merkle.leaf_count.
In sealed mode (manifest mode == "sealed"):
- Decode
master_salt = base64url_decode(manifest.salt_b64). - Recompute
HMAC-SHA256(master_salt, file_bytes), compare tosubject.proofs.byte_exact.commitment. - If
content_canonicalis present, repeat for the canonicalized form. - If
chunk_merkleis present, derive per-leaf salts via HKDF (full parameters in §5), computeHMAC-SHA256(salt_i, chunk_i_canonical), build the Merkle root (inner nodes plain SHA-256), compare tochunk_merkle.root. (Note the field name:byte_exactandcontent_canonicalusecommitment, butchunk_merkleusesrooteven in sealed mode — see §4.2.)
7.3 doc_hash consistency
- Re-encode
canonical.jsonto SCJ-v1 (Section 4.4), SHA-256, slice to 20 bytes. Hex-encode. - Compare to
manifest.doc_hash_expected. MUST match. (Error class CRYPTO — a mismatch here means the canonical doc in the bundle has been altered relative to what was anchored.)
7.4 Chain confirmation
- Resolve
manifest.txidto a raw transaction (Section 6.2). On network failure, error class NETWORK (retry-friendly). - Parse the OP_RETURN payload per Section 6.1. Confirm magic
MBNT, version0x01, subtype0x01. - Slice
doc_hash_on_chain = payload[8:28]. Constant-time compare to the 20-byte form ofmanifest.doc_hash_expected. MUST match. (Error class CHAIN.) - Read
confirmationsfrom the explorer response. If0, report PENDING (Section 8) — the anchor is broadcast but not yet mined. A verifier MUST NOT claim "verified" on a 0-confirmation proof; it MUST report "broadcast, awaiting confirmation" or equivalent. SeeSPEC_mbnt.md §10for use-case-specific minimum-depth recommendations.
A bundle that passes 7.1–7.3 with confirmations >= 1 is verified. A bundle that passes 7.1–7.3 with confirmations == 0 is pending.
7.5 Offline mode
A verifier MAY skip Section 7.4 if the caller explicitly opts in (e.g. --offline). Such a verifier MUST report results as "cryptographic checks pass; on-chain status NOT verified" and MUST NOT downgrade the warning under any flag. The reason is a documented threat: locally-fabricated bundles pass crypto-only checks; only the chain confirmation distinguishes a real anchor from a forged one. (Cross-ref the project's chain-confirm-in-helpers policy.)
8. Error model
A conformant verifier distinguishes these error classes and exposes them to its caller:
| class | meaning | retry semantics |
|---|---|---|
CRYPTO | bundle is malformed, hashes don't match, or contents have been altered | not retryable — the bundle is invalid |
CHAIN | bundle parses cleanly but the on-chain anchor does not commit to this canonical doc | not retryable — the bundle does not match its claimed transaction |
VERSION | mbnt_version or OP_RETURN version byte is unsupported | not retryable in this verifier; may succeed in a newer one |
NETWORK | could not reach explorer / /lookup_hash | retryable |
PENDING | crypto + on-chain checks pass but confirmations == 0 | retryable after the next block (~10 min on BSV) — not a failure per se |
OFFLINE | caller opted into offline mode; chain confirmation skipped | success-with-caveat, not a failure |
Recommended CLI exit codes (a future satsignal verify CLI is expected to use these; SDK callers MAY surface the class directly):
| exit | class |
|---|---|
| 0 | verified, pending, or offline-with-warning |
| 1 | CRYPTO |
| 2 | CHAIN |
| 3 | NETWORK |
| 4 | auth error (verifier needed credentialed lookup) |
| 5 | bundle not found / unreadable |
| 6 | VERSION |
| 7 | STRICT_INCOMPLETE — anchor broadcast on-chain but local sidecar was not written (e.g. server returned no bundle_url); only emitted when the caller opted in via --strict or equivalent |
| 8 | SPV — standard verify passed but SPV check (TSC merkle proof against the caller's local validated headers chain) failed: target block not in the local chain, bad merkle path, or proof fetch error. Only emitted when the caller opted in via --spv or equivalent |
Codes 7 and 8 are RESERVED extensions for a future satsignal verify CLI (with --strict / --spv) that is not yet shipped — no shipped CLI emits them today. They are CLI-level extensions, not verifier-conformance requirements, and would only fire when the caller opts in (--strict, --spv). An SDK or verifier surfacing classes directly can omit them; an SDK building an SPV-strict verifier MAY adopt 8 for parallel exit semantics.
PENDING returning exit 0 is intentional: a verify && commit sequence should succeed the moment a real anchor is broadcast, with the pending status surfaced as a stderr warning. Callers wanting strict confirmation gating opt in via --min-confirmations N. STRICT_INCOMPLETE is a strict-mode opt-in for anchor flows where a missing local sidecar is a script-correctness failure even when the on-chain anchor itself succeeded. SPV is the opt-in for self-sovereign verification: the explorer is reduced to a network proxy that can supply a proof but cannot lie about chain inclusion, because the authoritative merkleroot comes from the caller's own PoW-validated headers chain.
9. Versioning and forward compatibility
9.1 mbnt_version values
| value | introduced | meaning |
|---|---|---|
"1.1" | v1 launch | single-proof bundles (byte_exact only); canonical doc schema_version: 1 |
"2.0" | v2 launch | multi-proof bundles; canonical doc schema_version: 2; standard mode |
"2.1" | sealed launch | adds sealed-mode manifest fields (mode, salt_b64, bearer_secret, salt_version) |
A v1-only verifier MUST refuse "2.0" and "2.1" rather than attempt a partial parse. A v2-only verifier MUST refuse "2.1" unless it also supports sealed mode. A future "3.0" will keep the shape additive; existing fields will not be silently re-interpreted.
9.2 Unknown JSON fields
Verifiers MUST tolerate unknown top-level keys in manifest.json, canonical.json, and proofs.json (skip and continue). The Satsignal service may add informational fields in any release; existing verifiers continue to operate.
9.3 Unknown OP_RETURN fields
Verifiers MUST tolerate unknown TLV tags in the OP_RETURN payload (record as (tag, value) opaquely). Verifiers MUST refuse unknown version bytes and unknown subtype bytes — both are protocol-changing signals, and silently accepting them defeats the spec-as-contract.
9.4 Standard-mode dispatch — fragility and migration
Standard-mode dispatch today relies on the absence of manifest.mode (Section 3.2). This is a fragile signal — a forwarder, log-pipeline, or schema-conformant proxy that drops unknown top-level keys (per the permissive rule in §9.2) could silently transform a sealed manifest into one that parses as standard. The bundle would still fail verification at the cryptographic step (commitments won't match plain SHA-256), but the mode signal itself is not robust.
Planned two-stage migration:
Stage 1 — additive, no version bump. customer/anchor.py starts emitting "mode": "standard" explicitly on new standard bundles. Verifiers stay permissive: missing mode continues to mean standard. Bundles with explicit "mode": "standard" parse identically. No coordination required across implementations; existing verifiers operate unchanged.
Stage 2 — mbnt_version 3.0. Verifiers in this version reject manifests with no mode field. Bundles produced under mbnt_version "1.1" / "2.0" / "2.1" continue to parse under the v2 codepath; only new "3.0" bundles enforce the explicit-mode rule. Wire-format dispatch on mbnt_version keeps existing bundles verifiable indefinitely.
Stage 1 is a unilateral change on the producer side and may land at any time. Stage 2 is a coordinated version bump and waits for the next mbnt_version rev. This spec is authoritative for the intent; the actual rollout schedule is tracked separately.
9.5 Disclosure block (manifest.disclosure)
Selective disclosure is delivered as an additive optional top-level key disclosure on manifest.json, defined fully in disclosure-v1.md. The canonical document (canonical.json) is unchanged by selective disclosure: no new subtype is allocated, schema_version is not bumped, and mbnt_version is not bumped. The on-chain anchor of the original document is identical whether or not a disclosure is later derived from it. A disclosure bundle is an ordinary .mbnt whose manifest happens to carry the disclosure key.
Verifier behavior split. Disclosure carries no new wire-format dispatch byte; existing bundles continue to verify under §7 unchanged. Two classes of verifier coexist:
- Disclosure-unaware verifier. Treats
manifest.disclosureas an unknown top-level key under §9.2 and parses the rest of the bundle exactly as before. No new failure modes are introduced. A disclosure-unaware verifier MUST NOT pretend to have verified the disclosure: it MUST NOT surface revealed-leaf content as "verified," MUST NOT render the disclosure block's claims text, and MUST NOT emit a partial-success indicator for the disclosure checks it did not run. The two legal behaviors are (a) render the bundle as a normal proof, ignoringmanifest.disclosureentirely, or (b) decline to render the bundle at all. - Disclosure-aware verifier. In addition to the standard
bundle-v1checks (§7 of this document), runs the linked-anchor binding chain indisclosure-v1.md §4steps 1–5 and the linked-anchor verification contract indisclosure-v1.md §7steps 1–5. Both check sets MUST pass; partial success is not a legal outcome.
Constraints bundle-v1 conformance subsumes. Two .mbnt-shape constraints follow from disclosure-v1.md §4 and are restated here so a producer reading only this spec does not get them wrong:
- A disclosure bundle MUST NOT carry the original anchor's
proofs.json(or any other file that would reveal unrevealed leaves of the original). This is the forever-prohibition fromdisclosure-v1.md §4: a disclosure that ships the original'sproofs.jsonhas revealed every leaf and defeated the selective property. A bundle that carries it is malformed. This is the only new MUST thatbundle-v1conformance acquires from the disclosure feature. - A disclosure bundle MAY carry a copy of the original anchor's canonical bytes at the zip entry path
linked_anchor/canonical.json(the offline-verification carrier fromdisclosure-v1.md §4step 1). This is the only new entry path inside the.mbntzip thatbundle-v1needs to acknowledge. A verifier extracting bundle entries SHOULD recognize this path and route it to the disclosure-aware binding chain. Absent any such entry, online resolution of the original.mbntis implementation-defined.
API surface pointer. When a bundle carries manifest.disclosure, the read-side API endpoints (GET /api/v1/proofs/<id> and the legacy alias GET /api/v1/receipts/<id>) surface an additive proof_kind field (spelled receipt_kind before the vocabulary flip) — "disclosure" for such proofs, "standard" otherwise. Full contract (legacy-absence handling, forbidden values, non-overlap with the existing mode axis) lives in disclosure-v1.md §10.
Cross-references.
- Normative:
disclosure-v1.md(whole file). - Sibling conformance:
CONFORMANCE_disclosure.md. - Per-profile bindings (segmentation, preimage, salting,
leaf_id):profiles/csv-row-v1.md,profiles/json-field-v1.md,profiles/text-paragraph-sentence-v1.md.
10. Conformance summary
Full normative checklist with test-vector and test-script pointers: see CONFORMANCE_bundle.md. The list below is the prose summary; that document is the flat itemized form used by verifier / producer authors as a build-time checklist.
A verifier is conformant with bundle v1 if it:
- Parses the
.mbntZIP envelope per Section 2. - Reads
manifest.json,canonical.json, andproofs.jsonper Sections 3–5. - Distinguishes standard vs. sealed mode via the presence/absence of
manifest.mode("sealed"→ sealed mode; absent → standard mode; see §3.2 and §9.4) and applies the appropriate cryptographic checks (Section 7.2). - Re-derives
doc_hashper Section 4.4 and Section 7.3. - Fetches the raw transaction and parses the OP_RETURN payload per Section 6.
- Distinguishes
verified/pending/offline/CRYPTO/CHAIN/VERSION/NETWORKper Section 8. - Refuses unknown versions and unknown subtypes; tolerates unknown JSON fields, unknown TLV tags, and extra ZIP entries.
- Rejects ZIP buffers with leading data before the first local file header (§2.2).
- Rejects ZIP buffers with a non-empty EOCD comment (§2.2).
- Rejects ZIP buffers containing more than one EOCD signature (§2.2).
- Rejects ZIP central directories with duplicate filenames (§2.2).
- Rejects ZIP entries with
..path components, leading/, or backslashes in the filename (§2.2). - When producing a disclosure bundle (one whose
manifest.jsoncarriesdisclosure), MUST NOT include the original anchor'sproofs.jsonor any other file revealing unrevealed leaves of the original (§9.5; forever-prohibition fromdisclosure-v1.md §4).
A verifier that omits Section 7.4 by default (no chain confirmation) is not conformant. The chain check is the load-bearing distinction between this protocol and any locally re-derivable hash chain; making it opt-in inverts the safety property the protocol exists to provide.
Appendix A — External witness proofs as leaves (non-normative)
Status. Non-normative, additive, optional. Nothing in §§1–10 changes: existing leaves, the satsignal-agent-decision-v1 envelope, canonical.json, the merkle-row-v1 / merkle-row-sealed-v1 schemes (§4.3), the commitment recipe, doc_hash, the MBNT OP_RETURN payload (§6.1), and the verification procedure (§7) are all UNCHANGED. The notary stays content-blind: a leaf is sha256 over its canonical bytes — or, in sealed mode, HMAC-SHA256(salt_i, …) (§5) — whatever those bytes hold, so an externally-signed witness proof is already just a leaf. This appendix records ONE optional convention for shaping that leaf, so independent adopters do not each invent an incompatible one — no migration and no new verifier codepath are required to use it.
What it is. A witness proof is a statement signed by a party outside the operator's trust boundary, attesting one facet of an agent action — intent, identity, observation, action, outcome, policy, or execution-integrity. Example witnesses: a receiver or counterparty acknowledging what it got, a TLS witness (zkTLS / TLSNotary) attesting an observed exchange, a TEE attestation quote, or a policy-enforcement point signing what it admitted. The operator's own attestation block (§4.1, method: operator_attested) is self-attested; a witness proof is the same idea signed by someone else and carried as an ordinary leaf the operator cannot forge.
Suggested leaf shape (RECOMMENDED, not mandated). Canonicalize with SCJ-v1 (§4.4) — the same rule the canonical.json envelope and the manifest-items-v1 / merkle-row leaf preimages use (not RFC 8785 / JCS):
{
"type": "witness-proof-v1",
"witness": "did:web:counterparty.example",
"key_discovery": "did:web",
"facet": "observation",
"binds": "<target step's decision_sha256_hex, or a shared nonce_hex>",
"statement": { "...": "witness-defined attested fact" },
"sig_alg": "ed25519",
"signature": "<base64>"
}
type is the recognizer tag (e.g. "witness-proof-v1"); witness is the signing party's id (a DID or other stable id); key_discovery tells a verifier how to find and validate the witness public key (did:web, jwks_url, or x5c); facet names the attested facet from the list above; binds ties the leaf to a step (see below); statement is the witness-defined fact; sig_alg / signature carry the algorithm and the signature. The signed message is the SCJ-v1 canonical leaf bytes without the signature field, and those same canonical bytes are the leaf preimage hashed into the Merkle tree (§5).
Step binding. So that a witness proof is provably about a specific step rather than a free-floating claim, the witness SHOULD sign over that step's decision_sha256_hex (or a nonce_hex shared with the satsignal-agent-decision-v1 envelope — see the agent integration spec). This reuses the existing decision-envelope / nonce linkage rather than inventing a new one.
Verifier guidance. A verifier that recognizes type SHOULD (a) verify signature against the key discovered via key_discovery, (b) confirm binds matches a revealed step, and (c) treat the leaf as attesting ONLY what that witness observed.
What this does and does NOT prove. As stated canonically in What Satsignal proves and does NOT prove, the anchor binds the witness proof's existence, its time (an upper bound), and its integrity — NOT its truth. A favorable witness proof does not prove the action was complete or correct, and the absence of a contradicting one is not proof: a holder may omit inconvenient leaves, exactly the completeness limit described for selective row reveal in /spec-merkle-row §4.3. Whether to trust a given witness is the verifier's to assess; the chain only fixes that the leaf existed at time T.
Registry. A recognized type MAY later be registered as a scheme string in schemes.json (§4.3) IF auto-recognition by the hosted verifier is wanted. That step is optional and additive; the convention works today with no registry entry.
Appendix B — Making a precommit meaningful (non-normative)
Status. Non-normative, additive, optional. Nothing in §§1–10 or Appendix A changes. A "precommit" is not a new scheme, a new subtype, or a new verifier codepath — it is the existing category="commitment" anchor / commit-reveal (/spec §12.3) together with the agent pattern's policy_snapshot + per-decision + manifest-root discipline (see the agent integration spec), used deliberately. Both already verify under §7 exactly as written. This appendix records how to make a precommit carry evidentiary weight, so independent adopters do not each rediscover the same weaknesses.
What a precommit proves (scoped, precise). After a valid reveal, a precommit establishes exactly two facts about the precommitted content: (1) the revealed bytes were fixed no later than block_time(T_commit) — this is preimage resistance, the same upper-bound timing the rest of the protocol gives; and (2) total BSV block order puts T_commit before T_result — block_time(T_commit) < block_time(T_result). Together those are an ordering / lower-bound fact for the precommitted frame: a verifier MAY conclude that the governing frame (intent, policy, expected outcome or scope) was fixed before the result it governs, which defeats after-the-fact fabrication of that frame. This is the one new thing a precommit adds over a bare anchor; everything in What Satsignal proves and does NOT prove still bounds it.
What it does NOT prove. A precommit does not prove the action happened, that the agent obeyed the precommit, that the precommitted policy was enforced at runtime, or that any observation it references is real. Conformance of action-to-precommit is an off-chain divergence check the verifier performs by comparing the revealed precommit to the revealed result; the chain supplies only ordering + integrity, never ground truth. The same garbage-in ceiling that bounds every anchor bounds this — an agent that precommits a policy and then ignores it still produces a perfectly valid precommit of the policy, not of compliance, exactly the completeness limit of /spec-merkle-row §4.3.
Why a naive precommit is weak (attacks it must survive). A precommit that is merely anchored, with no further discipline, buys almost nothing:
- Grinding. Commit-reveal is purely client-side and the protocol meters it only by account quota, not by any cryptographic cost; the fresh-nonce envelope (the agent pattern's "timing pre-discloses without content pre-disclosing") hides content until reveal. So an operator MAY cheaply anchor many candidate frames and reveal only the one that matches the outcome. A chain observer sees opaque hashes plus timestamps and cannot distinguish a grinding spray from a single honest commitment.
- Vague / unfalsifiable scope. Open-vocab scope tags (provenance
scopesare open vocabulary;extensionsare "not interpreted by Satsignal" —/spec-provenance) and unrevealed digest pointers can bind nothing a verifier can check. - Non-enforcement. Nothing in the protocol couples the precommit to actual execution; truthful instrumentation stays an integration obligation.
- Selective non-reveal / equivocation / replay. The holder MAY reveal only the favorable precommit; the manifest Merkle root closes only the set the holder included; and with no recipient binding, an old precommit MAY be re-presented for a new action.
Disciplines that make a precommit meaningful (RECOMMENDED, ranked by leverage).
- Co-sign the precommit with a party OUTSIDE the operator's trust boundary. Carry an external signature over the precommitted frame — a
witness-proof-v1leaf (Appendix A), or an outside-signature pointer: a provenancesignature_ref(/spec-provenance) or acounterparty_hashTLV (/spec-mbnttag0x04). This is the only discipline that adds a fact the operator cannot manufacture alone: a counterparty would have to co-sign every candidate (defeats grinding), will not sign a meaningless commitment (defeats vagueness), and holds an independent copy (defeats selective non-reveal). Satsignal anchors the pointer and does not validate the outside signature, so the verifier MUST. - Make the precommitted scope a LOW-ENTROPY ENUMERABLE closed set. Precommit a checkable universe — e.g.
{"expected_bidder_ids":[...]}— so a single later reveal is verifiable against a pre-published set. Enumerable means falsifiable, and it caps grinding to one visible commitment./spec §12.4prescribes exactly this for binding a table's intended scope. - One binding precommit per decision, reveal-all-under-identity, audited via NON-suppressed
issuer_idenumeration. A verifier pins the issuer DID, uses theissuer_idTLV (/spec-mbnttag0x05) to enumerate that operator'scategory="commitment"anchors beforeT_result, and requires each to be revealed; unexplained extras are a red flag for grinding. This works only whereissuer_idsuppression is forbidden — suppression is an operator pipeline setting (/whats-on-chain §4). - Close the decision set with the manifest Merkle root — the agent pattern's end-of-run manifest — so the precommitted set itself is tamper-evident as a group.
Verifier guidance. A verifier relying on a precommit SHOULD confirm, in addition to the §7 checks: (a) the reveal preimages the on-chain commit hash under SCJ-v1 (§4.4); (b) block_time(T_commit) < block_time(T_result); (c) the precommitted scope is enumerable / falsifiable — a closed set, not a free-form tag or an unrevealed pointer; (d) any co-signer validates and binds to this step (per Appendix A's binds); (e) no unrevealed sibling commitments exist under the pinned issuer identity; and (f) the divergence, computed off-chain, between the precommitted frame and the revealed result.
Caveat. Every discipline here is OPT-IN and verifier-side — none is a fail-closed protocol invariant. The spec itself calls the commit-reveal pattern "overkill for most audit settings" and "purely client-side" (/spec §12.3). A precommit's weight is exactly what the verifier demands of it, and no more. Even the strongest stack — precommit + an external witness proof (Appendix A) + a post-anchor — tops out at ordering plus externally-witnessed facets; it never establishes the ground truth that the agent actually performed the action. As everywhere else, What Satsignal proves and does NOT prove is the ceiling.
Questions about this specification? Email hello@satsignal.cloud.