Bundle v1 — Satsignal .mbnt Receipt Spec

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 implementation: the official satsignal-cli (pip install satsignal-cli) implements §7 of this spec in pure Python (stdlib + requests; HKDF inline). Read its src/satsignal/verify.py alongside this document for a worked example of the conformant verification procedure.

1. Scope

A Satsignal .mbnt receipt binds an off-chain canonical document to an on-chain BSV transaction. This document specifies:

Relation to existing specs — for protocol rationale, cross-reference:

topicauthoritative sourcethis doc inlines
OP_RETURN wire format, TLV registry, subtype codesSPEC_mbnt.mdheader layout an implementer needs (§6.1)
canonical-doc schema rationale, scheme tags, Merkle constructionSPEC_v2.mdparser-facing field list (§4)
sealed-mode threat model, HKDF derivation, blind protocolSPEC_v2_sealed.mdsealed-mode manifest deltas (§3.2) + verification (§7.3)
row-reveal schemes merkle-row-v1, merkle-row-sealed-v1SPEC_merkle_row.mdscheme-tag dispatch (§4.3)

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.


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:

entryrequiredcontent typepurpose
manifest.jsonyesUTF-8 JSONbundle metadata, mode, chain anchor reference (§3)
canonical.jsonyesUTF-8 JSON, compact (no whitespace)the document whose hash is committed on-chain (§4)
proofs.jsonwhen chunk_merkle is present in canonical.jsonUTF-8 JSONMerkle leaves + scheme re-derivation hints (§5)
attachments/<name>noany bytesoptional 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 compact-JCS 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.


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>"
}
fieldtyperequiredmeaning
mbnt_versionstringyesone of "1.1", "2.0", "2.1". Selects verifier codepath (§9).
txidstringyes64 lowercase hex chars. The BSV transaction whose OP_RETURN carries the on-chain commitment.
networkstringyescurrently always "bsv-mainnet". Future deployments may add other values; verifiers MUST refuse unknown networks unless they explicitly support them.
doc_hash_expectedstringyes40 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:

fieldtypemeaning
categorystringoff-chain bucket tag (e.g. "agent-run", "evidence"). Display-only.
acceptanceobjectARC miner-signed acceptance metadata when the broadcast went via ARC. Keys: endpoint, miner, status, accepted_at_utc. Absent on ElectrumX broadcasts, mock receipts, dry-runs.
proof_modestringlegacy hint emitted by standard-mode bundles, 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 do not emit this field; they use mode: "sealed" (§3.3). Verifiers MUST NOT switch on proof_mode; dispatch on mode instead. Retained for shape compatibility with pre-2.0 verifiers.

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:

fieldtypemeaning
filenamestringsource filename for receipt-page rendering. Display-only; does not affect verification.

Correction to SPEC_v2_sealed.md §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"
}
fieldtyperequiredmeaning
modestringyesexactly "sealed".
salt_versionstringyescurrently "salt_v1". Names the salt-derivation scheme; future schemes will introduce new tags.
salt_b64stringyesbase64url encoding of 32 random bytes (the master salt). Re-decoded as raw bytes for HMAC and HKDF. Bearer secret.
bearer_secretboolyesalways true in sealed manifests. Renderers (verifiers, receipt pages) MUST display a prominent warning that this bundle is privacy-bearing.
server_retain_until_utcstring (ISO 8601 UTC)nowhen the server-side copy of the bundle is scheduled for deletion. Display-only; does not affect verification. Absent in blind-submission bundles (no server-side copy exists).

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_v2_sealed.md §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_v2_sealed.md §7.2 for /lookup_hash behavior on manifest-mode bundles.


4. canonical.json

The canonical document is the object whose hash is committed on-chain. Its shape depends on schema_version:

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-hosted-demo"
  },
  "attachments": []
}

Required fields: schema_version, issuer, issued_at, nonce, subject, 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).

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-hosted-demo"
  },
  "attachments": []
}

Field rules:

fieldsealed-mode meaning
algo"hmac-sha256" for byte_exact and content_canonical; "merkle-hmac-sha256" for chunk_merkle.
salt_versionrequired 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).
commitment64 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_countunchanged from standard mode.

For protocol rationale (why HMAC + HKDF per-leaf rather than a single salt), see SPEC_v2_sealed.md §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:

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:

  1. Decode source bytes as UTF-8. If a BOM (U+FEFF) is present at the start, strip it.
  2. Unicode NFC normalize the entire string.
  3. Split on \n (after normalizing CRLF / CR to LF — i.e. replace \r\n and standalone \r with \n).
  4. 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.
  5. Rejoin lines with \n.
  6. Apply .trim() on the final string (strips leading/trailing ASCII whitespace and blank lines).
  7. 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.

json-jcs-v1 (content_canonical) / json-keypath-v1 (chunk_merkle)

Applies to application/json.

Canonical-form construction follows RFC 8785 (JSON Canonicalization Scheme):

  1. Parse the source as JSON (UTF-8).
  2. UTF-8 output.
  3. NFC-normalize all string values.
  4. Sort all object keys lexicographically (by UTF-16 code-unit order per RFC 8785 §3.2.3).
  5. No insignificant whitespace.
  6. Reject NaN, +Inf, -Inf.
  7. 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:

  1. Parse the source as RFC 4180 CSV (UTF-8, double-quote escaping, , delimiter).
  2. Normalize line endings to \n.
  3. Preserve the header row.
  4. Preserve column order from the source.
  5. Re-emit each field with canonical quoting: quote a field iff it contains ,, ", \n, or \r. Quoted fields escape " as "".
  6. 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 notary/manifest.py in the source repo for the leaf-construction rule and SPEC_v2_sealed.md §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.md. 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 (functions recomputeProofsFor, recomputePdfProofs, recomputeImageProofs, plus the text/JSON/CSV recompute paths). Where this spec is ambiguous or silent, the verifier's output is authoritative. A planned follow-on SPEC_canonicalizers.md will lift this dependency by pinning each scheme byte-for-byte; until then, port from the verifier source and add cross-impl test vectors.

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 = JCS(canonical.json)
doc_hash = sha256(canonical_bytes)[:20]

