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).
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, automatically |
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: it never moves backwards and you don't manage it. It 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.
The cycle (created_cycle) is not a calendar. It is a monotonically
increasing integer you advance once per agent turn (see
Core Concepts → Cycle). 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.
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 and matches every "is it valid?" query.
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.
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 [<start>, <end>] |
tolerant |
valid_between <start> <end> |
two timestamps | the interval is fully contained in [<start>, <end>] |
strict — open-bound rows are excluded |
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_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 |
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 |
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.
A REFLECTION is created by dreaming from a cluster of member
thoughts. When dreaming builds one, 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.
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.
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 uuid
import aiosqlite
from engrava import (
LifecycleStatus,
Priority,
SqliteEngravaCore,
ThoughtRecord,
ThoughtType,
)
async def main() -> None:
async with aiosqlite.connect(":memory:") as conn:
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)
asyncio.run(main())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 uuid
import aiosqlite
from engrava import (
LifecycleStatus,
MindQLExecutor,
Priority,
SqliteEngravaCore,
ThoughtRecord,
ThoughtType,
parse,
)
async def main() -> None:
async with aiosqlite.connect(":memory:") as conn:
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))
asyncio.run(main())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 uuid
import aiosqlite
from engrava import (
LifecycleStatus,
MindQLExecutor,
Priority,
SqliteEngravaCore,
ThoughtRecord,
ThoughtType,
parse,
)
async def main() -> None:
async with aiosqlite.connect(":memory:") as conn:
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))
asyncio.run(main())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 means "valid for all time", 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.
- MindQL — the full query language the temporal predicates live in.
- Upgrade Guide — how an existing database gains the valid-time columns automatically.
- Core Concepts — thoughts, edges, cycles, and the rest of the model.
- Dreaming — how reflections (which inherit valid-time extent) are made.