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 IcebergBitemporalBackendFor 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
| 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 local and analytical stores. |
GlueIcebergBackend / KdbBitemporalBackend | AWS 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.