Extraction Run Store
Durable store of extraction runs, and the lifecycle state machine that governs them.
The state machine
A run starts RUNNING and ends in one of COMPLETED, FAILED or CANCELLED. There are no other edges: a terminal run never re-opens, and a run never moves from one terminal state to another.
RUNNING ──▶ COMPLETED
──▶ FAILED
──▶ CANCELLEDThe two write methods split along that line. save records a run and rejects anything that is not RUNNING, so a terminal status cannot enter the store through the door that also accepts new keys. transition is the only writer of a terminal status, and it is compare-and-set: it moves a run out of RUNNING or it does nothing.
Three writes, three kinds of state, one owner each
save owns the run header: the profile, the source revisions, the digests, the requested model, the counts, the failures — everything on ExtractionRun except its invocation list. It is compare-and-set on ExtractionRun.version. recordInvocation owns invocation rows: it is the only method that creates, updates or locks one, keyed by ExtractionInvocationRecord.id, and it is insert-or-compare on that key alone — a write to one attempt's row never touches another's, and never touches the header's version. transition owns the terminal write.
A header save ignores whatever invocation snapshot it is handed. save never creates, updates or deletes an invocation row, however non-empty ExtractionRun.invocations is on the run it is given. This was not always true — see save's own KDoc for the shared-generation defect that a merged write produced, and why the fix gives invocation rows their own door and their own key, with compare-and-set scoped to that key alone.
What COMPLETED asserts, and who may write it
COMPLETED means every product the run's request called for is either durably persisted or terminally disposed. The store cannot check that — it holds run headers, not products — so it does the next best thing and makes the claim reachable through exactly one narrow call whose precondition is written down:
on the legacy path, the coordinator calls it once
persistAndProjecthas returned;on the DICE #68 commit path, the commit transaction calls it, and only the commit whose cumulative outcomes bring every requested product to persisted or terminally disposed.
A run whose persistence never finished stays RUNNING and is retryable under compare-and-set. A commit that persists some products and leaves others outstanding leaves the run RUNNING too, so a terminal run never has re-committable products behind it. A run with zero products completes vacuously: there was nothing to persist, so the coverage claim holds.
FAILED and CANCELLED carry no such precondition. A run that could not finish, or that was stopped, terminalizes independently of whether anything was persisted.
Idempotency
Every terminal write carries a fingerprint of its payload (ExtractionRunTransition.fingerprint). A store records the fingerprint of the write that terminalized a run, and a second write against that run is decided by comparison, never by overwrite:
same fingerprint — the same terminal write, retried. It replays as success (ExtractionRunTransitionOutcome.REPLAYED) and changes nothing.
different fingerprint — a second, incompatible claim about how the run ended. Rejected with ExtractionRunConflictException.
That is insert-or-compare. DICE's existing MERGE … SET stores upsert by overwriting, which is safe for a record that is still being written and wrong for one that is finished: it would let a late or duplicated writer silently rewrite how a run ended, and the audit would carry the last write rather than the true one. No method here overwrites a terminal run.
Two mechanisms from the idempotency prior art are deliberately not adopted. There is no epoch or writer generation of the Kafka kind: epochs fence a zombie writer across systems, and the concurrency this contract has to survive is two writers racing on one row, which the compare-and-set inside a single store transaction already decides. And there is no key expiry of the Stripe kind: a run header is a permanent audit row, and pruning idempotency records after a day would delete the evidence rather than the bookkeeping.
Every read is tenant-scoped and bounded
A run store grows once per extraction forever, so there is no unbounded read here. Every page takes a positive limit, and the reads that can span a long history also take an optional since window.
How long a run stays readable is the store's own policy, not this contract's: this contract says nothing about retention, and the reference implementation caps how many runs it keeps and forgets the oldest ended ones past that cap.
Scope is pushed down, never applied afterwards. An implementation must restrict to the tenant inside the query and then limit. Fetching limit rows and filtering them by tenant afterwards would return fewer rows than asked for — or none — whenever a busy neighbouring tenant occupies the head of the index, and the caller cannot tell that from an empty tenant. This is why none of the scoped reads has a default body: a default that filtered in memory would be inherited silently by every backend that forgot to override it.
The ContextId-typed overloads do have default bodies, and they are a different thing: they forward to the String-typed method that is the override point. They cannot return the wrong rows, because they do not filter. The split exists because ContextId is a Kotlin value class, so any method taking one compiles to a mangled JVM name that Java callers cannot reach.
Cross-tenant reads fail closed. Every lookup, page, chain walk and aggregate is scoped to the tenant it was asked about. A run id that exists in two tenants is two runs, and a read against one never returns the other's. The chain walk stops rather than crossing: a parent reference that resolves only in another tenant is treated as unresolved. Slice 8 proves this against a real graph; here it is the contract every implementation is held to.
Ordering
Pages come back newest first by ExtractionRun.startedAt, tie-broken by run id ascending. The tie-break is what makes a page repeatable: two runs started in the same millisecond would otherwise come back in whatever order the backend felt like, and a caller paging through would see one of them twice or neither.
EXPERIMENTAL. The shape may still change while extraction runs (DICE #67) land.
Inheritors
Functions
Walks the parent chain up from the run at key, nearest ancestor first. The run itself is not in the result.
childrenOf for Kotlin callers holding typed references.
The runs whose immediate parent is parentRunId — one hop down the parent axis, in one tenant.
The run stored under key, or null.
Every attempt recorded against the run, in plan order: call 0 before call 1, and within a call, first attempt before second.
Records one attempt at one model call against a running run. This is the only door onto invocation state — save writes header fields only and never creates, updates or deletes an invocation row, however non-empty the invocation list on the run it is handed.
runsInContext for Kotlin callers holding a typed tenant.
One tenant's runs, newest first.
runsOfRoot for Kotlin callers holding typed references.
Every run in one lineage: those whose ExtractionRunLineage.rootRunRef is rootRunId, including the root itself.
Records a running run's header, inserting it under ExtractionRun.key or updating the one already there. This writes header fields only — it is not a door onto invocation state. recordInvocation is the only method that creates, updates or locks an invocation row; whatever ExtractionRun.invocations holds on run is not written anywhere and does not affect what a save accepts, rejects or replays as a no-op. A caller building run from a previous read does not need to strip that field, but nothing is lost either way if it does.
Ends a run: compare-and-set from RUNNING to the transition's terminal status.