Skip to content

Feature oekg api patch version guard - #2438

Merged
jh-RLI merged 4 commits into
developfrom
feature-oekg-api-patch-version-guard
Sep 11, 2026
Merged

Feature oekg api patch version guard#2438
jh-RLI merged 4 commits into
developfrom
feature-oekg-api-patch-version-guard

Conversation

@jh-RLI

@jh-RLI jh-RLI commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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 from
WF-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. POST and GET shipped in #2435, so a bundle could be created and
read 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 PATCH can destroy a change
somebody 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 not
hypothetical-until-groups-land.

What was decided (WF-09). Defend the silent races; document the visible one. The
resource model already removes most conflicts — a PATCH touches only the predicates it
names, 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 stating
because each was forced rather than chosen:

  • The triple points inward. The bundle shape is sh:closed with only rdf:type
    ignored, so <bundle> oekg:version 3 would invalidate every bundle this API writes.
    sh:closed constrains a focus node's outgoing properties, so the bundle as an
    object 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.
  • The guard is inside the write, not in front of it. One SPARQL request is one
    transaction (the measured claim from WF-04, now re-proved in this repo's own suite), so
    a DELETE/INSERT/WHERE binding the expected version cannot be overtaken between the
    test and the change.
  • That guard produces no signal of its own. The store answers 200 and changes
    nothing 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 write
    therefore also stamps an oekg:writeToken on 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-Match optional — 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-Match on 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:

Situation Code
no If-Match — the client did not say which version it was editing 428
an If-Match naming a version that is not the current one 412
the server's own guard fired: the bundle moved between the read and the write 409, retryable

If-Match: * is refused as absent. It is a legal header value and it satisfies the
letter 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 no 428.

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-identifies
its bundle — rather than scattered across every write, and that slice has to close it for
PATCH and replace at once. So uniqueness holds from creation and not thereafter: a
PATCH can currently give two bundles the same acronym, and a lookup by acronym then has
two answers. The gap is pinned down rather than left to be discovered —
PatchAcronymTest.test_renaming_onto_a_taken_acronym_is_currently_allowed asserts the
200 and the resulting ambiguity as characterisation, and says in its own comment that
turning 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. The
guard 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 added
triples a lossless diff needs, and BundleVersion gives 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 scenario
    bundle 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 ETag on reads, and a write must send
    it back as If-Match: without it the request is refused (428), with a
    version that is no longer current it is refused (412), and if the bundle
    moves 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 cannot
    both 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. GET gaining an ETag and _meta.version is part of
the 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-spectacular is already wired on an unmerged branch
(origin/feature-1971-api-documentation) which that ticket has to reconcile with. The
existing docs/oeplatform-code/web-api/oekg-api/oekg.yaml describes the SPARQL
passthrough, 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.py states why the version triple points inward and why the read-back
needs a token.

Workflow checklist

Automation

Closes #2439

PR-Assignee

  • 🐙 Follow the workflow in
    CONTRIBUTING.md
  • 📝 Update the
    CHANGELOG.md
  • 📙 Update the documentation on
    mkdocs — deliberately not
    done, see Documentation updates above

Reviewer

  • 🐙 Follow the
    Reviewer Guidelines
  • 🐙 Provided feedback and show sufficient appreciation for the work done

Reviewer's guide to the diff

Three commits, in the order they happened: the slice, then the review's yield, then the
changelog link.

File What to look at
oekg/versioning.py (new, 186 lines) The whole mechanism. The module docstring carries the reasoning; guarded_operation is the compare-and-set and write_applied is 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.py One method, guarded_modification. Scoped by WITH rather than GRAPH blocks, 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 answers 200 to, and that is the one answer a guarded write must never give without having written.
oekg/bundles.py field_triples / linked_field_triples / bundle_delta — the field-scoped delta, which is what makes an unnamed key untouched rather than rewritten identically. linked_field_triples removes links only; the "unlink, never delete" rule and what it costs are stated in its docstring. bundle_subgraph narrows a post-state to what a read would return, so validation is asked about the bundlethat will actually exist.
oekg/permissions.py (new) One function, because four more endpoints are about to ask the same question. Ownership is asked of ScenarioBundleAccessControl, not compared against a creator, since multi-owner bundles already exist.
oekg/api_views.py The order of operations in patch(). 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) InterleavedWriteTest is the interesting class: the 409 window 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.py Five tests on the new builder, including test_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.

jh-RLI and others added 2 commits September 10, 2026 17:27
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>
@jh-RLI jh-RLI self-assigned this Sep 10, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
@jh-RLI
jh-RLI merged commit bab4321 into develop Sep 11, 2026
5 checks passed
@jh-RLI
jh-RLI deleted the feature-oekg-api-patch-version-guard branch September 11, 2026 09:12
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.

Add API to edit existing scenario bundles

1 participant