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_at explicitly, 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_between is the exception and excludes a record when either bound is None (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_from and valid_until, the interval must run forwards — valid_from at or before valid_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 on invalidate. Because the invariant lives in the domain model, the store also validates it whenever a row is hydrated into a full record — get_thought() and get_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 a ValidationError there, 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_until converges to the same stored value;
  • non-cascading — invalidating a thought leaves every connected edge untouched (invalidate the edges separately with invalidate_edge if you need to);
  • not a delete — the row and its history remain. A point-in-time query for an instant before the new valid_until still 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_from becomes the earliest member valid_from — unless any member has an open (NULL) lower bound, in which case the reflection’s valid_from is also open (NULL). An interval that summarises something open-ended is itself open-ended.
  • valid_until becomes the latest member valid_until — but only if every member has a closed upper bound. If any member is still open, the reflection’s valid_until is 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_at query, 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_until fields and invalidate_* methods.