Lathe User Manual

Complete guide to Lathe: command-line usage, writing rules, supported operations and worked examples for transforming JSON, YAML, XML and key=value files.


On this page
  1. Quick Start
    1. 1. Prepare your data file
    2. 2. Write a rules file
    3. 3. Run Lathe
    4. 4. Result
  2. Command-Line Usage
    1. Previewing a change
    2. Output goes to stdout, diagnostics to stderr
    3. Line endings are preserved
    4. The encoding is preserved, and a file Lathe cannot read is refused
    5. Editing a file in place
    6. Editing a whole directory
    7. One-off edits with --set
    8. Multi-document YAML
    9. Splitting one file into many, or many into one
    10. Exit Codes
  3. Writing Rules
    1. Path Syntax
    2. Reading a value from the file with valueFrom
    3. Rule Structure
    4. Catching rules that never apply
    5. Types are preserved
    6. Dates and times are text
    7. An empty YAML value is an empty string, not null
    8. Comments are read, but not written
    9. Editing a file in place keeps its comments
    10. Asking whether a file is in Lathe's canonical style
  4. Supported Operations
    1. Changing Values
    2. Setting Defaults and Conditions
    3. Validating Your Data
    4. Transforming Values In Place
    5. Working with Arrays
    6. Restructuring Data
    7. Summarising and Splitting Text
    8. Filling In Values
    9. Looping Over Arrays
  5. Practical Examples
    1. Example 1: Migrate a config from YAML to JSON with fixes
    2. Example 2: Environment-specific config generation
    3. Example 3: Clean up and restructure a dataset
  6. Rule Execution Order
  7. Working with .properties and .env Files
    1. Quoting decides the type
    2. .env conventions are handled
    3. A dot that's part of the key
    4. Lists are comma-separated values
    5. A bracket in a key has to be escaped
    6. A key can be both a value and a group
    7. Four things that stop the run
  8. Format Compatibility Notes
  9. Quick Reference
  10. Worked Examples

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. For example, config.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:

[
  { "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#

Lathe config.json rules.json output.json

4. Result#

output.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
--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:

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:

--- 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:

# 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.

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.

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.

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:

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:

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:

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:

# 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:

# 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.

# 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:

# 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:

{ "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:

{
  "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#

# 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:

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:

[
  // 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:

{ "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:

{ "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:

{ "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:

{ "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:

{ "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:

    { "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).

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:

{ "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 *:

{ "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#

{
  "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:

{ "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:

{
  "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. 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:

[
  { "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:

{ "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:

{ "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 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:

// input.json - comments and trailing commas are accepted
{
  // which port to listen on
  "port": 8080,
}
// 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.

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:

{
  // 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:

# 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:

# 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 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:

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#

{ "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#

{ "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#

{ "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#

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

Removes app.debug entirely from the output.

RenameNode - Rename a field's key#

{ "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#

{ "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#

{ "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:

{ "path": "app.name", "action": "AssertCondition", "condition": "true" }

Assert a field does NOT exist:

{ "path": "app.deprecated", "action": "AssertCondition", "condition": "false" }

Assert a field equals a specific value:

{ "path": "app.version", "action": "AssertCondition", "condition": "2.0" }

Assert something more involved - the expression must hold for every node the path matched:

{ "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 below.

ConditionalRule - Apply rules only if a condition is met#

{
  "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:

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

SwitchCase - Choose rules based on a value#

{
  "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#

{ "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#

{ "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#

{ "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:

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

AssertMatchesRegex - The text must match a pattern#

{ "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#

{ "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#

{ "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#

{ "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#

{ "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:

{ "path": "app.name", "action": "TransformValue", "transform": "uppercase" }
{ "path": "app.name", "action": "TransformValue", "transform": "prefix:v2-" }
{ "path": "app.name", "action": "TransformValue", "transform": "replace:Old:New" }
{ "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
{ "path": "settings.port", "action": "CastValue", "transform": "number" }

"5432" (string) → 5432 (number)

{ "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#

{ "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.

{
  "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:

{ "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#

{ "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:

{ "path": "app.tags", "action": "SortArrayItems" }
Before After
["zebra", "apple", "mango"] ["apple", "mango", "zebra"]

Sort objects by a field:

{ "path": "app.users", "action": "SortArrayItems", "condition": "name" }

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

Sort descending:

{ "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:

{ "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#

{
  "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:

{
  "path": "app.tags",
  "action": "FilterArrayItems",
  "condition": ".",
  "newValue": "important"
}

To filter on more than one field, use where in place of the condition + newValue pair:

{
  "path": "services",
  "action": "FilterArrayItems",
  "where": "port >= 1024 && enabled == true"
}

DeduplicateArrayItems - Remove repeated items#

{ "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:

{ "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#

{
  "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:

[
  { "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#

{ "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#

{ "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:

{
  "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#

{ "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.

{ "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.

{ "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, and converting that straight to JSON has to give the value a #text key. Flatten first and it does not:

# log4j.appender.stdout stays log4j.appender.stdout, not log4j.appender.stdout.#text
lathe log4j.properties flatten-log4j.json config.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.

{ "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#

{ "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.

{ "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.

{ "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.

{ "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:

{ "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#

{
  "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#

{ "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.

{ "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#

{
  "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#

{ "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:

{
  "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#

{ "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#

{
  "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):

app:
  name: myapp
  port: "8080"
  debug: "true"
  log_level: "  INFO  "

Rules (migrate.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:

Lathe legacy.yaml migrate.json config.json json

Output (config.json):

{
  "app": {
    "name": "Myapp",
    "port": 8080,
    "debug": true,
    "logLevel": "info"
  }
}

Example 2: Environment-specific config generation#

Input (base.json):

{
  "app": { "name": "MyService", "environment": "production" },
  "logging": { "level": "info" },
  "features": { "cache": "true", "metrics": "false" }
}

Rules (production.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#

[
  { "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

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

describes exactly the same shape as this JSON

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

which means the same rules file works on either one:

{ "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:

{ "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:

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:

{ "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:

[
  { "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:

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:

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:

{ "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 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 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. 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:

    [
      { "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.

  • 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. 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 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.

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:

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. 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.

Last updated