Record every write to a bundle, and read the record back - #2441
Conversation
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>
DeploymentThis needs The migration is purely additive, and literally so: seven nullable columns and one Two things worth knowing before running it:
No Verifying the deploy — reads are public, so this needs no token: A paginated body with Reviewer's guide to the diffThree commits: the slice, then the review's yield, then the changelog.
Tests: 850 green (807 before this branch), against a real Fuseki and the fetched shape |
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 NULLis whattells 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_beforeandversion_aftercan.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
500for a write that succeeded would provoke exactly the retry thatcreates duplicates. The gap is named rather than hidden —
_meta.history_recordedand onestructured 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:
poisons the surrounding transaction and the next query raises, producing precisely the
500this ordering exists to avoid. The test for it uses a real over-long value ratherthan a mock, because a mocked exception cannot poison anything — the first version of that
test passed over the hole.
record_writesays it never raises, and at first it did not hold. OnlyDatabaseErrorwas caught while serialising the diff ran inside the sametry, so anrdflib failure would have escaped and turned a committed graph write into a
500. The netnow 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 payloadcolumns rather than
NULL, because the existing diff viewer hands both straight to acomponent that splits them and renders every row in one pass, so one null would blank the
whole page. And
bundle_existsasks with anASKand lets a store outage raise: "thereis 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
and
GET /api/v0/scenario-bundles/<uid>/history/reads it back. Previouslyonly 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 publicbut 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
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-spectacularis already wired on anunmerged branch that ticket has to reconcile with, and the existing
docs/oeplatform-code/web-api/oekg-api/oekg.yamldescribes the SPARQL passthroughrather than the bundle CRUD. The reasoning lives in the module docstrings meanwhile
—
oekg/history.pystates why the diff is stored untranslated and why the graphcommits 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
CONTRIBUTING.md
CHANGELOG.md
mkdocs — deliberately not
done, see Documentation updates above
Reviewer
Reviewer Guidelines