Skip to content

Record every write to a bundle, and read the record back - #2441

Merged
jh-RLI merged 3 commits into
developfrom
feature-oekg-api-write-history
Sep 11, 2026
Merged

jh-RLI merged 3 commits into
developfrom
feature-oekg-api-write-history

Conversation

@jh-RLI

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

Copy link
Copy Markdown
Contributor

Summary of the discussion

Fifth of the twelve slices of the OEKG REST API, from the wayfinder map's tickets note
(slice "Every write leaves a record, and the record reads back", derived from WF-08).
It lands deliberately early — directly after the guarded patch — so that every later
slice writes its own entries instead of being retrofitted with them.

The audit trail stops being silent. Today only the browser's update path writes an entry;
creates and every API write leave nothing behind. The rule here is flat — the history is
the log of writes
— because a per-operation judgement about what is worth recording is
what makes a trail patchy, and "no entry" would then be ambiguous between created and
never touched.

The existing model is extended rather than replaced. Two histories split by which
client did the writing is this table's own completeness problem in a new form, so the API
writes here. That leaves two generations of row and they are deliberately not reconciled:
pre-API rows keep their JSON-LD strings inside a JSON column, untouched by any data
migration, because rewriting them in place would edit the one table whose value is being an
unaltered record — and would break the diff viewer that reads them. verb IS NULL is what
tells the generations apart. New rows fill the columns that make the table answerable:
which operation, which resource, which version pair. A timestamp cannot say which change
produced a given state
; version_before and version_after can.

The stored diff is lossless and untranslated — no field names, no serializer
vocabulary — so a later shape change never re-interprets an old row. Field names are
computed instead at read time, from the same field table that builds the triples, so a
rendering cannot drift from what it describes. A triple that names no field is reported
under a null field rather than dropped: the type and label a minted contact brings with it
are not bundle fields, and dropping them would make a summary look complete when it is not.
Where a predicate is shared — frameworks and models both hang off has-part — the type in
the diff decides, and an unlinked part carries no type, so the field is reported as unknown
rather than guessed at. Every rendered change also names its predicate, so an entry that
could not be attributed still says what it was about.

The graph commits first and a failed history write does not fail the request. The two
stores cannot share a transaction; the data is the truth and the history is the note about
it, so answering 500 for a write that succeeded would provoke exactly the retry that
creates duplicates. The gap is named rather than hidden — _meta.history_recorded and one
structured log line — because a phantom entry would be worse: it looks like truth.

Two things about that which are worth the reviewer's attention, because both were found by
reviewing rather than by writing:

  • The insert takes a savepoint, and that is not decoration. Without it a real rejection
    poisons the surrounding transaction and the next query raises, producing precisely the
    500 this ordering exists to avoid. The test for it uses a real over-long value rather
    than a mock, because a mocked exception cannot poison anything — the first version of that
    test passed over the hole.
  • record_write says it never raises, and at first it did not hold. Only
    DatabaseError was caught while serialising the diff ran inside the same try, so an
    rdflib failure would have escaped and turned a committed graph write into a 500. The net
    now covers every exception, which is load-bearing rather than defensive.

Reads are public but per bundle and paginated with a ceiling, and name the actor by
username. WF-07's reasoning for public reads does not transfer: it rests on the SPARQL
endpoint already serving the same data, and this is in no graph. A per-bundle log is a
record about a bundle; a global one is a profile of a person's activity — which is what the
existing global dump is, and it is filed separately.

Two smaller things found while wiring it up. API rows write "" into the legacy payload
columns rather than NULL
, because the existing diff viewer hands both straight to a
component that splits them and renders every row in one pass, so one null would blank the
whole page. And bundle_exists asks with an ASK and lets a store outage raise: "there
is no such bundle" and "I could not find out" are different answers, and only one of them is
a 404.

Type of change (CHANGELOG.md)

