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-v1profile (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_profileliteral:json-ast-v1(hyphenated — the literal a deep-field JSON anchor stamps intosubject.proofs.chunk_merkle.scheme). SEALED ONLY.json-ast-v1is accepted only withchunk_merkle.algo == "merkle-hmac-sha256"(salt_version: "salt_v1"). A standard (algo:"sha256")json-ast-v1carrier is rejected at submit (scheme_requires_sealed) — finer JSON granularity lowers per-leaf entropy (a lone primitive is brute-forceable if its baresha256is published), so the privacy path is the only path. The leaf isHMAC(per-leaf HKDF salt, utf8(entry)), whereentryis 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):
- Parse the file bytes as JSON (
JSON.parse(file.text()), lenient UTF-8 decode → U+FFFD on invalid bytes, matching the anchor). - Any top-level value is accepted — object, array, or scalar. (This differs from
json-keypath-v1's objects-only gate:json-ast-v1commits the whole tree, so a top-level array or scalar is a valid single-or-multi-node source.) - Per-value JCS (
jcsCanonicalize, byte-identical tojson-keypath-v1):null/true/falseliterally; numbers via ECMAScriptNumber::toString(NaN/Infinity rejected); strings asJSON.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)
- Node = the document root + every container + every primitive. The root has pointer
""(whole document). An object node'svalueis the whole object; an array node'svalueis the whole array; a primitive node'svalueis the scalar. Empty object{}/ empty array[]are valid leaf nodes (no children). - Pointer encoding (RFC-6901). Root =
"". An object-key segment =escapeRfc6901(key.normalize("NFC")), whereescapeRfc6901replaces~→~0then/→~1. An array-index segment = the decimal index (String(i), no padding). The pointer = each segment prefixed by/and concatenated (e.g."",/user,/user/name,/tags,/tags/0). - Sorted-pointer order. Leaves are ordered by their JSON-Pointer string, sorted by UTF-16 code unit (the same default
.sort()json-keypath-v1uses for top-level keys). The leaf indexiis the pointer's position in that sorted list, zero-indexed. - Entry rule. The leaf entry for node
iis the NFC-quoted pointer (JSON.stringify(pointer_i)), a literal:, thenjcs(value_i). The pointer is in the preimage (so the same value at two pointers yields distinct leaves), exactly asjson-keypath-v1puts the key in the preimage. leaf_id="n"+ 6-digit zero-padded leaf index (e.g.n000000). Display / ordering handle only — NOT part of any hash preimage. (The"n"prefix differs fromcsv-row-v1's"r",text-line-v1's"l", andjson-keypath-v1's"k"for readability only.)- Cap.
leaf_count(= total node count) MUST be in[1 .. 100000](MAX_LEAF_COUNT). An over-cap source is rejected at the sealed submit path.
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_id | pointer | value (canonical entry) | HMAC(salt_i, utf8(entry)) |
|---|---|---|---|
| n000000 | "" | "":{"ok":true,"tags":["x","y","z"],"user":{"age":30,"name":"Alice"}} | 0345747a5c55a5b890650d1c1b6ab60614813a3a76b0734197809aff6c2144f2 |
| n000001 | /ok | "/ok":true | d26695b4a90bbe588ce8ef93c2828784c57ec8185714251a638a83b028824fcd |
| 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":30 | ae665fcd284ff361c31953b504d105967eb4dcdcece30bec7d66256474ea7407 |
| 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):
/tags(n000002, index 2) — 4-entry path[{R, ac8c6757…(/tags/0)}, {L, 59210932…}, {R, 57eb89e9…}, {R, 2c9efcfd…}]./user/name(n000008, index 8 — the odd-promoted last leaf) — 4-entry path whose first entry is the SELF-SIBLING:[{R, 550e3a0e…(its OWN leaf hash)}, {R, 61fbb02c…}, {R, 07bb788e…}, {L, 64fa6329…}]. A conforming verifier MUST walk a self-sibling entry; it MUST NOT reject it or assume promote-unchanged.
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:
- Sealed:
tests/vectors/disclosure-v1/json_ast_v1_native_sealed/S1— the §5b happy path: reveal/tags(a whole ARRAY subtree, one leaf) +/user/name(a primitive, the odd-promoted leaf whose path walks a self-sibling); per-leafsalt_b64; drop-mode copy.negatives/—S1_leaf_hash_mismatch,S1_wrong_salt(bothleaf_hash_mismatch),S1_merkle_path_mismatch,S1_linked_anchor_root_mismatch,S1_linked_anchor_canonical_hash_mismatch,S2_missing_salt(invalid_disclosure_structure— salt structurally required for this sealed-only profile),S3_wrong_salt_version(unsupported_linked_algo),S4_linked_anchor_profile_mismatch.
9. Out of scope / relation to json-keypath-v1
json-keypath-v1(spec) remains the top-level-key profile, in BOTH standard and sealed modes, and binds every existing JSON anchor.json-ast-v1does NOT replace it; it is the deeper, sealed-only sibling for new anchors that need field/subtree granularity.- Standard
json-ast-v1is rejected at submit (§4); there is no standard corpus. - Public
proof.*/sealed.*anchor-form surface and themaskrender mode are planned follow-up extensions; the dashboard anchor form + the headless API cover it today.
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.