satsignal.disclosure.v1 — selective disclosure of an anchored document
Versioning (2026-05-27). This is satsignal.disclosure.v1. The version literal inside the disclosure block is fixed at "satsignal.disclosure.v1". The shape evolves additively as v1.x: new fields are added as optional sub-keys of manifest.disclosure, every existing v1.0 disclosure block canonicalizes to identical bytes (when its v1.0 fields are unchanged), and unknown keys inside the disclosure block follow the existing bundle tolerance rule (bundle-v1.md §9.2). The on-chain canonical document and the bundle subtype are not touched. Breaking shape changes would ship as satsignal.disclosure.v2 carried in the same manifest.disclosure slot, alongside a new version literal — never as a quiet v1 mutation.
Terminology. Canonical vocabulary is proof / folder (full alias map: the compatibility map). A .mbnt bundle is the zip carrying manifest.json, canonical.json, optional proofs.json, optional sidecar. A disclosure is a partial-disclosure view of an original anchored document, carried in the manifest of a .mbnt bundle.
Status: draft 1, 2026-05-27. Audience: integrators who want to take an already-anchored document and later publish a redacted view of it with cryptographic proof that the revealed fragments are members of the original anchor; verifier authors who must render and check such a view. Goal: define exactly one shape for the disclosure block, the set of facts it commits to, and the membership-check contract a verifier runs against it — without changing the on-chain commitment, the canonical schema, or the bundle format.
Choosing a scheme — read before you redact. This format redacts within one anchored document: it requires the original anchor to carry a
chunk_merkleproof over a supported chunk profile (csv-row-v1,text-line-v1,json-keypath-v1, … — registry in §11), and the disclosure proves the revealed chunks into that existing on-chain root. If your need is instead "anchor many discrete items, later reveal a subset", the manifest batch path (items[]of{label, sha256_hex}committed under one Merkle root, schememanifest-items-v1) may fit with no redaction tooling at all — revealing one item is handing over its bytes plus its leaf position. The two leaf rules are not interchangeable: a manifest-items anchor is not a valid disclosure carrier (the redaction tool refuses carriers whosechunk_merkle.schemeit cannot list leaves for), and the disclosure profiles are not the manifest leaf rule — the manifest guide carries the mirror-image caveat. The choice happens at anchor time, not at disclosure time.
1. Why this exists
Satsignal already anchors a sha256 commitment of a client-supplied input and ships back a .mbnt bundle that lets any third party verify, fully offline, that the anchored bytes existed at the block time of the anchor's txid. That establishes a single fact: the anchorer held this exact document at time T1.
That fact alone is not enough when the anchorer later wants to publish part of the document — a few sentences from a contract, a few rows from a CSV, a few fields from a JSON record — and still let a verifier check that those revealed fragments came from the document that was anchored. The redacted view is a new artifact; its bytes are not the original document's bytes; recomputing sha256 of the view will never match the on-chain commitment.
satsignal.disclosure.v1 fills that gap. The anchorer (client-side) generates a redacted artifact and emits a disclosure block in the .mbnt manifest that lists the revealed leaves with the merkle proofs that bind them into the original anchor's leaf-set root. A verifier re-runs the original anchor verification AND walks each revealed leaf's proof path to the root committed in manifest.disclosure.linked_anchor.
What this spec deliberately does not do:
- It does not mutate the original document. The original's bytes are never re-encoded, re-formatted, or re-emitted by Satsignal; the redacted view is a separate artifact authored client-side.
- It does not redact PDFs, DOCX, or any opaque binary format. A disclosure operates over a profile-defined segmentation of plaintext- shaped inputs (see §11). Native binary-format redaction is out of scope for v1.
- It does not certify completeness of the disclosure. A disclosure is incomplete by design: an anchored document can produce many different disclosures revealing different subsets of leaves. Nothing in this spec proves a verifier is seeing the largest or the most faithful disclosure of the original.
- It does not introduce a new on-chain field, a new bundle subtype, a new
schema_version, or a new API endpoint. The disclosure block is an additive manifest key, full stop.
2. Where disclosure lives in the bundle
Prerequisite. Selective disclosure is only possible for original anchors that were committed under a leaf-set scheme — i.e. the original canonical doc carries subject.proofs.chunk_merkle.{scheme,algo,leaf_count,root}. A pure- byte_exact anchor (single hash, no leaves) cannot be selectively disclosed; an anchorer who anticipates future disclosure MUST anchor with a chunk_merkle proof from the start. This is a property of the original anchor, not of disclosure-v1: the disclosure spec cannot retrofit a leaf-set onto an anchor that was committed without one. Each profile spec names which chunk_merkle.scheme literal it emits, so an anchorer who knows which profile they intend to disclose under can pick the right profile at anchor time.
A disclosure bundle is an ordinary .mbnt. Its manifest.json carries an additive optional top-level key disclosure; everything else in the bundle is unchanged:
canonical.jsonremains aschema_version: 1document with the closedsubtypeenum (generic | wire | doc_sign | event) andadditionalProperties: false. It is not extended. The disclosure block never appears on-chain.- No new bundle subtype is allocated. No
mbnt_versionbump. - No
schema_version: 2is introduced. proofs.json, if present, is unchanged in shape.- The sidecar, if present, is unchanged in shape.
Verifier tolerance of an unknown top-level manifest key is the existing contract in bundle-v1.md §9.2: "Verifiers MUST tolerate unknown top-level keys in manifest.json … (skip and continue). The Satsignal service may add informational fields in any release; existing verifiers continue to operate." manifest.disclosure uses that surface as designed. A verifier that does not yet implement disclosure rendering parses the rest of the bundle exactly as it does today; a verifier that does implement disclosure rendering performs the additional checks defined in §7.
Four sub-decisions ride with this architectural choice: one bundle format, the receipt_kind lift, forever-contract profile literals, and the small initial profile set.
3. The object
The disclosure block is a single object under manifest.disclosure. A complete v1 example:
{
"disclosure": {
"version": "satsignal.disclosure.v1",
"disclosure_id": "acmecorp-2026-Q2-excerpt-014",
"linked_anchor": {
"root": "5d41402abc4b2a76b9719d911017c592d6f4a8a9c5f1c2b8e3d4a5b6c7d8e9f0",
"txid": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"subject_profile": "satsignal.text.paragraph_sentence.v1",
"bundle_id": "01HZ7N3K2P5XJ9R8WQYV6T4MAB"
},
"revealed": [
{
"leaf_id": "p3/s1",
"profile": "satsignal.text.paragraph_sentence.v1",
"value": "The supplier shall deliver no later than 2026-08-15.",
"salt_b64": "c2FsdC0wMDAxLWFiY2RlZmdoMTIzNDU2Nzg=",
"leaf_hash": "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
"proof_path": [
{ "side": "R", "hash": "fcde2b2edba56bf408601fb721fe9b5c338d10ee429ea04fae5511b68fbf8fb9" },
{ "side": "L", "hash": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" },
{ "side": "R", "hash": "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35" }
]
},
{
"leaf_id": "p3/s4",
"profile": "satsignal.text.paragraph_sentence.v1",
"value": "Either party may terminate with thirty days' written notice.",
"salt_b64": "c2FsdC0wMDA0LXp5eHd2dXRzcnFwb25tbA==",
"leaf_hash": "4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a",
"proof_path": [
{ "side": "L", "hash": "ef2d127de37b942baad06145e54b0c619a1f22327b2ebbcfbec78f5564afe39d" },
{ "side": "L", "hash": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b" },
{ "side": "R", "hash": "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35" }
]
}
],
"presentation": {
"format": "html",
"view_sha256": "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
"redaction_marker": "[REDACTED]",
"structure_disclosure": "positions_preserved"
},
"claims": {
"proves": [
{ "code": "leaf_set_membership",
"text": "The revealed fragments listed in `revealed[]` were members of the leaf-set of the document anchored at `linked_anchor.txid` at block time T1." },
{ "code": "leaf_value_match",
"text": "Each revealed fragment's content (`value`) matches the leaf the anchorer committed to under profile `linked_anchor.subject_profile`." },
{ "code": "presentation_integrity",
"text": "If a presentation artifact is included, its bytes match `presentation.view_sha256`." }
],
"does_not_prove": [
{ "code": "incomplete_by_design",
"text": "This disclosure is incomplete by design; undisclosed content of the original document is not verified and may contain additional context, contradictions, or qualifications." },
{ "code": "redacted_view_not_original",
"text": "The redacted presentation artifact is NOT the original document; its bytes will not match the on-chain anchor of the original." },
{ "code": "satsignal_does_not_certify",
"text": "Satsignal does not certify completeness, faithfulness of selection, authorship of the original, or the legal effect of any revealed fragment." }
]
}
}
}
3.1 version
Literal "satsignal.disclosure.v1". A verifier that does not recognize the literal MUST fail closed with unsupported_disclosure_version (see §8). The literal is the disclosure-block analogue of the canonical schema literal — pinning it prevents a future v2 block from being silently parsed as v1.
3.2 disclosure_id
Opaque client-side identifier (string). Satsignal does not interpret it; it gives the anchorer a stable handle to reference this specific disclosure (in cover letters, audit logs, follow-up correspondence). It is required so that a verifier rendering the disclosure has something stable to display alongside it.
3.3 linked_anchor
Sub-object pointing at the original anchor whose document is being partially disclosed:
root(hex string, lowercase, 64 chars) — the merkle root over the original document's leaf-set, computed under the profile named insubject_profile. This is the root the revealed leaves must prove into; it is the cryptographic anchor of the membership check.txid(hex string, lowercase, 64 chars) — the transaction id of the original anchor on BSV mainnet. A verifier resolves this vialookup_hash, a direct chain query, or any other Satsignal-spec verification path.subject_profile(string) — the profile literal under which the original document was segmented and the leaf-set built (e.g.satsignal.text.paragraph_sentence.v1). Every record inrevealed[]MUST carry an identicalprofilevalue; a mismatch fails the disclosure closed.bundle_id(string) — opaque pointer to the original proof's bundle id (the proof id under its frozen on-disk spelling), so a verifier can request the original.mbntfor cross-checks (the disclosure bundle itself does not have to ship the original document, and by design typically does not).
The linked_anchor block is the entire reason this spec exists: it is how the disclosure-package bundle reaches back to the original anchor without ever putting the link on-chain. Read the privacy posture in §9 before designing UI around it.
Three identifiers, read carefully. A reader walking a disclosure verification flow will encounter three different identifiers that look similar at a glance; they mean different things and a verifier MUST distinguish them:
disclosure.disclosure_id— logical identifier for the disclosure event. Anchorer-chosen. Stable across regenerations: if the anchorer re-generates the same selection as HTML and as TXT, both bundles carry the samedisclosure_id. If they re-anchor due to a failed broadcast,disclosure_idis unchanged.disclosure.linked_anchor.bundle_id— the original anchor's bundle id. Identifies the.mbntthat was anchored at T1, NOT the disclosure bundle being verified.- The disclosure bundle's own outer
bundle_id— the current.mbnt's identity, carried by the bundle's existing top-level manifest fields (NOT inside thedisclosureblock). Do NOT duplicate it insidedisclosure; readers looking for the current bundle's id read it from the manifest's standard slot, not fromdisclosure.
A reader who sees bundle_id somewhere in a disclosure verification flow MUST check which of the three it is. Inside the disclosure block, linked_anchor.bundle_id ALWAYS means the ORIGINAL anchor's bundle.
3.4 revealed
Array of revealed-leaf records. Each entry:
leaf_id(string) — profile-defined identifier for the leaf within the original document's segmentation. The exact construction rule is pinned forever in the profile spec (see §11). Examples (illustrative; the binding rule lives in the per-profile docs, not here):p3/s1(text-paragraph-sentence),r000041(csv-row),/items/3/price(json-field).profile(string) — the profile literal under which this leaf was segmented and hashed. MUST equallinked_anchor.subject_profile. A mismatch fails the disclosure closed (profile_mismatch).value(string or structured per profile) — the disclosed content of the leaf. The exact value type (string vs. structured) and the normalization rule that must be applied before hashing are pinned in the profile spec.salt_b64(string, optional / mode-dependent) — the per-leaf salt as base64, when the carrier's leaf rule is salted. The structural schema treatssalt_b64as optional; the verifier enforces its presence per the carrierchunk_merkle.algo:- ABSENT for a standard native rule (
csv-row-v1/text-line-v1/json-keypath-v1,algo == "sha256"): that leaf is the baresha256(utf8(value)), unsalted, so nosalt_b64is carried (seeprofiles/csv-row-v1.md §4–§5/profiles/text-line-v1.md §4–§5/profiles/json-keypath-v1.md §4–§5). - REQUIRED for the salted dotted profiles (e.g. the deprecated
satsignal.csv.row.v1, and the salted text / json profiles) — the per-leaf salt persisted by the anchorer; and REQUIRED for a sealed native rule (csv-row-v1/text-line-v1/json-keypath-v1,algo == "merkle-hmac-sha256",salt_version == "salt_v1"), where it carries the per-leaf HKDF salt (never the master salt; seeprofiles/csv-row-v1.md §5b/profiles/text-line-v1.md §5b/profiles/json-keypath-v1.md §5b).
- ABSENT for a standard native rule (
Where a salt is present, it is not derivable from the document, which is the intended privacy property of a salted leaf-set: an attacker who guesses a candidate value cannot test it against an undisclosed leaf without also obtaining the salt. The unsalted standard csv-row-v1 rule deliberately trades that property away (the anchor-time choice of standard vs sealed) — see profiles/csv-row-v1.md §5 for the privacy posture.
leaf_hash(hex string, lowercase, 64 chars) — the result of the profile's leaf-hash rule applied to the revealed entry. The exact rule is the per-profile forever-contract: for salted profiles it is a preimage over(profile, leaf_id, value, salt_b64)(separator bytes, value encoding, salt handling pinned per profile); for the standard nativecsv-row-v1rule it is the baresha256(utf8(value))with no profile / leaf_id / salt framing.proof_path(array) — ordered sibling hashes for the merkle membership proof. Each entry is an object:
``json { "side": "L", "hash": "<64-hex>" } ``
side is one of "L" or "R" and tells the verifier whether the sibling sits on the left or the right of the current frontier hash at that level. The verifier computes the next level by concatenating (sibling_hash || frontier) when side == "L" and (frontier || sibling_hash) when side == "R", then sha256-ing the result. The first entry's frontier is leaf_hash; the final iteration's output MUST equal linked_anchor.root. This direction encoding is pinned forever for v1 — a v2 disclosure would have to carry a different version literal to change it.
Before concatenation, each 64-character lowercase hex digest MUST be decoded to its raw 32-byte value. The verifier concatenates raw bytes (resulting in a 64-byte buffer), and SHA-256 hashes the resulting 64-byte buffer. Verifiers MUST NOT concatenate the ASCII hex strings; doing so produces a different (incorrect) digest.
Merkle proof invariants.
1. Hash algorithm. SHA-256, lowercase hex output, 64 characters. Any hash field not matching ^[0-9a-f]{64}$ fails closed with invalid_hash_format. 2. Single-leaf tree. When the original leaf-set contains exactly one leaf, the merkle root equals leaf_hash and proof_path is the empty array []. The verifier MUST accept an empty proof_path and check that the recomputed leaf_hash equals linked_anchor.root directly. 3. Odd-node behavior — the on-chain tree's rule governs; the walk is structure-agnostic. The odd-node rule that matters for membership is the rule the original anchor built its tree with, and the disclosure's proof_path encodes it. The verifier does not rebuild the root — it only walks proof_path, folding whatever siblings it is given (side/hash at each level) up to linked_anchor.root. The walk therefore verifies any odd-node convention correctly, because the convention is already baked into the sibling list: - Legacy salted profiles (the original salted disclosure trees built by the disclosure layer itself) use promote-unchanged: an unpaired node is lifted to the next level without re-hashing, and its proof_path simply skips that level (no sibling entry for the promoted step). This convention is unchanged for those profiles. - Native csv-row-v1 (standard and sealed) binds to the anchor's tree, which is duplicate-last-on-odd: an unpaired node's sibling is itself, and the builder emits a self-sibling entry ({ "side": "R", "hash": <the node's own hash> }) for the odd-promoted node — so the odd node's proof_path carries an extra entry rather than skipping a level. See profiles/csv-row-v1.md §6 for the worked self-sibling path.
A verifier MUST NOT assume a single odd-node convention and rebuild the root from it; it MUST walk the supplied proof_path. (The direction encoding and raw-byte concatenation are pinned forever for v1; a v2 disclosure carrying a different version literal would be required to change those.) 4. Leaf ordering. The leaf-set order is profile-defined and the profile spec MUST pin a total order over leaves (e.g. document-order for text profiles, row-index for csv, JSON Pointer lexicographic for json-field). The disclosure carries leaves in the order they appear in the leaf-set; the verifier does NOT re-sort. 5. Invalid hex / case / length. Any hash, root, or leaf_hash field that is not exactly 64 lowercase hex characters fails closed with invalid_hash_format. Uppercase, partial- length, base64, and any non-hex characters are rejected.
3.5 presentation
Sub-object describing the generated redacted artifact, if one is produced with the disclosure. Fields:
format— enum"html","txt","csv","json". Pinned for v1; new artifact formats require a new minor version.view_sha256(hex string, lowercase, 64 chars) — sha256 of the redacted artifact's raw bytes as generated. This hash covers the rendered artifact, not the manifest. The artifact travels ALONGSIDE the.mbnt(e.g. next to it in a delivery package) — the shipped disclosure.mbntlayout carries onlymanifest.json+linked_anchor/canonical.jsonand does not embed the rendered view.redaction_marker(string) — the string that appears in the view in place of omitted leaves. Default"[REDACTED]". Anchorers MAY pick a domain-specific marker (e.g."[…]") but MUST display it visibly; silent omission is not a legal value.structure_disclosure— enum describing what structural metadata the view leaks about the original:"positions_preserved"— omitted leaves are rendered as the redaction marker in their original position. A reader learns where in the document the omitted content sat and roughly how much of it there was."positions_hidden"— omitted leaves are not rendered at all; the revealed leaves are emitted in document order, but with no marker for what sits between them. A reader learns only what was revealed."count_only"— the view emits the revealed leaves and a footer summary (e.g. "3 of 27 sentences disclosed") but no in-line markers. A reader learns the total count of leaves in the original but not where the revealed ones came from.
3.6 claims
Sub-object with exactly two arrays, proves and does_not_prove. Each entry is a {code, text} pair: code is a machine-stable identifier, text is the human-readable prose displayed alongside the disclosure. Both arrays are part of the cryptographic commitment — they are JCS- canonicalized into the manifest bytes — and a verifier MUST display the text of every entry verbatim (§8). This is what protects against verifier mis-attribution: if a third party later argues "this disclosure proves authorship / completeness / legal effect," the disclosure itself carries an explicit, on-record refutation.
Codes are the forever-contract; text is anchorer-supplied. The set of code literals is the v1 contract — splitting code from text lets the protocol pin the meaning without locking the wording, so an anchorer can adapt the displayed copy to their audience (plain language, translated copy, domain-specific phrasing) without breaking the contract.
- The three
does_not_provecodes MUST all be present in v1:incomplete_by_design,redacted_view_not_original,satsignal_does_not_certify. - The three
provescodes MUST all be present in v1:leaf_set_membership,leaf_value_match,presentation_integrity— with the last one mandatory only whendisclosure.presentationis present in the same block. - The
textfield for each entry is anchorer-supplied. The defaults shown in the §3 example are RECOMMENDED. Anchorers MAY override the text (e.g. plain language, translated copy) but MUST preserve the code-to-meaning mapping for the required codes. - Adding a non-standard code (anchorer-defined, e.g.
redacted_for_pii) is permitted only as an additive entry alongside the required codes; the required codes can never be removed. - A verifier that finds any required code missing from
proves(subject to thepresentation_integrityprecondition) or fromdoes_not_provefails closed withmissing_claim_code.
4. Binding linked_anchor.root to the original anchor
The disclosure block asserts that linked_anchor.root is the merkle root the revealed leaves must prove into. That assertion is worthless on its own: a malicious disclosure could merkle-prove leaves into an arbitrary root, with no on-chain binding back to any real document. This section pins the binding chain the verifier MUST walk before treating linked_anchor.root as authoritative.
The binding chain (plain prose). A verifier MUST prove, in order:
revealed[i].value → revealed[i].leaf_hash (via the profile's leaf-hash rule — a salted preimage for salted carriers, a bare sha256(utf8(value)) for the standard native csv-row-v1 carrier) → linked_anchor.root (via proof_path walk) → original canonical-doc subject.proofs.chunk_merkle.root (MUST equal) → on-chain document_hash at linked_anchor.txid (via canonical-doc sha256) → BSV block timestamp at linked_anchor.txid (the T1 fact).
Any break in the chain fails the disclosure closed.
The original-canonical-doc carrier. The disclosure bundle SHOULD include a copy of the original anchor's canonical-doc bytes inside the .mbnt, under the filename linked_anchor/canonical.json. This file is the JCS-canonicalized bytes of the original canonical.json (verbatim — the exact bytes that were sha256'd into the on-chain document_hash at T1). When present, it allows fully offline binding verification: the verifier sha256s these bytes and compares against the on-chain document_hash, then reads subject.proofs.chunk_merkle.root from them and confirms it equals disclosure.linked_anchor.root.
Why carrying it does not leak the original. The original canonical document, by design, carries only digests / roots / metadata — it never contains the document's actual content (per bundle-v1.md §4: "leaves never enter the canonical doc; they ride off-chain in proofs.json"). Carrying canonical.json therefore reveals the original's anchor metadata (issuer, issued_at, leaf_count, profile scheme, merkle root) but not the unrevealed leaves. The unrevealed proofs.json of the original anchor MUST NOT be carried by the disclosure bundle.
The verifier procedure. A conforming verifier MUST run these five checks in order; any failure terminates closed with the indicated code.
- Locate the carrier. Locate
linked_anchor/canonical.jsoninside the disclosure.mbnt. If absent and the verifier is operating offline-only, fail closed withlinked_anchor_carrier_missing. (Online verifiers MAY instead fetch the original.mbntvialinked_anchor.bundle_idfrom the/api/v1/proofs/<bundle_id>endpoint and extract itscanonical.json; this is implementation- defined and OUT of the offline conformance suite.) - Bind carrier to on-chain commit. Compute
sha256(linked_anchor/canonical.json bytes as stored). This MUST equal the on-chaindocument_hashatlinked_anchor.txid(resolved via/lookup_hashor any other Satsignal-spec verification path). Mismatch → fail closed withlinked_anchor_canonical_hash_mismatch. - Bind carrier to disclosure root. Parse the canonical doc and read
subject.proofs.chunk_merkle.root. Confirm it equalsdisclosure.linked_anchor.root. Mismatch → fail closed withlinked_anchor_root_mismatch. - Bind carrier to disclosure profile. Read
subject.proofs.chunk_merkle.scheme. Confirm it equalsdisclosure.linked_anchor.subject_profile. Mismatch → fail closed withlinked_anchor_profile_mismatch. (This replaces the existingprofile_mismatchcode for this specific check; the per-leafprofile_mismatchfrom §7 — comparingrevealed[i].profileagainstlinked_anchor.subject_profile— still applies.) For a native anchor this scheme is a hyphenated literal —"csv-row-v1"for CSV,"text-line-v1"for text, or"json-keypath-v1"for JSON, the values standard and sealed anchors stamp intochunk_merkle.scheme— not a deprecated dotted profile (satsignal.csv.row.v1,satsignal.text.paragraph_sentence.v1,satsignal.json.field.v1); the disclosure binds to thechunk_merklethe anchor already committed, with no re-anchor and no new scheme. See §11. - Pin the merkle algorithm. Read
subject.proofs.chunk_merkle.algo. For a native carrier (csv-row-v1/text-line-v1/json-keypath-v1) a conforming verifier accepts two algos, each selecting a leaf-hash rule (§7 step 4):"sha256"→ the standard native rule: baresha256(utf8(value)), nosalt_b64(seeprofiles/csv-row-v1.md §4/profiles/text-line-v1.md §4/profiles/json-keypath-v1.md §4)."merkle-hmac-sha256"→ the sealed native rule. When this algo is present the carrier also carrieschunk_merkle.salt_version, which MUST be"salt_v1"; any othersalt_version→ fail closed withunsupported_linked_algo(the version is part of the algo dispatch). The leaf isHMAC(base64decode(salt_b64), utf8(value))andsalt_b64(the per-leaf HKDF salt, never the master salt) is REQUIRED on every revealed entry (seeprofiles/csv-row-v1.md §5b/profiles/text-line-v1.md §5b/profiles/json-keypath-v1.md §5b). The sealed algo is accepted ONLY for a native profile.
Both branches are part of the v1.x contract; the merkle-hmac-sha256 branch is the additive sealed lift (no version-literal bump, no on-chain or canonical-schema change, no new bundle subtype). Any algo a verifier does not implement (anything other than these two for a native profile, the sealed algo on a non-native profile, or a salted-dotted profile's own algo) → fail closed with unsupported_linked_algo.
Forever-prohibition: no proofs.json of the original. The disclosure bundle MUST NOT carry the original anchor's proofs.json (or any other file that would reveal unrevealed leaves of the original). The whole privacy claim of selective disclosure rests on this: a disclosure that ships the original's proofs.json has revealed every leaf, defeating the selective property. A bundle that carries it is malformed. A disclosure-aware verifier that encounters such a bundle MUST fail closed with original_proofs_carrier_forbidden.
5. Required vs optional fields
Required
| Field | Type | Notes |
|---|---|---|
disclosure.version | string literal | MUST be exactly "satsignal.disclosure.v1". |
disclosure.disclosure_id | string | Opaque client-side identifier. Must be non-empty. |
disclosure.linked_anchor | object | Sub-fields below are all required. |
disclosure.linked_anchor.root | hex string | 64 lowercase hex chars. |
disclosure.linked_anchor.txid | hex string | 64 lowercase hex chars. |
disclosure.linked_anchor.subject_profile | string | A profile literal in the registry (§11). |
disclosure.linked_anchor.bundle_id | string | Opaque pointer to the original proof. |
disclosure.revealed | array | MUST be non-empty. Each entry's sub-fields below are all required. |
revealed[i].leaf_id | string | Profile-defined. |
revealed[i].profile | string | MUST equal linked_anchor.subject_profile. |
revealed[i].value | string or struct | Profile-defined; verifier hashes it per the profile leaf-hash rule. |
revealed[i].leaf_hash | hex string | 64 lowercase hex chars. |
revealed[i].proof_path | array | Ordered {side, hash} entries; MAY be empty only when the leaf-set was a single leaf (root == leaf_hash). |
disclosure.claims | object | Both proves and does_not_prove sub-arrays required. |
disclosure.claims.proves | array<{code,text}> | MUST include the v1 required codes (leaf_set_membership, leaf_value_match, and — when presentation is present — presentation_integrity). Each entry's text is anchorer-supplied; defaults RECOMMENDED. |
disclosure.claims.does_not_prove | array<{code,text}> | MUST include all three v1 required codes: incomplete_by_design, redacted_view_not_original, satsignal_does_not_certify. Each entry's text is anchorer-supplied; defaults RECOMMENDED. Anchorer-defined codes MAY be added alongside. |
Optional
| Field | Type | Notes |
|---|---|---|
revealed[i].salt_b64 | base64 string | Mode-dependent. REQUIRED for salted carriers — the salted dotted profiles (e.g. deprecated satsignal.csv.row.v1) and the sealed native profiles (csv-row-v1 / text-line-v1 / json-keypath-v1, algo == "merkle-hmac-sha256", salt_version == "salt_v1"; the per-leaf HKDF salt, never the master salt). ABSENT for the standard native profiles (csv-row-v1 / text-line-v1 / json-keypath-v1, algo == "sha256", unsalted). The structural schema treats it as optional; the verifier enforces presence per the carrier chunk_merkle.algo (a salted or sealed carrier with salt_b64 missing fails closed; a standard carrier with a stray salt_b64 is ignored by the bare-sha256 leaf rule). |
disclosure.presentation | object | Required only if a redacted artifact ships with the disclosure (alongside the .mbnt — the bundle itself does not embed it). If the disclosure ships without a rendered view (e.g. raw-API consumer planning to render server-side or downstream), presentation MAY be omitted. |
disclosure.presentation.format | enum | Required if presentation present. |
disclosure.presentation.view_sha256 | hex | Required if presentation present. |
disclosure.presentation.redaction_marker | string | Required if presentation present. Defaults to "[REDACTED]" only as an anchorer convention; the manifest still emits the literal value used. |
disclosure.presentation.structure_disclosure | enum | Required if presentation present. |
Unknown sub-keys inside disclosure follow the bundle's standing tolerance rule (bundle-v1.md §9.2): a verifier MUST skip and continue on unknown keys; it MUST NOT fail closed on their presence. (Unknown version literals, by contrast, do fail closed — see §8.)
6. JCS canonicalization
The disclosure block participates in manifest.json canonicalization under the existing bundle rules: UTF-8 NFC, object keys sorted by code point, separators=(",",":"), integers as bare decimals, floats forbidden. The manifest's sha256 (where the bundle layout exposes one) covers the entire manifest including the disclosure block — there is no separate "disclosure hash" field; the disclosure's integrity rides on the manifest's integrity.
presentation.view_sha256 is a separate commitment. It covers the rendered artifact's bytes (the HTML / TXT / CSV / JSON file delivered alongside the .mbnt whose content is the redacted view), not the manifest bytes. A verifier checks the two hashes independently: the manifest binds the disclosure block to the proof; view_sha256 binds the rendered artifact to the disclosure block.
Leaf values (and salt_b64 when present, for salted carriers) are committed inside the manifest's JCS bytes, so any change to a revealed[] entry changes the manifest hash. A verifier that has access to the manifest bytes can detect any after-the-fact edit to the disclosure block by re-running JCS over the manifest.
7. Linked-anchor verification contract
A verifier processing manifest.disclosure MUST run these checks in order. Any failure terminates the disclosure check closed with the indicated code.
- Pin the version. Read
disclosure.version. If it is not exactly"satsignal.disclosure.v1", fail closed withunsupported_disclosure_version. - Bind to the original anchor. Run the binding-chain procedure in §4. All five checks there must pass before continuing. (Step §4.1 locates
linked_anchor/canonical.json; §4.2 binds its sha256 to the on-chaindocument_hashatlinked_anchor.txid; §4.3 binds the carrier'schunk_merkle.roottodisclosure.linked_anchor.root; §4.4 binds itschunk_merkle.schemetosubject_profile— for a native CSV anchor the literal"csv-row-v1"; §4.5 handles thechunk_merkle.algo, accepting forcsv-row-v1eitheralgo == "sha256"(standard, unsalted) oralgo == "merkle-hmac-sha256"withsalt_version == "salt_v1"(sealed, per-leaf HKDF salt). The sealed branch is the additive the additive lift — no version-literal bump; any other algo fails closed withunsupported_linked_algo.) - Pin the profile. Read
linked_anchor.subject_profile. If the verifier does not implement that profile literal, fail closed withunsupported_profile. Silent skip is not a legal behavior — silently skipping would let a future bad-profile leaf appear "verified." - Per revealed leaf, recompute. For each
revealed[i], apply the profile's leaf-hash rule selected by the carrierchunk_merkle.algoto recompute the leaf hash:- Salted dotted profiles (e.g. the deprecated
satsignal.csv.row.v1, the salted text / json profiles): the profile's preimage over(profile, leaf_id, value, salt_b64)(the exact byte layout pinned per profile; the verifier MUST findsalt_b64present and fail closed if it is missing). - Standard native profile (
csv-row-v1/text-line-v1/json-keypath-v1,algo == "sha256"): the baresha256(utf8(value))— no profile / leaf_id / salt framing, and nosalt_b64is required or read. The value is the canonical data row (csv-row-v1), canonical line (text-line-v1), or canonical top-level-key entry"key":jcs(value)(json-keypath-v1). Seeprofiles/csv-row-v1.md §4/profiles/text-line-v1.md §4/profiles/json-keypath-v1.md §4. - Sealed native profile (
csv-row-v1/text-line-v1/json-keypath-v1,algo == "merkle-hmac-sha256",salt_version == "salt_v1"): the keyedHMAC-SHA256(base64decode(salt_b64), utf8(value)), wheresalt_b64is the per-leaf HKDF salt (never the master salt).salt_b64is REQUIRED for sealed; if it is missing the verifier fails closed (sealed_leaf_missing_salt, the recompute cannot proceed). Seeprofiles/csv-row-v1.md §5b/profiles/text-line-v1.md §5b/profiles/json-keypath-v1.md §5b.
- Salted dotted profiles (e.g. the deprecated
Compare the recomputed hash against revealed[i].leaf_hash. Mismatch → fail closed (leaf_hash_mismatch). - Walk proof_path from the recomputed leaf hash. At each step, decode the 64-character lowercase hex digests of the sibling and the current frontier to their raw 32-byte values. Concatenate the raw bytes — (sibling_bytes || frontier_bytes) if the entry's side == "L", otherwise (frontier_bytes || sibling_bytes) — to form a 64-byte buffer, then SHA-256 that 64-byte buffer; that becomes the new frontier (re-encoded to 64-char lowercase hex for comparison). Verifiers MUST NOT concatenate the ASCII hex strings. If proof_path is the empty array [], the recomputed leaf_hash is itself the frontier (single-leaf tree case, §3.4 invariant 2). After the final step, the frontier MUST equal linked_anchor.root. Mismatch → fail closed (merkle_path_mismatch). - Confirm revealed[i].profile == linked_anchor.subject_profile. Mismatch → fail closed (profile_mismatch).
- Verify the rendered view, if present. If
disclosure.presentationis present, obtain the rendered artifact — the redacted copy supplied ALONGSIDE the.mbnt(the bundle itself carries onlymanifest.json+linked_anchor/canonical.json; it does not embed the rendered view) — sha256 its raw bytes, and compare againstpresentation.view_sha256. Mismatch → fail closed (view_hash_mismatch). A verifier that is GIVEN the redacted copy MUST run this check; it may be skipped only when no rendered artifact is available, and a verifier that skips it MUST NOT report the presentation as verified.
All five checks must pass for the disclosure to be considered verified. Partial verification (e.g. "3 of 5 leaves passed") MUST NOT be reported as a success state; either the whole disclosure verifies, or the whole disclosure fails.
8. Verifier rendering behavior
A bundle whose manifest.disclosure is present is rendered as a partial-disclosure view, not as a regular proof. The verifier MUST:
- Run the bundle's normal verification (canonical-doc hash → on-chain commit) AND the §7 disclosure checks. Both must pass.
- Display the revealed leaves'
valuecontent in the order specified by the profile (typically document order). The exact ordering rule is per-profile. - When
presentation.structure_disclosure == "positions_preserved", render theredaction_markerin the position(s) of omitted leaves. When it is"positions_hidden", render no markers between revealed leaves. When it is"count_only", render a footer summary of the total-vs-revealed count and no in-line markers. - Surface BOTH anchors as separate facts to the reader:
- "Original anchor verified at T1 (txid <
linked_anchor.txid>)" — always. - "This disclosure package anchored at T2 (txid <…>)" — only if the disclosure bundle was itself anchored (see §9). If the disclosure bundle is a transient handoff (not anchored), the verifier MUST NOT fabricate a T2 line. The verifier MUST NOT claim the redacted view IS the original document.
- "Original anchor verified at T1 (txid <
- Display the
textfield of every entry indisclosure.claims.does_not_proveverbatim, in full array order, prominently next to the disclosure view. - Display the
textfield of every entry indisclosure.claims.proveswhose preconditions are met (in particular:presentation_integrityonly whendisclosure.presentationis present and has been verified per §7 step 5). - The verifier MUST NOT silently drop, rewrite, or summarize the displayed text — if it displays a disclosure, it displays the anchorer-supplied
textfor each entry verbatim. This text is part of the cryptographic commitment; a verifier that suppresses or rewords it has broken the contract. - Fail closed with
unsupported_profileon any unknown profile literal. Never silent-skip an unknown profile; never render the leaf as "verified" without running the profile's preimage rule.
9. Auto-anchor of the disclosure package
Optionally, the disclosure bundle itself can be anchored. When the anchorer chooses to do so, the disclosure .mbnt is submitted through the standard anchor path: POST /api/v1/anchors. The server treats the hash as opaque — it sees only sha256(canonical disclosure-bundle contents), the same as for any other standard anchor. There is no disclosure-specific endpoint, mode, or persisted column.
The Disclosure Builder (client-side) defaults this option on, so an anchorer publishing a disclosure typically gets a second on-chain commit binding the disclosure's exact bytes to a time T2 later than the original's T1. Raw-API consumers default it off — they may treat the disclosure as a transient handoff and never anchor it.
Default privacy posture: linked_anchor.root and linked_anchor.txid live in the disclosure bundle's manifest only, NOT in the canonical doc that goes on-chain at T2. The T2 commit therefore does not publicly expose the link back to the original anchor; only parties who hold the disclosure .mbnt see the link. This preserves the property that anchoring a disclosure leaks no more about the original than the disclosure itself does.
A public-link disclosure mode — where the link is written into a published sidecar field so a third party can resolve the original anchor from on-chain data alone — is out of scope for v1. It is gated behind a future decision record; an anchorer who wants that property today must publish the disclosure .mbnt themselves.
10. receipt_kind API surface
Spelling note. The field is emitted as
proof_kind—receipt_kindis its pre-flip spelling, still accepted inbound but no longer emitted. The heading keeps the original name as a stable cross-reference target; the contract below is unchanged apart from the spelling.
A disclosure bundle's proof is read back through the existing endpoints — GET /api/v1/proofs/<id> and the legacy alias GET /api/v1/receipts/<id> — with one additive field on the response:
{
"proof_id": "…",
"mode": "standard",
"proof_kind": "disclosure",
…
}
The field's contract:
- New proofs (anchored after v1 disclosure ships) MUST include
proof_kind, set to either"standard"or"disclosure". The server MUST emit it on everyGET /api/v1/proofs/<id>andGET /api/v1/receipts/<id>response for proofs whose underlying bundle hasmanifest.disclosurepresent OR was anchored after the v1 disclosure ship date. - Legacy proofs (anchored before v1 disclosure ships) MAY omit
proof_kind. A consumer that finds the field absent MUST interpret absence as"standard". This preserves the existing contract for legacy reads. - Disclosure proofs MUST emit
proof_kind: "disclosure". The server infers the value frommanifest.disclosurepresence at read time; it is NOT a persisted column and NOT a new POST argument. modeis unchanged.modecontinues to return onlystandard | sealed | manifestfor the underlying anchor.proof_kindis a separate axis: adisclosureproof'smodeis still the mode the underlying anchor was committed under (typicallystandard). Audits MUST NOT propose overloadingmodewith adisclosurevalue.- Forbidden values. Anything other than
"standard"or"disclosure"in the field is malformed. Future proof kinds (e.g.provenance) would require a future revision of this spec explicitly listing them; a server MUST NOT emit unlisted values, and a consumer encountering one MAY treat it as malformed.
The field began as the deferred receipt_kind field (now spelled proof_kind): the additive-API-surface choice is deliberate and audits MUST NOT propose overloading mode instead.
11. Profile registry pointer
A profile pins the segmentation, canonicalization, normalization, salting strategy, and leaf_id construction rules under which the original document was hashed. Salting strategy is per-profile — it is not a global property of satsignal.disclosure.v1. The registered profile literals:
csv-row-v1— the native CSV row rule that CSV anchors actually commit, supporting both an anchor-time-chosen modes under the one literal, distinguished by the carrierchunk_merkle.algo. Both share the RFC-4180 canon, header row excluded, one leaf per data row, and duplicate-last merkle; they differ only in the leaf:- Standard (
algo == "sha256"): baresha256(utf8(canonical_data_row)), UNSALTED (nosalt_b64). - Sealed (
algo == "merkle-hmac-sha256",salt_version == "salt_v1"):HMAC-SHA256(per-leaf HKDF salt, utf8(canonical_data_row)); each revealed entry carries the per-leaf HKDF salt insalt_b64(never the master salt). The privacy path.
- Standard (
This is the primary CSV disclosure source — disclosures bind to the chunk_merkle the anchor already committed (scheme == "csv-row-v1"), with no re-anchor and no new scheme. Spec at profiles/csv-row-v1.md (§§2–8 standard, §5b sealed).
text-line-v1— the native text-line rule that text anchors (.txt/.md) actually commit, the same two anchor-time-chosen modes under one literal ascsv-row-v1. Canon =text-norm-v1(BOM strip + NFC +\r\n?→\n+ per-line trailing-[ \t]strip); segmentation =split("\n")then drop empty lines; NO header (leaf 0 is the first non-empty line; a leaf index is its position in the non-empty-line list, not the file line number); duplicate-last merkle. Modes differ only in the leaf:- Standard (
algo == "sha256"): baresha256(utf8(line)), UNSALTED (nosalt_b64). - Sealed (
algo == "merkle-hmac-sha256",salt_version == "salt_v1"):HMAC-SHA256(per-leaf HKDF salt, utf8(line)); each revealed entry carries the per-leaf HKDF salt insalt_b64(never the master salt). The privacy path.
- Standard (
Disclosures bind to the chunk_merkle the text anchor already committed (scheme == "text-line-v1"), no re-anchor and no new scheme. Spec at profiles/text-line-v1.md (§§2–4 standard, §5b sealed).
json-keypath-v1— the native JSON top-level-key rule that JSON anchors (.json) actually commit, the same two anchor-time-chosen modes under one literal ascsv-row-v1/text-line-v1. Canon =json-jcs-v1(RFC-8785 JCS, objects-only — a top-level array/scalar gets acontent_canonicalbut NOchunk_merkleand is not redactable); segmentation = top-level keys sorted by code point; per-key leaf entry =JSON.stringify(key.normalize("NFC")) + ":" + jcs(value); NO header (leaf 0 is the first sorted key; a leaf index is its position in the sorted-key list, not the source key order); duplicate-last merkle. Modes differ only in the leaf:- Standard (
algo == "sha256"): baresha256(utf8(entry)), UNSALTED (nosalt_b64). - Sealed (
algo == "merkle-hmac-sha256",salt_version == "salt_v1"):HMAC-SHA256(per-leaf HKDF salt, utf8(entry)); each revealed entry carries the per-leaf HKDF salt insalt_b64(never the master salt). The privacy path.
- Standard (
The redacted copy ships in one of TWO owner-chosen render modes (default drop: revealed-keys-only canonical JCS object, positions hidden; mask: all keys sorted with withheld → "key":"[REDACTED]", positions preserved) — presentation-only, binding the revealed keys to the root. Disclosures bind to the chunk_merkle the JSON anchor already committed (scheme == "json-keypath-v1"), no re-anchor and no new scheme. Spec at profiles/json-keypath-v1.md (§§2–4 standard, §5b sealed, §7 render modes).
csv-column-v1— the native CSV column rule (the orthogonal axis tocsv-row-v1): one leaf per CSV column identified by 0-based header INDEX, the header cells excluded from leaf values, each data cellcsvField-re-quoted then LF-joined (no trailing newline); duplicate-last merkle; same RFC-4180 §2 canon ascsv-row-v1. Unlike its row sibling this is a NET-NEW scheme — an anchor must be built to emit it, not merely a rule written down. Standard (algo == "sha256"): baresha256(utf8(canonical_column)), UNSALTED (nosalt_b64). Sealed (§5b,algo == "merkle-hmac-sha256",salt_version == "salt_v1") is FROZEN: the per-leaf HKDFinfois the bare"chunk/" || u32_be(j)shared with the other three native sealed profiles, andcsv-column-v1is in the native-sealed verifier set on equal terms. Disclosures bind to thechunk_merklethe column anchor committed (scheme == "csv-column-v1"), no re-anchor. Spec atprofiles/csv-column-v1.md(§§2–4 standard, §5b sealed).json-ast-v1— the native JSON deep-field rule (the finer-granularity axis tojson-keypath-v1): one leaf per JSON node — the document root, every object, every array, and every primitive — keyed by its RFC-6901 JSON Pointer, so a disclosure can reveal a single nested field, an array item, a whole subtree, a top-level key, or the whole file from ONE anchor. Canon =json-jcs-v1(the SAME RFC-8785 JCS asjson-keypath-v1); per-node leaf entry = the canonical"<pointer>":<value>string; any top-level value accepted (object, array, or scalar — unlikejson-keypath-v1's objects-only gate, since it commits the whole tree); duplicate-last merkle. Likecsv-column-v1this is a NET-NEW scheme — a deep-field anchor must be built to emit it, not merely a rule written down (ajson-keypath-v1anchor has no deep-field leaves to bind into). SEALED-ONLY in this release (algo == "merkle-hmac-sha256",salt_version == "salt_v1"):HMAC-SHA256(per-leaf HKDF salt, utf8(entry)), each revealed entry carrying the per-leaf HKDF salt insalt_b64(never the master salt). A standard (algo == "sha256")json-ast-v1carrier is hard-rejected at submit (scheme_requires_sealed) — finer JSON granularity lowers per-leaf entropy, so the privacy path is the only path. Disclosures bind to thechunk_merklethe deep-field anchor committed (scheme == "json-ast-v1"), no re-anchor. Spec atprofiles/json-ast-v1.md(§§2–4 leaf rule, §5b sealed).satsignal.csv.row.v1— DEPRECATED / INERT. The original salted dotted CSV profile (random per-row CSPRNG salts; every row a leaf incl. row 0; promote-unchanged merkle). Retired; the literal is retained in the merkle-scheme allowlist forever (an allowlist literal is never removed) and its frozen corpus is kept as a regression guard, but no production flow emits or consumes it. New CSV disclosures use the nativecsv-row-v1literal above. Spec / deprecation note atprofiles/csv-row-v1.md §9.satsignal.json.field.v1— DEPRECATED / INERT. JCS-canonicalized JSON; one leaf per nested field, addressed by RFC-6901 JSON Pointer; salted/framed preimage. Superseded by the nativejson-keypath-v1literal above (top-level-key granularity, bare/sealed leaf); it cannot bind to a livejson-keypath-v1anchor (deep pointer + salted/framed leaf vs. top-level-key + bare/sealed leaf). The literal is retained in the allowlist forever and its frozen corpus is kept as a regression guard, but no production flow emits or consumes it. New JSON disclosures use the nativejson-keypath-v1literal above. Spec atprofiles/json-field-v1.md.satsignal.text.paragraph_sentence.v1— DEPRECATED / INERT. UTF-8 plaintext; one leaf per sentence, addressed by paragraph and sentence index; salted. Superseded by the nativetext-line-v1literal above (LINE granularity vs. this profile's SENTENCE granularity); the literal is retained in the merkle-scheme allowlist forever (an allowlist literal is never removed) and its frozen corpus is kept as a regression guard, but no production flow emits or consumes it. New text disclosures use the nativetext-line-v1literal above. Spec atprofiles/text-paragraph-sentence-v1.md.
Profile literals are forever-contracts. Once any client has anchored under a literal, its rules are fixed for that literal forever. A bug in those rules cannot be patched in place; the only remedy is a new vN+1 profile that compatible verifiers must support in parallel. This is why each profile spec ships with its fixture set in the same PR and why a profile spec without test vectors is not mergeable.
This master spec does NOT define per-profile rules. The leaf-hash preimage, the exact salting strategy, the segmentation rules, and the leaf_id construction all live in the per-profile docs above.
12. Out of scope for v1
The following are explicitly out of scope for v1:
- Email profiles. No
satsignal.email.sentence.v1, no email-shaped segmentation profile of any kind. Email is the strongest commercial pull but the worst forever-contract surface (MIME multipart, HTML vs plain bodies, quoted-printable, reply chains, signatures, mailing- list footers, mojibake); it lands behind a separate decision record after the three plaintext-anchor profiles have been battle-tested. - PDF / DOCX native redaction. Disclosure generates a new presentation artifact (HTML / TXT / canonical CSV / canonical JSON); it does not mutate the original document and does not produce a redacted PDF or DOCX.
- Word-level disclosure. No
text.word.v1ortext.token.v1. The granularity of v1 is profile-defined (CSV row, JSON field, text sentence). Word-level disclosure is available only if a driver appears and a separate decision record gates it. - Server-side Disclosure Builder. The Builder runs client-side; the server only ever sees the disclosure bundle hash for the optional T2 anchor. The privacy story depends on this.
- Canonical schema change.
canonical.schema.jsonis not extended — no newsubtype: "disclosure", noschema_version: 2, no new required fields. - New on-chain subtype. No new MBNT subtype byte is allocated. The on-chain commitment for a disclosure-package anchor is mechanically identical to any other standard anchor.
Questions about this specification? Email hello@satsignal.cloud.