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 .mbnt file (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:

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

topicauthoritative sourcethis doc inlines
OP_RETURN wire format, TLV registry, subtype codes/spec-mbntheader layout an implementer needs (§6.1)
Merkle tree construction, scheme tags/spec-merkle-rowparser-facing field list (§4)
sealed-mode threat model, HKDF derivation, blind protocol/specsealed-mode manifest deltas (§3.2) + verification (§7.3)
row-reveal schemes merkle-row-v1, merkle-row-sealed-v1/spec-merkle-rowscheme-tag dispatch (§4.3)
selective-disclosure manifest, profile literals, binding chain/spec-disclosureadditive 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.

What an anchor does NOT prove.

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.

  1. 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.)
  2. 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.
  3. You get a bundle back. The .mbnt bundle (a ZIP — §2) carries the canonical document, the manifest with the txid, and any proof material. The proof is the logical record; the bundle is the file that lets anyone re-derive and check it.
  4. 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:

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 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.)


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.
acceptanceobjectInformational 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_modestringlegacy 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:

fieldtypemeaning
filenamestringsource 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"
}
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, proof 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. 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_retentionstringnoexactly "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:

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_commitment is recorded, not verified. A sealed closing handoff may carry an optional fourth proof type, session_commitment (scheme: "merkle-session-v1", with root, leaf_count; see /spec §4.5). It is not part of the §7.2 cryptographic check sequence, which covers only byte_exact, content_canonical, and chunk_merkle. The current reference verifier does not process or display session_commitment at all — it neither verifies nor surfaces the field. The reason it cannot be verified: the session leaves needed to re-derive the merkle-session-v1 root are not carried in the canonical doc, so an independent single-bundle verifier cannot recompute it. A future verifier MAY surface scheme / 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 rule root == sha256(leaf‖leaf)) is pinned by tests/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:

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 §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:

  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 (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):

  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 /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:

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.json envelope and the manifest-items-v1 leaf preimage. Code-point key sort, NFC, floats forbidden. notary/canonical.py (Py) + verifier/canon.mjs (JS). - json-jcs-v1 — the content_canonical / json-keypath-v1 leaf 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 profile satsignal.json.field.v1 only (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 for canonical.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:

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)

  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 SCJ-v1 (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 proof; 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 (a future satsignal verify CLI is expected to 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 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

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.

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:

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:

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.


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:

  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.
  8. Rejects ZIP buffers with leading data before the first local file header (§2.2).
  9. Rejects ZIP buffers with a non-empty EOCD comment (§2.2).
  10. Rejects ZIP buffers containing more than one EOCD signature (§2.2).
  11. Rejects ZIP central directories with duplicate filenames (§2.2).
  12. Rejects ZIP entries with .. path components, leading /, or backslashes in the filename (§2.2).
  13. When producing a disclosure bundle (one whose manifest.json carries disclosure), MUST NOT include the original anchor's proofs.json or any other file revealing unrevealed leaves of the original (§9.5; forever-prohibition from disclosure-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_resultblock_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:

Disciplines that make a precommit meaningful (RECOMMENDED, ranked by leverage).

  1. Co-sign the precommit with a party OUTSIDE the operator's trust boundary. Carry an external signature over the precommitted frame — a witness-proof-v1 leaf (Appendix A), or an outside-signature pointer: a provenance signature_ref (/spec-provenance) or a counterparty_hash TLV (/spec-mbnt tag 0x04). 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.
  2. 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.4 prescribes exactly this for binding a table's intended scope.
  3. One binding precommit per decision, reveal-all-under-identity, audited via NON-suppressed issuer_id enumeration. A verifier pins the issuer DID, uses the issuer_id TLV (/spec-mbnt tag 0x05) to enumerate that operator's category="commitment" anchors before T_result, and requires each to be revealed; unexplained extras are a red flag for grinding. This works only where issuer_id suppression is forbidden — suppression is an operator pipeline setting (/whats-on-chain §4).
  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.