Headless anchor — seal a deep-content-hashed .txt, no upload, no browser

Satsignal's text-tree-v1 profile commits one merkle leaf per node of a .txt file — the whole file, every paragraph, every sentence, every token — so a single sealed anchor can later disclose a lone token, a whole sentence, a paragraph, or the file, each proving into the one on-chain root. This guide anchors such a file headlessly (plain Node, no browser): you generate a master salt, build the sealed envelope with the frozen client-side leaf computer, and POST it. The deep hash is computed entirely on your machine — the notary never sees the raw text and never recomputes a hash. text-tree-v1 is sealed-only by construction; there is no standard path, and this guide explains why.

Companion docs: API reference · Sealed mode · text-tree-v1 profile · Disclosure spec · Bundle spec · Headless redaction · What to hash

1. The 60-second framing

You have a plain-text file and you want multi-tier selective disclosure later: the ability to reveal one sentence, or one token, or one paragraph from it — each provable against the same on-chain anchor, with everything else staying private and unguessable. That needs a deep anchor: one merkle leaf per decomposition node, not one per line. text-tree-v1 is that scheme.

The whole hashing happens client-side:

(file bytes)  +  (32-byte master salt)   ──►   sealed envelope   ──►   POST /api/v1/anchors
     local              local (yours)              local                   notary anchors the root

The notary receives the envelope — a per-node merkle root plus the off-chain leaf HMACs — and the master-salt-derived commitments. It never receives the raw file bytes and never recomputes a hash: it rides the client-supplied chunk_merkle.root verbatim, assembles the canonical source .mbnt, and broadcasts. You are the only party that ever holds the text and the salt.

This is the anchor half of the deep-content lifecycle. The disclosure half — turning the anchored file + its .mbnt into a redacted copy that reveals chosen nodes — is Headless redaction; the profile spec is the byte-level canon for both.

2. Why sealed-only (and what a standard carrier does)

text-tree-v1 is accepted only in sealed mode (chunk_merkle.algo == "merkle-hmac-sha256", salt_version: "salt_v1"). A standard (algo: "sha256") text-tree-v1 carrier is rejected at submit with scheme_requires_sealed.

The reason is entropy. At token granularity almost every leaf is a single low-entropy unit — a digit, a name, a word, a punctuation mark (yes, $, 12, go). A bare sha256 of "<path>":"<token>" published in the bundle is trivially guess-and-confirmed offline from a tiny candidate set, which would unseal every withheld node. So text-tree-v1 has no unsalted mode at all: every leaf is an HMAC under a per-leaf HKDF salt derived from your master salt, which makes withheld nodes unguessable and stops equal withheld values from colliding. This is the same rationale sealed mode documents generally — see Sealed mode §2 — taken to its conclusion: for this profile, privacy is the only path.

3. Prerequisites

> Availability: anchor-pack.mjs is served publicly (no > auth, no key, no repo access) from > https://proof.satsignal.cloud/static/disclosure-builder/anchor-pack.mjs. > It is still not on npm (§6b). Its imports are relative (./), > so fetch it together with its six transitive imports into one > directory and every import resolves — the same pattern §2 of the > headless redaction guide > uses. The complete set the recipe below needs is these 7:

`` https://proof.satsignal.cloud/static/disclosure-builder/anchor-pack.mjs https://proof.satsignal.cloud/static/disclosure-builder/text-line-v1-native.mjs https://proof.satsignal.cloud/static/disclosure-builder/text-tree-v1-native.mjs https://proof.satsignal.cloud/static/disclosure-builder/json-ast-v1-native.mjs https://proof.satsignal.cloud/static/disclosure-builder/json-keypath-v1-native.mjs https://proof.satsignal.cloud/static/disclosure-builder/merkle.mjs https://proof.satsignal.cloud/static/disclosure-builder/hex.mjs ``

With the set fetched into one directory, the §4 snippets import ./anchor-pack.mjs. Working from a repo checkout instead, import ./packages/disclosure-redact/anchor-pack.mjs — a thin re-export of the same single source (the module body lives in src/satsignal_notary/web/static/disclosure-builder/, which is the directory the URLs above serve).

4. Build the sealed envelope

4.1 Generate the master salt — the bearer secret

The master salt is 32 random bytes you generate and keep. It is the bearer secret of the whole anchor:

// 32 cryptographically-random bytes — the master salt you keep forever.
const masterSaltBytes = crypto.getRandomValues(new Uint8Array(32));

4.2 Call buildSealedTextTreeEnvelope

One call does the whole client-side deep hash — decompose the canonical text into the file / paragraph / sentence / token tree, derive a per-leaf HKDF salt for every node, HMAC each leaf, and build the duplicate-last merkle root:

// anchor.mjs  —  node anchor.mjs mydoc.txt
// (fetched-module layout per §3; from a repo checkout import
//  ./packages/disclosure-redact/anchor-pack.mjs instead)
import { readFileSync } from "node:fs";
import { buildSealedTextTreeEnvelope }
  from "./anchor-pack.mjs";

