Matching and Patching#

Our starting point is rfc 7396, which proposes a standard for using JSON documents as matching criteria of JSON documents and as declarative criteria used to patch JSON documents.

Tony uses this as a basis for matching and patching object notation which can be represented as JSON and extends it in various capacities.

MergeOps#

Tony operations are available in matches and patches when building from directories and via o match and o patch.

MergeOp Match Patch Arguments Description
!all + + - apply the match (resp. patch) to every element of an array or object
!and + - - conjoin a list of matches, each applied to the corresponding doc
!or + - - disjunction
!not + - - negate a match (eg !not.or [1,2,3])
!at + - kpath walk to the path and apply the match there; see below
!lt + - -, or scale the node is less than the operand, a number or RFC 3339 time; see below
!le + - -, or scale the node is at most the operand
!gt + - -, or scale the node is greater than the operand
!ge + - -, or scale the node is at least the operand: !ge(0.8).get-path(root) limit
!has-path + - - the document has the path the operand names
!subtree + - - match any subtree of the doc
!glob + - - glob match a string
!regexp + - - the string matches a Go (RE2) regular expression, anywhere unless anchored with ^ and $
!irtype + - - the node's kind equals the operand's: !irtype "" a string, !irtype 0 a number
!ir + - - match the node's IR fields, not its value: !ir {int: .[number]} an integer
!tag + - - match the tag of a node, not its value
!field + + -, or from,to match the field (a string), not its value
!key + + field to key by associative lists as objects
!let + + - bind names in let:, then match or patch with in:, referring to them as .[name]
!get-path + + -, or root the node at a kpath: !get-path(root) spec.image
!list-path + + -, or root the nodes at a kpath as a list; takes the wild paths !get-path refuses
!pass + + - match: accept anything / patch: leave the document as it is
!raw + + - the escape: treat the subtree as data, interpreting no operation at any depth
!if - + - evaluate if: and patch with then: or else:
!dive - + - dive into the doc and treat each subtree with a list of matches/patches
!embed - + key the operand is the result, with each occurrence of the key replaced by the doc
!quote - + - quote a document as a string
!unquote - + - unquote a string as a document
!nullify - + - turn a node into a null without deleting it
!json-patch - + - apply a json patch to the corresponding doc node
!pipe - + - pipe the doc node to a program and replace it with the program's output
!insert - + - add a value; the value is what results
!delete - + - remove a value; absence is what results
!replace - + - verify the node still equals from:, then install to:
!addtag - + tag add a tag; the tag is what results
!rmtag - + tag remove a tag; its absence is what results
!comment + + - match or state the comments here; the operand names head, line or both, [] for none
!retag - + from,to verify the tag is from, then make it to
!strdiff - + - a string edit, relative to the string that is there
!arraydiff - + - an array edit, relative and positional
!rename - + - rename fields, relative to the keys that are there

o match -tags and o patch -tags print this list from the binary, which is the authority; a test keeps the table above equal to it.

!comment is how a comment change is written without rewriting the value it describes:

a: !comment {head: ["# new"]}                  # the comment above a is now this
a: !comment {line: []}                         # the one after a is gone
a: !comment {head: ["# h"], line: [" # l"]}    # both, in one statement

A position the operand does not name is left alone, as a field an object patch does not name is. Both positions live in one operand because tag composition shares a child: !comment.comment could only ever carry one set of lines.

It states what the comment IS rather than what it was, so it applies to a document that has moved on -- which is what lets a store keep it, and why logd's storage vocabulary admits it beside !insert and !addtag.

As a pattern it asks that same statement as a question: a position the operand names is compared and one it does not name is not asked about, and [] asks that there be no comment there. It asks about the comments and not about the value, so a pattern wanting both is the composition it looks like, !and [!comment {head: ["# lead"]}, {name: svc}]. Every other question stays blind -- a comment describes a value and is not what the value IS, so {name: svc} matches a name: svc somebody wrote a note above.

On the command line both sides need -c. Without it o match never reads the comments, so a !comment pattern has nothing to look at and one naming a comment cannot match; o patch drops the comment the operator states from what it writes, silently and with a zero exit; and o diff sees a comment-only change as no change at all, so there is nothing for either to carry.

