Skip to content

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.

DimensionAnswersWhy it matters
Valid timeWhen was this fact true in the world?Lets a correction apply to a past date without rewriting history.
Transaction timeWhen 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

  1. Record a fact with a valid_time and a transaction_time via BitemporalRecord.new.

  2. Append a backdated correction with append_correction — same valid time, a fresh transaction time, so both versions coexist.

  3. Reconstruct the past by clamping reads through an AsOfView at 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

Terminal window
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 payload

valid_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 kept

append_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.075
print(sorted(store.keys())) # ['rate:EU', 'rate:US']
HelperWhen you’d reach for it
batch_appendA whole batch becomes known at once — settle it at one shared transaction_time.
stream_appendRecords 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:

Terminal window
pip install briefcase-ai[bitemporal-iceberg]
from briefcase.bitemporal.backends import IcebergBitemporalBackend

For AWS Glue catalog authentication, install bitemporal-glue and use GlueIcebergBackend. For kdb+, install the separately licensed kdb extra and use KdbBitemporalBackend; it is deliberately excluded from briefcase-ai[all].

All backends implement the same BitemporalStore protocol, so AsOfView and application code are backend-agnostic.

Key Classes

SymbolWhy it matters
BitemporalRecordImmutable record with valid_time, transaction_time, value, source; new() constructor, content_hash().
InMemoryBitemporalStoreReference store; append / append_many / latest / history / as_of / keys.
AsOfViewRead-only view clamped to transaction_time and/or valid_time — reconstructs a past belief.
append_correctionAppends a superseding record so the original belief is preserved, not overwritten.
batch_append / stream_appendShared-instant vs. per-record ingestion.
SqliteBitemporalBackend / IcebergBitemporalBackendDurable local and analytical stores.
GlueIcebergBackend / KdbBitemporalBackendAWS Glue-authenticated Iceberg and kdb+ enterprise-scale stores.

Limits

Append-only preserves rows; it does not stop you rewriting the past. append accepts any transaction_time, including one earlier than records already in the store. Insert a record backdated before a decision instant and an AsOfView at that instant returns the new value: measured, a past as-of read went from 50 to 999 after a backdated append. append_correction is the guarded path and refuses a transaction_time that is not strictly after the record it supersedes. Reach for append on ingest, append_correction on every restatement, and treat direct append access as a privileged operation.

Nothing signs the history. BitemporalRecord.content_hash() covers one record. There is no chained hash across the store, so a party who can write to it can extend the past as shown above without breaking anything. Anchor externally when the store is evidence. Audit Bundles has the same constraint.

Reads return None for a missing key, rather than raising. A typo in a key and a genuinely absent fact are indistinguishable at the call site.

Both times must be timezone-aware. BitemporalRecord.new raises ValueError on a naive datetime. That is deliberate: a naive timestamp in a bitemporal store is a bug that surfaces months later.

AsOfView is read-only. append inside a view raises AsOfViewWriteError. The clamp applies to reads through the view only, so a reference to the underlying store still sees everything.

Nothing is ever removed. History grows with every correction, and InMemoryBitemporalStore holds all of it in the process. Use a durable backend for anything long-lived.

API reference

briefcase.bitemporal has the full store protocol, record type, view, and ingestion helpers.

Where this fits