const fileBytes = new Uint8Array(readFileSync(process.argv[2]));
const masterSaltBytes = crypto.getRandomValues(new Uint8Array(32));

const envelope = await buildSealedTextTreeEnvelope({
  fileBytes,
  masterSaltBytes,
});

It returns the sealed envelope, exactly:

{
  mode: "sealed",
  salt_b64,                 // base64url of the 32-byte master salt
  byte_exact_commitment,    // HMAC of the canonical file bytes
  proof_set: {
    byte_exact,             // { algo:"hmac-sha256", commitment }
    content_canonical,      // canonical-text HMAC for this profile
    chunk_merkle,           // { algo:"merkle-hmac-sha256",
                            //   scheme:"text-tree-v1", leaf_count, root }
  },
  proof_leaves: {
    scheme: "text-tree-v1",
    merkle_leaves,          // the per-node leaf HMACs (ride OFF-chain)
  },
}

Every leaf — one per file / paragraph / sentence / token node — is computed here, on your machine, by the frozen text-tree-v1 leaf computer. The decomposition rules (NFC + line-ending canon, paragraph / sentence / token boundaries, the frozen tokenizer, the "<path>":"<value>" entry preimage, the per-leaf HKDF salt) are the protocol surface and are pinned byte-for-byte in the profile spec §§2–5b. Field-level shape of each envelope member is the helper's JSDoc plus that spec — this guide intentionally does not restate them.

proof_leaves rides off-chain. The leaf HMACs travel in the bundle's proofs.json, never on chain — only chunk_merkle.root is committed. That is what makes selective disclosure the holder's later decision: the notary structurally cannot recompute a leaf (it has neither the bytes nor the salt), so it cannot expand the tree. See Sealed mode §3.

4b. The same flow for JSON — json-ast-v1

A .json file gets the deep equivalent of text-tree-v1: json-ast-v1 commits one merkle leaf per node of the parsed document — the whole document (RFC-6901 pointer ""), every object, every array, every key, every scalar — so one sealed anchor can later disclose a single field, a sub-object, or the whole document, each proving into the same on-chain root. Like text-tree-v1 it is sealed-only by construction (low-entropy scalar leaves would be guessable as bare sha256).

The build is the mirror of §4 — same master salt, the sibling buildSealedJsonAstEnvelope helper, same returned envelope shape:

// anchor-json.mjs  —  node anchor-json.mjs config.json
// (fetched-module layout per §3; from a repo checkout import
//  ./packages/disclosure-redact/anchor-pack.mjs instead)
import { readFileSync } from "node:fs";
import { buildSealedJsonAstEnvelope }
  from "./anchor-pack.mjs";

const fileBytes = new Uint8Array(readFileSync(process.argv[2]));
const masterSaltBytes = crypto.getRandomValues(new Uint8Array(32));

const envelope = await buildSealedJsonAstEnvelope({
  fileBytes,
  masterSaltBytes,
});

It returns the identical envelope shape as §4.2 — mode: "sealed", salt_b64, byte_exact_commitment, proof_set (byte_exact, content_canonical, chunk_merkle), and proof_leaves. Only two fields differ by profile:

fieldtext-tree-v1 (§4)json-ast-v1
proof_set.chunk_merkle.schemetext-tree-v1json-ast-v1
proof_set.content_canonical.schemetext-norm-v1json-jcs-v1
proof_leaves.schemetext-tree-v1json-ast-v1

The content_canonical commitment for JSON is the HMAC of the JCS-canonical form of the parsed document (JSON.parse then the same jcsCanonicalize the disclosure builder uses) — so re-serialized whitespace / key order never changes the canonical commitment. The byte_exact_commitment is still the HMAC of the raw file bytes. Everything else — the per-leaf HKDF salt, the duplicate-last merkle root, the off-chain merkle_leaves — is computed by the frozen json-ast-v1 leaf computer exactly as text-tree-v1 is, and the POST in §5 is byte-for-byte the same for both schemes. A .json file anchored this way is redacted later with the json-ast-v1 worked example in Headless redaction.

One dispatcher. anchor-pack.mjs also exports buildSealedEnvelope({ fileBytes, masterSaltBytes, granularity }) where granularity: "tree" routes to buildSealedTextTreeEnvelope and "ast" to buildSealedJsonAstEnvelope. That is what the packaged CLI (§9) calls, auto-selecting the granularity from the file extension.

5. POST the envelope (sealed mode)

Add the destination folder and POST. Mirror mode (salt on the wire, notary retains the bundle until you delete the proof, or for an explicit retain_days window if you set one) is shown; for blind mode omit salt_b64 and assemble the .mbnt locally exactly as Sealed mode §6 describes — the envelope above is unchanged either way.

// ...continues anchor.mjs — requires SATSIGNAL_API_KEY + spends sats,
// so this is shown but NOT run in tests.
const body = JSON.stringify({
  ...envelope,
  folder_slug: "agent-runs-prod",   // your destination folder
  category: "policy_snapshot",
  file_size: fileBytes.length,
});