!get-path and !list-path change what a pattern IS, and it is worth saying out loud: a pattern holding one cannot be read on its own any more, because what it asserts depends on what the document says elsewhere. status: {replicas: !get-path(root) spec.replicas} is a statement about a RELATION, and no other operator makes one. The same goes for a patch, which is why logd's storage vocabulary refuses them beside !if and !let: what they answer against a base that has moved is a different value.

!lt, !le, !gt and !ge order a number or an RFC 3339 time against an operand that is either a literal or a !get-path. A number operand can be scaled by the tag argument:

used: !ge 400                              # a literal
used: !ge.get-path(root) limit             # another field in the document
used: !ge(0.8).get-path(root) limit        # within 80% of it
at: !lt "2026-10-01T00:00:00Z"             # a time

A node of another kind than the operand does not match: a string against a number, or a number against a time. An operand that cannot be compared against is an error: a literal that is neither a number nor an RFC 3339 time, a scale that is not a number or is on a time, a wild path, or a path that names nothing or names something else, as !get-path errors on a path that names nothing. Numbers compare by value and exactly, so 3.0 <= 3 holds, where equality, {n: 3}, is type-exact and does not match 3.0. Times compare by instant, not as text, so 2026-10-01T02:00:00+02:00 equals 2026-10-01T00:00:00Z. There is no clock: for "older than an hour" the caller computes the cutoff and writes it in.

The last nine are what a diff produces, and they divide on two lines worth knowing: checked operations assert something about what they meet and fail if it does not hold, while insert, delete, addtag and rmtag simply state a result; and strdiff, arraydiff and rename are relative, re-evaluating against whatever is there. Both distinctions matter to anything that stores a patch and applies it later, and logd draws two rules from them rather than one. Baseline replays against a base that never moves, so what it needs is that the patch applies at all, checked once before the delta is stored. A scope's base MOVES as baseline advances, so a relative or checked operation stored in one can stop applying long after it was written -- those it refuses outright. See system/logd/api/storage_context.go for the vocabulary, and What a write must be for both rules as a client meets them.

Patching an array by position#

An array patch which carries no operation is applied element by element, by position: the first element of the patch is applied to the first of the document, and so on. A patch longer than the document names elements which are not there, and each of those is a patch applied to an ABSENT document -- null, the same reading !key gives an element whose key the document does not have, and an object patch gives a field it does not have.

So an operation written past the end is still an operation, not data:

# doc
{xs: [1, 2]}
# patch
{xs: [1, 2, !insert(t) 3]}
# doc after
{xs: [1, 2, !t 3]}

and one that resolves to nothing adds nothing -- !delete past the end is a delete of an element which was never there, so {xs: [1, 2, !delete null]} leaves {xs: [1, 2]}, as !delete in bounds removes the element it meets. A plain value past the end is, as ever, itself.

An array patch where the document holds no array -- nothing at the path, or a scalar -- introduces every element the same way: each is a patch applied to an absent document, so {b: [!insert 5]} over {} gives {b: [5]}, and an operation that resolves to nothing adds nothing.

Use !key when the elements have an identity, and !arraydiff when the edit is relative to what is there; position is the fallback the two of them replace.

Reaching into a document with !at#

!at(kpath) walks down the path and applies the match it holds to the node it lands on:

o match '!at(spec.replicas).irtype 0' deploy.tony

matches a document whose spec.replicas is a number, whatever else it holds. This is how a document is filtered by a condition somewhere inside it, which is otherwise the thing people reach for o list -if to do -- the difference being that !at answers about the whole document, and -if answers with the nodes.

A path which names nothing does not match: !at(a.b) 3 asks for an a.b, so a document without one fails rather than matching vacuously, the same reading !has-path gives a missing path. A wildcard path (.*, [*], {*}) names every node it reaches, and all of them have to match, as every field an object pattern names has to match.

The path is a kpath, all of it, keyed segments included: !at(resources(joe).x) reaches into the element keyed joe of a list the document tags !key(name). A key names nothing in a list which is not keyed -- the tag is what says which field the key is -- so that is a mismatch, not an error.

Composition reaches either side of the walk, and the two are different questions. !not.at(a.b) 3 negates the whole thing: it holds when there is no a.b as much as when a.b is 4. !at(a.b).not 3 asks for an a.b which is something other than 3.

Operations are indicated by YAML tags within a match or a patch.

