Skip to content

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:

  1. 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."
  2. 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.
  3. Change tracking. Reviewing how a node's fields evolved over time requires manual SPARQL archaeology in the provenance graph.
  4. 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 when valid_until is None (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) and ctx.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 (every kg.save creates 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() and temporal_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 every save() in temporal_save().
  • If storage overhead becomes a concern, implement a compaction strategy that merges contiguous snapshots with identical values.