Skip to main content
A decision’s version is identified by its structure hash. Versions and hashes says what the hash means; this page is the algorithm, precise enough to compute it yourself, for instance to check in CI that a file is the version you think it is, without calling the API. 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

  1. 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.
  2. 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.
  3. Encode the text as UTF-8 and take its SHA-256.
  4. 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:
All five keys are always present: a structure with no attributes or aggregations hashes "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:
So renaming a question changes the structure hash and leaves that question’s hash alone, while rewording it changes both. The 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’s String.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 instructions when it is a string;
  • the values of a question’s criteria (true and false);
  • an attribute’s source.
Nothing else is trimmed. When 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
  1. Every string value inside a question is normalized to Unicode NFC: type, instructions (whether a string or {context, question}) and the values of criteria. Object keys are not normalized, and neither are the paths in reads, which select keys in your content.
  2. An empty criteria ({}) is removed.
  3. reads is sorted, by UTF-16 code units, with each path as written.
Attributes
  1. A string attribute’s enum is sorted. Its labels and default stay exactly as written, since they are compared with values in your content.
  2. An attribute’s source is normalized to NFC. Number and boolean attributes are otherwise unchanged.
Aggregations
  1. On every input of an aggregation and every comparison in a rule’s condition, invert: false is removed, and so is an explicit gte: 0.5 (the default threshold). Other thresholds (gt, lte, lt, any other gte) stay.
  2. On a weighted_mean, if every input has weight 1 or no weight, the weights are removed, so writing weight: 1 everywhere hashes like writing none. A logistic always keeps its weights.
  3. An aggregation’s inputs are sorted by the canonical JSON text of each input, compared by UTF-16 code units. Their order never counts.
What keeps its order
  1. rules keep their order, because the first rule that is true wins; so do the operands of all, any and not inside a condition. A bands aggregation’s cuts keep their order too (validation requires them ascending by at, so there is only one valid order). Everything else in an aggregation, such as bias, below, abstain, else and cases, is hashed as written.
Floating-point numbers are written by JCS in their shortest ECMAScript form, so 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:
The digest it prints is the 64 characters after 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 in packages/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.
Canonical text:
Structure hash:

key-order

Key order and formatting never count: the same structure as minimal, with every object’s keys in another order.
Structure hash, the same as minimal:

reads-sorted

A question’s reads are sorted, so their order never counts.
Canonical text:
Structure hash:

reads-reordered

The same question with reads in the other order.
Structure hash, the same as 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.
Canonical text:
Structure hash:

inputs-reordered

The same aggregation with its inputs in sorted order.
Structure hash, the same as inputs-sorted:

enum-sorted

A string attribute’s enum is sorted; the labels themselves stay exact.
Canonical text:
Structure hash:

enum-reordered

The same attribute with its enum already sorted.
Structure hash, the same as enum-sorted:

default-invert-false

invert: false equals the default and is stripped from an input.
Structure hash, the same as default-bare:

default-bare

The same aggregation with no invert.
Canonical text:
Structure hash:

default-gte-half

An explicit gte: 0.5, the default threshold of all and any, is stripped.
Structure hash, the same as default-threshold-bare:

default-threshold-bare

The same check with no threshold.
Canonical text:
Structure hash:

threshold-kept

Any other threshold is content: gte: 0.7 changes the hash.
Canonical text:
Structure hash:
This is not the hash of default-threshold-bare.

empty-criteria

Empty criteria ({}) is stripped from a question.
Structure hash, the same as minimal-angry:

minimal-angry

The same question with no criteria.
Canonical text:
Structure hash:

all-1-weights

On a weighted_mean, weights that are all 1 hash like no weights at all.
Structure hash, the same as no-weights:

no-weights

The same mean with no weights.
Canonical text:
Structure hash:

weights-kept

Any other weight is content: a 2 on one input changes the hash.
Canonical text:
Structure hash:
This is not the hash of no-weights.

nfc-decomposed

Instruction text is Unicode-normalized (NFC): e followed by U+0301 hashes like é (U+00E9).
Structure hash, the same as nfc-composed:

nfc-composed

The same instruction with the composed character.
Canonical text:
Structure hash:

trimmed

Strings the schema trims, already without surrounding whitespace: a string instructions, a criteria value and an attribute’s source.
Canonical text:
Structure hash:

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.
Structure hash, the same as trimmed:

nested-instructions

instructions as an object: its strings are hashed as written (after NFC), not trimmed.
Canonical text:
Structure hash:

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.
Canonical text:
Structure hash:
This is not the hash of nested-instructions.

rules-in-order

The order of rules is kept, because the first rule that is true wins.
Canonical text:
Structure hash:

rules-swapped

The same rules in the other order are another structure.
Canonical text:
Structure hash:
This is not the hash of rules-in-order.

bands-cuts

A bands aggregation’s cuts keep their order (ascending by at, which validation requires).
Canonical text:
Structure hash:

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.
Canonical text:
Structure hash:
This is not the hash of minimal.

Next

Versions and hashes covers what changes a hash and how to register a structure. The glossary defines version, structure and revision.