> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moonbounce.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Versions and hashes

> How a decision's version is named by two hashes, what changes each one, and how to register, pin and ship them from your repository.

> [!NOTE]
>
> The examples on this page use Northpine, a fictional outdoor-gear shop whose `refund-exception` decision grants, escalates or denies refund requests outside its standard return policy. [How Moonbounce works](https://docs.moonbounce.ai/learn/how-it-works.md#the-running-example) introduces it.

Every evaluation's `version` carries two hashes: `structure` and `weights`. This page explains what each one covers, what changes it, how a structure gets registered, and how one decision file in your repository keeps the features, the weights and the cut points in step.

## Two hashes, not a version number

A decision has no "v3". Its version is the pair of hashes of what ran: the **structure hash** covers the features and how they combine, and the **weights hash** covers the numbers that combine them. People shorten them to their first hex digits, so Northpine's current version is "structure c219, weights d134".

Hashes come from content, so nobody has to hand out numbers, and the same spec gets the same hash on your laptop, in CI and in production. Two evaluations with the same pair asked the same features and combined the answers the same way. What sits outside the pair, the cut points and the engine that answered, is recorded beside it on every evaluation.

## The structure hash

The structure hash covers what the engine is asked and how the answers become a score: each feature's `prompt`, `guidance`, `criteria` and `inputs`, the spec's `guidance`, each aggregator's definition (its terms, their `effect`, `prior`, `fixed` numbers, a calibrate's `node`), every node's name, `output` and `target`.

It leaves out `meta`, which is display only, and everything outside the spec: the cut points, the weights, the engine settings and the `decision_id`. The same spec under two decision ids has the same hash, registered separately under each.

Before hashing, the spec is put in a canonical form: key order and formatting are dropped, combine `terms` and a feature's `inputs` are sorted (their order means nothing), defaults written out are removed, whitespace around text is trimmed and text is Unicode-normalized (NFC).

### What changes which hash

Each row below starts from Northpine's `refund-exception` spec (structure `c219…`, default weights `d134…`). The weights column is the hash `"weights": "default"` resolves to; a pinned weights hash never changes by itself.

| Change to the spec | Structure hash | Default weights hash |
| - | - | - |
| Reorder keys, reorder `nodes`, reformat the file | same | same |
| Reorder combine `terms` or a feature's `inputs` | same | same |
| Spell out a default: `"effect": "raises"`, `"prior": "mean"`, `"criteria": {}` | same | same |
| Add spaces around a prompt, or switch its Unicode normalization | same | same |
| Edit `meta` (title, owner, display text) | same | same |
| Reword a prompt | new | same |
| Change the spec's `guidance` or a feature's `guidance` | new | same |
| Add or change a feature's `criteria` or `inputs` | new | same |
| Change `target` | new | same |
| Rename the output calibrate node (`grant`) | new | same |
| Add a feature no combine reads | new | same |
| Rename a feature (`outside_window` to `late_request`) | new | new |
| Rename the combine (`grant_score`) | new | new |
| Change a term's `effect` | new | new |
| Set `"prior": "any"` on the combine | new | new |
| Add or remove a combine term (add `carrier_delay`) | new | new |

Three things never touch either hash: your cut points (`outcomes`), the engine settings, and the `decision_id`.

The default weights hash changes only when the default numbers do: they are a mean over the combine's terms, keyed by term name and signed by `effect`, so renaming, adding or removing a term, flipping its direction or changing the `prior` makes new defaults, while rewording a prompt does not.

### Node hashes and renames

Each node also has its own hash, and the structure hash is built from them. A feature's hash covers its definition and the spec's `guidance`, but not its name. An aggregator's hash covers its definition and the hashes of the nodes it reads, by name. The structure hash covers `output`, `target`, and every node's name and hash.

So renaming `outside_window` to `late_request` keeps that feature's own hash, and corrections people gave about that feature still apply to it under the new name ([Feedback](https://docs.moonbounce.ai/learn/feedback.md)). The combine that reads it and the structure as a whole get new hashes, because they name it. Rewording the prompt is the opposite: the feature gets a new hash, so earlier corrections about the old wording don't carry over. Editing the spec's `guidance` changes every feature's hash at once, since every prompt is asked with it.

## Hash a spec without a key

`POST /v1/specs/hash` returns a spec's hashes. It needs no API key and stores nothing, so CI can check a file before anything ships:

```bash theme={null}
curl -sS -X POST https://api.moonbounce.ai/v1/specs/hash \
  -H 'Content-Type: application/json' \
  -d "$(jq '{spec}' moonbounce/refund-exception.json)"
```

