Skip to content

The Occurrence Pattern

Some subjects need to record what was attempted, not only what is true. A guard refuses an operation, and the refusal itself is worth keeping — who asked, against which state, and why the answer was no.

That is awkward in a bitemporal table, because the table’s rows are versions of an entity and a refusal is not a new version of anything. Written naively it becomes the entity’s current state; excluded naively it disappears along with the entity.

The occurrence capability, added in common-module 16.0.0, makes a subject able to say that some of its rows record attempts rather than states.

Companion to Bitemporal Persistence and Universe Design.

The capability is a set of declarations a subject adds to itself, not a base class it inherits from:

  • the table declares the envelope columns — occ_kind, occ_outcome, occ_reason;
  • the payload declares how to carry that envelope;
  • the condition declares which outcome counts as a version;
  • the compiler checks that a subject claiming the capability really carries what it needs.

A subject that does not opt in is untouched. Every one of its rows remains a version, exactly as before.

Kotlin allows one superclass. A base class would take that slot, so a subject could not be tenant-scoped and occurrence-carrying — and it would settle, for all time and for every module, that occurrences are a kind of scoping.

Composition has neither problem. UniversalCondition is closed under wrapping, so OccurrenceUniversalCondition(ScopedUniversalCondition()) composes in either order with any future condition. This mirrors how scoping is already expressed: AbstractScopedUniverse adds no members of its own, it narrows three type bounds over a table base class, a metadata interface and a condition object.

An occurrence log’s refused rows must be excluded from choosing the current version, not from qualifying the row that choice produced.

That distinction is the whole pattern, and it is described in full under version selection versus row qualification. Expressed as a row filter it appears to work — reading one entity by id takes the newest row satisfying the filter — and then fails for listing, counting and finding by filter, which take the newest row of any kind and test it afterwards. The entity disappears from every list until a later committed row lands.

OccurrenceUniversalCondition therefore contributes to versionSelection, not to filter.

Reading the log does not apply the rule. A subject declares one precisely because some of its rows are not versions, and those rows are exactly what a caller reading the log has asked to see — so the ordinary history read is the log read. Modules that hand-rolled this pattern each carried a private history read to get around their own filter; there is nothing left for those to do.

The code a subject writes for a committed attempt and the code its condition selects by must be the same value, and the way to guarantee that is to derive one from the other:

override val universalCondition = occurrenceCondition(ScopedUniversalCondition())

Two declarations asked to agree are a defect waiting for the first implementor who overrides one and not the other. The failure is silent and severe: every commitment is written with a code version selection rejects, so the entity vanishes from every list-shaped read — the defect this pattern exists to prevent, reproduced by the pattern itself.

Ordering: occurrences chain, they do not branch

Section titled “Ordering: occurrences chain, they do not branch”

An update may say whether the subject’s versions form a chain or may branch.

Occurrence logs chain. An attempt is a decision taken on a state someone read; if that state has since been superseded, the attempt was decided on information no longer true. Filing it quietly beneath the newer row would hide that, because a log is read newest-first.

Subjects that do not ask keep the behaviour they have.

A refusal is an ordinary version; the filtering is what makes it a log

Section titled “A refusal is an ordinary version; the filtering is what makes it a log”

A refused row is a version like any other. It carries an exact copy of the payload and metadata of the version before it — a refusal changes nothing, that is what refusing means — plus the envelope saying which operation was attempted and why it was refused. Its own time coordinates and author are therefore the audit record: who attempted something that was not allowed, and when.

What makes the log a log is the reading side: the universal condition excludes refused rows from every ordinary read, so a caller asking for the subject’s state never sees one. Nothing about the writing side is special.

That distinction is easy to lose, and losing it was a real defect. The lineage of a subject is built over the rows that are there, not over the rows a condition makes visible. When the two were conflated, a refusal and the commitment that followed it both attached to the same committed version, because the committed-only condition hid the refusal from the write path as well as from readers. One version then had several successors:

PlantUML diagram

Resolved over the rows that are actually present, the same writes produce the chain the pattern describes:

