ADR 0016 - Valid-time intervals and time-travel reads
Date: 2026-07-03
Status
Section titled “Status”Accepted.
Context
Section titled “Context”dent8 already distinguishes when a fact was recorded (provenance.recorded_at, the
transaction-time axis the hash chain orders) from when it holds (valid_from, feeding the
TTL freshness anchor). But the valid-time axis is half-built, and the read surface is
now-only:
- No
valid_to. A fact with a known end of validity (“the deploy freeze lasts until March 31”) can only approximate it with a TTL — but TTL is a staleness heuristic (how long until the fact should be re-checked), not an asserted validity boundary. ADR 0005 names closed valid-time intervals as the missing piece. - No time-travel. “What did we believe last Tuesday?” is unanswerable even though the event log contains exactly that information — replay always folds the full log and evaluates freshness at wall-clock now.
valid_fromexists in the model but was never settable from the CLI, so in practice the freshness anchor always collapsed torecorded_at.
Decision
Section titled “Decision”1. valid_to — the asserted end of validity
Section titled “1. valid_to — the asserted end of validity”FactEvent.valid_to: Option<TimestampMillis>, folded into
FactState.valid_to at assertion (like ttl). Read-time freshness bounds the full
validity window [valid_from, expires_at): FactState::expires_at() is the earliest of
the TTL bound and valid_to (the upper bound), and FactState::is_fresh_at(now) also
requires now >= valid_from — a fact whose valid_from is in the future is not yet
valid (is_not_yet_valid_at), read as not-fresh with a distinct [not yet valid] headline
rather than [stale]. explain’s receipt carries fresh, not_yet_valid, and the
valid_from / expires_at window (the CLI, the MCP explain tool, and resources/read
mirror them). Lifecycle is untouched — an out-of-window fact is not-fresh to read, not
terminally closed; explicit expire remains the authority-gated close (ADR 0011).
Validation rejects valid_to <= valid_from when both are set. Expiry is inclusive at the
upper boundary (expires_at <= now is stale) and the lower bound is inclusive too
(now >= valid_from is valid), matching the existing TTL comparison.
FactState stores valid_from distinctly from freshness_anchor (which still falls back
to observed_at/recorded_at for the TTL anchor): only an asserted valid_from gates the
lower bound, so a future TTL anchor from observed_at does not accidentally read as
not-yet-valid.
2. The write surface completes the interval
Section titled “2. The write surface completes the interval”assert, supersede, contradict, and derive gain --valid-from <millis> / --valid-to <millis> (the shared value-write surface): assert stamps its fact; supersede and
contradict stamp the replacement / opposing assertion they create; derive stamps the
derived assertion. Setting valid_from also restores its intended role as the freshness anchor.
3. Time-travel reads
Section titled “3. Time-travel reads”explain and replay gain two independent clocks:
--as-of <millis>(transaction time) — fold only events withrecorded_atat or before the instant: the store as it stood then. Trustsrecorded_atexactly as freshness always has (it is appender-supplied); the filtered prefix skips the trusted-reload uniqueness gate because it re-checks a historical state that was admitted event-by-event when written.--valid-at <millis>(valid time) — evaluate freshness/validity at that instant instead of wall-clock now: “was this fact valid on March 3rd?”.
They compose: --as-of T --valid-at T reads the log as of T and judges freshness at
T — the full “what did we believe, and was it fresh, last Tuesday”.
Compatibility
Section titled “Compatibility”valid_to is #[serde(default, skip_serializing_if = "Option::is_none")] — the ADR 0013
optional-field rule — so events written before it keep byte-identical canonical form and
stored hashes; no CANON_VERSION bump. (Unlike observed_at/valid_from, which
predate the rule and serialize as explicit nulls, absence is omitted.)
FactState.valid_to is #[serde(default)] for previously materialized projections.
Consequences
Section titled “Consequences”- Facts with known validity windows stop abusing TTL, and their expiry is asserted content (hash-covered, attested) rather than registry policy.
- Auditing gains the missing tense: a poisoning investigation can ask what the agent
believed at decision time (
--as-ofat the decision’s timestamp) instead of inferring it from the current fold. - MCP time-travel and validity parameters ride the same
op_*signatures and are now exposed as tool arguments:assert/supersede/contradict/derivetake optionalvalid_from/valid_toandexplain/replaytake optionalas_of/valid_at, advertised in their input schemas — surface plumbing over the existing signatures, not a new mechanism.