```json theme={null}
{
  "structure": "sha256:c21908531c5bb1e922591c1b6d2d9e238205a6ee4c87cddff4674003e880b313",
  "nodes": {
    "outside_window": "sha256:a6fcf147060ad8b28eabf190f06c6a312921fc7647272aab0fca8ad8b149f167",
    "item_used": "sha256:cf815ef02fd338924bdbe0a479e5dc6aad7afbb1e37d8ada6aa6f5c3075dfe1d",
    "defective": "sha256:656ea117c68383fb12d5af1196a9c77edf616a848975b01452f0b71b0f70a930",
    "repeat_refunder": "sha256:6ef67173ec43a0cfe19787aed03805d56f3d4487a66ee596d42a576ee909742e",
    "high_value": "sha256:2d89aea04d210d330eacc058a6cc565a05354cdc48cb0dc0254896b0c6ac5b20",
    "grant_score": "sha256:b45f247953bebd41eb2b99f205fdab855db4e700204c13bc5de76c4f404860d1",
    "grant": "sha256:36db177e4d3ccb72bff1f75c5a2d9f1f61d04d9b691db8d3932f680d1df82941"
  },
  "default_weights": "sha256:d1340418a598e440b1b7680807d525414e7b2ad6801caab117d123f731e62eba"
}
```

`nodes` maps each node to its own hash and `default_weights` is the hash `"weights": "default"` resolves to. An invalid spec is a 400 that names the field at fault.

## Registering a structure

Moonbounce has to know a structure before you can call it by hash. There are two ways to register one, and both are safe to repeat.

* **Inline on evaluate.** Send `spec` in `POST /v1/evaluate`. The first time the API sees that structure under the decision, it registers it. Northpine sends the spec on every call, so a merged change goes live with the next call and nothing else has to happen.
* **Ahead of traffic.** `POST /v1/decisions/{decision_id}/structures` with `{"spec": {...}}` (needs `decisions:develop`). It answers `201` when this call registered the structure and `200` when it already existed; re-registering changes nothing except the stored `meta`. The body says the same thing in `created`:

```json theme={null}
{
  "decision_id": "refund-exception",
  "structure": "sha256:c21908531c5bb1e922591c1b6d2d9e238205a6ee4c87cddff4674003e880b313",
  "nodes": { "outside_window": "sha256:a6fcf147060ad8b28eabf190f06c6a312921fc7647272aab0fca8ad8b149f167" },
  "default_weights": "sha256:d1340418a598e440b1b7680807d525414e7b2ad6801caab117d123f731e62eba",
  "first_seen_at": "2026-10-27T18:02:11.000Z",
  "meta": { "title": "Northpine refund exception", "owner": "northpine-support" },
  "created": true
}
```

(`nodes` is shortened here; it lists every node. The registration response is `StructureRecord`, without `spec`.) Once registered, evaluate can send `"structure": "sha256:c219…"` instead of the spec, which keeps request bodies small. A hash that was never registered for this decision is 404 `VERSION_UNKNOWN`. A registered structure belongs to your organization, so one registered with a test key can be called by hash with a production key.

`GET /v1/decisions/{decision_id}/structures/{structure}` returns `StructureRead` (the record with `created: false` and `spec`), or 404 `VERSION_UNKNOWN`. It accepts `decisions:evaluate` or `decisions:develop`. The `spec` is the first registered spec in the current vocabulary and authored node order. Older rows without authored-order storage fall back to the stored spec, whose key order may differ. Your repository still owns the version you deploy.

## The weights hash

### What weights are

Weights are the numbers inside the aggregators: each combine's per-term weights and bias, and each calibrate node's map from score to probability. The spec says which answers count and in which direction; the weights say how much. With the default weights, Northpine's `grant_score` weighs each of its five terms 0.2 (signed by `effect`) with a bias of 0.8, and `grant` has no map yet, so it passes the score through ([From answers to an outcome](https://docs.moonbounce.ai/learn/scores-and-outcomes.md)).

### `"default"` or a pinned hash

`"weights": "default"` means "the default numbers for whatever structure this is". Change the spec and the defaults follow it, which is why the default weights hash moves in some rows of the table above.

A **pinned** weights hash names one exact weights document and keeps it fixed while the spec changes. Registering a structure stores its default weights, so you can pin `d134…` as soon as the spec is registered; a hash Moonbounce doesn't hold for this decision is 404 `WEIGHTS_UNKNOWN`. The response says which you used:

* `version.weights_source` is `"default"` or `"pinned"` today. Treat any other value as unknown rather than failing: more may be added.
* `version.weights_fitted` is `true` only when the weights were fit on feedback.

### Weights don't fit themselves

Moonbounce doesn't fit weights to your feedback automatically today, so every evaluation reports `weights_fitted: false`. What changes over time is the features: a [suggestion](https://docs.moonbounce.ai/learn/improving.md) rewrites or adds a feature. A suggestion keeps a `"default"` pin as `"default"`. Under a pinned hash, a rewrite keeps the pin (weights are keyed by feature name, so they still fit) and a new feature comes with a new pin whose weights carry the old ones over and give the new term a weight.

### When a pin and the spec disagree

A pin is fixed, so editing the spec under it can leave the two out of step. Here is what a caller meets, each case computed on ticket C with `d134…` pinned:

