ADR-0035: Temporal Knowledge Graph — Bitemporal Queries on PROV-O¶
- Status: Accepted (2026-04-19)
- Date: 2026-04-18
Context¶
Trails already records transaction time implicitly: every
@capability invocation produces a PROV-O trace with
prov:startedAtTime / prov:endedAtTime (ADR-0009). However, the
framework has no first-class notion of valid time — when a fact was
true in the real world, as opposed to when it was recorded. This makes
several use cases awkward or impossible:
- Regulatory audits. A compliance officer asks "What did the knowledge graph look like on 2025-12-31?" The current store can only answer "what does it look like now."
- Correction workflows. A data steward fixes an error but needs to record that the old value was valid from date A to date B, and the new value is valid from date B onward. Today the old value is simply overwritten.
- Change tracking. Reviewing how a node's fields evolved over time requires manual SPARQL archaeology in the provenance graph.
- Event-sourced domains. Healthcare, finance, and legal KGs routinely need point-in-time queries and full history.
Bitemporal modelling (valid time + transaction time) is a well-studied
pattern in relational databases (SQL:2011 PERIOD FOR, Temporal Tables)
but has no established convention in the RDF / SPARQL ecosystem. The
closest W3C work is PROV-O itself (which covers transaction time) and
the draft RDF-star proposal (which could annotate individual statements
with temporal metadata). Trails is in a unique position to provide a
clean, opinionated temporal API on top of SPARQL named graphs.
Decision¶
1. Bitemporal model¶
Every KG write already has PROV-O timestamps. We extend the model to support two orthogonal time axes:
- Valid time (
valid_from,valid_until): when the fact was true in the real world. Supplied by the caller. Open-ended whenvalid_untilisNone(fact is still valid). - Transaction time (
transaction_time): when the fact was recorded in the store. Auto-set by the framework. Immutable once written.
2. Storage: named-graph annotations¶
Temporal metadata is stored as named-graph annotations, not as separate triples per statement (which would explode the store with reification overhead). Each temporal snapshot of a node lives in its own named graph:
GRAPH <trails://local/temporal/<uuid7>> {
<node_iri> rdf:type <NodeType> .
<node_iri> <pred1> "value1" .
...
}
The temporal metadata for the graph itself is stored in the default graph:
<trails://local/temporal/<uuid7>>
trails:validFrom "2025-01-01T00:00:00Z"^^xsd:dateTime ;
trails:validUntil "2025-06-30T00:00:00Z"^^xsd:dateTime ;
trails:transactionTime "2025-07-01T12:00:00Z"^^xsd:dateTime ;
prov:wasRevisionOf <trails://local/temporal/<prev_uuid7>> ;
trails:snapshotOf <node_iri> .
This design keeps the per-statement overhead constant (one named graph per snapshot, not one reification quad per triple) and works with any SPARQL 1.1 engine that supports named graphs (including Oxigraph).
3. trails.temporal module¶
A new top-level module providing five core operations:
from trails.temporal import (
temporal_save,
history,
as_of,
temporal_diff,
TemporalMetadata,
)
# Save with temporal metadata
temporal_save(ctx, instance, valid_from=dt1, valid_until=dt2)
# Point-in-time query
results = as_of(ctx, NodeType, dt).fetch()
# Full history
snapshots = history(ctx, iri)
# Field-level diff
changes = temporal_diff(ctx, iri, t1, t2)
4. Progressive: opt-in, non-breaking¶
Temporal features are opt-in. Non-temporal code works unchanged:
ctx.kg.add(instance)andctx.kg.save(instance)continue to write to the default graph with no temporal metadata.Model.where(...).fetch(ctx)continues to query the default graph.as_of(ctx, NodeType, dt)with no temporal data in the store returns the current state (graceful degradation).- No schema migration required — temporal named graphs coexist with the default graph.
5. CLI surface¶
trails kg history <iri> # show temporal history
trails kg diff <iri> --t1 <datetime> --t2 <datetime> # show changes
6. PROV-O integration¶
Each temporal_save emits prov:wasRevisionOf linking the new snapshot
graph to the previous one. This piggybacks on the existing PROV-O
infrastructure (ADR-0009) without duplicating it — the revision chain
is navigable from the provenance graph.
Non-goals¶
- Automatic versioning of every write. Temporal tracking is
explicit via
temporal_save. Implicit versioning (everykg.savecreates a snapshot) is a future opt-in mode, not the default. - Temporal SHACL validation. Shapes validate the current snapshot; cross-temporal shape evolution is out of scope for Phase 1.
- RDF-star annotations. When RDF-star support lands in Oxigraph and the ecosystem stabilises, we may migrate from named-graph annotations to statement-level annotations. The public API will not change.
- Bi-temporal joins. Joining two temporal node types at matching valid-time intervals is a future query-builder feature, not Phase 1.
Dependencies¶
| ADR | Relationship |
|---|---|
| ADR-0009 (PROV-O) | prov:wasRevisionOf for revision chains; transaction time from provenance |
| ADR-0017 (ORM) | @node_type instances are the unit of temporal snapshots |
| ADR-0021 (Progressive) | Temporal features are additive; non-temporal code unchanged |
Consequences¶
Positive¶
- Audit-grade point-in-time queries. Regulatory, compliance, and forensic use cases become first-class without custom SPARQL.
- Clean correction model. Data stewards can record that a value was valid for a specific period, not just overwrite-and-hope.
- Change tracking for free.
history()andtemporal_diff()replace manual provenance archaeology. - No store migration. Named-graph annotations coexist with existing data; temporal features are purely additive.
- Progressive. Zero impact on non-temporal code paths.
Negative¶
- Storage overhead. Each temporal snapshot duplicates the node's triples into a new named graph. Mitigated by: snapshots are opt-in, not automatic; cleanup/compaction is a future concern.
- Query complexity. Point-in-time queries add GRAPH + FILTER
clauses. Mitigated by: the
as_of()API hides the SPARQL complexity; direct SPARQL users can ignore temporal graphs. - Named-graph proliferation. Many snapshots = many named graphs. Mitigated by: Oxigraph handles named graphs efficiently; a future compaction command can merge old snapshots.
Revisit conditions¶
- If RDF-star becomes a W3C Recommendation and Oxigraph supports it natively, consider migrating from named-graph annotations to statement-level annotations (public API stays the same).
- If automatic versioning demand is strong, add an opt-in
@node_type(temporal=True)flag that wraps everysave()intemporal_save(). - If storage overhead becomes a concern, implement a compaction strategy that merges contiguous snapshots with identical values.