Reladomo semantics · drawn to the row

Bitemporality in pictures

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 row is a rectangle

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
quantity = 10 FROM_Z 1 Jan · THRU_Z ∞ · IN_Z P0 · OUT_Z ∞ as of 1 Jun, now → 10 before 1 Jan → null as of 1 Jun, before P0 → null: not recorded yet
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).
what we believed then what we believe now the moment we learned no row

The rule every write follows

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 rows

A 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
quantity = 10[1 Jan, ∞) × [P0, ∞) 10OUT_Z set to P1P1 10same value, new rowquantity = 55[1 Jun, ∞) × [P1, ∞) as of 1 Jun, P0 → 10as of 1 Jun, now → 55

Three rectangles are what H2 produced for this exact operation in 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.
quantitybusinessprocessing
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 tail

terminate 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
quantity = 10[1 Jan, ∞) × [P0, ∞) 10OUT_Z set to P1P1 10same value, new rowno rowas of 1 Jun, now → null as of 1 Jun, P0 → 10

Current as of 1 Jan still returns the row and as of 1 Jun returns null (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).
quantitybusinessprocessing
10[1 Jan, ∞)[P0, P1)
10[1 Jan, 1 Jun)[P1, ∞)

updateUntil: a window, not a tail

The 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
1 Sep quantity = 10[1 Jan, ∞) × [P0, ∞) 10OUT_Z set to P1P1 10same value, new row77[1 Jun, 1 Sep)10copied back as of 1 Sep → 10, not 77

Traced through 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.
quantitybusinessprocessing
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 window

incrementXUntil(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
1 Sep quantity = 10[1 Jan, ∞) × [P0, ∞) 10OUT_Z set to P1P1 10same value, new row11.510 + 1.510copied back

Traced through 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).
quantitybusinessprocessing
10[1 Jan, ∞)[P0, P1)
10[1 Jan, 1 Jun)[P1, ∞)
11.5[1 Jun, 1 Sep)[P1, ∞)
10[1 Sep, ∞)[P1, ∞)

What we said then, and what we should have said then

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.

businessDate — when the rule was valid processingDate · 12 Sep 2026 1 Jan 20101 Jun 2012∞ 10:0011:00∞ R8 → CLASSICrecorded 10:00 · closed 12:00 R8 → ECONOMYrecorded 12:00 · still current 12:00 what we said then: CLASSIC the audit row, written at 11:00 what we now think we should have said then: ECONOMY
Asserted by 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.


Where these pictures stop being true

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 correctionsetQuantity 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 msReladomo 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 futureExpiringRowsExistClosing 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.
setXUsingInPlaceUpdateRewrites 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.