Bitemporal Storage
Bitemporal Storage
Bitemporal storage tracks two independent time dimensions for every record: valid time (when a fact was true in the world) and transaction time (when the system learned about it) — so a later correction never erases what you actually knew at decision time.
bitemporal For: governance & audit
Valid time vs transaction time
Traditional storage overwrites: when a value changes, the old value is lost and the record of what you believed disappears with it. Bitemporal storage separates the two clocks so a backdated correction and the original belief can coexist. Writes are append-only — a correction is a new record, not an edit.
| Dimension | Answers | Why it matters |
|---|---|---|
| Valid time | When was this fact true in the world? | Lets a correction apply to a past date without rewriting history. |
| Transaction time | When did the system learn it? | Distinguishes “what we knew on May 2” from “what we know now” — the key to a defensible audit. |
How it works
-
Record a fact with a
valid_timeand atransaction_timeviaBitemporalRecord.new. -
Append a backdated correction with
append_correction— same valid time, a fresh transaction time, so both versions coexist. -
Reconstruct the past by clamping reads through an
AsOfViewat the decision’s transaction time.
flowchart LR
A["BitemporalRecord.new<br/>(valid_time, transaction_time)"] --> B["store.append"]
C["append_correction<br/>(new transaction_time)"] --> B
B --> D["BitemporalStore<br/>(append-only)"]
D --> E["store.latest / history"]
D --> F["AsOfView(transaction_time)<br/>clamped read"]
F --> G["reconstruct past belief"]
Install
pip install briefcase-ai[bitemporal]from briefcase.bitemporal import ( BitemporalRecord, InMemoryBitemporalStore, AsOfView, append_correction,)Record a Fact
from datetime import datetime, timezone
from briefcase.bitemporal import ( BitemporalRecord, InMemoryBitemporalStore,)
store = InMemoryBitemporalStore()
# A feature-flag rollout percentage that was true in the real world at t0.t0 = datetime(2026, 5, 1, tzinfo=timezone.utc)learned_t0 = datetime(2026, 5, 1, 9, 0, tzinfo=timezone.utc)
record = BitemporalRecord.new( key="flag:new_checkout", valid_time=t0, value={"rollout_percent": 25}, source="config_service", transaction_time=learned_t0,)store.append(record)
latest = store.latest("flag:new_checkout")print(latest.value) # {'rollout_percent': 25}print(latest.content_hash()[:12]) # SHA-256 of the value payloadvalid_time and transaction_time must be timezone-aware; BitemporalRecord.new raises ValueError otherwise. When transaction_time is omitted it defaults to now.
Correct a Value and Reconstruct the Past
A correction shares the original valid_time but gets a fresh transaction_time and a parent_record_id back to the original. The old belief stays in the store.
from datetime import datetime, timezone
from briefcase.bitemporal import ( BitemporalRecord, InMemoryBitemporalStore, AsOfView, append_correction,)
store = InMemoryBitemporalStore()
t0 = datetime(2026, 5, 1, tzinfo=timezone.utc)learned_t0 = datetime(2026, 5, 1, 9, 0, tzinfo=timezone.utc)
original = BitemporalRecord.new( key="config:max_upload_mb", valid_time=t0, value=50, source="config_service", transaction_time=learned_t0,)store.append(original)
# A decision was made at this instant, reading what the system knew then.decision_ts = datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc)
# Later, the config service corrects the same valid_time: it was 100, not 50.learned_correction = datetime(2026, 5, 3, 8, 0, tzinfo=timezone.utc)append_correction( store, original, corrected_value=100, source="config_service", transaction_time=learned_correction,)
# Current truth reflects the correction.print(store.latest("config:max_upload_mb").value) # 100
# As-of the decision, the system had not yet learned the correction.with AsOfView(store, transaction_time=decision_ts) as view: print(view.latest("config:max_upload_mb").value) # 50
print(len(store.history("config:max_upload_mb"))) # 2 — both beliefs keptappend_correction requires the correction’s transaction_time to be strictly after the original’s; it raises ValueError otherwise, so a correction can never silently fail to supersede.
Clamp Reads with AsOfView
AsOfView(store, transaction_time=...) wraps any store and clamps every read to a historical instant. Application code keeps calling latest(key) / as_of(key) unchanged — no post-instant information leaks in. The view is read-only; append raises.
with AsOfView(store, transaction_time=decision_ts) as view: record = view.latest("config:max_upload_mb") rows = view.history("config:max_upload_mb") keys = view.keys()Pass valid_time= as well to restrict to facts that were true at a past real-world moment, distinct from what the system had learned by then.
Batch vs. Stream Ingestion
Both produce identical bitemporal output; they differ in transaction_time semantics. batch_append settles a whole batch at one shared instant; stream_append learns each record independently.
from datetime import datetime, timezone
from briefcase.bitemporal import ( BitemporalRecord, InMemoryBitemporalStore, batch_append, stream_append,)
store = InMemoryBitemporalStore()valid = datetime(2026, 5, 1, tzinfo=timezone.utc)
# batch_append: many records settle at one shared transaction_time.settled_at = datetime(2026, 5, 1, 23, 0, tzinfo=timezone.utc)batch = [ BitemporalRecord.new(key="rate:US", valid_time=valid, value=0.07, source="rates_feed"), BitemporalRecord.new(key="rate:EU", valid_time=valid, value=0.19, source="rates_feed"),]batch_append(store, batch, transaction_time=settled_at)
# stream_append: each record is learned independently, at append time.tick = BitemporalRecord.new(key="rate:US", valid_time=valid, value=0.075, source="rates_feed")stream_append(store, tick)
print(store.latest("rate:US").value) # 0.075print(sorted(store.keys())) # ['rate:EU', 'rate:US']| Helper | When you’d reach for it |
|---|---|
batch_append | A whole batch becomes known at once — settle it at one shared transaction_time. |
stream_append | Records arrive one at a time — each is learned independently at append time. |
Durable Backends
InMemoryBitemporalStore is the reference implementation. For persistence across process restarts, use the SQLite backend. Append-only is enforced at the database layer via triggers.
from briefcase.bitemporal.backends import SqliteBitemporalBackend
backend = SqliteBitemporalBackend("evidence.db")For multi-writer analytics, the Iceberg backend wraps pyiceberg:
pip install briefcase-ai[bitemporal-iceberg]from briefcase.bitemporal.backends import IcebergBitemporalBackendAll backends implement the same BitemporalStore protocol, so AsOfView and application code are backend-agnostic.
Key Classes
| Symbol | Why it matters |
|---|---|
BitemporalRecord | Immutable record with valid_time, transaction_time, value, source; new() constructor, content_hash(). |
InMemoryBitemporalStore | Reference store; append / append_many / latest / history / as_of / keys. |
AsOfView | Read-only view clamped to transaction_time and/or valid_time — reconstructs a past belief. |
append_correction | Appends a superseding record so the original belief is preserved, not overwritten. |
batch_append / stream_append | Shared-instant vs. per-record ingestion. |
SqliteBitemporalBackend / IcebergBitemporalBackend | Durable backends sharing the BitemporalStore protocol. |