JSON Patch write-location and semantic qualification - #184
Merged
Conversation
Nineteen failing witnesses across the shared engine and both of its callers.
Two halves of one rule: traversal never manufactures missing intermediate
structure, and an operation requiring an existing target never turns absence
into success.
The engine did the opposite of both. `add` and `replace` shared a creating
walk, so `replace /a/b` on a document with no `a` invented one and reported
success, and `remove` treated a missing target as nothing to do.
Measured on a running instance, the consequence lands on published
configuration. `ArtifactPatchRequest` documents `{"op":"replace","path":
"/schema/foo"}`, while both callers hand the patch engine `artifact.schema`
itself — so the documented spelling addresses a key inside the schema. A
ConfigOps patch written that way returned 200, was marked `applied`, wrote a
new artifact_version, added a junk nested `schema` key, left the intended
value untouched, and the configuration serving consumes did not change. The
operator was told the change landed.
Kind-schema validation cannot catch it: an extra top-level key is valid for
the workflow kind, so the corrupted document passes.
The positive controls are as load-bearing as the refusals. "Reject every
absent location" breaks `add`, whose whole job is naming a member that is not
there yet, so a new member of an existing object, an append at index == len,
and `-` all have witnesses that must keep passing.
Four existing tests are revised rather than kept: they codified the old
contract directly — `add /a/b` on `{}` manufacturing `/a`, and removing a
missing key succeeding. The complaint the removal tests were written for, that
a refused walk must not leave containers behind, is kept and now asserted
against a document that is checked afterwards.
…thing One non-creating walk, and each verb states what it needs. `_walk_parent` resolves the parent of the target and invents nothing; `_require_target` enforces RFC 6902 §4.2/§4.3 for `remove` and `replace`; `add` requires only the parent, because naming a member that is not there yet is what it is for. Array bounds follow §4.1: `index == len` and `-` append, beyond that is out of range rather than a silent append to the end. Both documentation copies are corrected in the same change. The example said `path: "/schema/foo"` while both callers hand the engine `artifact.schema` itself, so the documented spelling addressed a key inside the schema. Leaving it would tell callers to write the one path the corrected engine now refuses. A hazard the fix uncovered: a move is a remove and an add, and the destination was resolved after the source was taken, so refusing the destination deleted a value on behalf of an operation that failed. The destination is checked first and resolved again afterwards — twice deliberately, because taking the value can invalidate the parent found a moment earlier when it moves within one list. Six mutations, all applied and all killed. Two kill exactly one witness each: resolving the move destination late kills only the value-loss case, and making ConfigOps swallow the refusal kills only the transactional case. Three witnesses were vacuous when first written. `apply_ops` deep-copies before it starts, so asserting the caller's document is unchanged after a failure proves `copy.deepcopy` works and nothing else. They now drive `apply_op`, which edits in place, which is where the risk is. `/xs/-1` briefly regressed to a generic "source path not found" and three existing tests caught it. The specific "negative list index" is restored and both the read and write paths now use it, so one mistake has one description instead of two.
`test_config_ops.py::test_a_missing_intermediate_is_created` asserted that
`add /a/b/c` on `{}` produces the whole chain. It is the same contract the
four in `test_json_patch.py` stated, and it broke on the full lane rather
than on the focused run.
Missed because the search was for the module: `json_patch|apply_op|apply_ops`
finds the engine's direct callers, and this one goes through the service
wrapper `ops._apply_patch_to_schema`. Searching for the behaviour rather than
the import name is what would have found it, which is the same lesson the
deletion tranche recorded and the second time it has cost a round here.
Revised like the others: the refusal is required, and a positive control
keeps what the test was written for — `add` still names a member that is not
there yet, once its parent exists.
Re-searched afterwards for any other route into the engine and any other
test asserting a missing target succeeds. There are none.
The destination check landed in the earlier commit of this tranche, but it
ran against the pre-removal document. Two destinations are valid before the
removal and invalid after it, and both delete the source while failing:
{"a": {"x": 1}} move /a -> /a/child left {}
{"xs": ["a","b","c"]} move /xs/0 -> /xs/3 left {"xs": ["b","c"]}
The first is the proper-prefix case RFC 6902 4.4 names outright. The second
has no prefix relationship at all: /xs/3 appends to three elements and is out
of range on the two that remain.
Both drive apply_op, which edits in place, because apply_ops deep-copies and
would prove nothing. The third witness is the positive control they could
otherwise break: moving within one list needs the destination judged after
the removal, not before it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Validating the destination against the pre-removal document is the wrong document. RFC 6902 4.4 defines a move as a remove followed by an add, so the add has to be legal in what the remove leaves behind. move now replays the whole operation on a deep copy first. Whatever the rehearsal raises is raised before the real document is touched, and if it raises nothing the replay cannot fail. That covers the proper-prefix case and the array-shrink case without enumerating either: an explicit prefix check is a better diagnostic but misses the second entirely. copy splits out and needs none of it. Reading mutates nothing, so the first thing that can change the document is the write, and a refused write leaves it alone. _walk_existing goes with the change. It was the non-creating walk remove used to avoid conjuring the containers it was about to remove from; every verb walks without creating now, so it had no caller left. Seven mutations, all killed. The move pair is the point: restoring the pre-removal check kills only the two new witnesses, while removing the check entirely kills those two and the older one. The two new witnesses also come apart under the others, which says they measure different mechanisms rather than one bug twice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
A fixed ceiling of 1024 sat in the write path only. Measured on a 1025-element list: replace /xs/1024 REFUSED "list index too large" add /xs/1025 REFUSED "list index too large" (append at len) remove /xs/1024 OK test /xs/1024 OK copy/move from /xs/1024 OK So position 1024 exists for four verbs and not for two. That is the same operation-dependent location semantics the rest of this file removes. The third witness is the control: /xs/999999999 on a two-element list must stay refused, because refusing a billion-entry allocation is what the ceiling was actually for. It passes today via the ceiling, and has to keep passing once the ceiling is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
MAX_LIST_EXTENSION and _ensure_list_capacity are gone. The ceiling sat in the write path only, so position 1024 was addressable by remove, test and both source reads while replace and add refused it — one location existing for four verbs and not for two. Its stated reason expired earlier in this tranche. It was there because add could name an index beyond the end, so /xs/999999999 would allocate a billion placeholders; index > len is now refused outright, so the list's own length already bounds that. Measured: refused in 0.0000s at 12MB RSS. _read_index takes the noun for its caller's direction, so one definition of a valid index serves both sides and a destination that is not a number is no longer reported as a missing source path. Two tests pinned "too large" and were updated, not worked around: the memory-exhaustion concern they were written for still holds and is still enforced. I missed the second on the first sweep because the grep ended in head -20 and the match was on line 21. A mutation then surfaced a hole older than this change: dropping the negative check from the write path killed nothing. add /xs/-1 on [1, 2] gives [1, 9, 2] and /xs/-2 gives [9, 1, 2], because list.insert(-1, v) writes before the last element. replace was already covered by requiring an existing target; add has none to require. Correct before and after — the witness was missing, and two review passes over this file had not found it. Nine mutations, all killed. M7 (ceiling returns) and M4 (length check deleted) are complements: each kills the witnesses the other leaves alive, so neither check can stand in for the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
RFC 6901 tokenization was strip("/"), split("/") and "drop the empty ones",
with no escape decoding. Each of those rewrites the caller's address:
/a~1b names key `a/b` -> wrote key `a~1b`
/a~0b names key `a~b` -> wrote key `a~0b`
/a//b is a, "", b -> wrote a.b
/a/ is a's "" member -> replaced a itself
a/b is not a pointer -> accepted as /a/b
The escape cases are the worst of them, because both spellings can be real
keys in the same document, so the operation lands on a valid location that
the operator did not name and nothing is raised.
The artifact witness shows the consequence through the real caller: publish a
workflow holding both "a/b" and "a~1b", patch /a~1b, and ConfigOps returns
200, marks the patch applied and writes a new artifact_version while the key
named keeps its old value.
Also witnessed: "" is the whole document (RFC 6901 5) and was silently
ignored rather than refused, while "/" was called the document root when it
names the member keyed "". An op that omits `path` entirely stays skipped —
structurally incomplete is not the same as naming the whole document.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
_segments_or_raise is an RFC 6901 tokenizer now instead of strip/split/drop:
leading `/` required, empty reference tokens kept, `~1` then `~0` decoded in
that order, and a `~` that escapes nothing refused. The order is not
cosmetic — decoding `~0` first turns `~01` into `~1` and then into `/`, which
is a third key again. Error details render through _pointer, which
re-escapes, so a key holding a `/` does not come back reading as two tokens.
The root claim was inverted in the code and in a test that agreed with it.
RFC 6901 5: "" is the whole document, `/` is the member keyed "". The engine
had `/` refused as "the document root" and "" ignored in silence. Now "" is
refused out loud and `/` is an ordinary location, with a positive witness
beside the refusal so neither half can be restored by reading the other.
An op that omits `path` is still skipped: structurally incomplete is not the
same as naming the whole document, and collapsing them through
op.get("path", "") is how the whole-document pointer came to be ignored.
move/copy with no `from` defaulted the same way and reported "addresses the
whole document" about an operand the caller never wrote; it now says which
operand is missing.
_read_index reaches four callers and only one reads a source, so both its
messages are direction-neutral and the parameter carrying the direction is
gone. replace /xs/nope no longer describes a bad destination as a missing
source path.
Corrected: the huge-gap witness's rationale. Nothing here pads a list, so
deleting the length check does not allocate — measured, /xs/999999999 on
[1, 2] gives [1, 2, 3], one append. What that check defends is the address,
not the heap. The old claim came from the deleted constant's own comment,
and carrying a comment forward is not verification.
Fifteen mutations, all applied and all killed. The six pointer mutations kill
one rewrite's witnesses each. Two of them measured nothing on the first run —
they anchored on the argument the diagnostic fix had just deleted — which the
driver reported rather than passing off as coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Two seams left at the same altitude as the rest of this tranche.
Operand shape. RFC 6902 4 gives every operation `op` and `path`, and each
verb the further members it needs. None of that was required:
{"op":"replace","path":"/k"} wrote {"k": None}
{"op":"add","path":"/new"} wrote {"new": None}
{"op":"replace","value":"X"} skipped in silence
{} skipped in silence
The first is the sharpest — it does not no-op, it destroys the value on
behalf of an operand nobody supplied. Through the private-artifact PATCH
route the silent skip is worse than it looks: the route carries on into
update_private_artifact, so a no-op patch returns 200 and writes a new
version. Measured on the red: version 2, schema unchanged.
`value: null` stays legal and has its own control, so the fix cannot become
"reject falsy values". `remove` has one too, being complete without a third
member.
Index grammar. RFC 6901 says an array index is `0` or a non-zero digit run,
ASCII. `seg.isdigit()` is a much larger set:
/xs/01 -> index 1 several spellings, one position
/xs/١ -> index 1 (arabic-indic)
/xs/0 -> index 0 (fullwidth)
/xs/² -> ValueError isdigit() true, int() refuses
The last is the only case in this tranche that was a 500 rather than a wrong
write. The route witness shows the rest: `/nodes/01/tool` returns 200 and
rewrites the *second* node's tool, so an operator's typo silently edits a
different node than the one they named.
The two permanent "half-formed ops are skipped" tests go with this. SPEC
promises JSON Patch, not silent acceptance of malformed JSON Patch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Operand shape. `validate_op` is one table of what each verb requires, and `apply_op` asks it before doing anything. The artifact request model asks the same function rather than keeping a copy, so the engine stays the boundary every caller crosses while the route can still refuse before it has decided to write. Absence is the question, never truthiness — `value: null` is a legal operand and has its own control. Index grammar. `_read_index` matches the RFC 6901 production, `0` or a non-zero digit run, ASCII. Both forms are matched strictly: the negative branch had the same isdigit/int mismatch, so `/xs/-²` was an uncaught ValueError for the same reason `/xs/²` was. A third instance of the first shape, found by grepping the class: an empty operation list names no change and both callers wrote a version for it. The route guarded the engine behind `if ops:` and went to the store directly; ConfigOps looped zero times and marked the patch applied. `validate_ops` refuses it, ConfigOps asks because it loops apply_op itself, and the route's guard is gone so nothing reaches the store without meeting a rule. Three tests asserted the old contract and are rewritten. One of them justified the silence with "both callers validated shape upstream and relied on this", which was not true — ArtifactPatchRequest took List[dict] and checked nothing. Twenty mutations. Eighteen killed. Two survive by design and a nineteenth exists to prove it: the operand rule has one definition and two enforcement points, so removing either alone changes nothing observable, while removing both exposes the route witness and only that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Two seams, both reachable over the wire as ordinary JSON.
Pointer operand types. validate_op requires `path` to be present and `from`
to be present for move/copy, but neither to be a string. _segments_or_raise
reaches straight for path.startswith("/"), so every non-string escapes as an
uncaught AttributeError:
path=42 path=null path=["/a"] path={"a":1} from=42
That is a 500 for what is plainly a bad request, and both API models take
List[dict], which admits any JSON value in any member.
`test` equality. RFC 6902 4.6 compares JSON values; the engine used Python
`==`. Python makes True == 1 and False == 0 and carries it recursively
through lists and dicts, so a precondition passes on a value of a different
JSON type.
That matters because `test` exists to guard the operations behind it. The
route witness holds `enabled: true`, asks for the number 1, and puts a
replace behind the guard. Measured: 200, "spare":"CHANGED", version 2 — the
guarded operation ran and a version was written on a precondition that was
never met. The inverse is witnessed too, so a fix cannot special-case one
direction, and the nested array/object cases stop a scalar-only fix passing.
Controls: 1 equals 1.0 because JSON has one number type; a boolean still
equals itself; structures still match; null equals null; and at the route, a
precondition that genuinely holds still lets the patch through and writes its
version.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
_require_pointer refuses a `path`, or a required `from`, that is not a string. Refused rather than coerced: str(42) is "42", which is a pointer nobody wrote, and manufacturing an address is the failure this module exists to stop. Non-string operands used to leave as an uncaught AttributeError, which made them a 500 rather than a 400. json_equal is RFC 6902 4.6 equality. Booleans equal only booleans; numbers compare numerically with bool excluded; arrays and objects recurse; everything else compares within its own type. JSON has one number type, so 1 and 1.0 are one value and a control says so. That matters most for `test`, whose whole job is guarding the operations behind it. Holding `enabled: true` and asking for the number 1 used to return 200, apply the replace behind the guard and write a version, on a precondition that was never met. Twenty-three mutations, twenty-one killed. The two new pairs are complements: restoring Python equality kills all seven inequality witnesses and both route witnesses, while removing only the container recursion — a scalar-only fix — kills exactly the array, object and nested three, which is what says those three measure the recursion rather than repeat the scalar case. The two survivors are the previously documented and approved pair: one rule for operand shape, two enforcement points, either individually sufficient. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Reported by Bugbot on #184 and confirmed by execution. Two producers write a single key under /meta: config_ops._fallback_patch add /meta/llm_autopatch training auto-prune add /meta/auto_prune A freshly created artifact has no `meta` in its schema, and this branch stopped traversal inventing missing parents, so both now emit a patch that is stored pending, approves cleanly, and fails on apply with "patch path not found". A dead end this branch introduced. The engine is not what is wrong here. A patch names a location in a document that already exists; the producers were relying on the creating walk, and they know the artifact, so they can emit ops that fit it. The naive repair is worse than the defect: `add` on a member that is already there replaces it, so unconditionally prepending `add /meta {}` trades a refused patch for a destroyed one. The third witness holds that line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
meta_ops returns the ops that write one key under /meta for a given schema, adding /meta only when it is genuinely absent. Both producers use it and both now pass the artifact they are proposing against: config_ops._fallback_patch takes it through _run_llm_for_patch, and the adapter auto-prune proposer already had it in hand. Adding /meta unconditionally would have been worse than the refusal it fixes. `add` on a member that is already there replaces it, so an artifact whose meta held anything would lose it. A meta that exists but is not an object is left for the engine to refuse rather than silently overwritten. The fallback witness drives the real path — the model is made to fail rather than stubbing _run_llm_for_patch — because threading the artifact into the fallback is the thing under test. Twenty-five mutations, twenty-three killed. The two new ones are complements: always adding /meta kills the surviving-meta witness, never adding it kills the bare-artifact ones including the caller witness. The two survivors are the previously documented redundant-enforcement pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2486f65. Configure here.
Reported by Bugbot on 2486f65 and confirmed by execution. The previous commit decided at proposal time whether to prepend `add /meta {}`, and a stored patch is applied later than it is written. `add` on a member that is already present replaces it, so anything that put a meta there in between was wiped. Measured: proposed against a bare artifact, applied after {"landed_in_between": "MUST SURVIVE"} appeared, meta held only the new key. The data loss was not avoided, only deferred across the propose/apply gap. RFC 6902 has no "add if absent" and no test for absence, so no proposal-time decision about a parent can be made stale-proof. The leaf op alone wins the trade everywhere except one case: at apply time parent-creating leaf only meta absent applies refused, nothing changed meta appeared destroys it applies, siblings kept meta_ops now emits one leaf op and holds the reasoning, so the tempting version does not come back. What it gives up is the bare-artifact case, which is a visible dead end rather than silent damage. Closing that properly is larger than the engine and belongs with ConfigOps: version-gate a stored patch so one written against a different document is refused rather than misapplied, which generalizes past meta to any stale patch; or move these annotations to the artifact.meta column that already exists instead of the schema document. The staleness witness is the one that makes this durable, and the mutation that bakes the parent create back in kills all five. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
SPEC 10.1 says applying a config patch loads the current artifact.schema, applies the patch, validates, writes the version, then marks the patch applied. The service read the artifact and computed the new document before calling the store, and the store locked the artifact row and wrote the document it had been handed. The lock serialized the write without covering the read behind it: apply reads schema N apply computes N + patch another replica commits N+1 apply locks the artifact row apply writes its precomputed N-derived schema -> the N+1 edit is gone apply_config_patch now takes a build_schema callable instead of a finished document, so the JSON Patch semantics stay in the service and the transaction stays in the store, and the patch is applied to the schema read under the lock. The patch row is locked and re-read there too. Its approved check ran outside the transaction and the status write had no approved guard, so two callers could both see approved, queue on the artifact lock, and each write a version for one patch. The description carried the same staleness through COALESCE, and its only caller passed back the value it had just read, so the argument and the column update are both gone. The meta staleness witness was asking the right question at the wrong altitude — it called apply_ops on an already-later dictionary, so it passed against code with the race in it. Two witnesses replace it at the lifecycle: a real concurrent edit committed through the ordinary mutation path between the read and the transaction, and two overlapping applies of one approved patch. Each dies to exactly one mutation. recommend_adapter_pruning is now driven for real, which it had not been through two changes to that call site. A mutation that re-adds the parent create there alone kills that witness and nothing else. Twenty-eight mutations, twenty-six killed; the two survivors are the documented redundant-enforcement pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
Grepping the class rather than the instance. Fixing ConfigOps closed the race for one writer; the ordinary private PATCH route still reads the artifact, computes the whole new schema, and hands it to a store method that locks the row and writes the document it was given. Interleaved the other way round it is the applied ConfigOps patch that disappears: private PATCH reads schema N, computes N + D ConfigOps locks, reads N, writes N + C, marks the patch applied private PATCH takes the lock, writes its precomputed N + D -> C is gone, and its audit trail still says applied Measured: field_c back to ORIGINAL with the patch row still 'applied'. That is the campaign invariant itself — the audit asserts a change the serving configuration does not have. update_artifact has the same shape as apply_config_patch did: FOR UPDATE, then validate and write the schema argument rather than a transformation of the row it just locked. Six callers pass a precomputed document. Training's promotion is the worst of them, since dict(adapter.schema) comes from a snapshot taken before the training run, so that window is minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
update_artifact and update_private_artifact take a build_schema callable instead of a finished document, applied to the schema read under FOR UPDATE. They had the same shape apply_config_patch did — lock the row, then write the argument — so the lock serialized the write without covering the read behind it. Six callers passed a precomputed document. The private PATCH route builds inside that lock now, which also moves the kind-prefix check into the transaction so a refusal writes nothing. Its description stopped being replayed: None when the request did not ask for one, which the store already reads as leave-it-alone. Writing back the value you read reverts a concurrent change to it. All five training call sites are builders, each changing only what it owns — base model, vocab size, fs dir, and promotion's version/fs-dir/mode/lifecycle. Promotion was the worst of them: dict(adapter.schema) came from a snapshot taken before the training run, so the window is minutes rather than microseconds. My first promotion witness was vacuous and the mutation is what caught it. It raced at the first update_artifact for that adapter, which is the pre-training vocab-size write, and training refreshes its snapshot afterwards — so the document already held the patch by promotion time and the witness passed against the defect. The seam is now inside the training run: after the last refresh, before promotion takes the lock. Thirty mutations, twenty-eight killed. The two new ones each kill exactly one witness: the route computing outside the lock, and promotion rebuilding from its starting snapshot. Two more had gone stale against this change and were retargeted. The survivors remain the documented redundant-enforcement pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
apply_config_patch locks config_patch and then artifact. Meanwhile
delete_private_artifact locks the artifact and then deletes it, and
config_patch.artifact_id is ON DELETE CASCADE, so the delete needs the patch
rows too:
delete: artifact -> config_patch (via cascade)
apply: config_patch -> artifact
An ABBA cycle, and reachable rather than theoretical: propose accepts any
artifact id, so a private artifact can carry an approved patch while its
owner deletes it, and account erasure meets the same relationship.
Measured: {'apply': 'DeadlockDetected', 'delete': 'ok'}.
Postgres resolves it by aborting a transaction, so nothing is corrupted, and
the comment in apply_config_patch even claims patch-first is the universal
order. It is not — the whole rest of this store takes the artifact first —
and "the loser gets a DeadlockDetected" is not two operations having an
intentional order.
Deterministic by construction rather than by timing: the delete is held after
it has taken the artifact lock and before the cascade, using the
_artifact_from_row call that already sits between them, and the apply is
released only once pg_stat_activity shows a backend genuinely waiting on a
lock.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
The config_patch lock the previous commit added went in the wrong place. delete_private_artifact takes the artifact and then deletes it, and config_patch.artifact_id is ON DELETE CASCADE, so the delete reaches the patch rows through the artifact: delete: artifact -> config_patch (via cascade) apply: config_patch -> artifact An ABBA cycle, and reachable: propose accepts any artifact id, so a private artifact can carry an approved patch while its owner deletes it, and account erasure meets the same relationship. Measured: apply DeadlockDetected. apply_config_patch now takes the artifact first, and re-checks the patch row's identity and status once both locks are held. patch.artifact_id is safe to look up before the patch row is locked because a patch never changes the artifact it targets, and the identity check covers it regardless. This does not cost the exactly-once property. Two applies of one patch contend on the artifact first; the winner marks the patch, and the loser takes the patch row afterwards and sees applied. The mutation restoring patch-first kills the deadlock witness alone, and both exactly-once witnesses survive it. Also: the mutation driver now marks what it has applied. Interrupting it left a mutation on disk in json_patch.py, because the restore is in a finally that a killed process never reaches; the routine git status afterwards is what caught it. Thirty-one mutations, twenty-nine killed. The survivors remain the documented redundant-enforcement pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DQtPsg9YSUXaStGXyUjozA
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Scope is the seams this campaign measured, not full RFC 6902 conformance.
One invariant throughout: an operation applies to a location that operation permits, or fails without changing the document. Every defect here is a violation reported as success — the audit trail asserting a change that did not happen, or a change to somewhere other than the place named.
Found by driving ConfigOps against SPEC §10 on a running instance. A published workflow was patched, the API returned 200, the patch was marked
applied, anartifact_versionwas written — and the configuration the chat path consumes did not change.What was wrong
Write locations.
addandreplaceshared a creating traversal, soreplace /a/bon a document with noainvented one and reported success.removetreated a missing target as nothing to do, which makes a removal that addressed the wrong path indistinguishable from one that did its job. Array bounds silently appended past the end.moveatomicity. The destination was validated against the pre-removal document. RFC 6902 §4.4 defines a move as a remove followed by an add, so the add must be legal in what the remove leaves behind:Both raised and destroyed the value.
movenow rehearses the whole operation on a copy first.A constant array ceiling.
MAX_LIST_EXTENSION = 1024sat in the write path only, so position 1024 was addressable byremove,test,copyandmoveand refused toreplaceandadd. Its stated reason — that/xs/999999999would allocate a billion placeholders — was obsolete; nothing pads a list. Measured with the length check removed, that pointer produces oneappendat index 2, so what the check defends is the address, not the heap.RFC 6901 tokenization. Parsing was
strip("/"),split("/")and "drop the empty ones", with no escape decoding — four separate rewrites of the caller's address:The escape case is the worst: both spellings can be real keys in one document, so it succeeds against a valid location nobody named. A root-path claim was also inverted in the code and in a test that agreed with it —
""is the whole document,/is the member keyed"".Operand shape.
{"op":"replace","path":"/k"}wrote{"k": None}, destroying a value on behalf of an operand nobody supplied. Half-formed ops were skipped in silence, and the artifact route carried on intoupdate_private_artifactand wrote a version. An empty operation list did the same on both callers.Index grammar.
str.isdigit()admitted01,007,١and0as ordinary indices, so several spellings named one position — and², whichisdigit()accepts andint()rejects, left as an uncaughtValueError.Pointer operand types. Presence was required, type was not, so a non-string
pathorfromreached.startswith()and escaped asAttributeError.testequality. Python makesTrue == 1and carries it recursively through containers.testguards the operations behind it, so this let a mutation run on a precondition never met — measured at the route as 200, the guardedreplaceapplied, and a version written.Every artifact writer held a lock over a write it had already computed. SPEC §10.1 says apply loads the current schema. ConfigOps, the private PATCH route, and all five training call sites read the row, computed the whole new document, and handed it to a store method that took
FOR UPDATEand wrote what it was given — so the lock serialized the write without covering the read behind it:Reachable in both directions. Interleaved one way an ordinary edit vanishes; the other way an applied ConfigOps patch does, while its audit row still says
applied. Training's promotion was the worst case:dict(adapter.schema)came from a snapshot taken before the training run, so that window is minutes.Apply and delete then took the two rows in opposite orders. Locking the
config_patchrow to make one patch apply exactly once created an ABBA cycle:delete_private_artifacttakes the artifact and deletes it, andconfig_patch.artifact_idisON DELETE CASCADE, so the delete reaches the patch rows through the artifact. Measured:{'apply': 'DeadlockDetected', 'delete': 'ok'}.What changed
_walk_parentcreates nothing;_require_targetenforces RFC §4.2/§4.3 forremoveandreplace.moverehearses on a deep copy before touching the real document;copyneeds none, since reading mutates nothing.MAX_LIST_EXTENSIONand_ensure_list_capacityare gone._segments_or_raiseis a real RFC 6901 tokenizer: leading/required, empty tokens preserved,~1then~0decoded in that order, malformed escapes refused. Error details render through_pointer, which re-escapes.validate_op/validate_opsare one operand rule;ArtifactPatchRequestand ConfigOps call it rather than keeping copies._read_indexmatches the RFC production, strictly on both the positive and negative forms.json_equalimplements RFC §4.6 equality.apply_config_patch,update_artifactandupdate_private_artifacttake abuild_schemacallable rather than a finished document, applied to the schema read under the row lock.descriptionis no longer replayed from a stale read.applied.meta_opsgives the internal producers one leaf op and never a parent create, since a parent decision made at proposal time can be stale by apply time.ArtifactPatchRequestand the route docstring said/schema/foo, which named a key inside the schema. Both callers hand the engineartifact.schemaitself._walk_existingremoved — no caller left once every verb walks without creating.Verification
Every defect was reproduced before being fixed, most through the real caller — private-artifact PATCH, ConfigOps propose/decide/apply, or
recommend_adapter_pruning— asserting the HTTP status, the stored schema, and theartifact_versioncount, because the version write is the dishonest part.The concurrency witnesses are deterministic by construction rather than by timing: each holds one operation at a seam that already exists in the code, and releases it only once the server reports a backend genuinely waiting on a lock.
31 mutations, 29 killed. Complements rather than repetition, so witnesses are shown to measure distinct mechanism:
movewitnesses; the older one survives it==vs. recursion removedTwo mutations survive by design and a third exists to say why: the operand rule has one definition and two enforcement points, so removing either alone changes nothing observable, while removing both exposes the route witness and only that.
Full lane at the current head: 2998 passed, 27 skipped. Both lint selections clean.
Not in scope
SPEC §10.2 sandbox simulation and artifact-version rollback remain unimplemented and are recorded in
docs/ISSUES.mdas nonconformances, each with its own tranche to follow. Reference validation — graph integrity first, then adapter/cluster/tool references scoped by each consumer's real resolver — is a separate tranche pair.ConfigPatchRequeststill validates onlyopandpathat proposal time; a malformed proposal can be storedpendingbut cannot be applied, since_apply_patch_to_schemacallsvalidate_opsbefore iterating and before persistence.docs/ISSUES.mdalso records aPoolClosedteardown race seen once in the xdist lane and not reproduced, unrelated to this change by reachability.🤖 Generated with Claude Code
Generated by Claude Code