Config Patch Service: Safe Edits, Generated Diffs and Rebasing
HardAI-assisted coding · AI-Assisted Debugging
Config Patch Service: Safe Edits, Generated Diffs and Rebasing
You've joined the platform team that owns cfgpatch, the service the rollout tooling uses to change production configuration. Configs are JSON documents. Teams change them by sending JSON Patch documents (a precise subset of RFC 6902, written out below). Last week three bad pushes corrupted live configs, and the roadmap has three features queued behind the fixes.
Documents and patches cross the API as JSON text (strings), and every document that comes back is the same canonical text: object keys sorted, no whitespace, non-ASCII characters written as themselves ({"a":1,"b":[1,2]}).
Codebase
| File | What it does |
|---|---|
cfgpatch/api.py | run(operations) entry point; Dispatcher validates arguments and routes operations |
cfgpatch/service.py | ConfigService: put / get / apply / diff / diff_stats / rebase / gen / mutate |
cfgpatch/store.py | DocumentStore: every committed document is the next numbered version of its name |
cfgpatch/engine.py | apply_patch: runs a whole patch, labels failures with the op index |
cfgpatch/operations.py | The six operations (add, remove, replace, move, copy, test) and their shape checks |
cfgpatch/pointer.py | JSON Pointer parsing, escaping, array-index parsing |
cfgpatch/navigate.py | Walking a document by reference tokens |
cfgpatch/jsonvalue.py | Parsing and canonical text, clone, json_equal |
cfgpatch/errors.py | PatchError and every error code |
cfgpatch/diffing.py | Placeholder for Parts 2 and 3 (diff answers "not supported") |
cfgpatch/rebase.py | Placeholder for Part 3 |
cfgpatch/fixtures.py | Deterministic big-document generator used by the load-test harness (Part 4) |
tests/ | Existing tests (some currently fail) |
How your code is run
run(operations: list[list]) -> list executes the operations in order against a fresh, empty service and returns exactly one result per operation. An operation is [name, *args]. Failures are returned, never raised, as "error: <code>" strings.
| Operation | Arguments | Returns |
|---|---|---|
put | name, text | the new version number (an int); creates the name or adds a version |
get | name[, version] | canonical text of the head (or of that version) |
apply | name, patch_text[, base_version] | the new version number |
diff | name_a, name_b | canonical text of a patch turning head of a into head of b (Part 2) |
diff_stats | name_a, name_b | summary dict of that patch (Part 2) |
rebase | name, patch_text, base_version | canonical text of a patch (Part 3) |
gen, mutate | see Part 4 | a version number |
- Versions of a name count from 1.
putand every successfulapplycommit a new version; the head is the newest. Anapplywith an empty patch[]still commits a new version. - Argument checks come first and return
"error: invalid arguments": the operation must be a non-empty list whose first item is a string; names must be non-empty strings;textandpatch_textmust be strings; versions must be integers that are not booleans; the wrong number of arguments. An unknown operation name returns"error: unknown operation"(checked before the argument count). - Lookup errors then follow this order:
unknown document, thenunknown version, theninvalid json(text that is not valid JSON, includingNaN,Infinity), then the patch itself.getwith a version outside1..headisunknown version. - A failure inside a patch is reported as
"error: <code> at op <i>", whereiis the 0-based position of the first operation that failed."error: invalid patch"(the patch text is valid JSON but not a list) has no position.
JSON values. Documents never contain NaN, infinities, negative zero, or non-string keys. Equality is exact on type: two values are equal only when they have the same JSON type, and numbers must also have the same *kind*: 1 (integer) and 1.0 (float) are different values, and true is not 1. Objects ignore key order; arrays do not. 1.0 in JSON text parses as a float and is written back as 1.0. Everywhere this README says *equal*, it means this equality.
Worked example
[["put", "cfg", "{\"service\":\"gateway\",\"routes\":[\"a\",\"b\"],\"limits\":{\"rps\":100}}"],
["apply", "cfg", "[{\"op\":\"add\",\"path\":\"/routes/1\",\"value\":\"x\"},{\"op\":\"replace\",\"path\":\"/limits/rps\",\"value\":250},{\"op\":\"test\",\"path\":\"/routes/1\",\"value\":\"x\"}]"],
["get", "cfg"],
["get", "cfg", 1]]returns
[1, 2,
"{\"limits\":{\"rps\":250},\"routes\":[\"a\",\"x\",\"b\"],\"service\":\"gateway\"}",
"{\"limits\":{\"rps\":100},\"routes\":[\"a\",\"b\"],\"service\":\"gateway\"}"]Part 1: Stop corrupting configs (INC-4417)
Goal: make apply follow the patch rules below exactly. The incident review found these symptoms (there may be more than one cause behind each):
- A push whose last operation failed left the config half applied, and the version history showed the damage in older versions too.
add /routes/1replaced the second route instead of inserting before it.- Keys containing
~and/could not be addressed reliably (a key literally named~1was unreachable). - A
moveof/ato/a/olddeleted the section and nothing complained. - A
testguard{"value": 1}passed against a config where the field wastrue. - Index tokens like
-1or01were accepted as array positions.
Rules
- A patch is a JSON array of operation objects, applied in order, all-or-nothing. If any operation fails, nothing changes: no new version, and no stored version (new or old) is altered. A successful patch commits exactly one new version.
- Shape. Each operation is an object with a string
"op"that is one ofadd,remove,replace,move,copy,test, and a string"path".add,replaceandtestalso need a"value"member (any JSON value,nullincluded).moveandcopyalso need a string"from". Anything else isinvalid operation. Extra members are ignored. Shape is checked before pointers. - Pointers. A pointer is
""(the whole document) or a/-prefixed list of reference tokens separated by/. Inside a token~1means/and~0means~. Decode~1first, then~0(so~01is the two characters~1). A pointer that does not start with/(and is not empty), or in which~is followed by anything other than0or1(or by nothing), isinvalid pointer.pathis checked beforefrom; both are checked before the document is looked at. - Array indices. A token addressing an array element must be one or more ASCII digits with no leading zero (
0itself is fine). Anything else, including-1,+1,01,1.0, spaces and non-ASCII digits, isinvalid index. The token-is alsoinvalid index, except as the final token of an add-style target (below), where it means "after the last element". - Walking a pointer goes token by token. In an object the key must exist, else
path not found. In an array the token must be a valid index (elseinvalid index) and smaller than the length (elsepath not found). Walking into a number, string, boolean ornullispath not found. The first failing token decides the error. - Operations
add: with path""the document becomesvalue. Otherwise the parent (all tokens but the last) must exist. In an object, the key is set (an existing key is overwritten). In an array, the last token-appends; otherwise it is an indexiandvalueis inserted before the element currently ati, pushing later elements right.iequal to the length appends;igreater than the length ispath not found. A scalar parent ispath not found.remove: the target must exist (as in rule 5, last token included). Path""iscannot remove root. Removing from an array shifts later elements left.replace: the target must exist; its value becomesvalue. Path""replaces the document.move: in this order: (1)frommust exist; (2) iffromis a proper prefix ofpath(token by token:/ais a proper prefix of/a/bbut not of/ab) the error isinvalid move; (3) iffromandpathare the same pointer nothing happens; (4) the value is removed fromfrom; (5) it is added atpathwithaddrules, evaluated on the document after the removal.copy:frommust exist; a deep copy of its value is added atpathwithaddrules. Copies never share structure with the original.test: the target must exist (path not found) and be equal tovalue, elsetest failed.
- No shared structure. Values written by
add,replaceandcopyare independent copies: a later operation that edits one of them never changes the patch text's parsed value, another place in the document, or any stored version.
Where to look: error codes in errors.py; each rule above lives in pointer.py, navigate.py, operations.py, engine.py, service.py and jsonvalue.py. tests/ has two failing tests, but they do not cover everything above.
Part 2: Generate a patch from two documents (CFG-212)
Goal: implement diff(a, b): given the heads of two documents, return the patch that turns a into b. Reviewers diff configs a hundred times a day, so the output must be exactly reproducible: the same two documents always give the same patch, byte for byte. Applying the patch to a with Part 1's apply must produce b (the round trip).
diff returns canonical text of a JSON array. Operations are written as {"op": "remove", "path": P}, {"op": "add", "path": P, "value": V} or {"op": "replace", "path": P, "value": V} (and, from Part 3, {"op": "move", "from": P, "path": Q}). Pointers escape ~ as ~0 and / as ~1. diff_stats(a, b) returns {"ops": N, "add": n, "remove": n, "replace": n, "move": n, "digest": D, "roundtrip": bool}, where D is the first 12 hex characters of the SHA-1 of the patch's canonical text (UTF-8), and roundtrip says whether applying the patch to a gave text equal to b's. (service.diff_stats is already written and calls your code.)
Rules
diff_value(x, y)at some location:
- if
xandyare equal, nothing; - both objects: visit the keys of
x∪yin sorted order (Python's default string ordering). A key only inxis removed; a key only inyis added (with its value); a key in both is diffed recursively; - both arrays: align them (rule 2) and handle gaps (rule 3);
- otherwise (different types, or two different scalars): one
replaceat that location withy(path""at the top).
- Array alignment pairs up equal elements. Let
AandBbe the arrays. (a) Match the longest common prefix element by element. (b) Of what remains, match the longest common suffix (never overlapping the prefix). (c) For the middle partsA'andB', letL(i, j)be the length of the longest common subsequence ofA'[i:]andB'[j:]. Walk from(0, 0): ifA'[i]equalsB'[j], match them and advance both; otherwise, ifL(i+1, j) == L(i, j)deleteA'[i]; otherwise insertB'[j]. When one side is used up, the rest of the other is deleted or inserted. - Gaps. Between consecutive matches (and before the first / after the last) the unmatched elements of
Aform a listDand the unmatched elements ofBa listI; lett = min(|D|, |I|). The firsttelements ofDare paired with the firsttofIin order, and each pair is diffed recursively (rule 1), so[{"a":1}]to[{"a":2}]givesreplace /0/a. The other|D| - telements are removed, the other|I| - tare added. - Two phases. Every
removecomes before everyaddandreplace: the patch is *all removals, in traversal order,* followed by *all adds and replaces, in traversal order.* Traversal order visits object keys in sorted order and arrays left to right (inside a gap: the recursion into pairs first, then the extra removals or extra adds). - Array positions. A removal's index is the number of elements to its left in
Athat are not removed (matched or paired elements, including those in earlier gaps). The|D| - textra removals of one gap therefore all use the same index. An add's index, and the index used for a recursive change inside an added/paired element in phase 2, is the element's final position inB. Inside a pair, the phase-1 path uses the survivor count and the phase-2 path uses the position inB. An add at the end of an array uses the numeric index (never-).
Worked example
a = {"name":"edge","ttl":30,"tags":["a"],"old":true}, b = {"name":"edge","ttl":60,"tags":["a","b"],"region":"eu"}:
[{"op":"remove","path":"/old"},
{"op":"add","path":"/region","value":"eu"},
{"op":"add","path":"/tags/1","value":"b"},
{"op":"replace","path":"/ttl","value":60}]Rule 2(b) matters: ["p","x"] to ["x","x"] is [{"op":"replace","path":"/0","value":"x"}] (the last x is matched as a suffix first), and ["a"] to ["a","a"] is [{"op":"add","path":"/1","value":"a"}].
Part 3: Move detection and stale-base rebasing (CFG-240, CFG-251)
Two requests landed at once. They change what diff returns, so Part 2 outputs change when the conditions below hold; Part 2's other rules stay.
3a. Moves
Goal: when something is removed in one place and the same value is added somewhere else, diff should say so with one move instead of a remove plus an add.
After building the patch exactly as in Part 2, post-process the list of operations:
- A
removeis movable when the value it removes (the value that was at that path ina) is a non-empty object or non-empty array. Scalars,{}and[]are never moved. - A
removeR may pair with a lateraddA (A after R in the list) when A's value is equal to R's removed value. - Blocked pairs. Look at the tokens of R's pointer that index into arrays of
aand take the *outermost* such array (for R =/x/y/3/zwhere/x/yis an array ina, that is/x/y; for R =/l/0it is/l; if R's pointer indexes into no array, R is never blocked). R is blocked from pairing with A if any operation strictly between them in the list has apath(or, for a move, afrom) that lies inside that array (starts with its pointer and is longer). Moving across such an operation would change which element an index refers to. - Procedure. Repeat until nothing changes: find the first
removein the list that has any eligibleadd(rules 1-3) and pair it with the first eligibleaddafter it. Replace thataddwith{"op":"move","from":<R's path>,"path":<A's path>}and delete R. A move created this way can sit between other pairs (rule 3 looks at itspathandfrom) but is never paired again.
move has the Part 1 meaning (remove at from, then add at path evaluated after the removal), so the round trip still holds. The diff_stats move counter counts them.
Worked example. a = {"db":{"host":"h","port":5432},"app":{"name":"x"}}, b = {"app":{"name":"x"},"database":{"host":"h","port":5432}} gives [{"from":"/db","op":"move","path":"/database"}] (keys print in sorted order in canonical text). [X, Y, Z] to [Y, Z, X] (objects) gives one move from /0 to /2; if the same diff also had to insert a new element into that array between the remove and the add, the pair is blocked and you keep remove + add.
3b. Applying a patch written against an older version
Goal: a client fetched version v, prepared a patch, and by the time it is submitted the head has moved on. Support rebase and a stale-base apply.
["rebase", name, patch_text, base_version]returns the patch to run on the current head that carries the client's intent. It changes nothing in the store.["apply", name, patch_text, base_version]: ifbase_versionequals the head's version it is exactly Part 1'sapply. Otherwise it applies the rebased patch (with Part 1'sapplyrules, all-or-nothing) and commits one new version.
Rules
- Lookups in order:
unknown document,unknown version(forbase_versionoutside1..head),invalid json. Thenbaseis the stored document atbase_version,headthe current head. - Replay. Apply the patch to
basewith Part 1 rules. Any failure is reported with Part 1's<code> at op <i>; the head plays no part (a patch is checked against the document its author saw). The result ismine. - Merge.
merge(base, mine, head)works value by value, with "missing" as a possible value: (i) ifmineequalsbase, the result ishead's value; (ii) else ifheadequalsbase, it ismine; (iii) else ifmineequalshead, that value; (iv) else if all three are objects, merge each key of the union in sorted order and drop keys whose merged result is "missing"; (v) otherwise conflict. Arrays are atomic: two different changes to an array (or one change to an array and a delete of it) always conflict. - A conflict fails with
"error: conflict at <pointer>", naming the first conflicting location in that sorted traversal (the root, which cannot be a key, is written(root)). Nothing changes. - Otherwise the rebased patch is
diff(head, merged)using the full diff of Parts 2 and 3a (so it contains moves, is normalized, and is empty when the head already has everything).rebasereturns it even whenbase_versionis the head's version.
Worked example
[["put", "cfg", "{\"a\":1,\"b\":{\"x\":1},\"l\":[1]}"],
["apply", "cfg", "[{\"op\":\"replace\",\"path\":\"/b/x\",\"value\":2}]"],
["rebase", "cfg", "[{\"op\":\"replace\",\"path\":\"/a\",\"value\":5},{\"op\":\"add\",\"path\":\"/c\",\"value\":{\"k\":1}}]", 1],
["apply", "cfg", "[{\"op\":\"replace\",\"path\":\"/a\",\"value\":5},{\"op\":\"add\",\"path\":\"/c\",\"value\":{\"k\":1}}]", 1],
["get", "cfg"],
["rebase", "cfg", "[{\"op\":\"replace\",\"path\":\"/b/x\",\"value\":3}]", 1]]returns [1, 2, "<the same two operations, as canonical text>", 3, "{\"a\":5,\"b\":{\"x\":2},\"c\":{\"k\":1},\"l\":[1]}", "error: conflict at /b/x"].
Part 4: Big documents (CFG-266)
Goal: diff must stay fast on the long route tables the platform team ships, without changing a single byte of its output (Parts 2 and 3 rules are the spec; the digest in diff_stats makes any deviation visible).
Two operations, already implemented in fixtures.py, build big documents from a few arguments:
["gen", name, {"n": N, "seed": S}]stores a document{"service": "...", "routes": [N route objects]}as the next version ofname(returns the version). Routes repeat a lot.["mutate", source, target, {"seed": S, "edits": E}]stores, as the next version oftarget, a copy of the head ofsourcewithEpseudo-random edits to its routes (delete, insert, modify, or move one route). Returns the version.
Both argument dictionaries must have non-negative integer members; anything else is invalid arguments.
Hidden tests call diff_stats on documents with thousands of routes and a few dozen differences. Comparing every element against every element will not finish, and neither will comparing nested values with a recursive function inside a quadratic loop. Whatever you change must give the same alignment, the same patch and the same digest as the rules above (the tie-breaks in rule 2 are part of the spec, and so is the blocked-pair rule for moves).
Constraints
- Python 3 standard library only; no wall clock, randomness or iteration-order effects in results.
- Earlier Parts keep working after later ones (the hidden tests mix them).
Example 1
- Input:
- [["put","cfg","{\"service\":\"gateway\",\"routes\":[\"a\",\"b\"],\"limits\":{\"rps\":100}}"],["apply","cfg","[{\"op\":\"add\",\"path\":\"/routes/1\",\"value\":\"x\"},{\"op\":\"replace\",\"path\":\"/limits/rps\",\"value\":250}]"],["get","cfg"],["get","cfg",1]]
- Output:
- [1,2,"{\"limits\":{\"rps\":250},\"routes\":[\"a\",\"x\",\"b\"],\"service\":\"gateway\"}","{\"limits\":{\"rps\":100},\"routes\":[\"a\",\"b\"],\"service\":\"gateway\"}"]
- Explanation
- The add inserts before the second route; the second apply creates version 2 and version 1 is still the original document.
Example 2
- Input:
- [["put","a","[\"p\",\"x\"]"],["put","b","[\"x\",\"x\"]"],["diff","a","b"]]
- Output:
- [1,1,"[{\"op\":\"replace\",\"path\":\"/0\",\"value\":\"x\"}]"]
- Explanation
- The common suffix is matched first, so the only difference left is the first element: one replace.
Example 3
- Input:
- [["put","a","{\"db\":{\"host\":\"h\",\"port\":5432},\"app\":{\"name\":\"x\"}}"],["put","b","{\"app\":{\"name\":\"x\"},\"database\":{\"host\":\"h\",\"port\":5432}}"],["diff","a","b"]]
- Output:
- [1,1,"[{\"from\":\"/db\",\"op\":\"move\",\"path\":\"/database\"}]"]
- Explanation
- The section removed from /db is added unchanged at /database, so the diff reports a single move.
Guidelines
- Python 3 standard library only
- Documents and patches travel as JSON text; results that are documents are canonical text (sorted keys, no whitespace)
- JSON equality is type-exact: 1 ≠ 1.0, true ≠ 1, object key order is irrelevant
- Every operation returns exactly one value; errors are returned as "error: <code>" strings, never raised
- diff output must be byte-for-byte deterministic: the array alignment tie-breaks, phase order and move rules in the README are the spec
- Part 4 diffs arrays of thousands of elements with a few dozen differences