Most operations are either match operations or patch operations but not both. Some operations, such as key and field, are both.

The raw escape#

The patch grammar and the data grammar share one tag namespace. Without an escape, a value whose tag happens to name a registered operation is always interpreted, so a tony document which itself contains tony operators — a match, a patch, a rule — cannot be written into a document at all:

# patch                                  # applied to {}
rule: {id: !glob hot-*}                  # error: cannot patch with glob operation
rule: {tmp: !delete null, keep: 1}       # keep: 1 — the !delete executed

!raw says this tag is data. Its subtree is stored as values, no operation is interpreted anywhere beneath it, and the !raw tag itself is consumed so the subtree lands with its own tags intact:

# patch
rule: !raw {id: !glob hotfix-*, patch: {tmp: !delete null}}
# doc after
rule: {id: !glob hotfix-*, patch: {tmp: !delete null}}

The escape belongs to the patch, not to the document, which is why the tag is consumed: a stored patch keeps its !raw, so replaying it escapes again. A !raw nested under a !raw is data like everything else beneath it.

A !raw patch merges, as a patch of the same shape without it would: an object field by field, an array by position, a scalar by replacing. Escaping is not replacing, and the document keeps what the raw value does not mention:

# doc
c: {b: 1}
# patch
c: !raw {a: !nullify null}
# doc after
c: {a: !nullify null, b: 1}

To state that a value is exactly this, as data, compose the escape under !insert, which applies its child against absence: c: !insert.raw {a: !nullify null} leaves c: {a: !nullify null}.

In a match, !raw compares its subtree to the document as literal data: tags are compared rather than evaluated, and the comparison is exact — same fields, same length, null means null — rather than the partial object match of an ordinary pattern. Put !raw at the depth where literal comparison starts and the enclosing pattern keeps ordinary match semantics:

# doc
rule: {id: !glob hot-*, stage: open}

rule: !raw {id: !glob hot-*}    # no match: rule has a stage field too
rule: {id: !raw.glob hot-*}     # match: id compared literally, stage ignored

Diff emits !insert.raw itself for a value which carries operator tags as data, so that Patch(a, Diff(a, b)) is b for documents which contain operations, and Reverse stops at a !raw rather than reversing the operations named inside it — those are the document's values, not the diff's instructions.

!raw executes nothing — it is the opposite of !pipe — so RejectUnsafe has no quarrel with it, whatever the data it stores happens to name.

Considerations#

Contrary to evaluation tags, match and patch operations relate the match (or patch) document to some input document. Evaluation tags just relate the node in the document in which they reside to the environment.

This relating of match or patch doc leads to some interesting cases.

For example, let's consider the and and all matches. The and match consists of a list of matches, each of which must match the corresponding input document. The all match consists of a single match which must apply to all array or object members of the document.

As a result and is not a patch operation. However, all is both a match and a patch operation: as a patch it applies the child patch to all object or array members of the corresponding input document.

Custom Ops#

Match and patch operations can be created by implementing a simple interface and registering the operation in the mergeop package.

A custom operation is registered under a namespace, and its tag is written !<namespace>:<name>:

// acmeShout implements mergeop.Symbol.
err := mergeop.RegisterNamespaced("acme", acmeShout{})
greeting: !acme:shout "hi"

The namespace is not decoration. The names without one belong to this package, and a consumer who takes !policy today, and later upgrades into a release where !policy is built in, gets ErrSymbolExists from an init which very likely ignores it. Their operation then silently stops existing, in documents already written, because of a release they did not make. A namespace is somewhere no release will build.

The two halves are kept apart by the registry rather than by agreement: RegisterNamespaced requires a namespace, Register refuses a name that has one, and no built-in operation is named with a :. A test holds that last part.

The separator is : because the alternatives are taken. A . composes tags, so !acme.shout is two operations rather than one, and the registry refuses such a name for that reason. YAML's verbatim form, !<tag:example.com,2026:shout>, does not parse here — its comma ends the tag.

A namespaced operation composes with the built-in ones like any other, in either position:

greeting: !acme:shout.raw "hi"

An operation which also implements Summary() string describes itself in the tooling, beside the built-ins:

o patch -tags | o get .'acme:shout'

Two consumers in one process still share a registry, so two of them must not both choose ext. A namespace someone owns — an organisation, a product — does not have that problem.