PlantUML diagram

Reads are unaffected — they still return ATTACHED, because the condition still hides both refusals. What changes is that previous now means one thing only: the version this row supersedes.

A refusal must outlive the transaction it was refused in

Section titled “A refusal must outlive the transaction it was refused in”

This is the pattern’s sharpest edge.

When a guard refuses, the unit of work that asked is rolled back — that is what refusing means. A trace written inside that transaction goes down with it, so the log would record only the attempts nobody objected to.

The trace is therefore written in a transaction of its own, opened after the first has closed, and the caller still receives the original failure:

inTransaction(db) { universe.doThing()() } // the caller delineates its own unit of work
.onFailureInNewTransaction(db) { err -> … } // the trace, in a transaction of its own

The Result receiver is what makes the correct call read naturally: inTransaction(…) has returned by the time there is a Result to act on.

Nesting a transaction is refused by default

Section titled “Nesting a transaction is refused by default”

Opening a second pooled connection beside a transaction still holding its locks deadlocks if the two touch the same rows. It is a bug that reproduces only under load: in a test the pool has spare connections and the two transactions rarely collide, so the code looks correct until production, where the caller waits on itself and the stack says nothing about why.

inNewTransaction therefore refuses a usable ambient transaction on the same database:

suspend fun <T> inNewTransaction(
db: Database,
readOnly: Boolean? = null,
ambient: AmbientTransaction = AmbientTransaction.REFUSE,
block: TDBIO<T>
): Result<T>

Three things about that shape are deliberate.

It is a default, not a law. There are rare but real reasons to nest deliberately, and a platform that forbids them outright is worse than one that makes them explicit. AmbientTransaction.PERMIT opts out, and the refusal message says so — a refusal that does not tell you how to proceed on purpose is only half of informing.

The check is on the primitive, not on the recovery helper. The dangerous shape is not confined to occurrence recovery: inTransaction(db) { … inNewTransaction(db) { … } } reaches it without a Result anywhere, and a guard that only protected callers who took the safe road would miss the case most likely to reach production.

“Usable” is not “present.” Detached work — a completion launched on its own coroutine — inherits the launching scope without inheriting its connection. That is not a transaction to deadlock against, so those callers are unaffected and need say nothing.

onFailureInNewTransaction keeps a check of its own, and it is not duplication. That helper logs and drops a failed recovery by design, because a trace that failed to write must not replace the error it was recording — so a returned refusal would be swallowed along with the diagnostic. A failed recovery and a misused helper are different things; only the second is a bug in the calling code, and only the second is thrown.

Static analysis flags the lexical form of the nesting as a warning; it cannot see the cross-function case, which is why the runtime check exists too.

A failure to record is logged and dropped. Whether the trace succeeded is not the caller’s answer and must not become one — a failed trace that replaced the original error would hide the very thing it was recording.

The log read requires a stated ceiling, and refuses rather than truncating when the log is longer.

The ceiling has no default, and that is load-bearing. A default gets chosen once, by whoever wrote the helper, and inherited by every call site that never considered the question — which is how unbounded reads arrive in a system. Stating the number makes a breach falsify a belief the caller expressed.

Refusal rather than truncation matters more here than elsewhere: for a log, the entries that a silent cap drops are the oldest ones, which are exactly the entries nobody remembers independently. A prefix that reads like the whole log is the worst available answer.

The same read is bounded by the same rule as Universe.listAll, and neither reads further than the allowance plus the one row that proves it was exceeded.

PieceHomeWhy there
Paged fold with an enforced ceilingUniverseGeneric — every universe can use it
Refusal append, wire projectionOccurrenceUniverseA universe without the trait cannot reach them
Recovery across a transaction boundaryTransactionExtPolicy, not persistence: one DBIO cannot span two transactions

The split at the transaction boundary is the one worth understanding. The occurrence half is a capability DBIO; the policy half is a Result extension, because a DBIO cannot span the two transactions this needs and the record has to survive the rollback.


Copyright: (c) Arda Systems 2025-2026, All rights reserved