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:
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:
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:
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>:
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:
An operation which also implements Summary() string describes itself in the tooling,
beside the built-ins:
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.