rtb-assets 0.6.5

Embedded-asset + overlay filesystem abstraction. Part of the phpboyscout Rust toolkit.
Documentation
---
title: Merge semantics reference
description: Exactly what load_merged_yaml and load_merged_json do to each kind of value — what merges, what replaces, what deletes, and what YAML loses on the way through.
tags: [reference, yaml, json, merge]
---

# Merge semantics reference

`load_merged_yaml` and `load_merged_json` fold a file across every layer
that provides it. The fold is [RFC 7396 JSON Merge
Patch](https://www.rfc-editor.org/rfc/rfc7396): each layer is applied,
in registration order, as a patch over everything below it.

RFC 7396 is a small specification with two rules that catch people out.
Both are set out below, with the behaviour verified against
`rtb-assets` 0.6.3.

## What happens to each kind of value?

Given a lower layer and a higher layer that both provide the file:

| In the higher layer | Result |
|---|---|
| A key the lower layer does not have | added |
| A scalar (string, number, bool) over a scalar | replaces |
| A map over a map | merged key by key, recursively |
| A map over a scalar | replaces |
| A scalar over a map | replaces — the whole subtree is discarded |
| An array over anything | **replaces wholesale** — arrays are never element-merged |
| `null` over anything | **deletes the key** — see below |
| A whole document that is not a map | replaces everything merged so far |

`defaults.yaml`, on the lower layer:

```text
name: classic
palette:
  primary: blue
  accent: orange
```

`user.yaml`, on the higher layer:

```text
palette:
  accent: pink
```

What `load_merged_yaml("theme.yaml")` deserialises:

```text
name: classic
palette:
  primary: blue
  accent: pink
```

## Why does setting a key to `null` remove it?

Because RFC 7396 defines `null` in a patch as "delete this member". A
higher layer cannot use it to set a value *to* null:

```text
lower layer:   a: 1
               b: 2

higher layer:  b: null

merged:        a: 1          <- b is gone entirely, not set to null
```

If your `T` has a required `b`, that override turns into a
deserialisation failure rather than an override. There is no escape
syntax and no opt-out; the merge is RFC 7396 or nothing.

Every YAML spelling of null does it, including the ones people write by
accident: `b: null`, `b: Null`, `b: ~`, and a bare `b:` with nothing
after the colon. An empty string (`b: ""`) is a value, not a null, and
overrides normally.

**Design your config so absence and null mean the same thing.** Give
optional fields `Option<T>` and `#[serde(default)]`, and let a user
remove a key rather than null it. If you genuinely need a tri-state —
unset, explicitly null, set — encode it as a value the format can carry
(`mode: inherit`), not as `null`.

## Why did an empty override file wipe my configuration?

Because an empty YAML document parses to `null`, and RFC 7396 applies a
non-map patch by replacing the target wholesale. A higher layer
containing nothing — an empty file, or a file with only comments —
therefore discards everything the layers below contributed:

```text
lower layer:   a full, valid config
higher layer:  a file containing only "# nothing yet"

merged:        null
```

Deserialising `null` into a struct then fails with a message about an
invalid type, which points at the wrong file. Two ways to avoid it:

- Ship an override template containing `{}` rather than an empty file.
  An empty map is a valid no-op patch.
- Do not create the override file at all until there is something to
  put in it. A layer that does not have the path is skipped entirely.

An empty file in the **lowest** layer is harmless: it becomes the
starting value and the next layer's map replaces it.

## What does YAML lose on the way through?

YAML is parsed by `serde-saphyr` straight into a `serde_json::Value`,
and merged there. The conversion is lossy in ways that matter for config:

| YAML input | Value after conversion |
|---|---|
| `x: .inf`, `x: -.inf`, `x: .nan` | **a parse error** naming the layer and line |
| `when: 2026-01-02` | the string `"2026-01-02"` |
| `1: one` (non-string key) | the string key `"1"` |
| `use: *anchor` | the anchor's value, expanded in place |
| `<<: *anchor` (merge key) | an ordinary key named `"<<"`; nothing is merged |
| `x: 1` twice in one map | a parse error; duplicate keys are rejected |
| `yes` / `no` / `on` / `off` / `y` / `n` | the strings `"yes"`, `"no"`, `"on"`, `"off"`, `"y"`, `"n"` — **not booleans** |
| `x: 1_000` | the number `1000` |
| `mode: 0644` (leading zero) | the number `644.0` — not octal, and not a string |
| `x: 0o644`, `x: 0x1F` | the numbers `420` and `31` |
| `!!binary aGk=` | the decoded string `"hi"` |
| `!custom value` and other unknown tags | the untagged value; the tag is dropped |

The `yes`/`no` row catches people migrating YAML 1.1 config: write
`true` and `false`. Quote anything that must stay a string but looks
like a number, such as a file mode or a version: `mode: "0644"`.

The parser used to be `serde_yaml`, and under it four rows differed:
`.inf` and `.nan` became `null` silently, `1_000` and `0644` stayed
strings, and `!!binary` and unknown tags were not decoded. A
non-finite float now fails loudly instead of arriving as `null`.

JSON files skip this conversion entirely and merge as parsed.

## Which layer is blamed when parsing fails?

A parse failure on **any** contributing layer aborts the load — there is
no fallback to the layers below, because a silent fallback hides the
broken file. The error names the layer by its registration label:

```text
failed to parse asset `cfg.yaml (layer `user`)` as YAML: ...
```

The final step — fitting the merged document into `T` — happens after
merging and so cannot attribute a layer. Its error carries the bare
path:

```text
failed to parse asset `cfg.yaml` as YAML: invalid value: integer `99999`, expected u16
```

If you see the second form, the individual files are all well-formed
and it is their *combination* that does not fit the type.

## What is not merged

- **Blob reads.** `open`, `open_text` and `exists` take the winning
  layer's bytes and stop; only these two methods fold across layers.
- **TOML, INI, XML, and every other format.** Only YAML and JSON have
  loaders. A TOML file can still be carried as an asset and read with
  `open_text`, then parsed by the caller — it just gets last-wins
  shadowing rather than a merge.
- **The same document under two different names.** Merging keys on
  `path`, so `config.yaml` and `config.yml` are two unrelated assets.