Skip to content

JSON Patch write-location and semantic qualification - #184

Merged
yellowman merged 21 commits into
mainfrom
claude/new-session-hafb2u-7jzpi5
Aug 26, 2026
Merged

JSON Patch write-location and semantic qualification#184
yellowman merged 21 commits into
mainfrom
claude/new-session-hafb2u-7jzpi5

Conversation

@yellowman

@yellowman yellowman commented Aug 25, 2026

Copy link
Copy Markdown
Owner

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, an artifact_version was written — and the configuration the chat path consumes did not change.

What was wrong

Write locations. add and replace shared a creating traversal, so replace /a/b on a document with no a invented one and reported success. remove treated 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.

move atomicity. 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:

{"a": {"x": 1}}          move /a    -> /a/child   left {}
{"xs": ["a","b","c"]}    move /xs/0 -> /xs/3      left {"xs": ["b","c"]}

Both raised and destroyed the value. move now rehearses the whole operation on a copy first.

A constant array ceiling. MAX_LIST_EXTENSION = 1024 sat in the write path only, so position 1024 was addressable by remove, test, copy and move and refused to replace and add. Its stated reason — that /xs/999999999 would allocate a billion placeholders — was obsolete; nothing pads a list. Measured with the length check removed, that pointer produces one append at 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:

/a~1b   names key `a/b`      wrote key `a~1b`
/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 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 into update_private_artifact and wrote a version. An empty operation list did the same on both callers.

Index grammar. str.isdigit() admitted 01, 007, ١ and as ordinary indices, so several spellings named one position — and ², which isdigit() accepts and int() rejects, left as an uncaught ValueError.

Pointer operand types. Presence was required, type was not, so a non-string path or from reached .startswith() and escaped as AttributeError.

test equality. Python makes True == 1 and carries it recursively through containers. test guards the operations behind it, so this let a mutation run on a precondition never met — measured at the route as 200, the guarded replace applied, 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 UPDATE and wrote what it was given — so the lock serialized the write without covering the read behind it:

writer A reads schema N, computes N + D
writer B locks, reads N, writes N + C, commits
writer A takes the lock, writes its precomputed N + D
-> C is gone

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_patch row to make one patch apply exactly once created an ABBA cycle: delete_private_artifact takes the artifact and deletes it, and config_patch.artifact_id is ON DELETE CASCADE, so the delete reaches the patch rows through the artifact. Measured: {'apply': 'DeadlockDetected', 'delete': 'ok'}.

What changed

  • _walk_parent creates nothing; _require_target enforces RFC §4.2/§4.3 for remove and replace.
  • move rehearses on a deep copy before touching the real document; copy needs none, since reading mutates nothing.
  • The array bound is the list's own length. MAX_LIST_EXTENSION and _ensure_list_capacity are gone.
  • _segments_or_raise is a real RFC 6901 tokenizer: leading / required, empty tokens preserved, ~1 then ~0 decoded in that order, malformed escapes refused. Error details render through _pointer, which re-escapes.
  • validate_op / validate_ops are one operand rule; ArtifactPatchRequest and ConfigOps call it rather than keeping copies.
  • _read_index matches the RFC production, strictly on both the positive and negative forms.
  • json_equal implements RFC §4.6 equality.
  • apply_config_patch, update_artifact and update_private_artifact take a build_schema callable rather than a finished document, applied to the schema read under the row lock. description is no longer replayed from a stale read.
  • Apply takes the artifact lock first, the order every other writer here uses, then locks the patch row and re-checks its identity and status. One approved patch still applies exactly once: two applies contend on the artifact, the winner marks the patch, and the loser then sees applied.
  • All five training call sites build from the locked row, each changing only the fields it owns.
  • meta_ops gives 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.
  • Docs: ArtifactPatchRequest and the route docstring said /schema/foo, which named a key inside the schema. Both callers hand the engine artifact.schema itself.
  • _walk_existing removed — 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 the artifact_version count, 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:

pair discrimination
pre-removal destination check vs. no check the first kills only the two new move witnesses; the older one survives it
ceiling restored vs. length check deleted each kills what the other leaves alive
six pointer rewrites, one at a time each kills only its own witnesses
Python == vs. recursion removed the second kills exactly the container three
five concurrency regressions each kills exactly one witness — lost update, exactly-once, deadlock, route overwrite, promotion

Two 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.md as 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.

ConfigPatchRequest still validates only op and path at proposal time; a malformed proposal can be stored pending but cannot be applied, since _apply_patch_to_schema calls validate_ops before iterating and before persistence.

docs/ISSUES.md also records a PoolClosed teardown race seen once in the xdist lane and not reproduced, unrelated to this change by reachability.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 13 commits August 25, 2026 02:42
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
Comment thread liminallm/service/config_ops.py
claude added 2 commits August 25, 2026 14:45
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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

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

Comment thread liminallm/service/json_patch.py Outdated
claude added 6 commits August 25, 2026 15:36
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
@yellowman
yellowman merged commit e1a830a into main Aug 26, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants