Reladomo semantics · drawn to the row
Every bitemporal row is a rectangle on a grid of two dates. Every write closes some rectangles and adds others. Once you can see the rectangles, the API stops surprising you.
A bitemporal Reladomo object carries two date ranges. businessDate says when a
fact was true in the world. processingDate says when the database held that
belief. In the DiffBalance test model they are four columns: FROM_Z and
THRU_Z for business, IN_Z and OUT_Z for processing. Put
business time on the horizontal axis and processing time on the vertical axis, and each row
covers a rectangle.
Both ranges are half-open: FROM_Z ≤ t < THRU_Z. A query "as of" a business
date and a processing date is a point, and at most one rectangle may contain it. A row
that is still believed has OUT_Z set to Reladomo's infinity sentinel
(9999-12-01 23:59:00). "As of now" asks for exactly that value. It does not use the
half-open test, because nothing is ever less than infinity.
DiffBalance b = new DiffBalance(jan1);
b.setQuantity(10.0);
b.insert(); // processing time P0
insert opens one rectangle. FROM_Z is the object's business
date, IN_Z is the transaction's processing time, and both upper bounds are infinity
(GenericBiTemporalDirector.insert; pinned by
infinity_sentinel_is_preserved_exactly_on_open_rows).Processing time only moves forward. A write at processing time P1 does exactly two
things. It sets OUT_Z = P1 on each row it supersedes, and it inserts new rows with
IN_Z = P1. It never edits an existing row's values or its IN_Z. That has
a consequence people miss: a row can only be closed whole, so any part of it that did
not change is inserted again. Same value, new row.
Every picture below starts from the fixture the differential tests use: quantity
10.0 inserted as of 1 Jan 2026 at P0. Then one operation runs on the
object found as of 1 Jun 2026, at a later P1. The tests pin both processing times
with MithraTransaction.setProcessingStartTime. Use the buttons to replay each write
one step at a time.
update: one setter, three rowsA plain setter is an update from the as-of date to infinity. On a row that started earlier, the director closes that row, re-inserts the unchanged part before the as-of date (the head), and inserts the new value from the as-of date on.
found.setQuantity(55.0); // as of 1 Jun, at P1
BoundWritePathTest.a_bound_update_produces_the_same_bitemporal_shape_as_h2 —
insert 10.0 as of 1 Jan, setQuantity(55.0) as of 1 Jun — and
finding 21
records those three rows literally. That test compares all four boundaries of every version
against H2. A point on the 1 Jun edge belongs to the rectangle that starts there.| quantity | business | processing |
|---|---|---|
| 10 | [1 Jan, ∞) | [P0, P1) |
| 10 | [1 Jan, 1 Jun) | [P1, ∞) |
| 55 | [1 Jun, ∞) | [P1, ∞) |
Finding 21 was this picture without step 2. The adapter wrote the superseded
row back without applying the OUT_Z change, so it stayed [P0, ∞) and
two rows claimed to be current. The row count, the values and the business boundaries all
matched. Only a comparison of all four boundaries caught it.
terminate: keep the head, drop the tailterminate ends existence from the as-of date on. It works like an update with no
new value: close the row, re-insert the head, and insert nothing after it.
found.terminate(); // as of 1 Jun, at P1
terminate_from_the_middle_then_reinsert_later_round_trips). A stored version must
carry a finite OUT_Z (BoundWritePathTest). Terminating as of the row's
own FROM_Z leaves no head, so the row is only closed
(chained_terminate_then_reinsert_round_trips).| quantity | business | processing |
|---|---|---|
| 10 | [1 Jan, ∞) | [P0, P1) |
| 10 | [1 Jan, 1 Jun) | [P1, ∞) |
updateUntil: a window, not a tailThe generated setXUntil(value, until) changes [asOf, until) and nothing
else. until is exclusive, so on the until date itself the old value is
back.
found.setQuantityUntil(77.0, sep1); // as of 1 Jun
GenericBiTemporalDirector.updateUntil:
splitTailEnd copies [1 Sep, ∞) back, the head is cut at 1 Jun, and the
window is inserted. BitemporalOperationMatrixTest.updateUntil_round_trips_identically
runs this fixture exactly — setQuantityUntil(77.0, 1 Sep) as of 1 Jun — and checks
that DynamoDB stores whatever H2's director produced, boundary for boundary. That the old value
comes back after the window is asserted on values by the CRM demo's price book: 100.00 before,
80.00 inside an updateUntil window, 100.00 again after it. The three new rows share
IN_Z but not FROM_Z, so their DynamoDB sort keys
v1#P#…#B#… stay distinct.| quantity | business | processing |
|---|---|---|
| 10 | [1 Jan, ∞) | [P0, P1) |
| 10 | [1 Jan, 1 Jun) | [P1, ∞) |
| 77 | [1 Jun, 1 Sep) | [P1, ∞) |
| 10 | [1 Sep, ∞) | [P1, ∞) |
incrementUntil: a delta inside the windowincrementXUntil(delta, until) adds to a numeric attribute over
[asOf, until). The rectangles are the same as for updateUntil. The
difference is where the number comes from. The window row is a copy of the row it overlaps, with
the delta added to that copy, so it reads 10 + 1.5. Outside the window, values are
copied unchanged.
found.incrementQuantityUntil(1.5, sep1); // as of 1 Jun
incrementMultipleWrappersUntil and
getObjectToInsertForIncrementUntil. That code trims each overlapping row to the
window and adds the delta to its copy, so a window across two rows with different values
increments each one. BitemporalOperationMatrixTest.incrementUntil_round_trips_identically
runs this fixture exactly — incrementQuantityUntil(1.5, 1 Sep) as of 1 Jun — but it
asserts store agreement, not the arithmetic. The values are asserted on the CRM demo instead: a
100.00 price with incrementUnitPriceUntil(5.00, 1 Aug) reads 105.00 inside the
window and 100.00 after it
(RequiredDemonstrationsTest.should_apply_updateUntil_and_incrementUntil_on_price_book_entry).| quantity | business | processing |
|---|---|---|
| 10 | [1 Jan, ∞) | [P0, P1) |
| 10 | [1 Jan, 1 Jun) | [P1, ∞) |
| 11.5 | [1 Jun, 1 Sep) | [P1, ∞) |
| 10 | [1 Sep, ∞) | [P1, ∞) |
The car
classifier demo stores decision-table rules bitemporally. On 12 Sep 2026 at 10:00, rule R8 is
recorded as valid from 2010: nineties cars are CLASSIC. At 11:00 a 1994 MX-5 is
classified as of 1 Jun 2012, and an audit row saying CLASSIC is written. At 12:00 we
learn the rule was wrong from the start and correct it to ECONOMY, as of its own
start date.
ClassifierTest.should_diverge_from_audit_after_processing_date_correction: R8 as of
2012 at 11:00 is CLASSIC, R8 as of 2012 now is ECONOMY, and the audit row
still says CLASSIC.Nothing about 2012 changed. What changed is what we know about 2012. So the same business date has
two honest answers, one for each processing date. The point at 11:00 is what we said
then. The point at infinity is what we now think we should have said then.
Because the correction was made as of the row's own FROM_Z, there is no head to keep:
the whole rectangle is closed and recorded again.
Each picture above holds for the fixture as drawn. These are the cases where the same call gives different rectangles. Each one has already fooled a test in this repository.
| A late, unbounded correction | setQuantity as of March, after a June update, covers [March, ∞). It closes the June row too, and current June becomes the March value; the June value survives only as history. setQuantityUntil(15.0, june) fills only the gap. Finding 10, late_arriving_fact_* tests. |
|---|---|
| Two writes within 10 ms | Reladomo rounds processing time down to 10 ms. If a superseded row's IN_Z equals the new transaction's time, the row is deleted instead of closed, and no history rectangle remains. Finding 11, same_millisecond_* tests. |
No futureExpiringRowsExist | Closing an open-ended row that has already started also cuts its THRU_Z back to the transaction time plus one day (inactivateObject). The CRM demo's Customer omits the flag, and its own history dump shows the consequence: a row closed at 2025-03-10 09:00 carries business thru 2025-03-11 09:00. DiffBalance and the classifier's ClassificationRule set it, as drawn here. |
setXUsingInPlaceUpdate | Rewrites the payload of the existing rectangle and keeps all four timestamps. It is meant for repair and deliberately leaves no history. Finding 6. |
Reladynamo draws none of these rectangles itself. The director hands the persister plain inserts
and OUT_Z updates, and the
differential
suite checks all four boundaries of every rectangle against Reladomo running on H2.