Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions docs/api/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,20 @@ curl -X POST "http://localhost:8090/v1/fluree/update?ledger=mydb:main" \
}'
```

JSON-LD transaction with a caller-supplied event time (backdated commit — see
[Time Travel: Event Time](../concepts/time-travel.md#event-time-backdated-commits);
must be RFC 3339, monotonically non-decreasing along the ledger's commit
chain, and not in the future):
```bash
curl -X POST "http://localhost:8090/v1/fluree/update?ledger=mydb:main" \
-H "Content-Type: application/json" \
-d '{
"@context": { "ex": "http://example.org/ns/" },
"@graph": [{ "@id": "ex:alice", "ex:role": "Engineer" }],
"opts": { "eventTime": "2021-03-15T00:00:00Z" }
}'
```

SPARQL UPDATE (ledger-scoped endpoint):
```bash
curl -X POST http://localhost:8090/v1/fluree/update/mydb:main \
Expand Down
78 changes: 73 additions & 5 deletions docs/concepts/time-travel.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,66 @@ Query at a specific commit using `@commit:` with a commit ContentId:
}
```

## Event Time: Backdated Commits

Every commit carries an **event time** (`db:time` in the txn-meta graph) — the
wall-clock instant the commit's changes are *about*. By default it is stamped
with the current time at commit, but a transaction may supply its own:

```json
{
"@context": { "ex": "http://example.org/ns/" },
"@graph": [{ "@id": "ex:alice", "ex:role": "Engineer" }],
"opts": { "eventTime": "2021-03-15T00:00:00Z" }
}
```

This is how historical data gets *real* time travel: replay a year of history
as ordinary transactions, each stamped with the date the change actually
happened, and `@iso:` queries work over that custom timeline with no further
setup. (The Rust API equivalent is `CommitOpts::with_timestamp`.)

Two rules keep the timeline coherent — both enforced at commit time:

1. **Monotonic**: a commit's event time must be `>=` the head commit's event
time. `@iso:` resolution depends on this ordering; a fresh ledger's first
commit can carry any past time (there is no floor — pre-1970 works).
2. **No future times**: event time may not exceed the current wall clock
(plus a small skew allowance). Commits are immutable, so one future-dated
stamp would otherwise permanently pin the ledger's timeline ahead of
reality.

The default stamp is also clamped to the head's event time, so a system clock
that steps backwards can no longer corrupt `@iso:` resolution.

### Recorded Time: the Audit Axis (`@recorded:`)

Once a ledger uses a caller-supplied event time, each commit records a second,
system-controlled timestamp: **`db:receivedAt`** — the wall-clock time the
commit was actually recorded. This begins at the first backdated commit and
continues on every commit thereafter (sticky), so the audit trail has no gaps.
Ledgers that never supply event times carry no extra metadata at all.
Comment on lines +136 to +140

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Once a ledger uses a caller-supplied event time, each commit records a second … db:receivedAt" is accurate for the HTTP path (the transact.rs route pairs eventTime with with_received_at(now)), but not for the Rust API: CommitOpts::with_timestamp alone does not flip dual-stamp mode (resolve_commit_times gates it on received.is_some() || head.dual_stamp()). fluree-memory git replay backdates via with_timestamp only, so those ledgers are event-axis-only and never carry receivedAt. Consider clarifying that the audit axis is enabled by the server pairing the two stamps (or by with_received_at directly), not by any event-time use.


The `@recorded:` selector time-travels along that axis:

```json
{
"@context": { "ex": "http://example.org/ns/" },
"from": "ledger:main@recorded:2026-01-15T00:00:00Z",
"select": ["?name"],
"where": [{ "@id": "?person", "ex:name": "?name" }]
}
```

- `@iso:` answers *"what was true at this time?"* (event axis)
- `@recorded:` answers *"what had been loaded into the ledger by this
time?"* (audit axis)

On a ledger that never used `eventTime`, the two axes are identical and
`@recorded:` behaves exactly like `@iso:`. Both timestamps live inside the
signed commit envelope, so a signature attests to the claimed event time and
the recording time together.

## Temporal Data Model

### Immutable Facts
Expand Down Expand Up @@ -142,9 +202,15 @@ Fluree primarily uses **transaction time** (when the fact was recorded in the da
```

This allows you to query by both:
- **Transaction time**: When was this recorded? (using `@t:`, `@iso:`, `@commit:`)
- **Transaction time**: When was this recorded? (using `@t:`, `@recorded:`, `@commit:`)
- **Valid time**: When was this true? (using standard WHERE clause filters on `ex:validFrom`/`ex:validTo`)