JCS (RFC 8785) means:

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_v2.md §"Merkle tree construction": 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.md §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:

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?h={byte_exact_sha256} endpoint on https://proof.satsignal.cloud is the canonical resolver. It returns {txid, mode, confirmations, ...} keyed on the naked file SHA-256.

Sealed-mode and manifest-mode bundles are excluded from /lookup_hash by design (see SPEC_v2_sealed.md §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(JCS(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)

  1. Open the .mbnt ZIP archive. Read manifest.json and canonical.json. (Error class CRYPTO if either is missing or not parseable as UTF-8 JSON.)
  2. If canonical.json.subject.proofs.chunk_merkle is present, read proofs.json. (Error class CRYPTO if absent.)
  3. Confirm manifest.mbnt_version is 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):

  1. Recompute sha256(file_bytes) from the user-supplied file. Compare to canonical.json.subject.proofs.byte_exact.hash (v2) or canonical.json.subject.document_sha256 (v1). MUST match. (Error class CRYPTO.)
  2. If content_canonical is present, re-canonicalize the file according to its scheme, recompute sha256(canonical_bytes), compare to content_canonical.hash. MUST match if computed.
  3. If chunk_merkle is present, chunk the file according to its scheme, compute leaf hashes, build the Merkle root per §5, compare to chunk_merkle.root. MUST match. MUST also confirm len(proofs.json.merkle_leaves) == chunk_merkle.leaf_count.

In sealed mode (manifest mode == "sealed"):

  1. Decode master_salt = base64url_decode(manifest.salt_b64).
  2. Recompute HMAC-SHA256(master_salt, file_bytes), compare to subject.proofs.byte_exact.commitment.
  3. If content_canonical is present, repeat for the canonicalized form.
  4. If chunk_merkle is present, derive per-leaf salts via HKDF (full parameters in §5), compute HMAC-SHA256(salt_i, chunk_i_canonical), build the Merkle root (inner nodes plain SHA-256), compare to chunk_merkle.root. (Note the field name: byte_exact and content_canonical use commitment, but chunk_merkle uses root even in sealed mode — see §4.2.)

7.3 doc_hash consistency

  1. Re-encode canonical.json to JCS (Section 4.4), SHA-256, slice to 20 bytes. Hex-encode.
  2. 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

  1. Resolve manifest.txid to a raw transaction (Section 6.2). On network failure, error class NETWORK (retry-friendly).
  2. Parse the OP_RETURN payload per Section 6.1. Confirm magic MBNT, version 0x01, subtype 0x01.
  3. Slice doc_hash_on_chain = payload[8:28]. Constant-time compare to the 20-byte form of manifest.doc_hash_expected. MUST match. (Error class CHAIN.)
  4. Read confirmations from the explorer response. If 0, report PENDING (Section 8) — the anchor is broadcast but not yet mined. A verifier MUST NOT claim "verified" on a 0-confirmation receipt; it MUST report "broadcast, awaiting confirmation" or equivalent. See SPEC_mbnt.md §10 for 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:

classmeaningretry semantics
CRYPTObundle is malformed, hashes don't match, or contents have been alterednot retryable — the bundle is invalid
CHAINbundle parses cleanly but the on-chain anchor does not commit to this canonical docnot retryable — the bundle does not match its claimed transaction
VERSIONmbnt_version or OP_RETURN version byte is unsupportednot retryable in this verifier; may succeed in a newer one
NETWORKcould not reach explorer / /lookup_hashretryable
PENDINGcrypto + on-chain checks pass but confirmations == 0retryable after the next block (~10 min on BSV) — not a failure per se
OFFLINEcaller opted into offline mode; chain confirmation skippedsuccess-with-caveat, not a failure

Recommended CLI exit codes (the satsignal verify sketch will use these; SDK callers MAY surface the class directly):

exitclass
0verified, pending, or offline-with-warning
1CRYPTO
2CHAIN
3NETWORK
4auth error (verifier needed credentialed lookup)
5bundle not found / unreadable
6VERSION
7STRICT_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
8SPV — 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 CLI-level extensions, not verifier-conformance requirements — they 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

valueintroducedmeaning
"1.1"v1 launchsingle-proof bundles (byte_exact only); canonical doc schema_version: 1
"2.0"v2 launchmulti-proof bundles; canonical doc schema_version: 2; standard mode
"2.1"sealed launchadds 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.


10. Conformance summary

A verifier is conformant with bundle v1 if it:

  1. Parses the .mbnt ZIP envelope per Section 2.
  2. Reads manifest.json, canonical.json, and proofs.json per Sections 3–5.
  3. 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).
  4. Re-derives doc_hash per Section 4.4 and Section 7.3.
  5. Fetches the raw transaction and parses the OP_RETURN payload per Section 6.
  6. Distinguishes verified / pending / offline / CRYPTO / CHAIN / VERSION / NETWORK per Section 8.
  7. Refuses unknown versions and unknown subtypes; tolerates unknown JSON fields, unknown TLV tags, and extra ZIP entries.

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.

Source: docs/notary_spec/bundle-v1.md. Email hello@satsignal.cloud for clarifications.