* **Add a term** (`carrier_delay`): the call succeeds with warning `WEIGHTS_PARTIAL`. The new feature is asked and its answer appears in `nodes`, but it has no weight, so it is observed and not scored until a pin covers it. Ticket C still scores 0.54.
* **Remove a term** (`high_value`): warning `WEIGHTS_STALE_TERM`. The pin's weight for it scores nothing; the bias stays, so ticket C scores 0.546.
* **Rename a term**: both warnings, since the old name is stale and the new one unweighted. Ticket C loses `outside_window`'s −0.19 and jumps to 0.73, which is `grant`. Watch for these warnings in CI.
* **Flip a term's `effect`**, or **rename the combine**: 409 `WEIGHTS_INCOMPATIBLE`, before any engine runs. A weight whose sign contradicts the term's direction, or a combine the weights hold nothing for, is refused rather than guessed.

Weights fit for one engine profile are also refused under another with 409 `WEIGHTS_INCOMPATIBLE`. With `"weights": "default"` none of this applies: the defaults always match the spec.

## The decision file

Keep each decision in your repository as `moonbounce/<decision_id>.json`, holding four keys: `decision_id`, `spec`, `weights` (the pin as you wrote it) and `outcomes`.

> [accordion] moonbounce/refund-exception.json
>
> ```json moonbounce/refund-exception.json theme={null}
> {
>   "decision_id": "refund-exception",
>   "spec": {
>     "format": "moonbounce.spec/0",
>     "meta": {
>       "title": "Northpine refund exception",
>       "description": "Northpine is a fictional outdoor-gear shop. Support tickets that fall outside the standard 30-day policy come here: grant the refund, escalate to a person, or deny.",
>       "owner": "northpine-support",
>       "tags": ["demo"]
>     },
>     "guidance": "Northpine sells outdoor gear online. Standard policy: unused items returned within 30 days of delivery get a full refund, and the support bot handles those without this decision. This decision covers the exceptions: requests outside the window or for used items. Our own failures (defects, late or lost shipping, billing errors) are reasons to grant. Repeated refund requests and high-value items are reasons for caution, not automatic denial.",
>     "nodes": {
>       "outside_window": { "type": "boolean", "prompt": "Is the request more than 30 days after delivery?" },
>       "item_used": { "type": "boolean", "prompt": "Has the customer used the item beyond trying it on or setting it up once?" },
>       "defective": { "type": "boolean", "prompt": "Does the customer describe a defect, damage on arrival, or the item failing under normal use?" },
>       "repeat_refunder": { "type": "boolean", "prompt": "Has the customer had two or more refunds in the last 12 months?" },
>       "high_value": { "type": "boolean", "prompt": "Was the order total above $250?" },
>       "grant_score": {
>         "type": "combine",
>         "terms": [
>           { "node": "outside_window", "effect": "lowers" },
>           { "node": "item_used", "effect": "lowers" },
>           { "node": "defective" },
>           { "node": "repeat_refunder", "effect": "lowers" },
>           { "node": "high_value", "effect": "lowers" }
>         ]
>       },
>       "grant": { "type": "calibrate", "node": "grant_score" }
>     },
>     "output": "grant",
>     "target": "The refund exception should be granted."
>   },
>   "weights": "default",
>   "outcomes": { "below": "deny", "cuts": [{ "at": 0.38, "outcome": "escalate" }, { "at": 0.6, "outcome": "grant" }] }
> }
> ```

Your app imports this file and sends it on every call, as on [Evaluate content](https://docs.moonbounce.ai/learn/evaluations.md). Because the structure, the weights pin and the cut points sit in one file, one pull request changes all three together, and what merges is exactly what was previewed: the hashes in the preview are the hashes your app sends.

Your code is the source of truth. Moonbounce stores what you send and proposes changes as suggestions, which you or your coding agent apply to the file, but it never writes to your repository.

## What counts as live

The live version of a decision is whatever your primary production traffic runs: the version in `POST /v1/evaluate` calls made with a production key. Seeds, previews and test-key calls don't count. Moonbounce has no pointer to flip; you ship a version by deploying code that sends it.

A gradual rollout is your own flag choosing between two versions, for example the current decision file and a candidate copy with a different spec or weights pin, kept outside `moonbounce/` so the folder still holds one file per decision. Make the choice sticky on `item_id`, so one ticket always gets the same version and its evaluations stay comparable:

```ts theme={null}
import { createHash } from "node:crypto";
import current from "../moonbounce/refund-exception.json" with { type: "json" };
import candidate from "../rollout/refund-exception.candidate.json" with { type: "json" };

const inRollout = (itemId: string, percent: number) =>
  createHash("sha256").update(itemId).digest().readUInt16BE(0) % 100 < percent;

const decision = inRollout(ticket.id, 10) ? candidate : current;
```

Both versions show up in your evaluations, each with its own `version`, so you can compare them before moving the rest of the traffic.

## Next

[Feedback](https://docs.moonbounce.ai/learn/feedback.md) covers what you send back once you know the right answer, and how it is matched to the exact version and feature that was judged.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.