json-ast-v1 — selective-disclosure profile for DEEP-FIELD JSON (one leaf per node, SEALED only)

Status: active. This profile is the deep-field JSON granularity the json-keypath-v1 profile (top-level key) does NOT cover. It commits one merkle 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 field, an array item, a whole subtree, a top-level key, or the whole file from ONE anchor. subject_profile literal: json-ast-v1 (hyphenated — the literal a deep-field JSON anchor stamps into subject.proofs.chunk_merkle.scheme). SEALED ONLY. json-ast-v1 is accepted only with chunk_merkle.algo == "merkle-hmac-sha256" (salt_version: "salt_v1"). A standard (algo:"sha256") json-ast-v1 carrier is rejected at submit (scheme_requires_sealed) — finer JSON granularity lowers per-leaf entropy (a lone primitive is brute-forceable if its bare sha256 is published), so the privacy path is the only path. The leaf is HMAC(per-leaf HKDF salt, utf8(entry)), where entry is the canonical "<pointer>":<value> string of §3.


1. Why this exists

A selective disclosure proves a revealed unit into the exact merkle leaf the anchor already committed on chain. json-keypath-v1 chunks a .json file by top-level key — there are no on-chain deep-field leaves to prove into, so it cannot reveal a nested field, an array item, or a subtree without revealing the whole top-level key. json-ast-v1 is the deep-field anchor scheme: its chunk_merkle commits a leaf for every node of the JSON, so a later disclosure can prove any node — at any depth, at any granularity — into the on-chain root, revealing nothing else.

json-ast-v1 is a new anchor scheme, not a re-binding of existing json-keypath-v1 anchors (their leaf-sets differ; a json-keypath-v1 anchor has no deep-field leaves). It reuses the SAME JCS canonicalization, the SAME duplicate-last merkle, and the SAME merkle-hmac-sha256 / salt_v1 sealed crypto as json-keypath-v1 — only the leaf-set (one per node, not one per top-level key) and the HKDF info domain-separator (§5b) differ.


2. Inputs and canonicalization (json-jcs-v1)

