POST /v1/validate computes the same thing and returns the text it hashed, so you can compare byte for byte.
A hash is a number-free name: the same structure gets the same hash on your laptop, in CI and in production. A version also has a number (1, 2, 3, in the order the structure was registered under its decision), but that number is for people. It is not part of the hash and two decisions that register the same structure number it independently.
The recipe
- Read the structure as the API does, which trims some strings (Whitespace trimmed when a structure is read), then build its preimage, an object with five keys (below), after the canonicalization rules.
- Serialize it as canonical JSON with the JSON Canonicalization Scheme, RFC 8785 (JCS): no whitespace, object keys sorted by their UTF-16 code units, strings and numbers written the way ECMAScript writes them.
- Encode the text as UTF-8 and take its SHA-256.
- Write the digest as lowercase hexadecimal and put
sha256:in front:sha256:plus 64 characters.
What is hashed
The structure hash is the hash of this object, where each value is the canonical form of what you wrote:"attributes": {} and "aggregations": {}. Names, and the paths in reads, result and aggregation inputs, are hashed as written. Nothing outside the structure is hashed: not the decision_id, not the objective, not the state or attribute values you send, not the model that answers.
A question hash is the hash of a smaller object, built from one question and not its name:
question_hashes that POST /v1/validate returns map each question’s name to its hash, and feedback about a question is stored against that hash.
The format string is part of the preimage, so a future change to these rules would be a new format (moonbounce.structure/2), and every existing hash would still resolve.
Whitespace trimmed when a structure is read
Before any canonicalization rule, the API reads the structure with its schema, and the schema trims leading and trailing whitespace from three kinds of string. Trimming is ECMAScript’sString.prototype.trim: it removes every WhiteSpace and LineTerminator character, which includes the no-break space (U+00A0), the byte order mark (U+FEFF) and the other Unicode space separators as well as spaces, tabs and newlines; a port that uses its own language’s strip function has to match that set. An implementation has to do the same first, or a padded string hashes differently from what the API computes:
- a question’s
instructionswhen it is a string; - the values of a question’s
criteria(trueandfalse); - an attribute’s
source.
instructions is an object or an array ({context, question}, for instance), the strings inside it are hashed as written, with their whitespace; so are names, paths, enum labels, default, outcomes and every other string. A string that is empty after trimming is invalid, a 400, not a hash. The vectors trimmed-padded and nested-instructions-padded show both sides.
Canonicalization rules
The rules run before step 2, in this order, on the structure as read. A structure that differs from another only in what these rules erase has the same hash. Questions- Every string value inside a question is normalized to Unicode NFC:
type,instructions(whether a string or{context, question}) and the values ofcriteria. Object keys are not normalized, and neither are the paths inreads, which select keys in your content. - An empty
criteria({}) is removed. readsis sorted, by UTF-16 code units, with each path as written.
- A string attribute’s
enumis sorted. Its labels anddefaultstay exactly as written, since they are compared with values in your content. - An attribute’s
sourceis normalized to NFC. Number and boolean attributes are otherwise unchanged.
- On every input of an aggregation and every comparison in a rule’s condition,
invert: falseis removed, and so is an explicitgte: 0.5(the default threshold). Other thresholds (gt,lte,lt, any othergte) stay. - On a
weighted_mean, if every input has weight 1 or no weight, the weights are removed, so writingweight: 1everywhere hashes like writing none. Alogisticalways keeps its weights. - An aggregation’s
inputsare sorted by the canonical JSON text of each input, compared by UTF-16 code units. Their order never counts.
ruleskeep their order, because the first rule that is true wins; so do the operands ofall,anyandnotinside a condition. Abandsaggregation’scutskeep their order too (validation requires them ascending byat, so there is only one valid order). Everything else in an aggregation, such asbias,below,abstain,elseandcases, is hashed as written.
0.50 and 0.5 are the same number and hash the same.
Check a hash with the API
POST /v1/validate needs no key and stores nothing. Besides the hashes it returns canonical, the exact JCS text the structure hash is the SHA-256 of, as a string. When your implementation disagrees with the API, compare your text with this one first:
sha256: in the response’s structure_hash. mb validate decision.json prints the hash from the command line, and mb validate --local decision.json computes it without calling the API.
Test vectors
Each vector below is a structure and the hash an implementation must produce. Vectors that say “the same as” differ from their partner in exactly the way the rule describes. The vectors live inpackages/spec/test/fixtures/hash-vectors.json in the Moonbounce repository, which holds each structure as written (the raw input, before it is read and trimmed), its canonical text, its structure hash and its question hashes. A test asserts every one against the code and checks that this page prints them, so the page and the fixture cannot drift apart.
minimal
The smallest structure: one question that is the result.
key-order
Key order and formatting never count: the same structure as minimal, with every object’s keys in another order.
minimal:
reads-sorted
A question’s reads are sorted, so their order never counts.
reads-reordered
The same question with reads in the other order.
reads-sorted:
inputs-sorted
An aggregation’s inputs are sorted, so their order never counts. Here max lists its inputs in the reverse of the sorted order.
inputs-reordered
The same aggregation with its inputs in sorted order.
inputs-sorted:
enum-sorted
A string attribute’s enum is sorted; the labels themselves stay exact.
enum-reordered
The same attribute with its enum already sorted.
enum-sorted:
default-invert-false
invert: false equals the default and is stripped from an input.
default-bare:
default-bare
The same aggregation with no invert.
default-gte-half
An explicit gte: 0.5, the default threshold of all and any, is stripped.
default-threshold-bare:
default-threshold-bare
The same check with no threshold.
threshold-kept
Any other threshold is content: gte: 0.7 changes the hash.
default-threshold-bare.
empty-criteria
Empty criteria ({}) is stripped from a question.
minimal-angry:
minimal-angry
The same question with no criteria.
all-1-weights
On a weighted_mean, weights that are all 1 hash like no weights at all.
no-weights:
no-weights
The same mean with no weights.
weights-kept
Any other weight is content: a 2 on one input changes the hash.
no-weights.
nfc-decomposed
Instruction text is Unicode-normalized (NFC): e followed by U+0301 hashes like é (U+00E9).
nfc-composed:
nfc-composed
The same instruction with the composed character.
trimmed
Strings the schema trims, already without surrounding whitespace: a string instructions, a criteria value and an attribute’s source.
trimmed-padded
The same structure with spaces and a newline around those strings. Reading the structure trims them, before any canonicalization rule runs, so the hash is the same.
trimmed:
nested-instructions
instructions as an object: its strings are hashed as written (after NFC), not trimmed.
nested-instructions-padded
The same object with spaces around its strings. Strings inside object or array instructions are not trimmed, so this is a different hash.
nested-instructions.
rules-in-order
The order of rules is kept, because the first rule that is true wins.
rules-swapped
The same rules in the other order are another structure.
rules-in-order.
bands-cuts
A bands aggregation’s cuts keep their order (ascending by at, which validation requires).
rename-question
A question’s name is part of the structure but not of the question’s own hash: renaming toxic changes structure_hash, not that question’s hash.
minimal.