rtb-assets 0.6.4

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_yaml`, converted to 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` | `null` — **silently** |
| `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 |
| `yes` / `no` / `on` / `off` | the strings `"yes"`, `"no"`, `"on"`, `"off"` — **not booleans** |
| `!!binary` and other explicit tags | not round-tripped; treat as unsupported |

The `yes`/`no` row catches people migrating YAML 1.1 config: write
`true` and `false`. The `.inf`/`.nan` row is the dangerous one, because
nothing reports it — a duration or threshold written as `.inf` arrives
as `null` and then fails deserialisation, or silently takes a default.

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.