const resp = await fetch("https://app.satsignal.cloud/api/v1/anchors", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${process.env.SATSIGNAL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body,
});
const out = await resp.json();
console.log(out.proof_id, out.txid);

What the server does with the envelope:

  1. Validates the sealed body (HMAC algos only; a bare sha256 400s, and a text-tree-v1 carrier with algo:"sha256" is rejected scheme_requires_sealed).
  2. Rides chunk_merkle.root verbatim — it does NOT recompute any leaf or root from content. It has no content and no master salt to do so with.
  3. Anchors the canonical-doc hash on chain, assembles the source .mbnt (manifest with salt_b64 + salt_version, canonical doc, proofs.json carrying merkle_leaves), and broadcasts.
  4. Returns proof_id / txid and (mirror mode) a bundle_url to download the salt-bearing .mbnt (kept until you delete the proof, or until any explicit retain_days window you set lapses). Persist that bundle — it is what the redaction tool reads later.

What the server sees / does NOT see

the notary seesthe notary never sees
chunk_merkle.root (the per-node merkle root)the raw .txt bytes — no upload, ever
byte_exact_commitment + the per-leaf HMACsany plaintext token, sentence, paragraph
salt_b64 only in mirror mode (blind: never)the master salt as a hashing input — it never recomputes a leaf
file_size, category, the folder sluga decomposed node, a path, or a leaf preimage

The notary is a broadcaster of a client-computed commitment, not a content processor. It cannot expand the tree, cannot enumerate nodes, and cannot brute-force a withheld leaf — it never holds the two inputs (bytes + salt) that the deep hash needs. This is the whole point of deferring all hashing to the client.

6. What you store

Retention, the 10-minute sealed-TTL sweep, mirror-vs-blind trade-offs, and salt-loss policy are all the standard sealed-mode rules — Sealed mode §§4–7.

6b. Packaged form — the satsignal-anchor CLI

Status: not yet published. satsignal-disclosure-redact / satsignal-anchor are not on npm yetnpm install will 404. Until they ship, use the in-repo helper path in §4–§6 (the no-package recipe). The SDK/CLI shapes shown below are stable and forward-compatible. Note the reference package's option / flag / output names (matterSlug, --matter, bundle_id) predate the canonical proof / folder vocabulary and keep the legacy spellings — see the Compatibility map.

Everything in §4–§6 — generate the master salt, build the sealed envelope, POST it, retrieve and write the source .mbnt — is also wrapped in a small reference package, so you don't hand-write the build-POST-retrieve-verify glue. It is the same pure modules this guide uses: no new cryptography, no extra upload. It is the symmetric twin of the satsignal-redact SDK/CLI (Headless redaction §11) — anchor then redact is the two-command deep-content lifecycle.

JS API. One async call does the whole §4–§6 flow — read the file, generate the salt, build the envelope, POST /api/v1/anchors, write the source .mbnt, and self-verify it:

import { anchorToMbnt } from "satsignal-disclosure-redact";

const out = await anchorToMbnt("mydoc.txt", {
  apiKey: process.env.SATSIGNAL_API_KEY,
  matterSlug: "agent-runs-prod",
  // granularity: "tree" | "ast",  // default from extension (.txt/.json)
  // storage: "mirror" | "blind",  // default "mirror"
  // base, createMatter, category, outDir, dryRun, selfVerify
});
// out.txid, out.bundleId, out.root, out.leafCount, out.scheme,
// out.sourceMbntPath, out.masterSaltHex,
// out.verify === { ok: true, fail_code: null, ... }

fetchImpl and randomBytes are injectable for testing (no real network, no spend). dryRun: true returns { body, root, leafCount, scheme, masterSaltHex, dryRun: true } with no network call and no file written.

CLI. One command for both file types — the granularity is auto-selected from the extension (.txttext-tree-v1, .jsonjson-ast-v1):

export SATSIGNAL_API_KEY=...                     # the key is env-only, never a flag

satsignal-anchor mydoc.txt   --matter agent-runs-prod    # -> text-tree-v1
satsignal-anchor config.json --matter agent-runs-prod    # -> json-ast-v1
satsignal-anchor <file> [--granularity tree|ast] [--matter SLUG] [--create-matter] \
    [--storage mirror|blind] [--category CAT] [-o DIR] [--base URL] [--dry-run]

It writes <file>.source.mbnt (the full input filename + .source.mbnt, e.g. mydoc.txt.source.mbnt, so same-stem .txt/.json inputs don't collide), self-verifies the served bundle (the served carrier root equals the locally built root, and a fresh recompute from your original bytes + the manifest salt equals the on-chain root), and prints the txid, bundle_id, root, leaf_count, the .mbnt path, a one-line bearer-secret reminder, and the ready next step — satsignal-redact <file> <source.mbnt> --list — so you can pick reveal indices and disclose. A failed self-verify exits non-zero.

7. What this does NOT do

8. Where this fits

Questions about this specification? Email hello@satsignal.cloud.