Bitemporal Time-Travel¶
IndentiaDB's bitemporal model tracks two independent time dimensions for every triple:
- Transaction time (also called system time or recording time): when the fact was inserted into the database.
- Valid time (also called business time or real-world time): when the fact is true in the real world.
Together these two dimensions make it possible to answer queries like: "As of our database snapshot on June 1st, what did we believe was true on March 15th?" — a question that a single-timeline system cannot answer.
Why Two Timelines?¶
| Scenario | Single timeline | Bitemporal |
|---|---|---|
| Late-arriving data (salary retroactively corrected for Jan 1) | Cannot distinguish correction from original | Separate valid time and transaction time |
| What did the system know at an earlier point in time? | Not supported | Query by transaction time |
| What was the real-world state at a past date? | Requires audit log | Query by valid time |
| Regulatory audit: prove what was in the system on a specific date | Not possible | Supported natively |
Use Cases¶
| Use Case | Bitemporal Solution |
|---|---|
| Historical salary data (report salaries as of any date) | TEMPORAL BETWEEN VALID |
| Audit compliance (prove what the system contained on date X) | TEMPORAL AS OF TX |
| Late data correction (fix a past salary entry without losing history) | Insert new valid-time entry; old entry remains queryable |
| What was known when (combine both timelines) | TEMPORAL AS OF TX + VALID |
| Data corrections affecting only future queries | TEMPORAL UPDATE |
| Regulatory retention (keep data for 7 years, then purge) | Purge with retention policy |
| Point-in-time reports (monthly snapshot reports) | TEMPORAL AS OF TX |
TEMPORAL AS OF — Point-in-Time Query¶
Query the state of the graph as it existed at a specific transaction time and/or valid time:
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
SELECT ?name WHERE {
TEMPORAL AS OF TX "2024-06-01T00:00:00Z"
VALID "2024-03-15T00:00:00Z"
?person foaf:name ?name .
}
This returns all names that were recorded in the database before June 1, 2024 and were true in the real world on March 15, 2024. A salary that was retroactively corrected after June 1st will not appear — the query reflects the state of knowledge at the TX snapshot.
Transaction time only (point-in-time snapshot):
SELECT ?person ?name ?email WHERE {
TEMPORAL AS OF TX "2024-01-01T00:00:00Z"
?person foaf:name ?name ;
foaf:mbox ?email .
}
Valid time only (current knowledge, historical real-world state):
PREFIX ex: <http://example.org/>
SELECT ?employee ?salary WHERE {
TEMPORAL AS OF VALID "2023-06-30T23:59:59Z"
?employee ex:salary ?salary .
}
TEMPORAL BETWEEN — Range Query¶
Query all versions of a fact within a valid-time window:
PREFIX ex: <http://example.org/>
SELECT ?salary ?valid_start ?valid_end WHERE {
TEMPORAL BETWEEN VALID "2024-01-01" AND "2024-12-31"
ex:alice ex:salary ?salary .
BIND(TEMPORAL_START(?salary) AS ?valid_start)
BIND(TEMPORAL_END(?salary) AS ?valid_end)
}
ORDER BY ?valid_start
This returns every value of ex:alice ex:salary that was valid at any point during 2024, along with the start and end of each validity interval.
Transaction time range (all versions recorded during a window):
SELECT ?person ?role ?tx_start ?tx_end WHERE {
TEMPORAL BETWEEN TX "2024-01-01T00:00:00Z" AND "2024-06-30T23:59:59Z"
?person ex:hasRole ?role .
BIND(TX_START(?role) AS ?tx_start)
BIND(TX_END(?role) AS ?tx_end)
}
Inserting Temporally Valid Data¶
When inserting data with explicit valid-time bounds, use SPARQL UPDATE with TEMPORAL VALID annotations:
PREFIX ex: <http://example.org/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
INSERT DATA {
TEMPORAL VALID "2024-01-01T00:00:00Z" TO "2024-06-30T23:59:59Z" {
ex:alice ex:salary "75000"^^xsd:decimal .
ex:alice ex:title "Senior Engineer" .
}
}
# Record a pay raise from July 1st onwards (no end date = still current)
INSERT DATA {
TEMPORAL VALID "2024-07-01T00:00:00Z" {
ex:alice ex:salary "82000"^^xsd:decimal .
}
}
Both inserts are now stored. Querying for Alice's salary: - On June 15 → 75000 - On August 1 → 82000 - Historical snapshot from before July 1 + valid June 15 → 75000
Configuration¶
[bitemporal]
enabled = true
default_valid_time = "now" # When no valid-time is specified, use current time
tx_precision = "millisecond" # "second" | "millisecond" | "microsecond"
[bitemporal.purge]
enabled = false
retention_period = "7 years" # Keep data for at least this long
purge_interval = "1 day" # How often the purge job runs
purge_what = "expired" # "expired" = only purge past retention; "all" = purge on retraction
[bitemporal.query]
default_mode = "current" # "current" = only current valid+tx time; "all" = all versions
default_mode¶
| Mode | Behavior |
|---|---|
"current" |
Queries without TEMPORAL clause return only currently-valid, latest-transaction-time triples. This is the standard database behavior most applications expect. |
"all" |
Queries without TEMPORAL clause return all versions across all time. Useful for audit-heavy applications where all history is always relevant. |
Storage Growth with Bitemporal Enabled
Each update creates a new temporal version rather than overwriting. A column that changes 100 times per year stores 100 versions. Size the storage backend accordingly, and configure the purge policy to match your regulatory retention requirements.
Retention and Purge Policy¶
Configure data retention for regulatory compliance:
[bitemporal.purge]
enabled = true
retention_period = "7 years" # EU GDPR / NL Archiefwet common minimum
purge_interval = "1 day"
The purge job runs on the configured interval and deletes temporal versions whose entire validity interval lies outside the retention window. Versions that are still within the retention window (i.e., the valid_end is within the last 7 years) are never purged.
Manual purge via admin CLI:
# Dry run — show what would be purged
indentiagraph admin bitemporal purge \
--profile prod \
--before "2017-01-01T00:00:00Z" \
--dry-run
# Execute purge
indentiagraph admin bitemporal purge \
--profile prod \
--before "2017-01-01T00:00:00Z"
Practical Examples¶
Salary History Report¶
Show every salary Alice received, in order:
PREFIX ex: <http://example.org/>
SELECT ?salary ?valid_start ?valid_end WHERE {
TEMPORAL BETWEEN VALID "2020-01-01" AND "2025-12-31"
ex:alice ex:salary ?salary .
BIND(TEMPORAL_START(?salary) AS ?valid_start)
BIND(TEMPORAL_END(?salary) AS ?valid_end)
}
ORDER BY ?valid_start
Audit Report: Database Contents on a Regulatory Deadline¶
Prove what the system contained on December 31, 2023 at 23:59:59:
PREFIX ex: <http://example.org/>
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
SELECT ?employee ?name ?role ?salary WHERE {
TEMPORAL AS OF TX "2023-12-31T23:59:59Z"
?employee a ex:Employee ;
foaf:name ?name ;
ex:hasRole ?role ;
ex:salary ?salary .
}
ORDER BY ?name
This is a legally defensible snapshot: it reflects exactly what was recorded in the system as of that timestamp, regardless of any corrections made afterwards.
Late-Arriving Correction¶
Alice's salary in Q1 2023 was recorded incorrectly as 70,000. The correct value is 72,000. Correct it without overwriting history:
PREFIX ex: <http://example.org/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
# Retract the incorrect value for Q1 2023
DELETE DATA {
TEMPORAL VALID "2023-01-01T00:00:00Z" TO "2023-03-31T23:59:59Z" {
ex:alice ex:salary "70000"^^xsd:decimal .
}
}
# Insert the corrected value for the same valid-time window
INSERT DATA {
TEMPORAL VALID "2023-01-01T00:00:00Z" TO "2023-03-31T23:59:59Z" {
ex:alice ex:salary "72000"^^xsd:decimal .
}
}
Queries using AS OF TX snapshots before the correction still return the original (incorrect) value — making it possible to prove what was believed at any point in time.
Point-in-Time Report Generation¶
Generate a consistent monthly snapshot report:
import requests
from datetime import datetime
def generate_monthly_report(snapshot_date: datetime) -> list:
"""Generate an employee report as of a specific date."""
iso_date = snapshot_date.strftime("%Y-%m-%dT23:59:59Z")
sparql = f"""
PREFIX ex: <http://example.org/>
PREFIX foaf: <http://xmlns.com/foaf/0.1/>
PREFIX org: <http://www.w3.org/ns/org#>
SELECT ?name ?department ?salary ?title WHERE {{
TEMPORAL AS OF TX "{iso_date}"
?employee a ex:Employee ;
foaf:name ?name ;
ex:salary ?salary ;
ex:title ?title ;
org:memberOf ?dept .
?dept foaf:name ?department .
}}
ORDER BY ?department ?name
"""
response = requests.post(
"http://localhost:7001/sparql",
headers={
"Content-Type": "application/sparql-query",
"Accept": "application/sparql-results+json",
},
data=sparql,
)
response.raise_for_status()
return response.json()["results"]["bindings"]
# Generate end-of-month reports
for month_end in ["2024-01-31", "2024-02-29", "2024-03-31"]:
dt = datetime.fromisoformat(month_end)
report = generate_monthly_report(dt)
print(f"\n=== Report for {month_end}: {len(report)} employees ===")
for row in report[:3]:
print(f" {row['name']['value']} — {row['department']['value']} — {row['salary']['value']}")
Temporal Joins (Allen's Interval Algebra)¶
IndentiaDB supports sequenced temporal joins based on Allen's interval algebra. When joining two temporal entities, the engine can compute the temporal relationship between their validity intervals.
Allen's Interval Relations¶
The following temporal relations are supported in SPARQL via the SEQUENCED and NONSEQUENCED keywords:
| Relation | Meaning | Condition |
|---|---|---|
DURING |
A is entirely within B | A.start >= B.start AND A.end <= B.end |
OVERLAPS |
A starts before B ends, ends after B starts | A.start < B.end AND A.end > B.start |
MEETS |
A ends exactly when B starts | A.end = B.start |
BEFORE |
A ends before B starts | A.end < B.start |
EQUALS |
Same interval | A.start = B.start AND A.end = B.end |
Sequenced Join Example¶
Find all employees who were in the same department at the same time:
PREFIX ex: <http://example.org/>
SELECT ?emp1 ?emp2 ?dept ?overlap_start ?overlap_end WHERE {
TEMPORAL SEQUENCED {
?emp1 ex:memberOf ?dept .
?emp2 ex:memberOf ?dept .
}
FILTER(?emp1 != ?emp2)
BIND(TEMPORAL_START(?emp1) AS ?start1)
BIND(TEMPORAL_END(?emp1) AS ?end1)
BIND(TEMPORAL_START(?emp2) AS ?start2)
BIND(TEMPORAL_END(?emp2) AS ?end2)
BIND(IF(?start1 > ?start2, ?start1, ?start2) AS ?overlap_start)
BIND(IF(?end1 < ?end2, ?end1, ?end2) AS ?overlap_end)
}
ORDER BY ?dept ?overlap_start
The SEQUENCED keyword tells the engine to compute the intersection of validity intervals for matched triples, returning only results where the intervals overlap.
Non-Sequenced Join¶
A NONSEQUENCED join ignores temporal overlap — it joins on data values regardless of whether the validity intervals coincide:
SELECT ?person ?past_role ?current_role WHERE {
TEMPORAL NONSEQUENCED {
?person ex:hasRole ?past_role .
}
?person ex:hasRole ?current_role .
FILTER(?past_role != ?current_role)
}
SurrealQL Temporal Queries¶
In addition to SPARQL, bitemporal data can be queried using SurrealQL with temporal extensions.
Point-in-Time Query¶
-- Current state (default)
SELECT * FROM employee WHERE name = 'Alice';
-- State as of a specific valid time
SELECT * FROM employee
AT VALID '2024-01-15T00:00:00Z'
WHERE name = 'Alice';
-- State as of a specific transaction time
SELECT * FROM employee
AT TX '2024-06-01T00:00:00Z'
WHERE name = 'Alice';
-- Bitemporal: what the system knew on June 1 about January 15
SELECT * FROM employee
AT TX '2024-06-01T00:00:00Z'
AT VALID '2024-01-15T00:00:00Z'
WHERE name = 'Alice';
Range Query¶
-- All salary versions valid during 2024
SELECT salary, valid_start, valid_end FROM employee
BETWEEN VALID '2024-01-01' AND '2024-12-31'
WHERE name = 'Alice'
ORDER BY valid_start;
Temporal Insert with Valid Time¶
-- Insert with explicit valid time range
INSERT INTO employee (name, salary, department)
VALID '2024-01-01T00:00:00Z' TO '2024-06-30T23:59:59Z'
VALUES ('Alice', 75000, 'Engineering');
-- Insert valid from now, no end date
INSERT INTO employee (name, salary, department)
VALID '2024-07-01T00:00:00Z'
VALUES ('Alice', 82000, 'Engineering');
Migration from Non-Temporal Data¶
IndentiaDB provides a migration utility to convert existing regular triples into bitemporal format without downtime.
Migration Strategy¶
When migrating, the utility:
- Sets
valid_time.startto the current timestamp (migration time) - Sets
valid_time.endtoNone(unbounded/current) - Sets
tx_time.startautomatically by SurrealDB - Sets
tx_time.endtoNone(current version)
This means all migrated data is treated as "currently valid starting from the migration time." If you need to backdate valid times, use the custom valid-time option.
Running the Migration¶
# Dry run — show what would be migrated
indentiagraph admin bitemporal migrate \
--profile prod \
--dry-run
# Execute migration with default settings (batch size 1000)
indentiagraph admin bitemporal migrate \
--profile prod
# Execute with custom batch size for large datasets
indentiagraph admin bitemporal migrate \
--profile prod \
--batch-size 5000
# Migrate with a custom valid-time start (backdate all triples)
indentiagraph admin bitemporal migrate \
--profile prod \
--valid-from "2020-01-01T00:00:00Z"
Post-Migration Verification¶
After migration, verify the data:
PREFIX ex: <http://example.org/>
# Count triples in the bitemporal table
SELECT (COUNT(*) AS ?count) WHERE {
TEMPORAL BETWEEN VALID "2000-01-01" AND "2099-12-31"
?s ?p ?o .
}
Migration is One-Way
After verifying the migration, you can delete the original non-temporal triples with indentiagraph admin bitemporal migrate --delete-originals. This operation is irreversible.
Temporal Aggregations¶
Bitemporal data can be aggregated using temporal windows, combining GROUP BY with temporal range queries.
Salary Statistics by Year¶
PREFIX ex: <http://example.org/>
PREFIX xsd: <http://www.w3.org/2001/XMLSchema#>
SELECT ?year (AVG(?salary) AS ?avg_salary) (COUNT(?employee) AS ?headcount) WHERE {
VALUES ?year { 2022 2023 2024 2025 }
BIND(CONCAT(STR(?year), "-06-30T23:59:59Z") AS ?mid_year)
TEMPORAL AS OF VALID ?mid_year
?employee a ex:Employee ;
ex:salary ?salary .
}
GROUP BY ?year
ORDER BY ?year
Change Frequency Analysis¶
Count how many times each employee's salary changed during a period:
PREFIX ex: <http://example.org/>
SELECT ?employee ?name (COUNT(?salary) AS ?changes) WHERE {
TEMPORAL BETWEEN VALID "2020-01-01" AND "2025-12-31"
?employee a ex:Employee ;
ex:name ?name ;
ex:salary ?salary .
}
GROUP BY ?employee ?name
HAVING(COUNT(?salary) > 1)
ORDER BY DESC(?changes)
Compliance Patterns¶
GDPR Right-to-Erasure¶
Under GDPR Article 17 (right to erasure), a data subject can request deletion of their personal data. With bitemporal storage, IndentiaDB supports two erasure strategies:
Logical erasure (default) — close all temporal versions by setting valid_end and tx_end to the current timestamp. Historical queries still show the data existed, but current queries return nothing:
PREFIX ex: <http://example.org/>
# Logically erase all data about a person
DELETE DATA {
TEMPORAL VALID "1970-01-01T00:00:00Z" {
ex:person_12345 ?p ?o .
}
}
Physical erasure — permanently remove all temporal versions, including from transaction history. Use the admin CLI:
# GDPR physical erasure: remove all versions of a subject
indentiagraph admin bitemporal erase \
--profile prod \
--subject "http://example.org/person_12345" \
--physical
Physical Erasure Is Irreversible
Physical erasure removes data from all temporal dimensions, including transaction history. This means AS OF TX queries will also return no results. Use only for GDPR compliance.
Archiefwet (Dutch Archival Law) Patterns¶
The Archiefwet requires government records to be retained for specific periods (typically 7, 10, or 20 years depending on the record type). Configure per-category retention:
[bitemporal.purge]
enabled = true
[[bitemporal.purge.policies]]
graph = "http://example.org/hr-records"
retention_period = "7 years"
[[bitemporal.purge.policies]]
graph = "http://example.org/financial-records"
retention_period = "10 years"
[[bitemporal.purge.policies]]
graph = "http://example.org/legal-records"
retention_period = "20 years"
Generate an Archiefwet compliance report:
# Show retention status per named graph
indentiagraph admin bitemporal retention-report \
--profile prod \
--format table
Output:
Graph | Oldest Version | Retention | Status
-----------------------------------------|----------------|-----------|--------
http://example.org/hr-records | 2019-03-15 | 7 years | OK
http://example.org/financial-records | 2016-01-01 | 10 years | OK
http://example.org/legal-records | 2008-06-30 | 20 years | OK