Why AI Agents Need Bi-Temporal Memory Architecture
An agent needs bi-temporal memory because two different clocks matter: when a fact was true in the world (valid time) and when the system asserted that fact (transaction time). Collapse them into one created_at column and the agent has no deterministic way to tell a current belief from a superseded one, so retrieval returns contradictory facts with near-identical similarity scores and the model guesses. Modeling both axes as PostgreSQL range types, indexed with GiST and paired with pgvector, lets you filter on temporal boundaries before ranking by cosine distance. That supports two queries a single-timestamp store cannot answer: what was true on a given date, and what the agent believed at the moment it produced a given response.
Teams building long-running LLM agents usually treat memory as a retrieval problem. Index the conversation turns into pgvector or Pinecone, stamp each row with created_at, rank by cosine distance. That holds up for documentation QA, where the underlying facts do not move. It falls apart once the agent has to reason about entity state that changes, corrections a user makes weeks later, or anything a person would describe with a date. The failure is quiet: the agent keeps answering, it just answers with state that expired months ago, and standard RAG evals do not catch it because the retrieved chunks look relevant. This piece walks through why one timestamp is not enough, how we model memory bi-temporally in PostgreSQL inside Suprflo, the two kinds of point-in-time question that model can answer, how retroactive corrections get recorded without destroying history, and what it takes to keep p99 latency under 50ms once the table passes 50 million rows.
Memory Is a Temporal Modeling Problem
An agent that works with the same user across months or years needs memory that models time explicitly, and vector similarity on its own does not do that. Most teams start somewhere else. They embed conversation turns, write a single created_at alongside each vector, and ship. For static documentation that is the right design. For anything holding state about a person or an account it is not.
The gap shows up the first time the agent has to reason over an entity whose state changed. Without explicit temporal boundaries, there is nothing in the store that separates what is true now from what used to be true, so the model conflates the two. What reaches the user is a confident answer built out of expired state, and an eval scored on retrieval relevance marks it correct, because the retrieved rows genuinely are relevant.
The Single-Timestamp Trap
Relational temporal database theory, worked out by Richard Snodgrass and others in the late 1980s, separates two orthogonal axes. Valid time is when a fact was true in the real world. Transaction time is when the database recorded or asserted it. They move independently, and most agent memory platforms collapse both into one created_at column that really only records when the embedding hit disk.
Take a customer support agent. On September 15th the user writes: "I moved from Chicago to Seattle back on July 1st, but my billing address was still my old Chicago home until August 15th." If the memory layer stores created_at = 2026-09-15 and nothing else, then a later query asking where the user was residing in July 2026 has only cosine distance to work with. Both the Chicago and the Seattle embeddings overlap heavily with "user address residence", so vector search returns both at near-identical similarity.
The model now has a context window holding two contradictory residence claims carrying the same timestamp. There is no deterministic basis for choosing between them, so it either picks one or synthesises a hybrid answer in which the user lived in two cities at once. Multiply that across thousands of sessions and a single-timestamp store steadily fills with superseded entity state that corrupts every prompt built from it.

How the Bi-Temporal Model Works in PostgreSQL
To remove the ambiguity, Suprflo models every memory atom as a bi-temporal record, and both time axes are stored as native PostgreSQL range types rather than as pairs of loose timestamp columns. One range holds valid time, the interval during which the real-world state existed. The other holds transaction time, the window during which the system believed that record to be its active, un-superseded truth. Both are half-open, so a fact that is still true has no upper bound yet.
Around those two ranges sits what any memory atom needs: the tenant and entity it belongs to, a key naming the kind of fact, the structured value itself, an extraction confidence score, and the embedding of the fact statement. Each of the two ranges carries a GiST index. The embedding carries an HNSW index for cosine ranking.
That pairing is the whole point of the design. Because the ranges are native types and not encoded strings, PostgreSQL can evaluate them as hard containment filters before or during vector index traversal. Temporal correctness then happens in an index scan, before the model is involved at all, so nothing about it has to be inferred from context at read time.
- The valid-time range tracks when the fact held true in the physical or domain universe, drawn from what the user said rather than from when the row was written.
- The transaction-time range records the system assertion window, which is what makes point-in-time reconstruction of the agent's own belief state possible.
- A GiST index on each range gives O(log N) temporal boundary filtering, so the expensive vector distance work only ever runs against a small candidate set.