The leaf-set is computed from the original file bytes via the SAME canon json-keypath-v1 uses (JCS / RFC-8785-ish — JSON.parse then re-serialize each value canonically):

  1. Parse the file bytes as JSON (JSON.parse(file.text()), lenient UTF-8 decode → U+FFFD on invalid bytes, matching the anchor).
  2. Any top-level value is accepted — object, array, or scalar. (This differs from json-keypath-v1's objects-only gate: json-ast-v1 commits the whole tree, so a top-level array or scalar is a valid single-or-multi-node source.)
  3. Per-value JCS (jcsCanonicalize, byte-identical to json-keypath-v1): null/true/false literally; numbers via ECMAScript Number::toString (NaN/Infinity rejected); strings as JSON.stringify(s.normalize("NFC")); arrays as "["+ items.map(jcs).join(",") +"]"; objects as "{"+ sorted-keys.map(k => JSON.stringify(k.normalize("NFC"))+":"+jcs(v)).join(",") +"}". No whitespace; NFC; object keys sorted by code point.

This is a string contract: leaves are byte/string, never semantic — no numeric normalization beyond Number::toString (1.0 and 1.00 are the source author's responsibility; the leaf hashes the canonical string). A file the anchor accepted recomputes to the same leaves here; a true mismatch surfaces as the distinct recompute-mismatch failure (§7), never a silent reject.


3. Leaf extraction — ONE LEAF PER NODE, RFC-6901 pointer, sorted-pointer order

After parse, enumerate every node of the JSON tree and emit one leaf each:

nodes   = [ {pointer, value} for every node: the root, every object,
            every array, and every primitive ]
nodes.sort(by pointer)                       // UTF-16 code-unit order
entry_i = JSON.stringify(pointer_i) + ":" + jcs(value_i)

4. Standard mode is REJECTED (sealed-only)

json-ast-v1 is sealed-only. A carrier with chunk_merkle.algo == "sha256" and scheme == "json-ast-v1" is rejected at anchor submit with scheme_requires_sealed (the standard validator _proof_chunk_merkle). For verifier symmetry the bare standard leaf rule would be leaf_hash_i = sha256(utf8(entry_i)), identical in shape to json-keypath-v1 §4 — but it is unreachable: no standard json-ast-v1 carrier can be minted. There is therefore no standard test corpus for this profile.


5. Salts — there is no unsalted mode

Unlike json-keypath-v1 (which offers an unsalted standard mode with the documented brute-force tradeoff), json-ast-v1 has no unsalted mode. Every leaf is sealed (§5b). This is deliberate: at node granularity many leaves are low-entropy primitives (a boolean, an enum, a small number, a known-format id), and an unsalted bare sha256 of "<pointer>":<value> is trivially guess-and- confirmed offline. Sealing every leaf under a per-leaf HKDF salt makes withheld nodes unguessable and prevents equal withheld values from colliding.


5b. Sealed leaf rule — HMAC under a SCHEME-PREFIXED per-leaf HKDF salt

For the (only) sealed mode (chunk_merkle.algo == "merkle-hmac-sha256", chunk_merkle.salt_version == "salt_v1"):

salt_i      = HKDF-SHA256(ikm = master_salt,
                          salt = "satsignal-sealed-v1/per-leaf",
                          info = "json-ast-v1/chunk/" || u32_be(i), L = 32)
leaf_hash_i = HMAC-SHA256(key = salt_i, msg = utf8(entry_i))

The HKDF/HMAC mechanism is the SAME the sealed CSV/text/keypath schemes use — only the info prefix differs. json-ast-v1 uses the scheme-prefixed "json-ast-v1/chunk/" (NOT the bare "chunk/" the three earlier sealed schemes share) for forward domain separation: in a future multi-axis container a json-ast-v1 leaf can never collide with a CSV/row leaf of the same value + index. The u32_be(i) counter is the sorted-pointer leaf index, big-endian.

A sealed revealed[i] carries salt_b64 = base64(salt_i) — the PER-LEAF salt for that revealed node. salt_b64 is REQUIRED for every json-ast-v1 revealed leaf. Because json-ast-v1 is sealed-only, salt_b64 is structurally required (the schema does NOT put json-ast-v1 in its salt-optional native set), so a revealed leaf missing salt_b64 fails closed at structural validation with invalid_disclosure_structure — a stronger fail-closed than the runtime sealed_leaf_missing_salt that applies to profiles which also have an unsalted standard mode.

5b.1 What a sealed disclosure carries — per-leaf salt, NEVER the master

The redact tool reads the 32-byte master salt from the SOURCE .mbnt manifest.json and derives the per-leaf salts. The disclosure output carries ONLY the per-leaf salts of the revealed nodes. THE MASTER-SALT-STRIP RULE (forever): a disclosure .mbnt MUST NOT contain the master salt in any encoding, and MUST NOT carry a withheld node's per-leaf salt. Shipping the master salt re-derives every per-leaf salt and unseals every withheld node. The tool enforces this structurally (it never ships the source manifest.json) and with a P0 runtime guard (redact-core.mjs:_assertMasterSaltStripped, scheme/mode-independent). Revealing the per-leaf HKDF salts of revealed nodes leaks nothing about the master salt or other nodes (HKDF-Expand is a PRF).

5b.2 Worked example (NOT placeholders — computed against the leaf rule)

Source file bytes = the compact JSON.stringify of {"user":{"name":"Alice","age":30},"tags":["x","y","z"],"ok":true}; master salt = 0x00 0x01 … 0x1f (the bearer secret, NEVER shipped). Enumerated + sorted-pointer → 9 leaves:

leaf_idpointervalue (canonical entry)HMAC(salt_i, utf8(entry))
n000000"""":{"ok":true,"tags":["x","y","z"],"user":{"age":30,"name":"Alice"}}0345747a5c55a5b890650d1c1b6ab60614813a3a76b0734197809aff6c2144f2
n000001/ok"/ok":trued26695b4a90bbe588ce8ef93c2828784c57ec8185714251a638a83b028824fcd
n000002/tags"/tags":["x","y","z"]0875900922b5fdbab13e67f954c3e8dfa1bb8687a35e2ca4b1c4ae2299e8cc50
n000003/tags/0"/tags/0":"x"ac8c675797383eb86997eaf94db8587eade0e438ce0ebb30a2c7ad7d79725534
n000004/tags/1"/tags/1":"y"ee3ba25e42d4bb2379d21cad1f14e8eda292229424a0aae0c8c6ca7075681ea4
n000005/tags/2"/tags/2":"z"e2aa6b03783fcd9adb6d96f3bc3041c987b88d359e795c1d40ffcde0c16d7118
n000006/user"/user":{"age":30,"name":"Alice"}4a1b79bcc30fbc3cd0dd519e9877ccf9c2be023e3a5e31918036d45aa250b874
n000007/user/age"/user/age":30ae665fcd284ff361c31953b504d105967eb4dcdcece30bec7d66256474ea7407
n000008/user/name"/user/name":"Alice"550e3a0ec9d246e3e3e3dba8c065933e72f1fea52286faaf8337a6e74acd5258

Sealed root = 30b2b1b10101cccde37e85da44cb665d23f79296a39b3f7bceb7b41cd60fc0f2. Frozen in tests/vectors/disclosure-v1/json_ast_v1_native_sealed/S1.fixture.json.

Note how granularity falls out of one tree: revealing n000002 (/tags) discloses the whole array as one leaf; revealing n000008 (/user/name) discloses one primitive; revealing n000006 (/user) would disclose the whole user subtree as one leaf — all proving into the SAME root.


6. Merkle behavior — DUPLICATE-LAST on odd

The tree is duplicate-last-on-odd, identical to csv-row-v1 / text-line-v1 / json-keypath-v1 and to the anchor (merkleRootDuplicateLast): at each level an odd last node pairs with itself (SHA-256(node || node)). The verifier only walks proof_path — it never rebuilds the root — so the duplicate-last tree verifies with no merkle-walk change. The redact tool emits duplicate-last-correct paths (a self-sibling {side:"R", hash:<the node's own hash>} for an odd-promoted node).

The §5b example has 9 leaves (non-power-of-two), so odd-promotion DOES arise (unlike the power-of-two json-keypath-v1 example). Level reduction: 9 → 5 → 3 → 2 → 1 (the last node self-pairs at the 9-, 5-, and 3-node levels).

Frozen proof paths (S1, both verifying into the root):


7. Original anchor binding + render mode

A disclosure binds to the existing anchor via the §4 chain of disclosure-v1.md: the carrier canonical.json (carried VERBATIM) hashes to the on-chain document_hash; its subject.proofs.chunk_merkle.root equals linked_anchor.root; its scheme equals linked_anchor.subject_profile == "json-ast-v1"; and its algo (merkle-hmac-sha256) selects the sealed leaf rule. The redact tool recomputes the leaves from the original file, hard-fails if they do not match the committed merkle_leaves + root (wrong file / wrong bundle / edited file), then builds proof paths for the revealed nodes. No re-anchor; no new scheme.

The proof binds the revealed nodes to the on-chain root via the proof_path walk. The redacted copy is presentation-only: its bytes feed ONLY presentation.view_sha256 — they are NOT part of any leaf preimage and NOT independently re-attested. presentation.format == "json", .json extension.

Render mode — drop (Phase 1 default). structure_disclosure: "positions_hidden", redaction_marker: "(key omitted)". The copy is the canonical JCS document reconstructed from the revealed nodes — each revealed node's value placed at its pointer, then jcsCanonicalized; withheld nodes do not appear. For the §5b reveal (/tags + /user/name) the drop copy is {"tags":["x","y","z"],"user":{"name":"Alice"}}, so presentation.view_sha256 = d2fe0b21fa5354fc23b01a6b3cb81381228e4995303f146790e87520aa869fe7 (frozen in S1). A mask render mode (positions-preserved [REDACTED]) is a planned follow-up extension; being presentation-only it changes nothing cryptographically.


8. Fixtures (test vectors)

[FOREVER-CONTRACT] — disclosure-v1.md §11 forbids a profile without vectors. Frozen, generated deterministically and cross-checked by TWO independent oracles (scripts/json_ast_v1_oracle.mjs reusing the shipped primitives + scripts/json_ast_v1_oracle.py, zero shared code) plus the §5b/§6 worked example. Sealed only:


9. Out of scope / relation to json-keypath-v1


11. Profile registry pointer

Registered in disclosure-v1.md §11. json-ast-v1 is the native deep-field rule a JSON anchor emits when it commits one leaf per node; a disclosure binds to the chunk_merkle the anchor committed (scheme == "json-ast-v1", algo == "merkle-hmac-sha256"), revealing a subset of its per-node leaves — no re-anchor, no new scheme. Leaf rule: §§2–3 + §5b (sealed only; §4 standard is rejected); merkle §6; binding + render §7; vectors §8.

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