﻿# Lathe User Guide

**Lathe** is a command-line tool that transforms data files using declarative rules. The data it reads and writes can be **JSON**, **YAML**, **XML**, or a flat **`key=value`** file (`.properties` / `.env`) - you can read in one format and write out another, or keep the same format and just reshape the data. The rules themselves are always JSON.

You describe *what* to change in a rules file, and Lathe applies those changes automatically.

---

## Quick Start

### 1. Prepare your data file

Any JSON, YAML, XML or flat `key=value` file. JSON may contain comments and trailing commas (as `tsconfig.json` and `appsettings.json` do), and both [survive a run written back to the same file](#editing-a-file-in-place-keeps-its-comments). For example, `config.json`:

```json
{
  "app": {
    "name": "MyApp",
    "version": "1.0",
    "debug": "true"
  },
  "database": {
    "host": "localhost",
    "port": "5432"
  }
}
```

### 2. Write a rules file

A JSON array describing the changes. For example, `rules.json`:

```json
[
  { "path": "app.name", "action": "ReplaceValue", "newValue": "MyApp Pro" },
  { "path": "app.debug", "action": "CastValue", "transform": "boolean" },
  { "path": "database.port", "action": "CastValue", "transform": "number" }
]
```

### 3. Run Lathe

```bash
Lathe config.json rules.json output.json
```

### 4. Result

`output.json`:
```json
{
  "app": {
    "name": "MyApp Pro",
    "version": "1.0",
    "debug": true
  },
  "database": {
    "host": "localhost",
    "port": 5432
  }
}
```

Notice that `debug` is now a real boolean and `port` is now a number - not quoted strings.

---

## Command-Line Usage

```
Lathe <input> <rulesFile> <outputFile> [outputFormat]
Lathe <input> <rulesFile> --in-place [--no-backup]
Lathe <input> <rulesFile> --diff | --check | --dry-run
Lathe <directory> <rulesFile> --in-place [--recursive] [--include <pattern>]
Lathe <input> [<rulesFile>] <outputFile> --set <path=value> ...
Lathe <input> <rulesFile> <outputFile> --split <pattern>
Lathe <directory> <rulesFile> <outputFile> --merge
Lathe <input> <rulesFile> --check --no-patch
```

| Argument       | Required | Description                                                        |
|----------------|----------|--------------------------------------------------------------------|
| `input`        | Yes      | A source data file (`.json`, `.yaml`, `.yml`, `.xml`, `.properties`, or `.env`), a directory, or a wildcard path |
| `rulesFile`    | Yes\*\*   | Path to the rules file. **JSON only** - comments and trailing commas allowed |
| `outputFile`   | Yes\*    | Path where the result will be written                              |
| `outputFormat` | No       | `json`, `yaml`/`yml`, `xml`, or `properties`/`env`. If omitted, it follows **the destination you named** - the output path's extension, or a `--split` pattern's - and falls back to the input's extension when nothing names one. So `Lathe a.json rules.json out.yaml` writes YAML. An unrecognised extension (`out.txt`) is not an error, it simply falls through. With `--in-place` a third path-like argument is an error, since there is no output file for it to be |
| `--set <path=value>` | No | Change one value without a rules file. Repeatable                |
| `--split <pattern>` | No  | Write one file per YAML document, e.g. `"{name}-{n}"`. One input file at a time |
| `--merge`      | No       | Combine every input into one YAML document stream                  |
| `--in-place`, `-i` | No   | Rewrite the input file, backing it up first                        |
| `--no-backup`  | No       | Skip the backup when editing in place. An error on a run that writes somewhere else, where there is no backup to skip |
| `--recursive`, `-r` | No  | Descend into subdirectories. Only for a directory or wildcard input |
| `--include <pattern>` | No | Narrow a directory input, e.g. `*.appsettings.json`. File names only - a pattern containing a path is refused, since it would move where Lathe looks rather than narrowing it. Use `--recursive` to reach subdirectories. Only for a directory input |
| `--no-patch`   | No       | Write the whole file from the tree instead of editing your text, losing comments and formatting. With `--check`, reports a file that is not already in Lathe's canonical style. See [Editing a file in place keeps its comments](#editing-a-file-in-place-keeps-its-comments) |
| `--dry-run`, `-n` | No    | Apply every rule but write nothing                                 |
| `--diff`       | No       | Print a unified diff of what would change, writing nothing         |
| `--check`      | No       | Exit `6` if the file would change, writing nothing                 |
| `--verbose`, `-v` | No    | Print timings and per-rule diagnostics to stderr                   |

\* Not needed with `--in-place`, `--dry-run`, `--diff` or `--check`, and not allowed for a directory or wildcard input - except with `--merge`, which needs one.

\*\* Optional when you give at least one `--set`.

### Previewing a change

Lathe rewrites config files, so it can show you what it would do before it does it. All three preview
switches leave the disk untouched - no output, and no `.bak`:

```bash
Lathe appsettings.json rules.json --diff       # show the change
Lathe appsettings.json rules.json --dry-run    # run the rules, write nothing
Lathe appsettings.json rules.json --check      # exit 6 if it would change
```

`--diff` prints an ordinary unified diff, so it pipes and pastes wherever `git diff` output would:

```diff
--- a/appsettings.json
+++ b/appsettings.json
@@ -1,5 +1,5 @@
   "Logging": {
-    "LogLevel": "Information"
+    "LogLevel": "Warning"
   },
```

`--check` is the pre-commit-hook idiom - it fails when a file is not already in its transformed state:

```bash
# fails the commit if appsettings.json still needs the rules applied
Lathe appsettings.json rules.json --check || {
  echo "appsettings.json is out of date - run Lathe on it"; exit 1
}
```

Point it at a directory and the same command gates a whole tree - see
[editing a whole directory](#editing-a-whole-directory).

**A file checked at its own path reports only a real value change**, not reformatting: Lathe edits your text
there rather than rewriting it, so your indentation, comments and quoting style are not differences. That
holds for all four formats. A run that converts format, or writes to a different file, compares against
whatever that destination currently holds and so reports *any* difference, reformatting included - see
[comments are read, but not written](#comments-are-read-but-not-written).

A preview switch beats anything that writes, so `--in-place --check` still writes nothing and takes no
backup. Give `--check` and `--diff` together and you get both: the diff on stdout, and exit `6`.

Some changes have nothing to show as a diff - a line ending changing, a final newline appearing, a
byte-order mark being added or dropped. `--check` counts those as changes, because it compares the bytes a
write would produce rather than the decoded text, so all three switches explain them on stderr instead of
leaving you with an empty diff and a failing gate. See
[the encoding is preserved](#the-encoding-is-preserved-and-a-file-lathe-cannot-read-is-refused).

### Output goes to stdout, diagnostics to stderr

A successful run prints **nothing** unless it did something you'd want to know about. Timings and per-rule
messages need `--verbose`, and go to stderr, so a diff can be piped without log lines contaminating it:

```bash
Lathe config.yaml rules.json --diff > change.patch   # the diff, and only the diff
Lathe config.yaml rules.json --diff --verbose 2> log # diff to the file, diagnostics to log
```

Warnings and error messages always print, verbose or not. The rule is that anything **lost** is said aloud:
a rule skipped under `onMissing: Warn`, a rewrite that discarded your comments, a `--merge` that left one of
its own sources out, and a change too small for a diff to show (a line ending, a byte-order mark).

### Line endings are preserved

The output keeps whatever the input used - CRLF, LF, or a lone CR - so editing one value doesn't come
back as a whole-file diff. A single-line file has no style to preserve, so the platform default is used.

`--check` compares exactly, including line endings and the trailing newline. If a run *would* change
those, it says so rather than reporting the file clean. Since there are no changed lines to show, the
explanation goes to stderr and the diff itself is empty - keeping whatever reaches stdout a patch that
`git apply` will accept:

```
WARNING: config.yaml: line endings change from mixed (CRLF/LF) to LF - no line content changed, so
there is nothing to show as a diff.
```

That matters for a CI gate: a check that passed while the write dirtied the file would be worse than
one that is occasionally noisy.

A file with no newline at the end gets the same `\ No newline at end of file` marker `git diff` writes,
so applying the patch gives you the same bytes `--in-place` would.

### The encoding is preserved, and a file Lathe cannot read is refused

The output is written in whatever encoding the input was stored in, so a UTF-8 file with a byte-order mark
keeps it and a UTF-16 file stays UTF-16. **The encoding is read from that mark and from nothing else.** A
file without one is read as UTF-8, because a legacy single-byte encoding cannot be told from UTF-8 by
looking at it, and a wrong guess would rewrite the values Lathe exists to protect.

So a file that is *not* UTF-8 and carries no mark is **refused**, at exit `2`, naming the byte:

```
ERROR: 'app.properties' does not decode as utf-8: byte 0xE9 at offset 24 is not valid there, so Lathe
reads it as U+FFFD and writing the file back would replace it permanently.
```

That byte is `é` in ISO-8859-1. Every run is refused, `--check` and `--diff` included: nothing Lathe could
report about the file would be true. Convert it to UTF-8, or save it with a byte-order mark. An XML
declaration naming an encoding is *not* consulted - the bytes are what decide.

**UTF-16 and UTF-32 without a mark are refused too**, for a reason worth knowing: every byte of such a file
is *individually* valid UTF-8, NUL included, so there is nothing invalid to catch. What gives it away is the
NUL between every real character, which no supported format allows:

```
ERROR: 'app.properties' decodes as utf-8 but holds a NUL character at index 1, which no supported format
allows - so it is almost certainly UTF-16 or UTF-32 stored without a byte-order mark.
```

Without this, a `.properties` file stored that way read every key wrongly and `--in-place --set` appended
UTF-8 bytes into it, leaving a file no reader could recover. (`.json`, `.yaml` and `.xml` were already
refused by their own parsers.)

**One unreadable file does not silence the rest of a sweep.** A read-only run over a directory examines
every file and reports each one, then exits `2` because part of the tree could not be read - so you get the
whole list to fix, not just the first problem.

`--check` compares the **bytes** a write would produce against the bytes on disk, not the decoded text, so
it cannot call a file clean when writing it would change a single byte of it - a different encoding, a
byte-order mark, or a byte that decodes to the same replacement character on both sides.

### Editing a file in place

Most of the time you want to change a config file, not produce a copy of it. `--in-place` rewrites the input:

```bash
Lathe appsettings.json rules.json --in-place
```

**The original is backed up first**, beside the file, with a timestamp:

```
appsettings.json                        <- rewritten
appsettings.20260827_143052.bak         <- the original, untouched
```

Naming the input as the output does the same thing, so this is equally safe:

```bash
Lathe appsettings.json rules.json appsettings.json
```

Points worth knowing:

- **The backup is only written if the run succeeds.** A failed rule, or a destination that can't be written, leaves your file exactly as it was and creates no `.bak`, so failures don't litter the directory.
- **Backups are never overwritten.** Two runs in the same second produce `...143052.bak` and `...143052.2.bak`, so the pristine original is always the earliest one.
- **The `.bak` replaces the extension** rather than being appended, so a later glob over `*.json` won't pick up your backups as input.
- **What gets backed up is what gets overwritten.** Normally that is the input. With `--split` the pieces are new files beside an original nothing writes to, so it is the pieces that are backed up - once each, and only if they already exist. With `--merge` it is the combined file, which matters most when that file is also one of the inputs. A destination you named outright is *not* backed up: choosing it is choosing to write it, and your input is untouched either way.
- **A run that changes nothing does not write at all.** If the file already holds exactly what the rules produce, it is left untouched - the same bytes *and* the same modification time - so `make`, a file watcher or an incremental build is not re-triggered by a tool that changed nothing.
- **A run that changes nothing takes no backup.** If the file already holds exactly what the rules produce, nothing is being overwritten and there is nothing to recover - so re-running the same command doesn't leave a `.bak` each time. That matches `--check`, which would have told you the file was already up to date.
- **The format follows the file.** In-place output is always written in the input's own format - an explicit `outputFormat` is ignored, since the file keeps its extension. And since `--in-place` has no output argument, a third path-like argument is a mistake rather than a format, and the run stops: `Lathe config.yaml rules.json out.json --in-place` used to rewrite `config.yaml` and never create `out.json`.
- `--no-backup` skips the copy. Reasonable when the file is already in version control and stray `.bak` files would be noise; you lose the safety net.

**Examples:**

```bash
# JSON to JSON
Lathe data.json rules.json output.json

# YAML to JSON
Lathe config.yaml rules.json output.json json

# JSON to XML
Lathe data.json rules.json output.xml xml
```

### Editing a whole directory

Config changes usually apply to a set of files, not one - every `appsettings.*.json`, every manifest in
a folder. Point Lathe at a directory or a wildcard instead of a file:

```bash
# every supported file in configs/
Lathe configs/ rules.json --in-place

# ...and in its subdirectories
Lathe configs/ rules.json --in-place --recursive

# just the ones you mean
Lathe configs/ rules.json -i -r --include *.appsettings.json

# a wildcard works too - quote it, or your shell will expand it first
Lathe "configs/*.json" rules.json --in-place
```

This composes with `--check` into a **repo-wide CI gate**: one command that fails if any config in the
tree is not in its transformed state.

```bash
# fails the build if any file under configs/ still needs the rules applied
Lathe configs/ rules.json --check --recursive || {
  echo "some configs are out of date - run Lathe on them"; exit 1
}
```

Points worth knowing:

- **Either everything is written, or nothing is.** Every file is transformed, and every destination is proved
  writable, before any of them is written - so a rule that fails on the last file, or a last file that can't
  be written at all, leaves the first one exactly as it was, with no `.bak` anywhere. You never get a
  half-migrated directory. If a write still fails after all that (the disk filling up mid-run, say), Lathe
  says how many files it had already written rather than implying the set was left alone.
- **Only files Lathe can read are touched.** A `README.md` beside your configs is passed over, not
  rejected, so `--include *` means "every supported file" rather than every file on disk.
- **Your rules file is never transformed**, even when it sits in the directory being swept.
- **Nothing matched is an error, not a quiet success** (exit `2`). A typo in `--include` fails loudly
  rather than passing a CI gate that examined no files at all.
- **Each file keeps its own format**, along with its line endings and trailing newline - a CRLF file
  beside an LF one leaves both as their authors wrote them.
- **There is no single output file.** A directory input needs `--in-place` or a preview switch; naming
  one output path for many inputs is an error rather than N files overwriting each other.
- **`--diff` prints one diff per changed file**, each labelled with its path, and skips the files that
  are already up to date.

---

### One-off edits with `--set`

Sometimes you want to change one value and writing a rules file is more ceremony than the job deserves. `--set path=value` does it inline, and the rules file becomes optional:

```bash
# no rules file at all
Lathe appsettings.json out.json --set Logging.Level=Debug

# repeatable, and works in place
Lathe appsettings.json --in-place --set Logging.Level=Debug --set AllowedHosts=localhost
```

| What you type | What it writes |
|---------------|----------------|
| `--set port=8080` | the number `8080` |
| `--set port='"8080"'` | the string `"8080"` |
| `--set version=1.0` | the number `1.0`, written back as `1.0` - the same as a JSON file's `1.0` |
| `--set build=007` | the string `"007"` - a leading zero is not a number in any format |
| `--set flag=true` | a real boolean |
| `--set note=null` | a real null, not an empty string |
| `--set conn=Server=db;Trusted=yes` | `Server=db;Trusted=yes` - only the first `=` splits |

Values are typed exactly as they would be if they'd come from a data file, so there's one rule to remember rather than a special case for the command line. Every spelling a YAML file accepts works here too: `True`, `TRUE`, `False`, `Null`, `NULL` and `~` mean what they mean in the file. Quote a value to keep it text - `--set flag='"True"'` writes the string.

Two things worth knowing:

- **It adds the field if it isn't there**, rather than quietly doing nothing. That's usually what you want from a one-off edit. One limit to know: the field's *parent* must already exist, so `--set app.region=EU` works on a file that has an `app` and does nothing on one that doesn't. Same for `SetValue` and `AddNode` in a rules file.
- **`--set` wins over the rules file.** The overrides are applied last, so if a rule and a `--set` target the same path, yours is the one that sticks. That's what makes it useful for a temporary tweak on top of a standard rules file.

Because the rules file is optional here, `Lathe in.json out.json --set …` and `Lathe in.json rules.json --set …` look identical - two paths and a switch. Lathe reads the second path as the **output**, so forgetting the output path would write over your rules file. If that second path turns out to hold rules, the run is refused instead; name an output as well, or use `--in-place`.

You can combine it with `--check` or `--diff` to see what it would do first.

---

### Multi-document YAML

A `---`-separated YAML file - the shape most Kubernetes manifests have - is fully supported. Rules apply to **every** document by default:

```json
{ "path": "metadata.namespace", "action": "SetValue", "newValue": "staging" }
```

That sets the namespace on every object in the file. But a manifest is a mixed bag, and most rules only make sense for one kind of object. Add a `document` filter for those:

```json
{
  "path": "spec.ports[name=http].port",
  "action": "SetValue",
  "newValue": 8080,
  "document": "kind=Service",
  "onMissing": "Fail"
}
```

Without the filter, that rule would find nothing in your ConfigMaps and Deployments - and with `onMissing: "Fail"` it would stop the run. The filter is what lets you keep `Fail` switched on: it means "fail if this doesn't apply to the documents it's *supposed* to apply to".

The filter matches on the data itself, the same way `kubectl` does. `kind=Service` works, and so does a nested path like `metadata.name=app-svc`. It is a plain `key=value` filter, not an expression, so `kind==Service` is an error rather than a filter that quietly matches nothing. A value containing `=` is fine: only the first one splits.

One limitation: **only YAML has multiple documents**, so converting a multi-document file to JSON or XML is refused rather than silently keeping one document and dropping the rest. Use `--split` if you want each document as its own file.

See `Samples/Stream` for a full worked example. Editing a stream at its own path keeps its `---`
separators and its comments, because they are never regenerated - `rules-inplace.json` and
`output-inplace.yaml` there are that run.

---

### Splitting one file into many, or many into one

```bash
# manifest.yaml -> manifest-1.yaml, manifest-2.yaml, ...
Lathe manifest.yaml rules.json out.yaml --split "{name}-{n}"

# every YAML file in manifests/ -> one combined stream
Lathe manifests/ rules.json combined.yaml --merge
```

In a `--split` pattern, `{n}` is the document number and `{name}` is the input file's name. The extension is added for you - and if you put one in the pattern yourself, that is what the pieces are *written as*, so `--split "{name}-{n}.json"` over a YAML input gives you real JSON files rather than YAML content in a `.json` file. Leaving out `{n}` is an error, since every document would be written to the same path and you'd end up with just the last one.

Three more patterns are errors, each because of where the pieces would land:

- A pattern that names the input itself - `--split "{name}"` - would overwrite the file you're splitting with one of its own documents. Same for one that names your rules file.
- An empty pattern, which would write a file called `.yaml`.
- Splitting a directory or a wildcard. Every input's pieces would land in one directory under names taken from the input, so two inputs sharing a name would overwrite each other's pieces, and the next run would split the pieces too. Split one file at a time.

The pieces land in the **output path's** directory, not the input's - the output file itself is never written, but where you point it is where the pieces go. A piece that lands on a file that already exists is backed up first, once each, unless you pass `--no-backup`.

Splitting is also how you convert a multi-document file to another format - each piece holds one document, so JSON or XML can express it:

```bash
Lathe manifest.yaml rules.json out.json json --split "{name}-{n}"
```

`--merge` needs an output path (that's the whole point) and can't be combined with `--in-place`. The two switches are exact opposites, so using both is an error. The merged file's format comes from the output path's own extension, so `--merge` over `*.json` into `combined.yaml` writes YAML. Splitting a file and merging the pieces gets you back where you started.

One thing to watch: the destination is left out of the set being merged, so that re-running a merge into the same directory settles instead of doubling the document count. If you point `--merge` at a file that is also one of your *sources*, that source is excluded and then overwritten by a merge that doesn't contain it. Lathe warns when that happens, naming the file.

Both switches work under `--check`, `--diff` and `--dry-run`: a split is previewed one piece at a time, a merge as the single combined file, and neither writes anything.

### Exit Codes

Useful in CI - a non-zero code means the run did not succeed.

| Code | Meaning |
|------|---------|
| `0`  | Success |
| `1`  | Bad arguments - including an unsupported output format, one output path for many input files, an output path that is your rules file, a bad `--split` pattern, `--no-backup` on a run that isn't editing in place, and `--check` asking for a different format with no output file to compare against |
| `2`  | Input or rules file missing, an unsupported input format, a file whose bytes do not decode in the encoding its byte-order mark declares, or a directory/wildcard that matched nothing |
| `3`  | The rules file could not be read or parsed - including a rule with a property name Lathe doesn't recognise |
| `4`  | A rule failed - **no output is written** |
| `5`  | The output could not be written |
| `6`  | `--check` only: a file **would change** |

If a rule fails part-way through, the output file is left exactly as it was. You never get a half-transformed file - or, when processing a directory, a half-migrated one.

`6` is deliberately not `1`: a hook needs to tell "this file needs the rules applied" apart from "you
invoked Lathe wrongly". A failing rule still fails under `--dry-run` and `--check`, so a preview never
reports success for a run that would go on to fail.

---

## Writing Rules

A rules file is a JSON array. Each rule has at minimum a `path` and an `action`. Additional properties depend on the action.

> **Rules must be JSON or JSONC.** The data you transform can be JSON, YAML or XML in any combination;
> rule files are always JSON. Comments (`//`, `/* */`) and trailing commas are accepted, so a rule file
> can be annotated:
>
> ```jsonc
> [
>   // bump the API image for the 2.0 rollout
>   { "path": "containers[name=api].image", "action": "ReplaceValue", "newValue": "api:2.0" },
> ]
> ```
>
> Any other rule-file format exits `3`.

### Path Syntax

Paths use dot notation to navigate your data:

| Path                     | Points to                                        |
|--------------------------|--------------------------------------------------|
| `app`                    | The `app` node at the top level                  |
| `app.name`               | The `name` field inside `app`                    |
| `users[0]`               | The first item in the `users` array              |
| `users[1].email`         | The `email` field of the second user             |
| `users[-1]`              | The **last** item in the array                   |
| `users[*].email`         | The `email` of **every** user                    |
| `users[role=admin]`      | Users whose `role` is `admin`                    |
| `users[role == "admin" && age > 30]` | Users matching an **expression**     |
| `pods[metadata.name == "api"]` | Matching on a **nested** field              |
| `services.*.port`        | The `port` of **every** child of `services`      |
| `app.settings.timeout`   | Deeply nested: `app` → `settings` → `timeout`    |
| `matrix[0][1]`           | Item 1 of item 0 - nested arrays                 |

A path can match several nodes at once, and the rule applies to all of them - so `users[*].email` updates every user in one rule.

Two small rules keep a path from meaning something you did not intend:

- **One selector per step.** `matrix[0][1]` reads as "item 0, then item 1 of that", so nested arrays work as you would expect.
- **No empty steps.** A leading, trailing or doubled dot - `app.`, `.app`, `app..name` - is an error rather than a path that quietly resolves to the node before it. Write `tags.[0]` if you mean an item of `tags`.

#### Picking array items by value, not position

Array positions change. If somebody reorders a list, `containers[0]` quietly starts editing a different entry, and nothing warns you. Match on a field instead:

```json
{ "path": "containers[name=api].image", "action": "ReplaceValue", "newValue": "api:2.0" }
```

This keeps working no matter how the list is ordered. Quote values containing spaces: `rows[label="two words"]`.

A selector picks an item so you can address something **inside** it, so it belongs before the last step of the path - `containers[name=api].image`, not `containers[name=api]`. Operations that work on a key (`RemoveNode`, `RenameNode`, `MoveNode`, `CopyNode`, `AddNode`, `WrapNode`, `FlattenNode`) refuse a path ending in a selector, because the key they would act on is the array itself: `RemoveNode` at `containers[name=api]` would delete every container. To act on an item as a whole, use an array operation such as `RemoveArrayItem` or `UpsertArrayItem`.

#### Matching on more than one condition

`[role=admin]` tests one field for equality. When you need more than that, write an expression:

```json
{ "path": "users[role == \"admin\" && age > 30].tier", "action": "SetValue", "newValue": "senior" }
{ "path": "services[port >= 1024 && enabled == true].exposed", "action": "SetValue", "newValue": true }
{ "path": "pods[startsWith(metadata.name, \"api\")].labels.team", "action": "SetValue", "newValue": "platform" }
```

What you can write:

| | |
|---|---|
| Compare | `==` `!=` `>` `<` `>=` `<=` |
| Combine | `&&` `\|\|` `!` and parentheses |
| Text | `startsWith(…)` `endsWith(…)` `contains(…)` `matches(…)` for regex |
| Values | `"text"` `42` `true` `false` `null` |

Three things worth knowing:

- **A field name can be a nested path**, so `pods[metadata.name == "api"]` reaches a field one level down.
- **Comparisons work the same on every format.** A port stored as XML text `8080` and as a JSON number `8080` are one value, and `>=` compares by size, so `port >= 1024` never matches `80`. Likewise `enabled == true` matches XML's `<enabled>true</enabled>`.
- **`<` `>` `<=` `>=` between a number and something that isn't one are false.** `port >= 1024` doesn't match a `port` of `"unset"`, of `""`, of `true`, or of a quoted `"0080"` - a value whose exact text matters isn't a number, so there is nothing to order. Two *non*-numbers still compare as text, which is what makes `due > "2026-08-01"` work on ISO dates. Use `AssertOfType` or `AssertRange` if you need to catch a field that should be a number and isn't.
- **Your existing rules keep working.** `[role=admin]` with a single `=` behaves exactly as before, including unquoted values with spaces.
- **A field that isn't there matches nothing** - not even `!= null`. So `tier == null` finds items whose `tier` is explicitly null, not items missing it. Reach for `SetDefault` when you want to fill in a missing field.
- **A field name may start with a digit.** `errors[404.page == "x"]` and `rows[1st == "yes"]` read `404.page` and `1st` as field names, since only a token that is a number all the way through - `42`, `2.5`, `1.10` - is a value.

Use `==` to compare, not `=`. Inside an expression a single `=` is an error rather than a rule that quietly matches nothing.

#### Testing values with `where`

`AssertCondition`, `ConditionalRule` and `FilterArrayItems` accept the same expressions through a `where` property:

```json
{ "path": "services", "action": "FilterArrayItems", "where": "port >= 1024 && enabled == true" }
{ "path": "users[*]", "action": "AssertCondition", "where": "age >= 18", "onMissing": "Fail" }
```

For `FilterArrayItems` this replaces the `condition` + `newValue` pair, since one expression says both what to test and what to match. A bare field name is a yes/no test - `where: "enabled"` keeps items whose `enabled` is set and not false, and `"!enabled"` keeps the rest.

**Those three are the only actions that read it, and a `where` on any other is refused.** Nothing else looks at the property, so it would not merely be ignored - the rule would silently apply to *every* node its path matches, which is the opposite of what a reader would assume it says. It is refused when the rules are read rather than when the rule is reached, so a rule file is either well-formed or it isn't, whatever the data happens to look like.

**Naming both a `where` and a `condition` is refused too**, for the mirror reason: those three read the expression *instead of* the condition, so the condition would be silently dropped - which turned a false `ConditionalRule` into one that fired, and a failing `AssertCondition` into a pass. An expression can state both halves: `"where": "tier == \"prod\" && port >= 1024"`. A `SwitchCase` **case** takes no `where` at all - it matches on `condition` alone - so one written there is refused rather than quietly letting the case run anyway.

To make any other rule conditional, put the expression in its path as a selector, or wrap it in a `ConditionalRule`:

```json
{ "path": "services[port >= 1024].tls", "action": "SetValue", "newValue": true }

{ "path": "app", "action": "ConditionalRule", "where": "tier == \"prod\"",
  "nested": [ { "path": "app.debug", "action": "SetValue", "newValue": false } ] }
```

### Reading a value from the file with `valueFrom`

Most rules state the value they write. `valueFrom` reads it from somewhere else in the same file instead, which is how you propagate one value without repeating it in the rule file:

```json
{ "path": "containers[*].image", "action": "ReplaceValue", "valueFrom": "release.image", "onMissing": "Fail" }
```

Every container's image becomes whatever `release.image` holds. Change the release once and the rule file never needs editing.

It works with every action that takes a `newValue`:

| Action | What the read value becomes |
|---|---|
| `ReplaceValue`, `SetValue`, `AddNode`, `SetDefault` | The value written |
| `InsertArrayItem` | The item appended |
| `UpsertArrayItem` | The item's identity, alongside `mergeKey` |
| `FilterArrayItems` | The value matched against |
| `AssertInSet` | The one value that is allowed |

Four things to know:

- **The path is absolute**, resolved from the top of the file exactly like `path` and `sources`. To read a *sibling* of each match, put the rule inside an `IterateNodes` - nested rules run against each item, so the read is relative to it:

  ```json
  { "path": "workloads", "action": "IterateNodes", "nested": [
      { "path": "label", "action": "SetValue", "valueFrom": "metadata.name" } ] }
  ```

- **The value keeps its type.** Copying a number gives you a number, not the text of one, so a copied port stays unquoted and reloads as a number.
- **The read happens where the rule sits**, so it sees the result of the rules above it - the same ordering every path in a rule file has.
- **A read that can't produce one value is an error**, not a silent skip: a path matching nothing is a typo, a path matching several nodes has no obvious winner, and an object or array has no single value to copy. This holds regardless of `onMissing`, which is about what a rule *writes to*. In a multi-document stream where only some documents have the field, scope the rule with `document` (see [Multi-document YAML](#multi-document-yaml)).

Setting both `newValue` and `valueFrom` is an error, since that names two values. `CoalesceValue` doesn't take `valueFrom` - it already reads paths through `sources`, where the order decides which wins.

#### Fields whose name contains a dot

Keys like `log4net.config` or `app.settings` can't be reached with a plain dotted path, so escape them:

```json
{ "path": "logging.log4net\\.config",     "action": "ReplaceValue", "newValue": "new.xml" }
{ "path": "logging.[\"log4net.config\"]", "action": "ReplaceValue", "newValue": "new.xml" }
```

Both forms address the single key `log4net.config`.

The same escaping covers the other characters that mean something in a path - `[`, `]` and `*`:

```json
{ "path": "[\"a[0]\"].b", "action": "ReplaceValue", "newValue": 1 }
{ "path": "[\"*\"].v",    "action": "ReplaceValue", "newValue": 1 }
```

The first addresses a field literally named `a[0]`; the second a field literally named `*`, rather than every field. Unescaped, `*` is the wildcard, so leaving it bare would edit all of them.

### Rule Structure

```json
{
  "path": "where.to.apply",
  "action": "WhatToDo",
  "newValue": "...",
  "valueFrom": "read.the.value.from.here",
  "newName": "...",
  "targetPath": "...",
  "sources": [ "path.one", "path.two" ],
  "condition": "...",
  "where": "port >= 1024 && enabled == true",
  "transform": "...",
  "min": 0,
  "max": 100,
  "nested": [...],
  "onMissing": "Skip | Warn | Fail"
}
```

Only include the properties that the action needs. Unused properties can be omitted.

**A property name Lathe doesn't recognise stops the run** (exit `3`), naming the rule and listing the names that do exist:

```
Rule $[1] has an unknown property 'operation'. Lathe would ignore it and apply whatever the rest
of the rule happens to say, so it is refused instead. Valid properties are: action, arrayStrategy,
condition, document, max, mergeKey, mergeStrategy, min, nested, newName, newValue, onMissing, path,
sources, targetPath, transform, valueFrom, where. To annotate a rule, use a // comment.
```

This is a hard error rather than a warning because a typo isn't harmless. `{"operation": "SetValue", "path": "b", "value": "X"}` matched *no* property name, so the rule fell back to its defaults - `ReplaceValue` with no new value - and wrote a null over `b`, reporting success. Property names are case-insensitive, so `newValue` and `NewValue` are both fine; the check also reaches rules inside `nested`.

Two more shapes are refused for the same reason:

- **A rule with no `action`.** Without one the rule would fall back to `ReplaceValue`, which is not what leaving it out means. (A `SwitchCase`'s cases are the exception - they're a `condition` plus the rules for it, so they need no action of their own.)
- **An entry that isn't a rule object**, such as a stray `null` left behind by an edit.

And an action whose job is to write a value **needs one**:

```json
{ "path": "connectionString", "action": "ReplaceValue" }
```

That used to write a null over your value and report success. `ReplaceValue`, `SetValue`, `AddNode`, `SetDefault` and `InsertArrayItem` now need a `newValue` or a `valueFrom`. If you really mean null, say so: `"newValue": null`.

### Catching rules that never apply

By default, a rule whose path matches nothing does nothing - so a typo looks exactly like a successful run. If a rule *must* apply to be correct, say so:

```json
{
  "path": "containers[name=api].image",
  "action": "ReplaceValue",
  "newValue": "api:2.0",
  "onMissing": "Fail"
}
```

- `Skip` - do nothing (default)
- `Warn` - log a warning naming the rule, and carry on
- `Fail` - stop the run and write no output

Some rules can't possibly have worked, so they fail whatever `onMissing` says:

- **A blank `path`** - the rule was never going to do anything. (`Skip` still skips it, so existing rule files keep working.)
- **Any assertion on a path that matches nothing** - an assertion that checked nothing isn't a pass. That covers `AssertCondition` and all eight of the [named checks](#validating-your-data). Use `AssertCondition` with `condition: "false"` when you mean "this must *not* be here"; a `where` expression, like every named check, always tests a value rather than asserting absence.
- **`SwapNodes` where either path doesn't match exactly one node** - a swap needs one node on each side. This includes `targetPath`, which `onMissing` doesn't inspect.
- **`SwapNodes` where one path contains the other** - swapping `a` with `a.b`, or a list with one of its own items, has no valid reading: the inner node is reached *through* the collection being replaced, so its siblings would be discarded. Swap two nodes that sit side by side instead. Giving the same path twice is refused for the same reason, since it would do nothing.
- **A rule missing a property its action needs** - a `RenameNode` with no `newName`, a `ConditionalRule` with no `condition` or `where`, an `IterateNodes` with no `nested`. The error names the rule and the property, rather than leaving you with a null-reference message.
- **A property the action would ignore** - a `valueFrom` on an action that does not write a value, a `where` on one of the 41 actions that never reads it, or a `min`/`max` on one of the 41 that never reads a bound. A rule carrying a `where` reads as conditional and a rule carrying a bound reads as restricted; on those actions each would have applied everywhere the path matched. A bound written as `null` is caught too, since `null` means "no bound" and is therefore indistinguishable from leaving it out.

### Types are preserved

Lathe does not rewrite the values you give it. `"007"` stays `"007"`, `"1.500"` stays `"1.500"`, `0.00001` stays `0.00001` rather than turning into `1E-05`, and a phone number like `"+61-3-1234-5678"` stays intact. In YAML, your quoting is honoured for *storage* - `port: 8080` is stored as a number, `port: "8080"` as a string, and each is written back the way you wrote it.

Quoting does **not** change how a value *compares*, which is a separate question and the one that surprises people: `port == 8080` matches `"8080"` too, and `AssertOfType number` passes on it, because text that looks like a number is one everywhere Lathe compares. That is deliberate - it is what lets one rule file work against XML, which cannot say "string" at all - but it means quoting is not a way to exempt a value from a numeric rule. `CastValue` changes storage; nothing changes the reading.

That includes not *adding* quotes. A bare `defaultMode: 0644` is written back bare, because those characters are the value you wrote and quoting it would change what your other tools read - to Kubernetes, bare `0644` is an octal number and `"0644"` is a string. Quotes are added only where leaving them off would change the type on reload, which is why a string `"123"` keeps them.

Values still compare sensibly across formats: a `"30"` read from XML matches a `30` from JSON, and numbers sort by size, so `9` comes before `10`. Booleans reconcile the same way, so `enabled == true` matches XML's `<enabled>true</enabled>` even though XML has no boolean type. Use `CastValue` when you want to change a type on purpose.

**What counts as a number is what JSON writes as one**, in every format. So `1.0`, `1.500`, `1e10`, `0.00001`, `-0` and a 20-digit id are numbers whether you wrote them in JSON, YAML, XML or a `.properties` file - `<version>1.0</version>` and `{"version": 1.0}` mean the same thing, and `sum` works on both. And `007`, `0644`, `+5`, `0x10`, `1_000` and `1.2.3` are text in all four, because JSON cannot spell any of them as a number - which is what keeps your part numbers, postcodes and file modes as you wrote them. Keeping the characters and reading the value are separate things: `1.0` is a number *and* is written back as `1.0`.

Padding is content too, so a quoted `" 42 "` is text - but in a `.properties` file only if you quote it, because that format strips the padding off a bare value before anything else looks at it, and `a= 42 ` is the number `42`.

**Numbers keep the form you wrote them in.** A JSON `1.0` stays `1.0` rather than becoming `1`, `1e5` stays `1e5`, a 21-digit id keeps every digit, and `threshold: 0.00001` is not rewritten as `1E-05`. They still compare and sort as the numbers they are, so `1.0` equals `1`.

**A multi-line YAML value keeps its block form.** A `script: |` block comes back as a block, not as a one-line `"echo hi\n"` string.

### Dates and times are text

Lathe has no date type, and never converts a date-shaped value into one. `2026-09-15`, `23:59:59`, `2026-09-15T22:00:00+10:00` and `P30D` all stay exactly the text you wrote - unquoted, in every format. This matters because some YAML readers resolve a bare date to a timestamp and a bare `12:30:45` to a number; Lathe does neither, so a date written in your file is the date written back.

That makes ordinary text rules the way to work with dates:

```json
[
  { "path": "schedule.releaseDate", "action": "AssertCondition", "condition": "2026-09-15" },
  { "path": "schedule.milestones", "action": "SortArrayItems", "condition": "due" },
  { "path": "schedule.milestones[due > \"2026-08-01\"].late", "action": "SetValue", "newValue": true }
]
```

Sorting and comparing work because ISO-8601 was designed so that alphabetical order *is* chronological order. No date support is needed for the common cases.

The one shape that cannot survive is a date with no separators: `20260915` is indistinguishable from an integer, so in YAML, XML and `.properties` it loads as the number `20260915`. Quote it - `"20260915"` - or convert it back with `CastValue` to `string`. JSON carries the type itself, so a quoted date there needs no help.

There is no date arithmetic. Adding days or reformatting between calendars is not supported; a regex over the ISO text handles the reformatting cases:

```json
{ "path": "schedule.releaseYear", "action": "TransformValue", "transform": "regex:^(\\d{4})-.*$:$1" }
```

See `Samples/Input/input.yaml` (the `schedule` section) and `Samples/Flat/` for worked examples.

### An empty YAML value is an empty string, not null

A key you leave blank stays a blank string. Nulls are kept separate:

| You write | You get back |
|-----------|--------------|
| `key: ~` or `key: null` | `null` |
| `key: ''` | `""` |
| `key:` | `""` |

All three are stable, so running Lathe repeatedly never changes them.

If a blank key in your file really does mean null, say so in a rule:

```json
{ "path": "spec.selector", "action": "SetValue", "newValue": null }
```

Use a wildcard path like `spec.*` to do a whole group at once. Note that this has to name the fields you mean - Lathe cannot tell, after loading, which blanks you wrote as `key:` and which as `key: ''`.

### Comments are read, but not written

Lathe reads commented files happily - including `//` and `/* */` in JSON, which strict JSON does not allow but `tsconfig.json`, `.vscode/settings.json` and `appsettings.json` use routinely. Trailing commas are fine too.

> **One exception, and it is the useful one: a JSON, YAML, `.properties` or `.env` file edited *at its own
> path* keeps its comments.** See
> [Editing a file in place keeps its comments](#editing-a-file-in-place-keeps-its-comments) below. Everything
> in this section applies to any run that writes to a different path or converts format. XML is a separate
> case: it keeps its *formatting* when edited at its own path, but a commented XML file is refused outright
> rather than edited, for the reason below.

**But comments are not preserved when the output is a different file.** They are dropped:

```jsonc
// input.json - comments and trailing commas are accepted
{
  // which port to listen on
  "port": 8080,
}
```

```json
// output.json - the comment is gone
{
  "port": 8080
}
```

The same applies to `#` comments in a YAML or `.properties` file you are *converting* or writing somewhere
else. Written back to its own path, any of the three keeps them.

**XML is different: a commented XML file is refused, not rewritten.** Because Lathe cannot represent an
XML comment, dropping it from a file being edited in place would destroy it silently - so the run exits
`2` and leaves the file alone. The same applies to CDATA, processing instructions and mixed content
(`<p>text<b>bold</b>tail</p>`). See [Format Compatibility Notes](#format-compatibility-notes).

**A run that drops comments says how many.** You do not have to notice afterwards:

```
$ lathe app.yaml rules.json out.json json
WARNING: 'app.yaml' holds 3 comments. This run writes a new document from the tree rather than
editing the input's own text, so none of them are carried into the output.
```

The count comes from the format's own reader, so a `#` that is data - inside a YAML block scalar, or on a
`.properties` continuation line - is not counted. A file with no comments says nothing, and under `--check`,
`--diff` or `--dry-run` the message says what *would* happen. It is a warning rather than a refusal, so a
conversion you meant still works; if you want the run to stop instead, gate it on the warning in your own
script.

**Before converting an annotated file:** commit it first, or run `--diff` to see exactly what the rewrite
would cost you. Once the comments are gone they cannot be recovered from the output.

The same reserialization is why **indentation** may shift on such a run, and why `--check` can report a
change with no rule having matched.

### Editing a file in place keeps its comments

For a file written back to **its own path** in **its own format**, Lathe edits your text instead of
regenerating it: only the values your rules actually changed are replaced, and every other byte is left
exactly as you wrote it. **All four formats work this way** - JSON, YAML, XML and `.properties`/`.env`.

The practical consequence is the one CI cares about: **a run that changes no value is byte-identical, so
`--check` exits `0`** instead of reporting your whole file as pending.

In JSON that means your `//` and `/* */` comments, your trailing commas, your indentation, your blank lines
and your own escape spellings:

```jsonc
{
  // the listener              <- kept
  "Kestrel": { "Port": 5000 }, <- rule changes 5000 to 8080, and only that

  /* how much we say */        <- kept
  "Logging": {
        "LogLevel": { "Default": "Information" }   <- your indentation, not Lathe's
  },
  "City": "Zürich",       <- your escape, not the ü Lathe would have written
}                              <- your trailing comma
```

That is what makes `--check` usable on an `appsettings.json` or a `tsconfig.json`, which is almost never
written in Lathe's own style.

In YAML that means your comments, blank lines, indentation width, `---` separators and quoting style all
survive - including the two quoting choices a rewrite used to change out from under you, `"2023-01-01"`
losing its quotes and `tls: "off"` becoming the boolean-looking `tls: off`:

```yaml
# Deployment settings     <- kept
app:
    port: 8080            <- rule changes this to 9090, and only this
    tls: "off"            <- kept quoted, so it still reads as text
    features:
        - alpha           <- your indentation, not Lathe's
```

In `.properties` it means comments, blank lines, the padding in `logging.level=  info`, whether you used `=`
or `:`, the `export ` prefix, and the order you interleaved your keys in:

```properties
# Application settings          <- kept
server.http.port=8080           <- rule changes this to 9090

# Quoting means "this is text"  <- kept
logging.level=  info            <- becomes "logging.level=  debug", padding and all
```

In XML it means your indentation, the `<?xml … ?>` declaration, blank lines, attribute spacing, whichever
quote character you wrote an attribute with, `<empty />` staying self-closing, and your own spelling of an
entity:

```xml
<?xml version="1.0" encoding="utf-8"?>   <!-- kept, declaration and all -->
<configuration>
  <appSettings>                          <!-- two-space indentation, not Lathe's tabs -->
    <add key='Environment' value="Production" />   <!-- your quote characters -->
    <add key="Retries">3</add>           <!-- rule changes this to 9, and only this -->

    <add key="Slogan">fast &#38; small</add>       <!-- &#38; kept, not rewritten to &amp; -->
  </appSettings>
</configuration>
```

This is what makes `--check` usable on a repository's `.csproj`, `.config` and `.xaml` files, which are
almost never written in Lathe's own style. (XML *comments* are a separate matter: Lathe cannot represent one,
so a commented XML file is refused rather than edited - see above.)

The worked examples are `Samples/Stream/rules-inplace.json` for YAML - compare `input.yaml` with
`output-inplace.yaml`, then with `output.yaml`, which is the same manifest rewritten from the tree - and
`Samples/Flat/rules-inplace.json` for `.properties`.

**JSON needs no refusal of its own, and it is the only format of which that is simply true.** The three
refusals below exist because those formats can read a value differently from the way you wrote it, and keeping
your bytes would then preserve a value Lathe had already misread. JSON's escapes are lossless both ways, its
types come from the token rather than from the text, and a number Lathe could not reprint exactly keeps your
own digits - so there is nothing to refuse. If a JSON file cannot be indexed for some other reason, the run
quietly writes from the tree instead, exactly as it did before.

**When it does not apply,** Lathe writes the file afresh from the tree exactly as it always did - losing the
comments. If the file actually had comments to lose, it says so on stderr even without `--verbose`; with
`--verbose` it says which of these it was:

| Situation | Why |
|---|---|
| You named a different output file | Your input's bytes say nothing about what a different file should contain, and the first write would destroy whatever comments *it* had |
| You converted format | Same reason: there is no source text for the target format |
| A rule added, removed or renamed a key | A new key has no text to be written in |
| A rule reordered a list | Each value would land in a position belonging to a different item, leaving your comments describing data that moved |
| A rule changed a multi-line value | A YAML block scalar (`\|`), a `\`-continued `.properties` pair, or XML element text laid out across lines has no single-line text to replace. An *unchanged* one is kept, so a ConfigMap script survives a run that edits the keys around it. JSON is exempt: a line break inside a string is written `\n`, so no JSON value is ever laid out across lines |
| A rule gave a value to an empty XML element | `<a />` and `<a></a>` have no text to replace, the same way a YAML key written `name:` has none |
| You passed `--no-patch` | You asked for the rewrite |

**A file Lathe would misread is refused rather than quietly preserved.** Because editing keeps your bytes, a
value Lathe reads wrongly would be silently kept too - so instead the run exits `2` and names the line.

In a `.properties` or `.env` file that is a backslash escape:

```
Cannot safely edit 'app.properties': line 4 has the value 'C:\temp', which Lathe reads as 'C:<tab>emp'.
Editing the file would rewrite it, so the run is refused instead. Double the backslash to keep it verbatim.
```

Only `\\`, `\r` and `\n` survive a round trip in that format, so a Windows path (`C:\temp`), a regex
(`\d+`) or a Java unicode escape (`\u20AC`) has to be written with a doubled backslash. This is strictly
safer than before, when the same file was silently written back with the value corrupted.

In XML it is a literal tab or line break written *inside an attribute's quotes*:

```
Cannot safely edit 'app.config': line 3 writes the attribute 'path' with a literal tab or line break inside
its quotes. XML replaces each with a space before Lathe sees it, so the value it would keep is not the one
the file holds. Write it as &#x9; or &#xA; instead.
```

That replacement is XML's own rule, not Lathe's, so `path="a<TAB>b"` really does mean `a b` to every reader -
but keeping your bytes would leave the tab in a file Lathe believes holds a space. The escaped spellings
(`&#x9;`, `&#xA;`) mean the tab and are accepted. Nothing else in XML is refused over a value: type inference
never changes a character, so `007` and `+5` stay the exact strings you wrote, and `0.00001`, `1.500` and `-0`
read as numbers and are still written with the digits you typed.

**`--no-patch` gets you past any of these refusals - and warns you.** The switch means "write this from the
tree", which is the behaviour Lathe had before it could edit your text, corruption included. It does not make
the file safe, so a run that bypasses a refusal this way says on stderr what it is about to write and that it
would otherwise have stopped. A file with nothing to lose stays silent.

YAML has no equivalent escape problem, so nothing there is refused over a *value*. What is refused is YAML
Lathe cannot address at all, and all four are rare:

- an **anchor or alias** (`&d` / `*d`), which Lathe already refuses everywhere because it would write the
  shared value out in full in both places
- a **collection used as a key** (`? [a, b]`), which has no name a rule could target
- a document whose root is a quoted empty string (`''`), which Lathe's document count disagrees with
- a duplicate key, which the loader refuses first anyway

Everything else is either edited or rewritten, never corrupted: after splicing your values in, Lathe reads
its own result back and checks it says what the rules asked for. If it does not, you get the old rewrite
rather than a wrong file.

**A type the format cannot express is not a reason to rewrite the file.** Setting an XML value to the string
`"99"`, or to a boolean, reads back as a number or as text, because XML has no types - but so does the same
value written by a full rewrite, so reformatting your whole file to produce an identically-typed document
bought nothing. The check therefore asks whether the edited file is the same document as the file a rewrite
*would* have produced, not whether it matches the tree exactly. Ports, versions, ids and every `CastValue`
now keep your formatting.

Two honest caveats:

- **The checks only run when Lathe is editing your file at its own path.** A run that converts format or
  writes somewhere else still corrupts these values the way it always has, so check for backslashes before
  converting a `.properties` file.
- **`--no-patch` bypasses the check**, because it turns the whole editing path off - and then you get the old
  behaviour, corruption included. It forces a rewrite; it does not make a file safe.

### Asking whether a file is in Lathe's canonical style

Because editing keeps your formatting, `--check` no longer reports differences that are *only* formatting.
`--no-patch` turns editing off, so pair the two to ask the opposite question:

```bash
Lathe app.yaml rules.json --check --no-patch   # exit 6 if the file is not in Lathe's own style
```

`--no-patch` is also the way to route around a suspected problem in the editing path without pinning an
older build.

**Your key order is kept.** Fields are written back in the order your file had them, and editing a value
leaves that field where it was - so a one-line change produces a one-line diff. A field a rule *adds* goes
at the end of its object. Line endings are preserved too, as is a line break *inside* a value (XML writes
it as `&#xA;` so it cannot be mistaken for formatting).

---

## Supported Operations

Lathe provides **44 operations** across nine categories.

---

### Changing Values

#### ReplaceValue - Overwrite a value

```json
{ "path": "app.name", "action": "ReplaceValue", "newValue": "NewApp" }
```

| Before | After |
|--------|-------|
| `"name": "OldApp"` | `"name": "NewApp"` |

Note this does nothing if the field isn't there. Use `SetValue` if you want it created.

#### SetValue - Set a value, adding the field if it's missing

```json
{ "path": "app.version", "action": "SetValue", "newValue": "2.0" }
```

| Before | After |
|--------|-------|
| `"version": "1.0"` | `"version": "2.0"` |
| *(no `version` field)* | `"version": "2.0"` |

The one you usually want. `ReplaceValue` skips a field that doesn't exist and `AddNode` needs it to be new, so `SetValue` covers both - and it's what `--set` on the command line uses. It's also the mirror of `SetDefault`: that one writes *only* if the field is missing, this one writes either way.

If the path points at an object or array, it becomes a plain value - its children or items are **gone**.
That is what the action is for, but it is a loss, so the run says so on stderr even without `--verbose`.
If you meant to change a node's own value and keep its children - the `<add key="Timeout">30</add>` shape,
where the text is at `add` and the attribute is at `add.@key` - use `ReplaceValue` instead.

#### AddNode - Add a new field

```json
{ "path": "app.release", "action": "AddNode", "newValue": "2025-03-18" }
```

Creates `app.release` with the given value. The parent (`app`) must already exist.

**A field that is already there is replaced, and the run says so** - naming what was lost and pointing you at
`ReplaceValue`, which changes a field's own value and leaves anything nested under it alone. `AddNode` is for a
field that is new; aiming it at an occupied one used to delete whatever was there without a word.

#### RemoveNode - Delete a field

```json
{ "path": "app.debug", "action": "RemoveNode" }
```

Removes `app.debug` entirely from the output.

#### RenameNode - Rename a field's key

```json
{ "path": "app.name", "action": "RenameNode", "newName": "title" }
```

| Before | After |
|--------|-------|
| `"name": "MyApp"` | `"title": "MyApp"` |

The value stays the same - only the key changes. Renaming onto a key a sibling already holds is refused, since that sibling's value would be lost. Renaming a field to the name it already has does nothing.

#### MoveNode - Relocate a field

```json
{ "path": "database.host", "action": "MoveNode", "targetPath": "app.dbHost" }
```

Removes `database.host` and places it at `app.dbHost`. If `targetPath`'s parent doesn't exist the move is refused rather than performed, so the field is never removed without somewhere to go. Moving a field onto its own path does nothing, rather than deleting it.

Moving a field **inside itself** - `MoveNode a → a.inner` - is refused too. The node would become its own descendant and then be unlinked from the tree, so it and everything under it would be gone. Use `CopyNode` if a nested copy is what you meant.

If the destination is **already there** it is replaced, and Lathe says so on stderr - `MergeNodes` is the one to reach for when you want a policy about that (it keeps the existing value by default). The same applies to `CopyNode`.

---

### Setting Defaults and Conditions

#### SetDefault - Add only if missing

```json
{ "path": "app.region", "action": "SetDefault", "newValue": "US" }
```

If `app.region` already exists, nothing happens. If it does not exist, it is created with `"US"`. This is safe to use when you want to guarantee a field exists without overwriting intentional values.

#### AssertCondition - Validate your data

Throws an error and stops processing if the condition is not met. Use this to catch problems early.

**Assert a field exists:**
```json
{ "path": "app.name", "action": "AssertCondition", "condition": "true" }
```

**Assert a field does NOT exist:**
```json
{ "path": "app.deprecated", "action": "AssertCondition", "condition": "false" }
```

**Assert a field equals a specific value:**
```json
{ "path": "app.version", "action": "AssertCondition", "condition": "2.0" }
```

**Assert something more involved** - the expression must hold for every node the path matched:
```json
{ "path": "users[*]", "action": "AssertCondition", "where": "age >= 18", "onMissing": "Fail" }
```

For the eight named checks - "must not be null", "must be unique", "must be one of these" and so on - see
[Validating Your Data](#validating-your-data) below.

#### ConditionalRule - Apply rules only if a condition is met

```json
{
  "path": "app.premium",
  "action": "ConditionalRule",
  "condition": "true",
  "nested": [
    { "path": "app.features", "action": "AddNode", "newValue": "advanced" }
  ]
}
```

If `app.premium` exists, the nested rules run. If not, they are skipped. You can also use `"false"` (field must not exist) or a literal value (field must equal that value).

For a compound test, use `where` instead:

```json
{
  "path": "app",
  "action": "ConditionalRule",
  "where": "environment == \"production\" && version >= 2",
  "nested": [
    { "path": "app.hardened", "action": "SetValue", "newValue": true }
  ]
}
```

#### SwitchCase - Choose rules based on a value

```json
{
  "path": "app.environment",
  "action": "SwitchCase",
  "nested": [
    {
      "condition": "production",
      "nested": [
        { "path": "app.debug", "action": "ReplaceValue", "newValue": "false" },
        { "path": "app.logLevel", "action": "ReplaceValue", "newValue": "error" }
      ]
    },
    {
      "condition": "staging",
      "nested": [
        { "path": "app.logLevel", "action": "ReplaceValue", "newValue": "warn" }
      ]
    },
    {
      "condition": "default",
      "nested": [
        { "path": "app.logLevel", "action": "ReplaceValue", "newValue": "debug" }
      ]
    }
  ]
}
```

Reads the value of `app.environment` and runs the first matching case. Use `"default"` as a catch-all. Only the first match executes.

---

### Validating Your Data

Eight named checks, each its own action. They **change nothing** - they either pass in silence or stop the run
at exit `4` with a message naming the path and the value - so a rule file of them is a data quality gate you can
point at a file in CI, and one left at the end of a rule file that *does* make changes checks the result of
those changes.

Because they change nothing, they are also safe under `--check`: a file that already satisfies them is reported
as needing no change.

#### AssertNotNull - The value must actually be set

```json
{ "path": "database.password", "action": "AssertNotNull" }
```

Fails where the field is there and empty - `"password": null`. An object or a list **passes**: neither is a null
*value*, and "this must be a plain value" is what `AssertOfType` says.

#### AssertUnique - No two items may share a value

```json
{ "path": "servers", "action": "AssertUnique", "condition": "name" }
{ "path": "tags", "action": "AssertUnique" }
```

`condition` names the field that identifies an item. Leave it out (or use `"."`) for a list of plain values.

It is the mirror of `DeduplicateArrayItems` and holds exactly where that one would remove nothing, so an item
that has no `name` at all is not counted as a duplicate of every other item that hasn't got one. Two things are
**refused** rather than passed: asking to compare *objects* with no `condition` - every object would read as the
same nothing, so the check would be vacuous - and naming a field that no item carries.

#### AssertInSet - The value must be one of these

```json
{ "path": "app.environment", "action": "AssertInSet", "newValue": [ "dev", "staging", "prod" ] }
{ "path": "app.port", "action": "AssertInSet", "newValue": [ 8080, 9090 ] }
```

A single value instead of a list is a one-value set. `valueFrom` works too, to check against a value read from
elsewhere in the same file:

```json
{ "path": "release.deployed", "action": "AssertInSet", "valueFrom": "release.approved" }
```

#### AssertMatchesRegex - The text must match a pattern

```json
{ "path": "schedule.releaseDate", "action": "AssertMatchesRegex", "condition": "^\\d{4}-\\d{2}-\\d{2}$" }
```

A **search**, like `TransformValue`'s `regex:`, so anchor it with `^` and `$` when you mean the whole value. A
pattern that isn't a valid regular expression is reported before any rule runs, rather than at whichever field
the path happened to reach first.

#### AssertRange - The number must be within bounds

```json
{ "path": "app.port", "action": "AssertRange", "min": 1024, "max": 65535 }
{ "path": "app.replicas", "action": "AssertRange", "min": 1 }
```

Both bounds are inclusive and either may be left out, but not both. A value that isn't a number **fails** - it
has no place in a range - so this also catches a port that has become `"eighty-eighty"`.

#### AssertLength - The text must be the right length

```json
{ "path": "app.name", "action": "AssertLength", "min": 1, "max": 63 }
```

`min: 1` is how you say "must not be blank". It measures the text Lathe would write, so a number's digits count
and `AssertLength` on `8080` is 4.

#### AssertOfType - The value must be the right kind of thing

```json
{ "path": "app.port", "action": "AssertOfType", "transform": "number" }
{ "path": "app.features", "action": "AssertOfType", "transform": "array" }
```

One of `string`, `number`, `boolean`, `object`, `array` or `null`.

**It asks how the value reads, not how it happens to be stored**, so the same rule works on all four formats -
XML and `.properties` have no types at all, and `<port>8080</port>` reads as a number for the same reason
`port == 8080` matches it. The flip side is worth knowing: a JSON `"20"` you deliberately quoted also reads as a
number, because everywhere else in Lathe it *is* one - and so do `"1.10"` and `"2.0"`. Text JSON cannot spell as
a number is not one, so `"007"`, `"0644"`, `"+5"` and `"1.2.3"` are all strings.

#### AssertCount - The list must be the right size

```json
{ "path": "servers", "action": "AssertCount", "min": 1 }
{ "path": "regions", "action": "AssertCount", "min": 2, "max": 5 }
```

`min: 1` is how you say "must not be empty". It counts the way `AggregateNodes`' `count` does: a path matching
several lists counts all of them together, so `services.*.ports` is "how many ports in the whole file", and a
field that isn't a list counts as one item.

#### What every one of them shares

**A check that checked nothing is not a pass.** If the path matches no field, the run fails, whatever
`onMissing` says - the same rule `AssertCondition` has always had. Use `AssertCondition` with `condition:
"false"` where the field is genuinely allowed to be missing, or a `document:` filter to restrict the rule to the
documents of a stream it applies to.

**They compare across formats.** An allowed `8080` matches the text `8080` in XML or a `.properties` file, and
`AssertRange` reads that text as a number, so one rule file means one thing whichever spelling of a document it
is pointed at.

**Broken rules are refused before anything runs**, whatever the data says: a `min` above its `max`, a negative
or fractional `min` on a length or a count, a `min` on an action that doesn't read one, an unknown type name, a
malformed pattern, an `AssertInSet` with nothing in it. A rule file is either well-formed or it isn't.

---

### Transforming Values In Place

#### TransformValue - Modify text without replacing it entirely

Use `transform` to specify the operation:

| Transform | What it does | Example |
|-----------|-------------|---------|
| `uppercase` | Convert to UPPER CASE | `"hello"` → `"HELLO"` |
| `lowercase` | Convert to lower case | `"HELLO"` → `"hello"` |
| `trim` | Remove leading/trailing whitespace | `"  hello  "` → `"hello"` |
| `titlecase` | Capitalise Each Word | `"hello world"` → `"Hello World"` |
| `prefix:text` | Add text to the beginning | `"world"` → `"hello-world"` |
| `suffix:text` | Add text to the end | `"hello"` → `"hello-world"` |
| `replace:old:new` | Replace a substring | `"foo-bar"` → `"foo_bar"` |
| `regex:pattern:replacement` | Regex find-and-replace | `"2023-01-01"` → `"2023/01/01"` |

**Examples:**

```json
{ "path": "app.name", "action": "TransformValue", "transform": "uppercase" }
```

```json
{ "path": "app.name", "action": "TransformValue", "transform": "prefix:v2-" }
```

```json
{ "path": "app.name", "action": "TransformValue", "transform": "replace:Old:New" }
```

```json
{ "path": "app.date", "action": "TransformValue", "transform": "regex:-:/" }
```

#### CastValue - Convert a value's type

| Target type | What it accepts | Result |
|------------|----------------|--------|
| `string` | Anything | Text |
| `number` | `"42"`, `"3.14"` | A real number (not a quoted string) |
| `boolean` | `"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"` | A real boolean |
| `double` | `"3.14"`, `"42"` | A decimal number |

```json
{ "path": "settings.port", "action": "CastValue", "transform": "number" }
```
`"5432"` (string) → `5432` (number)

```json
{ "path": "settings.verbose", "action": "CastValue", "transform": "boolean" }
```
`"true"` (string) → `true` (boolean)

This is especially useful for the types XML and YAML *can't* declare - a boolean above all, since `<debug>true</debug>` is the text `"true"` and no amount of inference will change that. Numbers need no help: `<port>8080</port>` and `<rate>1e10</rate>` are already numbers, so a cast on them is skipped rather than applied - a cast re-renders the value, which would have discarded the digits you wrote.

---

### Working with Arrays

#### InsertArrayItem - Add an item to the end

```json
{ "path": "app.tags", "action": "InsertArrayItem", "newValue": "production" }
```

| Before | After |
|--------|-------|
| `["api", "v2"]` | `["api", "v2", "production"]` |

#### UpsertArrayItem - Update an item, or add it if it isn't there

This is the edit you'll reach for most often. "Set the container called `api`" normally means "…and add one if it's missing", and doing that with the other operations took two rules kept in step.

```json
{
  "path": "spec.containers[name=api]",
  "action": "UpsertArrayItem",
  "nested": [
    { "path": "image", "action": "ReplaceValue", "newValue": "api:2.0" }
  ]
}
```

| Before | After |
|--------|-------|
| `[{"name": "api", "image": "api:1.0"}]` | `[{"name": "api", "image": "api:2.0"}]` |
| `[{"name": "web", …}]` | `[{"name": "web", …}, {"name": "api", "image": "api:2.0"}]` |

The `nested` rules are relative to the item, and run the same way whether it was found or created - so **running this twice changes nothing the second time**. That's what makes it safe in a `--check` gate.

If the value you're matching on doesn't suit the `[key=value]` syntax, name it separately instead:

```json
{ "path": "ports", "action": "UpsertArrayItem", "mergeKey": "name", "newValue": "https" }
```

Note that `onMissing: "Fail"` here means "fail if the *array* is missing", not "fail if no item matched" - a non-matching item is the whole point. A typo in the array's own name is still caught.

#### RemoveArrayItem - Remove an item by position

```json
{ "path": "app.tags", "action": "RemoveArrayItem", "condition": "0" }
```

| Before | After |
|--------|-------|
| `["api", "v2", "production"]` | `["v2", "production"]` |

The `condition` is the zero-based index: `"0"` for the first item, `"1"` for the second, etc.

**Running this rule twice removes two different items**, because the index is a position: applied again to `["v2", "production"]` it removes `"v2"`. So a rules file containing it never settles, and `--check` will keep reporting a change. To remove an item by *what it is*, use `FilterArrayItems` with a `where` expression - that one is safe to run repeatedly. `InsertArrayItem` and `TransformValue` with `prefix:`/`suffix:` are the other two that change something on every run.

#### SortArrayItems - Sort an array

**Sort simple values:**
```json
{ "path": "app.tags", "action": "SortArrayItems" }
```

| Before | After |
|--------|-------|
| `["zebra", "apple", "mango"]` | `["apple", "mango", "zebra"]` |

**Sort objects by a field:**
```json
{ "path": "app.users", "action": "SortArrayItems", "condition": "name" }
```

Sorts the `users` array alphabetically by each item's `name` field.

**Sort descending:**
```json
{ "path": "app.users", "action": "SortArrayItems", "condition": "name", "transform": "desc" }
```

**Sort by several fields**, each with its own direction. List them comma-separated, adding `:asc` or `:desc` where you want to be explicit:
```json
{ "path": "app.servers", "action": "SortArrayItems", "condition": "region:desc,port:asc" }
```

Region descending, then port ascending *within* each region - a later field only breaks ties in the earlier ones.

A field with no suffix follows `transform`, so your existing rules are unaffected. Note that a field name may itself contain a colon (`app:tier` sorts by that field), which means a misspelled direction is read as part of the name rather than reported.

#### FilterArrayItems - Keep only matching items

```json
{
  "path": "app.users",
  "action": "FilterArrayItems",
  "condition": "role",
  "newValue": "admin"
}
```

Keeps only the users where `role` equals `"admin"`. All other items are removed.

For simple value arrays, use `"."` as the condition:
```json
{
  "path": "app.tags",
  "action": "FilterArrayItems",
  "condition": ".",
  "newValue": "important"
}
```

To filter on more than one field, use `where` in place of the `condition` + `newValue` pair:
```json
{
  "path": "services",
  "action": "FilterArrayItems",
  "where": "port >= 1024 && enabled == true"
}
```

#### DeduplicateArrayItems - Remove repeated items

```json
{ "path": "app.tags", "action": "DeduplicateArrayItems", "condition": "." }
```

| Before | After |
|--------|-------|
| `["api", "v2", "api"]` | `["api", "v2"]` |

The **first** copy is the one kept, so the order you had is the order you keep. Use `"."` (or leave `condition` out) for simple values, or name a field to dedupe objects by:

```json
{ "path": "servers", "action": "DeduplicateArrayItems", "condition": "name" }
```

An item that doesn't have that field is left alone rather than lumped in with every other one that's missing it. Numbers and numeric text count as the same value, so a list built from two formats won't keep both `8080` and `"8080"`.

**To dedupe a list of objects you must name a field.** Objects have no single value to compare, so without a `condition` they're all kept - otherwise every object would look identical and you'd be left with just the first one.

#### EnsureArray - Declare that a field is a list

```json
{
  "path": "app.tags",
  "action": "EnsureArray"
}
```

Only needed for **XML**, which has no array syntax. `<tags><tag>a</tag></tags>` is indistinguishable from
an ordinary single field, so Lathe reads it as one - and array rules against it then do nothing. This
declares the intent, turning it into a one-item list so the rules that follow work:

```json
[
  { "path": "tags", "action": "EnsureArray" },
  { "path": "tags", "action": "InsertArrayItem", "newValue": "b" }
]
```

Safe to include even when you don't know how many items the data has: a field that's already a list is
left alone. Put it before the array rules that depend on it. JSON and YAML state cardinality in their own
syntax, so it does nothing there.

---

### Restructuring Data

#### CopyNode - Duplicate a field to another location

```json
{ "path": "app.version", "action": "CopyNode", "targetPath": "metadata.appVersion" }
```

Creates a copy at `metadata.appVersion`. The original `app.version` remains unchanged. The copy is fully independent. If `metadata.appVersion` already exists it is replaced, and Lathe warns that it was.

#### MergeNodes - Combine two objects

```json
{ "path": "defaults", "action": "MergeNodes", "targetPath": "settings" }
```

Copies all fields from `defaults` into `settings`. If `settings` already has a field with the same name, the existing value is kept (not overwritten).

**A source with no fields copies nothing, and the run says so.** Pointing this at a plain value or a list is a
rule that cannot do anything - it merges named *fields* - so rather than reporting success on a no-op it names
the source and points you at `ReplaceValue`, or at `arrayStrategy` on a merge between the objects that hold the
two lists.

Add `mergeStrategy` when you want something else:

| `mergeStrategy` | What it does |
|-----------------|--------------|
| `OnlyNew` | Add only fields the target doesn't have. **This is the default** |
| `Overwrite` | Write every source field, replacing what's there |
| `OnlyExisting` | Only update fields the target already has; add nothing new |
| `Merge` | Go *into* nested objects and combine them, instead of replacing them whole |

**`Merge` is the one you want for overlay files** - a `staging.yaml` layered onto a `base.yaml`. Without it, a nested object in the overlay replaces its counterpart entirely, throwing away fields the overlay never mentioned:

```json
{
  "path": "overlay",
  "action": "MergeNodes",
  "targetPath": "deployment",
  "mergeStrategy": "Merge",
  "arrayStrategy": "ByKey",
  "mergeKey": "name"
}
```

Under `Merge`, `arrayStrategy` says what to do when both sides have an array:

| `arrayStrategy` | What it does |
|-----------------|--------------|
| `Replace` | The overlay's array wins outright. **This is the default** |
| `Concat` | Stick them together, duplicates and all |
| `ByKey` | Match items up by `mergeKey` and merge each pair; add the ones that don't match |
| `ByIndex` | Merge first-with-first, second-with-second, and append any extras |

`ByKey` is how you merge a list of *named* things - containers, ports, environment variables - since their position isn't reliable. It needs `mergeKey`; leaving it out is an error rather than a quiet fallback.

⚠️ **`Concat` is the one option you can't safely re-run.** "Keep the duplicates" means running it twice appends everything a second time, so a rules file using it will never settle and can't be used with `--check`. Use `ByKey` if you need a merge that's repeatable.

Whatever gets merged in is a **copy**, so a later rule editing the result won't reach back and change the source. Where the two sides disagree about shape - a plain value against an object, say - there's no sensible blend, so the source wins.

**An element's own text merges like any other field.** `<add key="T">30</add>` carries text as well as an attribute, and that text is treated as though it were the `#text` field the other formats spell it with - so `Overwrite` and `Merge` bring the source's text across, `OnlyNew` keeps the target's, and `OnlyExisting` writes it only where the target already has text of its own. A source that is a plain value with no fields still merges nothing, as it always has.

#### WrapNode - Nest a field inside a new container

```json
{ "path": "app.created", "action": "WrapNode", "newName": "dates" }
```

| Before | After |
|--------|-------|
| `"app": { "created": "2023-01-01" }` | `"app": { "dates": { "created": "2023-01-01" } }` |

As with `RenameNode`, a `newName` that a sibling already holds is refused - the wrapper would replace it.

#### FlattenNode - Promote children up one level

The reverse of WrapNode. All fields inside the target are moved up to its parent, and the target is removed.

```json
{ "path": "app.dates", "action": "FlattenNode" }
```

| Before | After |
|--------|-------|
| `"app": { "dates": { "created": "2023" } }` | `"app": { "created": "2023" }` |

If the parent already has a field with the same name, the existing value is kept.

#### DeepFlatten - Turn nesting into dotted keys

Everything below the target becomes one level of dotted keys - the shape Spring's `application.properties`, a Kubernetes ConfigMap's `data`, and most Java tooling use.

```json
{ "path": "telemetry", "action": "DeepFlatten" }
```

| Before | After |
|--------|-------|
| `"telemetry": { "exporter": { "otlp": { "endpoint": "collector" } } }` | `"telemetry": { "exporter.otlp.endpoint": "collector" }` |

Lists become numbered segments, exactly as a `.properties` file spells them: `"tags": ["core","team"]` becomes `tags.0` and `tags.1`.

Three things worth knowing:

- **A field that already has a dot in its name keeps it, untouched.** `"app.kubernetes.io/name"` is left alone, because re-escaping it on every run would grow a backslash each time and the rules file would never settle. The consequence is that it becomes indistinguishable from nesting, so `Unflatten` will split it - and if that name collides with one nesting produces, the run stops and names the key rather than losing a value.
- **A dot inside a name that *is* being joined is escaped**, so `"annotations": { "app.kubernetes.io/name": "api" }` flattens to `annotations.app\.kubernetes\.io/name` and `Unflatten` reads it back as the one field it was.
- **An empty object or list keeps its own key** rather than disappearing.

Writing a *flattened* document to a `.properties` file escapes the separators again, because by then each dotted name really is one field - `data.a\.b=1`. If a dotted flat file is what you want, just write the **nested** document as `.properties`; that is what this format does already. `DeepFlatten` is for producing dotted keys *inside* JSON, YAML or XML.

**This is how you convert a `log4j.properties` to JSON without the `#text` rename.** A bare key beside a dotted one - `log4j.appender.stdout` next to `log4j.appender.stdout.layout` - is a field holding [its own value beside its children](#a-key-can-be-both-a-value-and-a-group), and converting that straight to JSON has to give the value a `#text` key. Flatten first and it does not:

```bash
# log4j.appender.stdout stays log4j.appender.stdout, not log4j.appender.stdout.#text
lathe log4j.properties flatten-log4j.json config.json json
```

```json
[ { "path": "log4j", "action": "DeepFlatten", "onMissing": "Fail" } ]
```

`Unflatten` at the same path converts it back, and the `.properties` file you get is key-for-key the one you started with.

#### Unflatten - Read dotted keys back into nesting

The reverse.

```json
{ "path": "datasource", "action": "Unflatten" }
```

| Before | After |
|--------|-------|
| `"primary.url": "db", "primary.pool.size": 20` | `"primary": { "url": "db", "pool": { "size": 20 } }` |

- **A name Lathe cannot read as a path is left exactly as it is**, spelling and all - so `C:\temp`, `metrics[0]`, `["a.b"]`, `a..b` and `*` all pass straight through untouched. Where such a name holds a dot, so you might have meant nesting by it, the run says which one it left and why; where it holds no dot there is nothing to say.
- **Numbered segments are not turned back into a list.** `tags.0` and `tags.1` become fields named `0` and `1`, because guessing a list would turn an object legitimately keyed `"0"` into one. Use `EnsureArray` to say a field holds a list.
- **A bare name beside a dotted one is that field's own text**, which is what `log4j.properties` means by `log4j.appender.stdout` sitting beside `log4j.appender.stdout.layout`. Either order gives the same result.
- Two things stop the run, each naming the key: two names that resolve to the same field (`x.a]b` and `x.a\]b` are one path), and a name holding an object where another name writes *inside* it - use `MergeNodes` if a merge was meant.

#### SwapNodes - Exchange two fields' values

```json
{ "path": "contact.primary", "action": "SwapNodes", "targetPath": "contact.secondary" }
```

| Before | After |
|--------|-------|
| `"primary": "alice", "secondary": "bob"` | `"primary": "bob", "secondary": "alice"` |

#### ToEntries - Turn an object into a list of key/value pairs

Different tools write the same data in opposite shapes. Kubernetes wants a container's `env` as a *list* of `{name, value}` objects; docker-compose writes it as a *map*. This is how you convert between them.

```json
{ "path": "environment", "action": "ToEntries", "newName": "name", "condition": "value" }
```

| Before | After |
|--------|-------|
| `{"LOG_LEVEL": "info", "REGION": "apac"}` | `[{"name": "LOG_LEVEL", "value": "info"}, {"name": "REGION", "value": "apac"}]` |

`newName` and `condition` name the two fields. They default to `key` and `value`; Kubernetes wants `name` and `value`, as above. A field whose value is itself an object comes across whole, so nesting survives. If it's already a list, nothing happens - so it's safe to re-run.

#### FromEntries - Turn a list of pairs back into an object

The reverse of `ToEntries`.

```json
{ "path": "annotations", "action": "FromEntries" }
```

| Before | After |
|--------|-------|
| `[{"key": "owner", "value": "platform"}]` | `{"owner": "platform"}` |

Three things stop the run rather than quietly losing an entry: an item with **no key field**, **two items with the same key** (an object can't hold it twice), and an item carrying **any field besides the two named** - the result is one key to one value, so a third field would simply disappear. An item with no *value* field is fine - that's just a flag - and becomes an empty field.

#### CompactNode - Clean out empty fields

Useful as a tidy-up pass after other rules have emptied things out.

```json
{ "path": "app", "action": "CompactNode" }
```

| Before | After |
|--------|-------|
| `{"kept": "yes", "nulled": null, "blank": "", "hollow": {"gone": null}}` | `{"kept": "yes"}` |

By default it removes empty values, blank strings, and any object or array left with nothing in it. Narrow that with `transform` - `"null"`, `"empty"`, `"container"`, or a comma-separated combination:

```json
{ "path": "app", "action": "CompactNode", "transform": "null" }
```

The field you point at is never itself removed, only what's inside it - so pointing this at the whole document won't empty your file.

**An element that carries text of its own is not "empty" once its attributes go.** `<a k="">30</a>` loses the blank attribute and stays `<a>30</a>`; what's left is judged as the plain value it now is, so it goes only if you asked for `empty` and the text is a blank string.

---

### Summarising and Splitting Text

#### AggregateNodes - Total up an array

```json
{
  "path": "departments",
  "action": "AggregateNodes",
  "transform": "sum",
  "condition": "headcount",
  "targetPath": "report.totalHeadcount"
}
```

| Before | After |
|--------|-------|
| `[{"headcount": 12}, {"headcount": 5}]` | adds `"report": {"totalHeadcount": 17}` |

`transform` is `sum`, `min`, `max`, `avg`, or `count`. `condition` names the field to read from each item - leave it out (or use `"."`) for an array of plain numbers. The `targetPath` doesn't have to exist yet; any containers it needs are created.

Point `targetPath` at a plain field or somewhere that doesn't exist yet. Aiming it at an existing object or array stops the run rather than replacing that data with a number, and so does routing it *through* a field that already has a value (`targetPath: "totals.sum"` when `totals` is already `5`).

If `path` matches several arrays, they're all totalled **together** into the one result - `services.*.ports` with `count` gives you every port in the file, not just one service's.

Whole numbers stay whole numbers, so summing `12` and `5` gives `17`, not `17.0`. Anything that isn't a number stops the run for every function except `count` - an average over whichever half of the list happened to parse would be worse than no answer at all.

#### SplitValue - Split text into an array

```json
{ "path": "featureFlags", "action": "SplitValue", "condition": ",", "transform": "trim" }
```

| Before | After |
|--------|-------|
| `"search, export"` | `["search", "export"]` |

`condition` is the separator (`,` if you leave it out). `transform` can be `trim` to strip spaces from each piece, `infer` to turn numeric pieces into real numbers, or both. Neither happens unless you ask - splitting `"a, b"` gives you `" b"` with the space intact, because Lathe doesn't quietly rewrite your values.

If it's already an array, nothing happens, so a rules file with this in it can be run twice.

#### JoinValue - Join an array back into text

The reverse of `SplitValue`.

```json
{ "path": "app.tags", "action": "JoinValue", "condition": "," }
```

| Before | After |
|--------|-------|
| `["api", "v2"]` | `"api,v2"` |

An array of *objects* has no sensible text form, so that stops the run rather than producing nonsense.

---

### Filling In Values

These three write a value that isn't in the rule file: taken from elsewhere in the data, generated, or read from an environment variable.

#### CoalesceValue - Use the first value that's actually set

```json
{
  "path": "resolved.zone",
  "action": "CoalesceValue",
  "sources": [ "overrides.zone", "defaults.zone" ],
  "newValue": "unknown"
}
```

Tries each path in `sources` in order and writes the first one that has a value. If none do, it writes `newValue`.

| `overrides.zone` | `defaults.zone` | Result |
|---|---|---|
| `"emea"` | `"apac"` | `"emea"` |
| missing, or `null` | `"apac"` | `"apac"` |
| missing | missing | `"unknown"` |

This is the "use the override if it's set, otherwise fall back" pattern - one rule instead of a `ConditionalRule` per candidate. A missing path and an explicit `null` mean the same thing here, so an overlay file can leave a key out or null it out with the same effect.

If every source is empty and you gave no `newValue`, the run stops rather than quietly writing nothing - a typo in `sources` should not look like a clean run.

#### GenerateValue - Create a value that isn't in the file

```json
{ "path": "release.id", "action": "GenerateValue", "transform": "uuid" }
```

| `transform` | Writes |
|---|---|
| `uuid` | A new GUID |
| `now` or `now:yyyy-MM-dd` | The current UTC time |
| `hash` | SHA-256 of another field, as hex |
| `base64` | Base64 of another field |

`hash` and `base64` read their input from `sources`:
```json
{
  "path": "release.fingerprint",
  "action": "GenerateValue",
  "transform": "hash",
  "sources": [ "release.version" ]
}
```

**It only writes where the field doesn't already exist.** That's deliberate: a uuid or timestamp differs every run, so overwriting would mean `--check` reported a change forever and the file never settled. Because it skips a value that's already there, running the rule twice is safe - the first run fills the field in and later runs leave it alone. Use `SetValue` if you actually want to replace something.

#### SubstituteEnv - Fill in environment variables

```json
{ "path": "app.dataDir", "action": "SubstituteEnv" }
```

| Before | Environment | After |
|---|---|---|
| `"${DATA_DIR}"` | `DATA_DIR=/srv/data` | `"/srv/data"` |
| `"${DATA_DIR:-/var/lib}"` | unset | `"/var/lib"` |
| `"Server=${DB_HOST};Port=${DB_PORT}"` | both set | both filled in |

Write `${VAR:-default}` to supply a fallback. **A variable that isn't set and has no default stops the run**, naming the variable - better than shipping a literal `${VAR}` into your config or silently blanking a connection string. Use `${VAR:-}` if you genuinely want an empty value.

The result is typed as if you'd written it in the file, so `${PORT}` giving `8080` becomes a number. Only `${...}` counts as a reference - a bare `$VAR` is left as text. Values are expanded once, so a variable whose own value looks like `${OTHER}` is not expanded again.

---

### Looping Over Arrays

#### IterateNodes - Apply rules to every item in an array

```json
{
  "path": "app.servers",
  "action": "IterateNodes",
  "nested": [
    { "path": "port", "action": "CastValue", "transform": "number" },
    { "path": "enabled", "action": "CastValue", "transform": "boolean" }
  ]
}
```

The nested rules run once per array item. Paths inside `nested` are **relative to each item**, not the root of the file.

This is useful for bulk operations - cast every server's `port` to a number and `enabled` to a boolean in one rule.

---

## Practical Examples

### Example 1: Migrate a config from YAML to JSON with fixes

**Input** (`legacy.yaml`):
```yaml
app:
  name: myapp
  port: "8080"
  debug: "true"
  log_level: "  INFO  "
```

**Rules** (`migrate.json`):
```json
[
  { "path": "app.port", "action": "CastValue", "transform": "number" },
  { "path": "app.debug", "action": "CastValue", "transform": "boolean" },
  { "path": "app.log_level", "action": "TransformValue", "transform": "trim" },
  { "path": "app.log_level", "action": "TransformValue", "transform": "lowercase" },
  { "path": "app.log_level", "action": "RenameNode", "newName": "logLevel" },
  { "path": "app.name", "action": "TransformValue", "transform": "titlecase" }
]
```

**Run:**
```bash
Lathe legacy.yaml migrate.json config.json json
```

**Output** (`config.json`):
```json
{
  "app": {
    "name": "Myapp",
    "port": 8080,
    "debug": true,
    "logLevel": "info"
  }
}
```

### Example 2: Environment-specific config generation

**Input** (`base.json`):
```json
{
  "app": { "name": "MyService", "environment": "production" },
  "logging": { "level": "info" },
  "features": { "cache": "true", "metrics": "false" }
}
```

**Rules** (`production.json`):
```json
[
  {
    "path": "app.environment",
    "action": "SwitchCase",
    "nested": [
      {
        "condition": "production",
        "nested": [
          { "path": "logging.level", "action": "ReplaceValue", "newValue": "error" },
          { "path": "features.cache", "action": "CastValue", "transform": "boolean" },
          { "path": "features.metrics", "action": "CastValue", "transform": "boolean" }
        ]
      }
    ]
  },
  { "path": "app.environment", "action": "AssertCondition", "condition": "production" },
  { "path": "app.deployedAt", "action": "SetDefault", "newValue": "2025-03-18" }
]
```

### Example 3: Clean up and restructure a dataset

```json
[
  { "path": "legacy_data", "action": "RenameNode", "newName": "data" },
  { "path": "data.metadata", "action": "FlattenNode" },
  { "path": "data.items", "action": "SortArrayItems", "condition": "name" },
  { "path": "data.items", "action": "FilterArrayItems", "condition": "status", "newValue": "active" },
  {
    "path": "data.items",
    "action": "IterateNodes",
    "nested": [
      { "path": "internal_id", "action": "RemoveNode" },
      { "path": "created", "action": "WrapNode", "newName": "dates" }
    ]
  }
]
```

---

## Rule Execution Order

Rules are applied **top to bottom**, one at a time. Each rule sees the result of all previous rules. This means:

1. You can **chain transformations** - rename a field, then transform its value, then move it.
2. **Order matters** - removing a field before trying to read it will cause the read to find nothing.
3. **Assert early** - place `AssertCondition` rules right after the operation you want to verify.

---

## Working with `.properties` and `.env` Files

These are flat `key=value` files, and Lathe treats both extensions as one format.

The thing that makes them work like any other format: **a dot in a key is the nesting separator.** So this file

```properties
# Comments use # or !
server.http.port=8080
server.http.host=localhost
database.timeout=30
```

describes exactly the same shape as this JSON

```json
{
  "server": { "http": { "port": 8080, "host": "localhost" } },
  "database": { "timeout": 30 }
}
```

which means the same rules file works on either one:

```json
{ "path": "server.http.port", "action": "ReplaceValue", "newValue": 9090 }
```

**`=` and `:` are the only separators.** A Java-style `key value` line, with nothing but whitespace between the
two, is refused and the line is named - guessing where the key ended would be guessing what the file means.
Lathe always writes `=`, so a file it produced reloads.

### Quoting decides the type

Same rule as YAML - bare text is inferred, quoted text stays text:

| Written | Loads as |
|---------|----------|
| `port=8080` | the number `8080` |
| `port="8080"` | the string `"8080"` |
| `version=1.0` | the number `1.0`, written back as `1.0` - same as JSON's `{"version": 1.0}` |
| `build=007` | the string `"007"` - no JSON number starts with a redundant zero, so it's left alone |
| `flag=true` | the string `"true"` - booleans are **not** inferred |
| `blank=` | an empty string |

Use `CastValue` when you need a real boolean:

```json
{ "path": "APP_DEBUG", "action": "CastValue", "transform": "boolean" }
```

### `.env` conventions are handled

`export FOO=bar` is accepted - the prefix is a writing convention, not part of the key, so you target it as just `FOO`. Quotes around a value are the format's escaping and are stripped: `GREETING="hello world"` is the string `hello world`.

Note the prefix is **not written back**: `export FOO=bar` comes out as `FOO=bar`. The key is unchanged and the file still works as a `.env`, but if you rely on it being shell-sourceable, check the diff before accepting an in-place edit.

### A dot that's part of the key

Escape it, exactly as you would in a rule path:

```properties
logging\.config=old.xml
```

That's one key named `logging.config`, not a `logging` group. Target it by bracket-quoting in your rules file, which avoids double-escaping the backslash:

```json
{ "path": "[\"logging.config\"]", "action": "ReplaceValue", "newValue": "new.xml" }
```

### Lists are comma-separated values

The format has no array syntax, so a list is normally one delimited value. Split it, work on it, and join it back:

```json
[
  { "path": "featureFlags", "action": "SplitValue", "condition": ",", "transform": "trim" },
  { "path": "featureFlags", "action": "DeduplicateArrayItems", "condition": "." },
  { "path": "featureFlags", "action": "JoinValue", "condition": "," }
]
```

`search,export,search` becomes `search,export`, still a single flat value.

If you *don't* join it back, or you convert JSON containing an array **into** this format, arrays are written as numbered keys - `tags.0=a`, `tags.1=b`. Those don't read back as an array, because guessing would turn an object with keys `"0"` and `"1"` into a list. So a `.properties` file edited at its own path round-trips byte-for-byte - a *conversion* run rewrites it from the tree and drops comments, `export ` and padding - but a JSON array converted out and back returns as an object.

### A bracket in a key has to be escaped

A key is read with the same syntax a rule path uses, so `[` in one means an item selector:

```properties
my.servers[0]=web      # refused, exit 2
my.servers\[0]=web     # one key, literally named "my.servers[0]"
```

The first is refused rather than read, because the `[0]` would simply disappear and the key would come back
as `my.servers`. This is the Spring Boot list idiom, and the flat format has no array syntax to map it onto -
write the index as its own segment (`my.servers.0=web`) if a list is what you meant. A closing `]` on its own
needs nothing: `a]b=1` is an ordinary key.

Lathe writes the escape itself, so a key containing a bracket survives a round trip through this format.

### A key can be both a value and a group

`log4j.properties` is the file that makes this matter, and most hand-written `.properties` files are in the
same shape - a thing named by a bare key with its settings dotted beneath it:

```properties
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
```

Lathe reads the bare key as that node's **own value**, sitting beside its children, and a rule addresses it at
its own path:

```json
{ "path": "log4j.appender.stdout", "action": "ReplaceValue", "newValue": "org.apache.log4j.FileAppender" }
```

Either order works, and written back as a flat file the value stays on the key you gave it. **Converting to
another format is where the key changes**, because no other format can put a value and nested keys under one
name: JSON and YAML give the value a key of its own called `#text`
(`log4j.appender.stdout.#text`), and XML refuses the document at exit `5`, naming the element - an element
with both text and child elements is *mixed content*, which Lathe does not represent. Converting back to
`.properties` restores your bare key.

**If you don't want the `#text` key, run [`DeepFlatten`](#deepflatten---turn-nesting-into-dotted-keys) first.**
It puts each value on a dotted key of its own, so the whole file converts with no reserved key anywhere in it -
and [`Unflatten`](#unflatten---read-dotted-keys-back-into-nesting) at the same path converts it back, key for
key. It usually makes the document writable as XML too, since nothing is left carrying text beside children -
though each dotted name then has to be a legal XML element name, so one that starts with a digit or holds a
space is still refused, naming the character.

What is still refused is one node's own value spelled **both** ways - a bare `a=1` alongside an explicit
`a.#text=2`. They name the same slot, so one of the two values would silently win.

### Four things that stop the run

All four would otherwise lose data silently:

- **A duplicate key** - `k=1` then `k=2`. Only one value can survive and nothing would tell you which. Same
  for one node's own value written twice, as above.
- **An unescaped `[` in a key**, as above.
- **A key this format cannot spell**, when *writing* one: a key with no name at all, one containing a line
  break, or one with whitespace at an end of its line (`a. x =1` comes back as `" x"`, since the format
  trims both ends of a key). These can only arise converting *into* the format, and the run exits `5` rather
  than writing a file that reads back differently. Whitespace away from the line's ends is fine - `a. x .b=1`
  round-trips.
- **An empty object or empty list under a key.** A container's key only exists in this format through the
  keys beneath it, so an empty one has nothing to write and the key would simply disappear - which is how a
  `FilterArrayItems` that matched nothing used to delete the list it was filtering. Writing `key=` instead
  would be worse: that reads back as an empty *string*. An empty **document** is fine, since it has no key to
  lose.

See `Samples/Flat` for a complete runnable example of all of the above.

---

## Format Compatibility Notes

- **JSON ↔ YAML**: Fully compatible. All data types map cleanly between formats.
- **XML → JSON/YAML**: XML attributes appear as fields with an `@` prefix (e.g. `@id`). You can rename or remove them like any other field. An element's own **text**, where it also has attributes, appears under `#text` - see [below](#format-compatibility-notes). Neither prefix is addressable as a key in a rule path except `@`: `@id` is a real field, `#text` is not.
- **JSON/YAML → XML**: Arrays are serialised as repeated elements with the same tag name (e.g. `<item>` / `<item>`). Booleans are written as lowercase `true`/`false`.
- **XML arrays are detected per element name.** `<a>1</a><a>2</a><b>3</b>` becomes an array of two `a` plus a plain `b`, and an attribute alongside repeated children is kept.
- **A one-element list is not an array in XML - declare it with `EnsureArray`.** XML has no array syntax, so `<tags><tag>a</tag></tags>` is *byte-identical* to an ordinary single child and Lathe reads it as a plain field. Two or more `<tag>` elements are an array. Without a declaration that means:
  - **A one-item array does not survive a conversion through XML.** `{"tags":["a"]}` becomes `<tags><item>a</item></tags>`, which reads back as `{"tags":{"item":"a"}}` - an object, not an array.
  - **An array that shrinks to one item changes shape.** If a rule filters a list down to a single element, the file it writes reads back as a plain field.
  - **Array rules are data-dependent.** `SortArrayItems`, `RemoveArrayItem` and `tags[0]` do nothing against a list that happens to have one element.

  Add one rule ahead of the others and all three go away:

  ```json
  [
    { "path": "tags", "action": "EnsureArray" },
    { "path": "tags", "action": "SortArrayItems", "condition": "." }
  ]
  ```

  `EnsureArray` is idempotent, so it is safe to include whether or not the list turns out to have one element. Inferring cardinality from names (`<tags>` implies `<tag>` is a list) is deliberately *not* attempted: it breaks on plurals that are not lists (`<settings>`, `<series>`, `<data>`) and on containers whose children are named `<item>`.
- **Type casting**: XML has no scalar types, so numeric text is inferred - anything JSON would write as a number, including `1.0` and `1e10` - but `true`/`false` stays a string. YAML honours your quoting. Use `CastValue` when you need a specific stored type - most often to turn XML flags into real booleans.
- **XML root element**: the root keeps its name, so `<configuration>` round-trips as `<configuration>`. JSON and YAML have no root name, so converting *to* XML uses `<root>`.
- **Multi-document YAML is fully supported.** Every document is transformed; rules apply to all of them unless given a `document` filter. Converting a multi-document file to JSON or XML is refused, since those formats can't hold several - use `--split` for that. See [multi-document YAML](#multi-document-yaml).
- **Some XML is rejected rather than rewritten lossily.** Lathe stores one value per node, so these have nowhere to go and the run exits `2` with your file untouched: comments, CDATA, processing instructions, a `<!DOCTYPE …>` declaration, and mixed content (text alongside child elements, as in `<p>text<b>bold</b>tail</p>`). A DOCTYPE is refused because Lathe does not process the DTD, so it would drop the declaration and anything it defines - note that a reference to an entity the DTD declares fails for the same reason. The `<?xml … ?>` declaration is *not* in this list: it is kept. Repeated elements split apart by another element - `<a/><b/><a/>` - are refused too: repeats are read as a list, and a list has one place in its parent, so writing it back would move the two `<a>` elements together and reorder your file. Put them next to each other, or wrap them in a container element. Ordinary XML - elements, attributes, nesting, adjacent repeated children, indentation - is unaffected.
- **Empty and whitespace-only XML content loses a little**, the same way a `.properties` blank does. `<a></a>`, `<a />` and `<a> </a>` all read back as `null`, so setting a value to `""` or to whitespace gives you a node Lathe then reads as null - stably, but not as the rule asked. Content written entirely as a whitespace character reference (`<a>&#32;</a>`) is read the same way, and is therefore dropped when the file is rewritten from the tree. Use a real character alongside it if the whitespace matters.
- **An element with both an attribute and text keeps the text, spelled `#text`.** `<add key="Timeout">30</add>` becomes `{"add": {"@key": "Timeout", "#text": 30}}`, and converts back to the same XML. This is the `web.config` / `App.config` / Maven / `log4net` shape, so it is most real XML. Three things follow:
  - **The rule path is the element's own**, not `#text`. `#text` is how the *file* spells the text; Lathe folds it back onto the element when reading, so `{ "path": "add", "action": "ReplaceValue", "newValue": 60 }` edits the `30` in **all four formats** from one rule file. A path naming `add.#text` matches nothing - use `onMissing: "Fail"` and it says so.
  - **`ReplaceValue`, not `SetValue`.** `SetValue` turns the node into a scalar, which is its job, so it drops the `key` attribute along with the children. `ReplaceValue` writes the text and leaves everything else alone.
  - **Every rule that reads a value reads this one.** `valueFrom`, `CoalesceValue`'s `sources`, `GenerateValue`'s `hash`/`base64` input, `SubstituteEnv`, and expressions in `where` or in a path selector all see the text, so `where: "add == 30"` holds and `${DB}` inside `<add name="db">${DB}</add>` expands. `AggregateNodes` and the other derived-value writers can target it too, leaving the attributes in place. Adding an attribute to an element never changes whether a rule can see its text.
  - **In a flat file it is just `add=30`**, on the node's own key, beside `add.@key=Timeout`. That format's keys are already Lathe paths, so it needs no reserved key for this - which is also [why `log4j.properties` loads](#a-key-can-be-both-a-value-and-a-group). The explicit `add.#text=30` and `add.\#text=30` are still *read* as the same thing, so a file written by an earlier version is accepted unchanged.
  - **`#text` is written last**, after the attributes, and a file that has it there round-trips unchanged. One written with it earlier is normalised to that order on the first run.
  - **A `#text` whose value is `null` stays a plain key**, since there is no way to tell "this node's text is null" from "this node has no text" once it is folded onto the node. XML cannot express it either way.
  - **The document's own text**, where the whole file is a scalar or its root carries text, is `\#text=30` in a flat file - escaped, because there it really is the first thing on the line. Without it, converting a scalar-rooted document to `.properties` wrote an empty file.

  Two shapes cannot be written at all, and are refused rather than silently losing the text:

  - **Text alongside a real child element** is mixed content, which XML cannot express - so `{"a": {"#text": 2, "b": 1}}` written as XML exits `5` rather than dropping the `2`.
  - **A `#text` key holding a nested structure** (`{"t": {"#text": {"p": 2}}}`) is not a scalar and no element may be named with a `#`, so that exits `5` too.

    The other three formats write both of those, so **a rule that creates either one warns**, naming the rule, the path and which of the two it is:

    ```
    WARNING: Rule 3 (AggregateNodes) gives 'totals' both a value and nested keys. JSON, YAML and
    .properties can write that ... but XML cannot, so these rules exit 5 against an XML document.

    WARNING: Rule 0 (CopyNode) gives 't' a '#text' key holding a nested structure ... but XML has no
    element name of that shape, so these rules exit 5 against an XML document.
    ```

    Without them a rule file written for a `web.config` succeeded against JSON and was permanently broken
    against the XML it was aimed at, with nothing said either way. An *attribute* is not a child element, so
    editing an ordinary `<add key="Timeout">30</add>` says nothing - and neither does a shape that was already
    in your input, or one a later rule undoes. Only what the rules actually leave behind is reported, once
    each.
  - **Text alongside a *list*** has nowhere to go in any format, since a list has no keys - so a `ToEntries` or `EnsureArray` on an element that carries text exits `5`. Likewise `FlattenNode` on one exits `4`: promoting the children deletes the node the text belonged to.
- **YAML anchors are rejected rather than expanded.** An alias (`prod: *defaults`) would come back as a second full copy of the anchored block, with the anchor and the alias gone - one definition you wrote on purpose, silently duplicated - so the file is left untouched instead. A `<<` merge key is refused for the same reason.
- **A node cannot be both an object and an array.** JSON and YAML cannot express an array that also has named keys, so adding an array item to an object (or a named child to an array) exits `4` rather than discarding one of them. In XML an element with attributes *and* repeated children is fine and round-trips normally.
- **Line endings and the trailing newline are preserved.** A run that changes nothing doesn't write at all, so `--check` stays quiet and the file's modification time doesn't move unless something really would change.
- **`.properties` and `.env` files use dots for nesting.** `server.http.port=8080` is the same data as the nested JSON object, which is why one rules file works on both. `.properties` and `.env` are treated as one format, and `export FOO=bar` is accepted. A dot that's part of a key rather than a separator is escaped - `log4net\.config` - just like in a rule path. A line ending in a backslash continues onto the next one, as the format defines, and the pieces are joined into a single value - so a long `classpath` written across several lines is one entry. It's written back out on one line.
  - **Quoting means "treat this as text"**, as in YAML: `port=8080` is a number, `port="8080"` is a string. Numeric text is inferred as in XML, but `flag=true` stays the string `"true"` - use `CastValue` for a real boolean.
  - **Arrays become numbered keys.** `{"tags":["a","b"]}` writes as `tags.0=a` / `tags.1=b`, and those *don't* read back as an array - guessing would turn an object with keys `"0"` and `"1"` into a list. So a `.properties` file edited at its own path round-trips byte-for-byte - a *conversion* run rewrites it from the tree and drops comments, `export ` and padding - but an array converted out to `.properties` and back returns as an object. Same trade-off as XML one-element lists.
  - **One key *can* be both a value and a group**, which is what makes `log4j.properties` readable: `a=1` alongside `a.b=2` is a node holding its own value beside its children, and the value stays on that key when the file is written back. Converting elsewhere gives the value a `#text` key of its own; see [above](#a-key-can-be-both-a-value-and-a-group). A plain duplicate (`k=1` then `k=2`) still stops the run - only one value can survive and nothing would tell you which - and so does one node's own value spelled both ways (`a=1` beside `a.#text=2`). Sibling keys like `a.b` and `a.c` are fine, of course.
  - **A key starting with `#`, `!`, or `export ` gets escaped** on the way out (`\#hash`), because otherwise it would read back as a comment or a shell prefix and the field would disappear. Editing a file at its own path leaves the author's `export ` alone instead, since nothing is rewritten.
  - **`\\`, `\r`, `\n` and `\uXXXX` are the escapes that survive.** A Java-style `\u20AC` is decoded to `€`, so a message bundle converts correctly. Any *other* backslash is dropped, so `C:\temp` reads as `C:` + a tab and `\d+` as `d+`. Double the backslash (`C:\\temp`) to keep it. A malformed `\u` is deliberately not an error - `C:\users\bin` is far too common to refuse - so it keeps the same backslash-dropping reading.
  - **A literal non-ASCII character needs no escape** and is left exactly as written; `€` round-trips as `€`. That is why a decoded `\u20AC` is written back as `€` rather than re-escaped: re-escaping would let `\u20AC` round-trip only by breaking every file that writes the character directly. So editing such a file at its own path is still refused with the line named, rather than the value being corrupted; on any other run it now converts to the right character.
  - **Empty values and booleans lose a little.** `null` and `""` are both written as bare `key=`, so a null comes back as an empty string, and `true` comes back as the *text* `"true"`. Use `CastValue` if the type matters after a conversion - the same caveat as XML.

---

## Quick Reference

| Action | What it does | Key properties |
|--------|-------------|----------------|
| `ReplaceValue` | Overwrite a value | `newValue` |
| `SetValue` | Set a value, adding the field if missing | `newValue` |
| `AddNode` | Create a new field | `newValue` |
| `RemoveNode` | Delete a field | - |
| `RenameNode` | Change a field's key | `newName` |
| `MoveNode` | Relocate a field | `targetPath` |
| `SetDefault` | Add only if missing | `newValue` |
| `AssertCondition` | Validate data | `condition` |
| `ConditionalRule` | Rules with an if-check | `condition`, `nested` |
| `SwitchCase` | Rules with multi-branch logic | `nested` (with `condition` per case) |
| `TransformValue` | Text transform (uppercase, trim, etc.) | `transform` |
| `CastValue` | Type conversion (string → number, etc.) | `transform` |
| `IterateNodes` | Loop over array items | `nested` |
| `InsertArrayItem` | Append to an array | `newValue` |
| `UpsertArrayItem` | Update an array item, adding it if absent | `nested`, or `mergeKey` + `newValue` |
| `RemoveArrayItem` | Remove by index | `condition` (index) |
| `SortArrayItems` | Sort an array | `condition` (sort key), `transform` (`desc`) |
| `FilterArrayItems` | Keep matching items | `condition` (key), `newValue` (match) |
| `DeduplicateArrayItems` | Remove repeated items | `condition` (key, or `.`) |
| `EnsureArray` | Declare a node holds a list (for XML one-element lists) | - |
| `AggregateNodes` | Total up an array | `transform` (`sum`…`count`), `targetPath`, `condition` |
| `CopyNode` | Duplicate a field | `targetPath` |
| `MergeNodes` | Combine two objects | `targetPath`, `mergeStrategy`, `arrayStrategy`, `mergeKey` |
| `WrapNode` | Nest inside a new container | `newName` |
| `FlattenNode` | Promote children up | - |
| `DeepFlatten` | Turn nesting into dotted keys | - |
| `Unflatten` | Read dotted keys back into nesting | - |
| `SwapNodes` | Exchange two fields | `targetPath` |
| `ToEntries` | Object → list of key/value pairs | `newName` (key field), `condition` (value field) |
| `FromEntries` | List of pairs → object | `newName` (key field), `condition` (value field) |
| `CompactNode` | Clean out empty fields | `transform` (`null`, `empty`, `container`) |
| `SplitValue` | Split text into an array | `condition` (separator), `transform` (`trim`, `infer`) |
| `JoinValue` | Join an array into text | `condition` (separator) |
| `CoalesceValue` | Write the first value that is actually there | `sources`, `newValue` (fallback) |
| `GenerateValue` | Fill in a missing value - uuid, timestamp, hash, base64 | `transform`, `sources` |
| `SubstituteEnv` | Expand `${VAR}` from the environment | - |
| `AssertNotNull` | The value must be set | - |
| `AssertUnique` | No two items share a value | `condition` (key, or `.`) |
| `AssertInSet` | The value must be one of these | `newValue` (the list), or `valueFrom` |
| `AssertMatchesRegex` | The text must match a pattern | `condition` (the pattern) |
| `AssertRange` | The number must be within bounds | `min`, `max` |
| `AssertLength` | The text must be the right length | `min`, `max` |
| `AssertOfType` | The value must be the right kind of thing | `transform` (`string`…`null`) |
| `AssertCount` | The list must be the right size | `min`, `max` |

Every rule also accepts `onMissing` (`Skip`, `Warn`, or `Fail`) to control what happens when its path matches nothing, and `document` to restrict it to matching documents of a multi-document YAML file. `min` and `max` are read only by the three actions above that measure something, and refused on the rest. Any *other* property name is an error, so a typo is reported rather than quietly ignored - see [Rule Structure](#rule-structure).

One caveat worth knowing: don't put `onMissing: "Fail"` on a `RemoveNode`. The field is already gone the second time you run, so `Fail` makes the rules file impossible to apply twice - and that's exactly what `--check` does. Save `Fail` for rules that have to *find* something.

---

## Worked Examples

Runnable examples ship in the `Samples/` folder beside this guide:

| Folder | What it shows |
|--------|---------------|
| `Samples/Input`, `Samples/Rules`, `Samples/Output` | One rules file applied to the same data in JSON, YAML and XML. Every operation appears here. `output.json` and `output.yaml` are exactly what the tool writes; `output.xml` is the JSON-sourced form, and an XML *input* writes the same tree with the author's own element names (`<department>` where a JSON source writes `<item>`), since XML cannot express cardinality |
| `Samples/Flat` | The same idea for `.properties` and `.env` - a dotted file, and a flat `.env` with no nesting at all, with a rules file for each |
| `Samples/Stream` | A multi-document YAML manifest, showing rules that apply to every document against rules filtered to one - and `rules-inplace.json`, the value-only version that keeps the manifest's comments and separators |

Try one:

```bash
Lathe Samples/Input/input.json Samples/Rules/rules.json out.yaml yaml
Lathe Samples/Flat/input.env Samples/Flat/rules-env.json out.env
Lathe Samples/Stream/input.yaml Samples/Stream/rules.json out.yaml
```

Two of them print a warning naming the comments they drop, because they write to a new file rather than editing the input - see [Comments are read, but not written](#comments-are-read-but-not-written). Add `--in-place` instead and the comments stay.

All three sets are re-runnable: applying the rules to their own output changes nothing, which is what makes `--check` usable as a build gate.