Time-Travel Queries: Reality Against Perception
Two query shapes become available once the axes are separate, and neither one can be expressed against a single-timestamp store.
The first is world state as-of, which reads the past through current knowledge. "Given everything the agent knows today, what was the user's state on July 20th?" You ask for the records whose valid-time range covers July 20th and whose transaction-time range is still open, meaning nothing has since superseded them. Semantic ranking then runs only over what survives that filter, so what comes back is the current best understanding of a past reality.
The second is agent perception as-of, and it is an audit question rather than a factual one. "What did the agent think the user's address was when it generated response #4021 last Tuesday at 14:00 UTC?" Here you leave valid time alone and pin transaction time to that instant instead, which selects whatever the system was asserting at the moment the response was produced. It reproduces the exact belief state behind a specific output, which is what compliance reviews, incident debugging, and regulatory safety work need.
Retroactive Revisions Without Destroying History
In a conventional table, fixing a past entry means an UPDATE that overwrites the row. In agent memory that is the wrong operation for two reasons. It destroys the audit trail, and it takes away the agent's ability to explain why its own reasoning changed.
Suprflo treats transaction history as append-only. Say the user comes back with "actually, I moved on June 15th, not July 1st." That correction is written in two steps inside one transaction. First the superseded fact has its assertion window closed, by fixing the upper bound of its transaction-time range at the current moment. Then a new row goes in carrying a valid-time range that starts on June 15th and a transaction-time range that opens now.
The old row stays exactly where it was. It simply drops out of any query that filters on an open transaction window, so current lookups route to the corrected assertion while the audit history remains readable.

Performance: Hybrid Filtering and Autovacuum Tuning
Putting range types in front of vector retrieval creates real production problems around index selection. The PostgreSQL planner can misestimate costs when a GiST index scan has to combine with HNSW distance ordering, so the plan it lands on is not something you can rely on at this scale.
Holding p99 under 50ms on tables past 50 million memory rows means forcing the staging explicitly, either through a CTE-based two-stage retrieval or straightforward SQL pre-filtering. Constrain by tenant and valid-time containment first and the working set drops from millions of vectors to dozens of candidates before any floating-point cosine distance gets evaluated.
The second issue is bloat. Superseding a fact means updating the upper bound of a transaction-time range, and on a high-churn memory table dead tuples pile up fast. We tune autovacuum aggressively on the memory table specifically rather than relying on cluster defaults, lowering the scale factor by roughly an order of magnitude and raising the cost limit, which keeps dead tuple overhead low without stalling active readers.
- Pre-filter by tenant and temporal constraints before anything touches vector distance, because that is where the cost is.
- Keep the transaction dimension append-only, so the audit trail stays complete and reproducible under review.
- Tune autovacuum per-table on high-churn bi-temporal memory, otherwise index bloat turns into latency spikes.
Related guides, services & programmes
Frequently Asked Questions
Bi-temporal means the memory tracks two independent clocks per fact. Valid time is the interval during which the fact was true in the real world. Transaction time is the interval during which the system asserted that fact as its active belief. Keeping them separate is what lets an agent record that a user moved in June even though they only mentioned it in September.
Because created_at only records when the embedding was written, not when the fact held true. Two contradictory facts ingested in the same message share that timestamp, and semantically similar embeddings return near-identical cosine scores, so the model has no deterministic basis for picking the one that applies to the date being asked about. It guesses, or it synthesises a false hybrid answer.
Store each time axis as a native PostgreSQL range type rather than as a pair of timestamp columns, one range for valid time and one for transaction time, each a half-open interval so a still-current fact has no upper bound. Index both ranges with GiST, and keep the embedding in a pgvector column with an HNSW index. Range containment then works as a hard filter that runs before cosine ranking, instead of as a post-filter applied to whatever the vector search happened to return.
Nothing is overwritten. The superseded row has the upper bound of its transaction-time range closed at the current moment, and a new row goes in carrying the corrected valid-time range and a freshly opened transaction-time range. The old assertion stays readable for audit but drops out of any query that filters on an open transaction window.
Done correctly it speeds it up. GiST range containment is O(log N), so filtering by tenant and temporal bounds first reduces tens of millions of rows to a handful of candidates before expensive floating-point cosine distance is evaluated. The risk is the planner combining GiST and HNSW badly, which is why the two stages are forced explicitly through a CTE or SQL pre-filter.
Audit and debugging. By pinning the transaction-time range to a specific instant, you reconstruct exactly what the agent believed when it produced a given response, rather than what is known now. That makes a past output reproducible, which matters for compliance review and for root-causing an incident.
Want this distilled for your roadmap? Brief the Baaz squad or scan ship evidence in case studies.