Feature oekg api patch version guard - #2438
Merged
Merged
Conversation
PATCH /api/v0/scenario-bundles/<uid>/ touches only the keys a payload names. A key that is absent is genuinely untouched rather than read and rewritten, which is also what stops an unrelated patch re-minting every framework and model in the bundle. A set-valued field that is named is replaced whole, so emptying one the shape requires is a rejection and never a silent wipe. Every bundle now carries a monotonic version. It is a triple pointing AT the bundle from a version node, because the bundle shape is sh:closed and hanging it off the bundle would invalidate every bundle this API writes. Two things fall out of that direction: nothing in the shape targets the version node, and a read walks outward from the bundle, so the bookkeeping stays out of the payload without any filtering. Reads carry it as an ETag; writes send it back as If-Match. The guard is bound inside the write, not checked in front of it: one SPARQL request is one transaction, so the version sits in the update's own WHERE. That guard produces no signal of its own -- the store answers 200 and changes nothing whether or not the pattern matched -- and reading the version back is not enough either, because two writers guarding on version 3 both intend 4. So each write stamps a token on the version node and reads that back. It costs no extra round trip, and it is the difference between reporting a conflict and reporting a success that did not happen. Three refusals, three statuses, because they mean three different things to a client: 428 when If-Match is absent, 412 when it names a version that is not current, 409 when the server's own guard fires. Requiring If-Match is the one place this takes WF-09's offered alternative over its default, as the tickets note asks. Ownership is asked of ScenarioBundleAccessControl through one function, since four more endpoints are about to ask the same question. Multi-owner bundles already exist, so this is not a single-creator comparison, and a bundle with no ownership record stays administrator-only. Also closes the limit slice 3 named: the acronym check and the insert were two requests, so two concurrent creates could both pass the check and both land. The check now sits inside the create's own write. And a patch can no longer rename a bundle onto an acronym another bundle holds, which would have made uniqueness true only until somebody patched -- the acronym lookup is what a stateless pipeline re-identifies its bundle through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two fixes from reviewing the slice, and one of them was a live bug. The version node points AT the bundle, so a delete that removes the bundle's outgoing triples leaves the version node behind -- which is exactly what the user interface's delete does (factsheet/views.py:1394 removes (bundle, None, None) only). Guarding on the version alone therefore matched a bundle that was already gone: the patch wrote its fields back as untyped orphan triples, bumped the version and answered 200. The guard now asserts the bundle's own type beside the version. The test for that requirement wiped the whole graph and so passed over the hole; the new one deletes the way the interface deletes, and fails without the fix. The other: DRF's CharField trims by default, so both acronym strips were dead code whose comments claimed they were load-bearing. The rule moves onto the serializer field, stated rather than inherited, because uniqueness depends on it -- the value the check compares and the value the write stores are that one. The rest is naming and placement. `stamped` becomes `write_applied`, which is what the call site asks; `_representation` becomes `_bundle_response`, so it no longer differs from `_represent` by a suffix while returning a different type; `_delta` moves to oekg/bundles.py as `bundle_delta`, since it touched only that module; and the If-Match parser returns None for `*` rather than carrying a sentinel for two cases with one outcome. In the tests, one competitor helper replaces two identical closures and the fixture takes an `as_user`, so the ownership tests stop re-inlining the request. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 task
Maintainer's decision. The check was built on the patch path -- a rename onto an acronym another bundle holds was refused, compared like with like and bound inside the write -- and is removed again, because it belongs to the endpoint that makes the promise rather than being scattered across every write. That endpoint is the read side, where the ?acronym= lookup lives, and it has to close this for PATCH and for replace at once. So uniqueness holds from creation and not thereafter. The gap is pinned down rather than left to be discovered: PatchAcronymTest now asserts the 200 and the resulting ambiguity as characterisation, and says in its own comment that turning that test around is what closing the gap looks like. The changelog says it too, because a client would otherwise reasonably assume uniqueness. The `also_require` parameter goes with it. It had exactly one caller and no other condition needs binding into the guard yet, so keeping it would be an abstraction held open for a need that has moved to another slice -- GraphStore.guarded_modification already takes the pattern directly for whoever gets there first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Sep 11, 2026
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.
Summary of the discussion
Fourth of the twelve slices of the OEKG REST API, from the wayfinder map's spec and
tickets notes (
Spec/Tickets - OEKG REST API from the shape, derived fromWF-01..WF-12). It implements the ticket "Change one field, guarded by version" and
closes the limit the create slice (#2434 / #2435) named for itself.
The problem.
POSTandGETshipped in #2435, so a bundle could be created andread but not edited, and the only way to change one field was to create a new bundle.
Worse, editing at all opens a failure that no amount of shape validation catches: two
writers, last one wins, silently. A perfectly valid
PATCHcan destroy a changesomebody made a second earlier and both requests succeed. That gets materially more
likely with a programmatic API than with the browser UI, because scripts retry, run in
parallel and do not look at the screen first — and multi-owner bundles are reachable
today through
factsheet/management/commands/add_bundle_access.py, so this is nothypothetical-until-groups-land.
What was decided (WF-09). Defend the silent races; document the visible one. The
resource model already removes most conflicts — a
PATCHtouches only the predicates itnames, so two writers on different fields never collide, and field-level merge is the
semantics rather than an option. What is defended is the read-modify-write window that
shape validation itself opens (validation needs the post-state of a whole bundle, so a
patch has to read before it writes) and the patch whose target vanished in that window.
The mechanism. A monotonic version, carried as a triple pointing at the bundle
from a version node, bound in the update's own
WHERE. Three points are worth statingbecause each was forced rather than chosen:
sh:closedwith onlyrdf:typeignored, so
<bundle> oekg:version 3would invalidate every bundle this API writes.sh:closedconstrains a focus node's outgoing properties, so the bundle as anobject is untouched, and nothing in the shape targets the version node. Two things
then fall out for free: the shape needs no edit, and the version stays out of the read
payload without any filtering, because a read walks outward from the bundle.
transaction (the measured claim from WF-04, now re-proved in this repo's own suite), so
a
DELETE/INSERT/WHEREbinding the expected version cannot be overtaken between thetest and the change.
200and changesnothing whether or not the pattern matched, so a read-back is required. WF-09 specified
reading the version back; on contact with the code that turned out to be insufficient —
two writers guarding on version 3 both intend 4, so finding 4 afterwards does not
distinguish the loser from the winner, and the loser would be told
200. Each writetherefore also stamps an
oekg:writeTokenon the version node and reads that back.It costs no extra round trip, since the token comes back with the version the write
already has to read.
One deliberate divergence from WF-09, flagged for the record. WF-09's resolution
left
If-Matchoptional — server-side guard always on, client precondition opt-in,same-field overwrite last-write-wins — and explicitly offered the alternative: "If you
would rather require
If-Matchon every mutating call, that is a one-line change here— it makes clients strictly safer and strictly more annoying, and it is your call rather
than mine." The tickets note takes that offer ("Mutating calls require the
precondition. Missing precondition and stale precondition are different statuses, and
the server's own guard failing is a third"), so this PR requires it and answers three
codes:
If-Match— the client did not say which version it was editing428If-Matchnaming a version that is not the current one412409, retryableIf-Match: *is refused as absent. It is a legal header value and it satisfies theletter of the precondition while withholding the one thing the precondition is for. This
is a documented, deliberate deviation from RFC 9110.
Consequence for the spec note, which needs a text edit rather than a code change:
requiring the header retires the spec's line "Same-field overwrite stays
last-write-wins, written down rather than silently true" — a stale same-field writer
now gets
412— and the spec's endpoint table names no428.Also fixed here: the create-side race the previous slice named. #2435 checked the
acronym and then inserted, which is two requests, so two concurrent creates could both
pass the check and both land. The check is now bound inside the create's own write.
A rename is deliberately NOT checked for acronym uniqueness. The check was built here
and then removed again on review: it belongs to the endpoint that makes the promise —
the read side, where the
?acronym=lookup lives and where a pipeline re-identifiesits bundle — rather than scattered across every write, and that slice has to close it for
PATCHandreplaceat once. So uniqueness holds from creation and not thereafter: aPATCHcan currently give two bundles the same acronym, and a lookup by acronym then hastwo answers. The gap is pinned down rather than left to be discovered —
PatchAcronymTest.test_renaming_onto_a_taken_acronym_is_currently_allowedasserts the200and the resulting ambiguity as characterisation, and says in its own comment thatturning it around is what closing the gap looks like — and the changelog says it too,
because a client would otherwise reasonably assume uniqueness.
Found by the review pass, and it was live. The versioned guard originally bound only
the version node. Because that node points at the bundle, the UI's delete
(
factsheet/views.py:1394,oekg.remove((bundle, None, None))— outgoing triples only)leaves it behind, so the guard matched a bundle that was already gone: the patch wrote
its fields back as untyped orphan triples, bumped the version and answered
200. Theguard now asserts the bundle's own type beside the version. The first test for that
requirement wiped the whole graph and so passed over the hole; the replacement deletes
the way the interface deletes, and was watched failing against the reverted fix before
being kept. See #2439 for the residue this leaves in the UI's delete path.
What is deliberately not here. No history: that is the next slice, and it lands
directly after this one so that the later slices are not retrofitted with entries. Two
seams are left ready for it —
bundle_delta()already returns the removed and addedtriples a lossless diff needs, and
BundleVersiongives it the version-before /version-after pair, both without an extra read.
Type of change (CHANGELOG.md)
Features
PATCH /api/v0/scenario-bundles/<uid>/changes single fields of a scenariobundle without sending the rest. A field the payload does not name is left
alone; a set-valued field it does name is replaced whole, and emptying one the
shape requires is refused rather than silently applied. Only an owner may
write - a bundle with no recorded owner stays administrator-only. Every bundle
now carries a version, returned as an
ETagon reads, and a write must sendit back as
If-Match: without it the request is refused (428), with aversion that is no longer current it is refused (
412), and if the bundlemoves while the request is being prepared nothing is written and the answer is
409. The version check is part of the write itself, so two clients cannotboth succeed against the same version
(#2438)
Creating a bundle now binds the acronym uniqueness check inside the write, so
two simultaneous creates can no longer both take an acronym both of them found
free (#2438)
Changes
None beyond the entries above.
GETgaining anETagand_meta.versionis part ofthe same unreleased endpoint (#2435), so it is described in the feature entry rather
than as a change to shipped behaviour.
Bugs
None as a separate entry. Both races closed here are in code that has not been
released — the create-side one is #2435's, still unreleased, and the version-guard hole
was introduced and fixed inside this PR. Recording them under Bugs would tell a reader
of the release notes that something they were running was broken, which is not true.
Removed
None.
Documentation updates
None. The API's own documentation is a separate, currently blocked strand of the map:
WF-13 (where the API description comes from) gates WF-14 (developer documentation), and
drf-spectacularis already wired on an unmerged branch(
origin/feature-1971-api-documentation) which that ticket has to reconcile with. Theexisting
docs/oeplatform-code/web-api/oekg-api/oekg.yamldescribes the SPARQLpassthrough, not the bundle CRUD, so extending it here would deepen the thing WF-13 has
to undo. The reasoning is carried in the module docstrings meanwhile —
oekg/versioning.pystates why the version triple points inward and why the read-backneeds a token.
Workflow checklist
Automation
Closes #2439
PR-Assignee
CONTRIBUTING.md
CHANGELOG.md
mkdocs — deliberately not
done, see Documentation updates above
Reviewer
Reviewer Guidelines
Reviewer's guide to the diff
Three commits, in the order they happened: the slice, then the review's yield, then the
changelog link.
oekg/versioning.py(new, 186 lines)guarded_operationis the compare-and-set andwrite_appliedis the read-back. Note the two branches: an existing bundle guards on its version, a bundle written before this API existed guards on the absence of a version node and is taken to 1 by its first write. No data migration needed because of that second branch.oekg/graph_store.pyguarded_modification. Scoped byWITHrather thanGRAPHblocks, so the target graph is decided once instead of in three clauses — forgetting one of them writes the default graph, which in production is the graph the platform serves. It refuses a modification with nothing to change, because such a request is valid SPARQL the store answers200to, and that is the one answer a guarded write must never give without having written.oekg/bundles.pyfield_triples/linked_field_triples/bundle_delta— the field-scoped delta, which is what makes an unnamed key untouched rather than rewritten identically.linked_field_triplesremoves links only; the "unlink, never delete" rule and what it costs are stated in its docstring.bundle_subgraphnarrows a post-state to what a read would return, so validation is asked about the bundlethat will actually exist.oekg/permissions.py(new)ScenarioBundleAccessControl, not compared against a creator, since multi-owner bundles already exist.oekg/api_views.pypatch(). Existence first (matching the two-step delete's documented order), then ownership, then the precondition. Nothing is written before the guarded update.oekg/tests/test_bundle_patch.py(new, 51 tests)InterleavedWriteTestis the interesting class: the409window cannot be opened from a test client, so a competing write is injected inside the transport, using the API's own compare-and-set as the competitor.oekg/tests/test_graph_store.pytest_a_guard_that_does_not_match_is_still_a_success— the property that forces every caller to read back.Tests: 807 green (751 before this branch), against a real Fuseki and the fetched
shape artifacts. Without a store or artifacts the graph tests skip with a stated reason,
so a local run remains green.