For commit-granular valid time — importing historical data so `@iso:` time
travel works over the dates things actually changed — see
[Event Time: Backdated Commits](#event-time-backdated-commits) above. Explicit
`validFrom`/`validTo` properties remain the right model for *fact*-granular
intervals and overlapping validity.

## Snapshot and Indexing

### Database Snapshots
Expand Down Expand Up @@ -393,7 +459,8 @@ Query changes for a specific property across all subjects:
Different time specifiers have different performance characteristics:

- **@t:NNN** (fastest): Direct transaction number, no resolution needed
- **@iso:DATETIME**: O(log n) binary search through commit timestamps using POST index
- **@iso:DATETIME**: O(log n) binary search through commit event timestamps using POST index
- **@recorded:DATETIME**: Same POST-index probes over `db:receivedAt`; identical to `@iso:` cost (and identical *behavior* on ledgers that never used `eventTime`)
- **@commit:CID**: Bounded SPOT scan, O(k) where k is commits matching prefix (use longer prefixes for better performance)

### Index Selection
Expand Down Expand Up @@ -679,11 +746,12 @@ This recreates the exact state across multiple ledgers at the time the bug occur

### Time Travel Resolution

When you query with `@t:`, `@iso:`, or `@commit:`:
When you query with `@t:`, `@iso:`, `@recorded:`, or `@commit:`:

1. **@t:NNN** - Direct transaction number (fastest)
2. **@iso:DATETIME** - Binary search through commit timestamps using POST index
3. **@commit:CID** - Bounded SPOT scan to find matching commit
2. **@iso:DATETIME** - Binary search through commit event timestamps using POST index
3. **@recorded:DATETIME** - Same probes over `db:receivedAt` (audit axis), falling back to the event axis for history before the first backdated commit
4. **@commit:CID** - Bounded SPOT scan to find matching commit

### Query Execution

Expand Down
21 changes: 19 additions & 2 deletions fluree-db-api/src/dataset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,8 +435,14 @@ pub enum TimeSpec {
AtT(i64),
/// At a specific commit hash
AtCommit(String),
/// At a specific ISO 8601 timestamp
/// At a specific ISO 8601 timestamp, resolved against commit *event
/// time* (`db:time` — user-suppliable for backdated historical loads)
AtTime(String),
/// At a specific ISO 8601 timestamp, resolved against the wall-clock
/// time commits were *recorded* (`db:receivedAt`, audit axis).
/// Identical to `AtTime` on ledgers that never used caller-supplied
/// event times.
AtRecorded(String),
/// "latest" keyword - resolves to current ledger t
Latest,
}
Expand All @@ -457,6 +463,11 @@ impl TimeSpec {
Self::AtTime(time.into())
}

/// Create at-recorded specification (audit axis)
pub fn at_recorded(time: impl Into<String>) -> Self {
Self::AtRecorded(time.into())
}

/// Create latest specification
pub fn latest() -> Self {
Self::Latest
Expand Down Expand Up @@ -905,6 +916,7 @@ fn parse_ledger_id_time_travel(
LedgerIdTimeSpec::AtT(t) => TimeSpec::AtT(t),
LedgerIdTimeSpec::AtIso(value) => TimeSpec::AtTime(value),
LedgerIdTimeSpec::AtCommit(value) => TimeSpec::AtCommit(value),
LedgerIdTimeSpec::AtRecorded(value) => TimeSpec::AtRecorded(value),
});

Ok((format!("{identifier}{fragment_suffix}"), time_spec))
Expand Down Expand Up @@ -994,6 +1006,8 @@ fn parse_named_graph_object(
if let Some(at_str) = at_val.as_str() {
if let Some(commit_hash) = at_str.strip_prefix("commit:") {
source.time_spec = Some(TimeSpec::AtCommit(commit_hash.to_string()));
} else if let Some(recorded) = at_str.strip_prefix("recorded:") {
source.time_spec = Some(TimeSpec::AtRecorded(recorded.to_string()));
} else {
source.time_spec = Some(TimeSpec::AtTime(at_str.to_string()));
}
Expand Down Expand Up @@ -1072,9 +1086,12 @@ fn parse_single_graph_source(
}
} else if let Some(at_val) = obj.get("at") {
if let Some(at_str) = at_val.as_str() {
// Determine if it's a commit hash or timestamp
// Determine if it's a commit hash, recorded-axis
// timestamp, or event-time timestamp
if let Some(commit_hash) = at_str.strip_prefix("commit:") {
source.time_spec = Some(TimeSpec::AtCommit(commit_hash.to_string()));
} else if let Some(recorded) = at_str.strip_prefix("recorded:") {
source.time_spec = Some(TimeSpec::AtRecorded(recorded.to_string()));
} else {
// Assume ISO timestamp
source.time_spec = Some(TimeSpec::AtTime(at_str.to_string()));
Expand Down
4 changes: 4 additions & 0 deletions fluree-db-api/src/ledger_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ impl LedgerView {
ns_record: self.ns_record,
binary_store: self.binary_store.map(|store| TypeErasedStore(store)),
spatial_indexes: None,
// Read-path conversion: head temporal metadata is only needed by
// the write path, which resolves it lazily via
// `ensure_head_temporal`.
head_temporal: None,
}
}
}
Expand Down
Loading