The Bi-temporal Model
Engrava can track two independent time axes for a fact: when you stored it and when it is true in the world. This page explains the second axis — valid time — what it is, how it differs from the clocks Engrava already has, and the small set of opt-in tools that work with it.
Valid time is entirely optional. If you only ever care about “what is true now”, you can ignore everything on this page — the defaults already behave the way you want (see When you don’t need valid time).
Two clocks (three, actually)
A thought carries more than one notion of “time”, and they answer different questions. Keep them apart:
| Field | Axis | Answers | Who sets it |
|---|---|---|---|
created_at / updated_at |
transaction time | “When did we record (or last change) this?” | Engrava, when omitted — callers may also set/update it |
valid_from / valid_until |
valid time | “During what real-world period is this fact true?” | you (optional) |
created_cycle / updated_cycle |
logical clock | “At which agent tick did this appear?” | you (your cycle counter) |
- Transaction time is bookkeeping: Engrava supplies it when you omit it.
Callers may also supply or update
created_at/updated_atexplicitly, and Engrava does not enforce that it moves forward. It normally tells you the order in which your system learned things. - Valid time is about the world, not your database. “The user lived in Berlin from January to June” is a statement about reality — it is true for a window that has nothing to do with when you happened to write it down. You set it; Engrava only stores and queries it.
Don’t confuse the cycle with valid time
The cycle (created_cycle) is not a calendar. It is a monotonically
increasing integer you advance once per agent turn. It drives recency and
dreaming math; it is deliberately wall-clock-independent.
Valid time, by contrast, is an ISO-8601 calendar timestamp describing the
real world. A fact can have a low created_cycle (you learned it early in the
agent’s life) yet a valid_from far in the future, or vice versa. Never reach
for the cycle when you mean a date — they are different tools for different jobs.
valid_from / valid_until: an interval, with open ends
Both valid_from and valid_until are nullable ISO-8601 strings on
ThoughtRecord and EdgeRecord. Together they describe the half-open interval
during which the fact is considered true:
valid_from = None— open lower bound. The fact is treated as valid from the beginning of time (−∞). “We don’t know (or don’t care) when this started; it has always been true as far as we’re concerned.”valid_until = None— open upper bound. The fact is treated as still valid, with no known end (+∞). This is the common case for a fact that is currently true.- Both
None(the default for every newly created record) — the fact is valid for all time under the NULL-tolerant predicates (valid_now,valid_at,valid_within);valid_betweenis the exception and excludes a record when either bound isNone(see the four query predicates below).
NULL = open, not unknown-and-excluded. This is the single most important rule on this page. A NULL bound means the interval extends to infinity on that side, so a row with open bounds stays visible to point-in-time queries — it is not filtered out. That is what makes adopting valid time incremental: facts you never annotate keep showing up exactly as before.
Bounds may not be inverted. When you set both
valid_fromandvalid_until, the interval must run forwards —valid_fromat or beforevalid_until, compared as instants, so differing offsets are reconciled first. An equal pair (a zero-length, instantaneous fact) is allowed; a strictly inverted pair is rejected, on construction, on the update path, and oninvalidate. Because the invariant lives in the domain model, the store also validates it whenever a row is hydrated into a full record —get_thought()andget_edges(): a row that became inverted out of band — written by an older build before this check existed, or edited directly in the database file — raises aValidationErrorthere, rather than returning a corrupt interval. Ranked search (search_hybrid(),recall(),search_similar(),search_fts()) returns ids and scores without hydrating a record, so an inverted row can still surface in a ranked result; the error only appears once you fetch that id. Use an open (None) bound for “no known start/end”, never an inverted one.
The four query predicates
Valid time is queried through four opt-in WHERE predicates in MindQL. They
are only valid against the thoughts and edges tables (the two record types
that carry valid-time columns). A query that uses no temporal predicate
behaves exactly as it did before this feature existed.
| Predicate | Arguments | Matches a row when… | NULL bounds |
|---|---|---|---|
valid_now |
none | the interval contains the current instant | tolerant (open bound = always in range) |
valid_at <ts> |
one timestamp | the interval contains <ts> |
tolerant |
valid_within <start> <end> |
two timestamps | the interval overlaps the half-open window [<start>, <end>) — not the closed range the arguments suggest (assuming <start> is before <end>; the parser does not enforce that ordering, and a degenerate or inverted window can still match) |
tolerant |
valid_between <start> <end> |
two timestamps | the interval is fully contained in [<start>, <end>] |
strict — open-bound rows are excluded |
Worked semantics
The first three predicates treat a NULL bound as ±∞, so open-ended facts stay in
the result. valid_between is the deliberate exception: “fully contained”
cannot be true of an interval that runs to infinity, so it requires real
bounds on both ends and drops any row with a NULL valid_from or
valid_until.
The upper bound is exclusive (valid_until is the first instant the fact is
no longer true). Concretely, for a fact valid [2026-01-01, 2026-07-01):
| Query | Result | Why |
|---|---|---|
valid_at '2026-03-15...' |
match | 2026-03-15 is inside [Jan 1, Jul 1) |
valid_at '2026-07-01...' |
no match | upper bound is exclusive — Jul 1 is already out |
valid_at '2025-12-01...' |
no match | before valid_from |
valid_within '2026-06-01...' '2026-12-01...' |
match | the intervals overlap (Jun–Jul) |
valid_within '2026-07-01...' '2026-12-01...' |
no match | the window’s lower bound sits exactly at the fact’s exclusive upper bound — both intervals are half-open, so a shared boundary instant is not an overlap |
valid_between '2025-01-01...' '2026-12-31...' |
match | [Jan, Jul) is fully inside the range |
valid_between '2026-02-01...' '2026-12-31...' |
no match | starts before the range’s lower bound |
A timestamp without an offset is read as UTC. Every valid-time value you
store, and every timestamp you pass to a temporal predicate, is converted to
one canonical UTC form before it is compared: an offset-aware value is
converted to UTC, and a value with no offset is taken to already be UTC.
'2026-07-01T00:00:00', '2026-07-01 00:00:00' and
'2026-07-01T00:00:00+00:00' therefore compare as the same instant, and the
boundary rules above hold for any of them. For a time in another zone, give
its offset ('2026-07-01T02:00:00+02:00'); without one, it is read as that
clock time in UTC.
And for a fact with an open upper bound — valid [2026-01-01, ∞):
| Query | Result | Why |
|---|---|---|
valid_now |
match (if now ≥ Jan 2026) | open upper bound = still valid |
valid_at '2030-01-01...' |
match | open upper bound reaches any future instant |
valid_between '2026-01-01...' '2026-12-31...' |
no match | valid_between rejects the open valid_until |
invalidate vs delete
Engrava gives you two very different ways to retire a fact, and choosing the right one is a modelling decision, not a performance one.
invalidate_thought / invalidate_edge |
delete_thought |
|
|---|---|---|
| Meaning | “This was true, and is now superseded.” | “This should never have existed.” |
| Effect | Sets valid_until; the row stays on file |
Removes the row entirely |
| History | Preserved — fully auditable, still retrievable | Gone |
| Past queries | A valid_at in the still-valid window still finds it |
Finds nothing |
| LLM / search | None — deterministic, valid-time only | n/a |
invalidate_thought(id, valid_until) simply closes the valid-time interval at
the instant you pass. It is:
- deterministic — it performs no similarity search, no model inference, no automatic discovery of related facts;
- idempotent — calling it twice with the same
valid_untilconverges to the same stored value; - non-cascading — invalidating a thought leaves every connected edge
untouched (invalidate the edges separately with
invalidate_edgeif you need to); - not a delete — the row and its history remain. A point-in-time query for
an instant before the new
valid_untilstill returns it.
Reach for invalidate when reality changed (the user moved cities, a price was
updated, a status closed). Reach for delete only when a fact was an outright
mistake and you want it gone, history and all.
Reflections inherit their members’ extent
When dreaming builds a REFLECTION from a cluster
of member thoughts, it derives the reflection’s valid-time extent from its
members rather than leaving it blank:
valid_frombecomes the earliest membervalid_from— unless any member has an open (NULL) lower bound, in which case the reflection’svalid_fromis also open (NULL). An interval that summarises something open-ended is itself open-ended.valid_untilbecomes the latest membervalid_until— but only if every member has a closed upper bound. If any member is still open, the reflection’svalid_untilis open (NULL) too.
In short: the reflection’s interval is the union of its members’ intervals, and “open on either side” is contagious. A summary of facts that are still true is itself still true.
Worked examples
The three snippets below are complete, runnable scripts. Each opens an in-memory database, so you can paste any one of them into a file and run it directly.
(a) Set valid time on a new fact
Pass valid_from (and optionally valid_until) when you build the
ThoughtRecord. Here we record a fact known to be true from the start of 2026,
with no known end (valid_until left open):
import asyncio
import sys
import uuid
import aiosqlite
from engrava import (
LifecycleStatus,
Priority,
SqliteEngravaCore,
ThoughtRecord,
ThoughtType,
)
async def _close_quietly(conn: aiosqlite.Connection) -> None:
"""Close *conn* without letting an ordinary close failure replace a real error.
``aiosqlite.Connection.__aexit__`` is an unconditional ``await close()``, so
a bare ``async with aiosqlite.connect(...)`` would let a close failure here
replace whatever the block above actually raised. This logs an ordinary
close failure instead of raising it, so the original error still
propagates; a cancellation landing during this close is not covered the
same way.
"""
try:
await conn.close()
except Exception as exc: # noqa: BLE001 - logged, not raised
print(f"warning: failed to close the database connection: {exc}", file=sys.stderr)
async def main() -> None:
conn = await aiosqlite.connect(":memory:")
try:
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn)
await store.ensure_schema()
fact = ThoughtRecord(
thought_id=str(uuid.uuid4()),
thought_type=ThoughtType.BELIEF,
essence="The user lives in Berlin",
content="Stated during the 2026 onboarding call.",
priority=Priority.P2,
lifecycle_status=LifecycleStatus.ACTIVE,
created_cycle=10,
updated_cycle=10,
source="onboarding",
valid_from="2026-01-01T00:00:00+00:00", # true from the start of 2026
# valid_until omitted -> open upper bound -> still valid
)
stored = await store.create_thought(fact)
fetched = await store.get_thought(stored.thought_id)
assert fetched is not None
assert fetched.valid_from == "2026-01-01T00:00:00+00:00"
assert fetched.valid_until is None # open upper bound
print("valid_from:", fetched.valid_from, "valid_until:", fetched.valid_until)
except BaseException:
await _close_quietly(conn)
raise
else:
await conn.close()
asyncio.run(main())
(b) Time-travel: query a past instant
valid_at <timestamp> returns the facts that were true at that instant. Here a
belief is true only for the first half of 2026; a query inside that window finds
it, one after it does not:
import asyncio
import sys
import uuid
import aiosqlite
from engrava import (
LifecycleStatus,
MindQLExecutor,
Priority,
SqliteEngravaCore,
ThoughtRecord,
ThoughtType,
parse,
)
async def _close_quietly(conn: aiosqlite.Connection) -> None:
"""Close *conn* without letting an ordinary close failure replace a real error.
``aiosqlite.Connection.__aexit__`` is an unconditional ``await close()``, so
a bare ``async with aiosqlite.connect(...)`` would let a close failure here
replace whatever the block above actually raised. This logs an ordinary
close failure instead of raising it, so the original error still
propagates; a cancellation landing during this close is not covered the
same way.
"""
try:
await conn.close()
except Exception as exc: # noqa: BLE001 - logged, not raised
print(f"warning: failed to close the database connection: {exc}", file=sys.stderr)
async def main() -> None:
conn = await aiosqlite.connect(":memory:")
try:
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn)
await store.ensure_schema()
await store.create_thought(
ThoughtRecord(
thought_id=str(uuid.uuid4()),
thought_type=ThoughtType.BELIEF,
essence="The user lives in Berlin",
content="True for the first half of 2026.",
priority=Priority.P2,
lifecycle_status=LifecycleStatus.ACTIVE,
created_cycle=10,
updated_cycle=10,
source="onboarding",
valid_from="2026-01-01T00:00:00+00:00",
valid_until="2026-07-01T00:00:00+00:00", # closed in mid-2026
),
)
executor = MindQLExecutor(conn)
march = await executor.execute(
parse("FIND thoughts WHERE valid_at '2026-03-15T00:00:00+00:00'"),
)
september = await executor.execute(
parse("FIND thoughts WHERE valid_at '2026-09-15T00:00:00+00:00'"),
)
assert len(march.rows) == 1 # inside the valid window
assert len(september.rows) == 0 # after valid_until
print("March match:", len(march.rows), "September match:", len(september.rows))
except BaseException:
await _close_quietly(conn)
raise
else:
await conn.close()
asyncio.run(main())
(c) Invalidate a fact, then watch it drop out of valid_now
invalidate_thought closes the valid-time interval. After invalidation the fact
no longer matches valid_now, but it is not deleted — it remains on file and
a query for an instant before the cut-off still finds it:
import asyncio
import sys
import uuid
import aiosqlite
from engrava import (
LifecycleStatus,
MindQLExecutor,
Priority,
SqliteEngravaCore,
ThoughtRecord,
ThoughtType,
parse,
)
async def _close_quietly(conn: aiosqlite.Connection) -> None:
"""Close *conn* without letting an ordinary close failure replace a real error.
``aiosqlite.Connection.__aexit__`` is an unconditional ``await close()``, so
a bare ``async with aiosqlite.connect(...)`` would let a close failure here
replace whatever the block above actually raised. This logs an ordinary
close failure instead of raising it, so the original error still
propagates; a cancellation landing during this close is not covered the
same way.
"""
try:
await conn.close()
except Exception as exc: # noqa: BLE001 - logged, not raised
print(f"warning: failed to close the database connection: {exc}", file=sys.stderr)
async def main() -> None:
conn = await aiosqlite.connect(":memory:")
try:
conn.row_factory = aiosqlite.Row
store = SqliteEngravaCore(conn)
await store.ensure_schema()
fact = await store.create_thought(
ThoughtRecord(
thought_id=str(uuid.uuid4()),
thought_type=ThoughtType.BELIEF,
essence="The user lives in Berlin",
content="Open-ended until superseded.",
priority=Priority.P2,
lifecycle_status=LifecycleStatus.ACTIVE,
created_cycle=10,
updated_cycle=10,
source="onboarding",
valid_from="2026-01-01T00:00:00+00:00",
),
)
executor = MindQLExecutor(conn)
before = await executor.execute(parse("FIND thoughts WHERE valid_now"))
assert len(before.rows) == 1 # currently valid
# Reality changed: the fact stopped being true on 2026-06-01.
await store.invalidate_thought(
fact.thought_id,
valid_until="2026-06-01T00:00:00+00:00",
)
after = await executor.execute(parse("FIND thoughts WHERE valid_now"))
assert len(after.rows) == 0 # no longer valid "now"
# Not a delete: the row is still on file and auditable.
still_there = await store.get_thought(fact.thought_id)
assert still_there is not None
assert still_there.valid_until == "2026-06-01T00:00:00+00:00"
print("valid_now before:", len(before.rows), "after:", len(after.rows))
except BaseException:
await _close_quietly(conn)
raise
else:
await conn.close()
asyncio.run(main())
When you don’t need valid time
If your application only ever asks “what is true now”, you do not need to do
anything. Every record is created with valid_from = None and
valid_until = None, which the NULL-tolerant predicates above (valid_now,
valid_at, valid_within) treat as “valid for all time” (valid_between is
the deliberate exception — see the four query predicates), so:
- you never have to set a timestamp,
- queries that use no temporal predicate are unchanged, and
- if you do later run a
valid_now/valid_atquery, your never-annotated facts still match (open bounds are ±∞).
Valid time is a tool for the cases where history matters — auditing what an agent believed at some past moment, or modelling facts with a real-world lifespan. When that is not your problem, ignore it; it imposes no cost and changes no existing behaviour.
Next
- Hybrid Search — the query surface the temporal predicates live in.
- Dreaming — how reflections (which inherit valid-time extent) are made.
- API Reference —
valid_from/valid_untilfields andinvalidate_*methods.