Features

  • Every write to a scenario bundle through the REST API now leaves a record,
    and GET /api/v0/scenario-bundles/<uid>/history/ reads it back. Previously
    only the browser's edit path recorded anything, so creates and API writes were
    silent. An entry says which operation it was, who made it, when, which version
    it produced, and which triples changed; the reader renders those as field
    names and returns the raw triples on ?expand=triples. The history is public
    but per bundle and paginated, and names the actor by username rather than by
    internal id. Entries written before this release are kept exactly as they are
    and read as coming from before the API
    (#2441)

Changes

  • The scenario-bundle changelog page under Factsheets now also lists changes
    made through the REST API. Those rows show who changed what and when, but no
    side-by-side diff: the API records what changed in a newer form that this page
    does not read yet
    (#2441)

Bugs

None as a separate entry. Nothing released was broken: both defects fixed here —
the un-savepointed insert and the too-narrow exception net — were introduced and
fixed inside this PR. Recording them under Bugs would tell a reader of the release
notes that something they were running had failed, which is not true.

Removed

None.

Documentation updates

None, for the same reason as #2438: 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), drf-spectacular is already wired on an
unmerged branch that ticket has to reconcile with, and the existing
docs/oeplatform-code/web-api/oekg-api/oekg.yaml describes the SPARQL passthrough
rather than the bundle CRUD. The reasoning lives in the module docstrings meanwhile
oekg/history.py states why the diff is stored untranslated and why the graph
commits first.

This is now the second slice to leave documentation open, and the gap is the map's
actual frontier.
WF-13 has been the next unblocked ticket since 2026-09-08 and
nothing has been done on it, while each slice adds endpoints that will have to be
described afterwards.

Workflow checklist

Automation

Closes #2442

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

jh-RLI and others added 3 commits September 11, 2026 11:41
The audit trail stops being silent. Today only the browser's update path writes
an entry; creates and every API write leave nothing behind. The rule here is
flat -- the history is the log of writes -- because a per-operation judgement
about what is worth recording is what makes a trail patchy, and "no entry"
would then be ambiguous between created and never touched.

The existing model is extended rather than replaced. Two histories split by
which client did the writing is this table's own completeness problem in a new
form, so the API writes here. That leaves two generations of row and they are
deliberately not reconciled: pre-API rows keep their JSON-LD strings inside a
JSON column, untouched by any data migration, because rewriting them in place
would edit the one table whose value is being an unaltered record -- and would
break the diff viewer that reads them. `verb IS NULL` is what tells the
generations apart. New rows fill the columns that make the table answerable:
which operation, which resource, which version pair. A timestamp cannot say
which change produced a given state; version_before and version_after can.

The stored diff is lossless and untranslated -- no field names, no serializer
vocabulary -- so a later shape change never re-interprets an old row. Field
names are computed instead at read time, from the same field table that builds
the triples, so a rendering cannot drift from what it describes. A triple that
names no field is reported under a null field rather than dropped: the type and
label a minted contact brings with it are not bundle fields, and dropping them
would make a summary look complete when it is not. Where a predicate is shared
-- frameworks and models both hang off has-part -- the type in the diff decides,
and an unlinked part carries no type, so the field is reported as unknown
rather than guessed at.

The graph commits first and a failed history write does not fail the request.
The two stores cannot share a transaction; the data is the truth and the
history is the note about it, so answering 500 for a write that succeeded would
provoke exactly the retry that creates duplicates. The gap is named rather than
hidden -- `_meta.history_recorded` and one structured log line -- because a
phantom entry would be worse: it looks like truth. The insert takes a savepoint,
which is not decoration: without it a real rejection poisons the surrounding
transaction and the next query raises, producing precisely the 500 this
ordering exists to avoid. The test for that uses a real over-long value rather
than a mock, because a mocked exception cannot poison anything.

Reads are public but per bundle and paginated with a ceiling, and name the
actor by username. WF-07's reasoning for public reads does not transfer: it
rests on the SPARQL endpoint already serving the same data, and this is in no
graph. A per-bundle log is a record about a bundle; a global one is a profile
of a person's activity.

Two smaller things found while wiring it up. API rows write "" into the legacy
payload columns rather than NULL, because the existing diff viewer hands both
straight to a component that splits them and renders every row in one pass, so
one null would blank the whole page. And `bundle_exists` asks with an ASK and
lets a store outage raise: "there is no such bundle" and "I could not find out"
are different answers, and only one of them is a 404.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes from reviewing the slice.

`record_write` says it never raises, and it did not hold: only DatabaseError
was caught, while serialising the diff ran inside the same try. An rdflib
failure would have escaped and turned a committed graph write into a 500 --
the single outcome this whole ordering exists to avoid. The net now covers
every exception, which is load-bearing rather than defensive, and the docstring
says why.

The migration is now literally what it claimed to be. Nothing writes NULL into
the two legacy payload columns any more -- API rows put an empty string there
so the existing diff viewer keeps working -- so the two DROP NOT NULLs bought
nothing and are gone; it is seven nullable columns and one index, with no
ALTER on a production audit table. The index moves from `db_index=True` to a
declared btree, because on a CharField that shortcut also builds a second
varchar_pattern_ops index for LIKE queries nothing here makes. The deploy note
that CREATE INDEX is not concurrent is on the migration.

The shared answers move out of `api_views` into `oekg/api_support.py`. The
second view module had to import five names from the first, which is the
signal: throttles, refusals and the existence check are not the bundle
endpoint's property, and five more view modules are coming. Left where they
were, `api_views` would have become a utility module by accident.

Also: every rendered change now names its predicate. Without it the entries the
renderer cannot attribute to a field were a list of bare values with nothing
saying what they were values of -- barely better than dropping them, which is
the thing that bucket exists to avoid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The entry had no PR number, which CONTRIBUTING asks for.

It also said nothing about a page that visibly changes: the scenario-bundle
changelog under Factsheets reads every row of this table, so it now lists API
writes too -- with the columns it understands and an empty diff, because the
API records what changed in the newer columns that page does not read. Worth
saying out loud rather than leaving somebody to find an empty diff and wonder
what broke.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jh-RLI

jh-RLI commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Deployment

This needs python manage.py migrate — Django migration factsheet.0012. It is
the first migration this API work has needed; #2435 and #2438 needed none, so the
deploy story changes here. Without it every bundle write fails at the point it tries
to record itself.

The migration is purely additive, and literally so: seven nullable columns and one
index. Verified with sqlmigrate — no UPDATE, no DEFAULT backfill, and nothing
altered. Rows written before the API keep everything they have. This is deliberately
unlike modelview.0066, where AddField on an auto_now_add column silently stamped
every existing row.

Two things worth knowing before running it:

  • The CREATE INDEX is not CONCURRENT, so it takes a write lock on the audit
    table for as long as it runs. That table holds one row per scenario-bundle edit made
    through the browser since 2023, so it is short — but it is a lock on the audit table,
    not nothing.
  • The index is declared as a plain btree rather than db_index=True, because on a
    CharField that shortcut also builds a second varchar_pattern_ops index for LIKE
    queries nothing here makes. bundle_id had no index at all before this.

No alembic upgrade head, no new requirements, no configuration change. The standing
requirement from #2428 is unchanged: python manage.py fetch_oekg_shapes must have run,
or the bundle endpoints answer 503.

Verifying the deploy — reads are public, so this needs no token:

curl -s https://openenergyplatform.org/api/v0/scenario-bundles/<uid>/history/

A paginated body with count, next and results means the slice is live. An empty
results on a bundle the browser wrote is correct rather than a fault: nothing recorded
its earlier edits in the new form.

Reviewer's guide to the diff

Three commits: the slice, then the review's yield, then the changelog.

File What to look at
factsheet/models.py + migrations/0012 The seam. Two generations of row in one table, told apart by verb IS NULL, with the reasoning for not reconciling them in the model docstring.
oekg/history.py record_write — the ordering, the savepoint, and the contract that it never raises. changed_fields — rendering computed at read time, and what it does with a triple it cannot attribute.
oekg/history_views.py Public, per bundle, paginated with a ceiling. The docstring says why WF-07's public-reads argument does not transfer.
oekg/api_support.py New: the throttles, the shared refusals and the existence check. Extracted when the second view module had to import five names from the first, and five more view modules are coming.
oekg/api_views.py Where the two record_write calls sit — after the graph has committed, outside the try that could still turn an error into a response.
oekg/tests/test_bundle_history.py 41 tests. RealHistoryFailureTest is the one to read: it uses a real over-long value, because a mocked exception cannot poison a transaction and so cannot test the savepoint.

Tests: 850 green (807 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.

@jh-RLI jh-RLI self-assigned this Sep 11, 2026
@jh-RLI
jh-RLI merged commit 6382e4d into develop Sep 11, 2026
5 checks passed
@jh-RLI
jh-RLI deleted the feature-oekg-api-write-history branch September 11, 2026 10:10
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.

Every write to a scenario bundle leaves a record, and the record reads back

1 participant