lontar

Corrected vs. ended: two ways a fact stops being true

A fact can stop being true in two different ways. The world can change: the acme-api project deployed from main, and now it deploys from trunk. Or the fact was never true: I wrote down that it runs on Postgres 15, and it runs on 16. Both facts have to stop being returned, but they are different events. The first was true for a while. The second never was, and the record that I once believed it is worth keeping.

A common schema folds both into one UPDATE, or one valid_to column, and loses which. That loss gets expensive when the writer is a coding agent, because the writer is often wrong, and it retries.

lontar is the memory store I built for that case: a Postgres database behind six MCP tools, run locally as a Claude Code plugin. The first post covers what it takes to share one memory across agents. This one is about the two ways a fact in it can stop being true, and the rules that keep them apart.

Everything I describe about lontar is in the code today, and where a test pins a rule, I name the test. Not every rule has one, so I don't claim they all do. Anything that isn't built is marked designed.

Two timelines

Every fact row carries two intervals, one on the world axis and one on the belief axis. valid_from and valid_to say when the statement was true in the world. recorded_at and invalidated_at say when the store held it as something it believed. A fact stops being true by changing one end of one of them, and which end is the whole difference between ended and corrected.

Both intervals are half-open: valid_from and recorded_at are inclusive, valid_to and invalidated_at exclusive. A fact valid until April 14 is gone at the instant of April 14. The queries exist twice, as SQL and as a pure function, and a contract test, "the SQL predicates agree with domain/temporal.ts on every boundary", drives both over query points that land exactly on each boundary.

valid_from has no database default. The application fills it in when the caller omits it, so an insert that forgets it fails instead of quietly collapsing the model into one timeline.

Here is the running example, invented, for a project called acme-api. On February 2 an agent records "deploys from main" and "runs on Postgres 15". On April 14 the team moves to trunk, so the first fact is ended and "deploys from trunk" starts that day. On June 9 the team finds the database was on 16 all along, so the second fact is corrected, and "runs on Postgres 16" replaces it with the same window. What recall_facts returns:

Called with Returns
nothing deploys from trunk; runs on Postgres 16
as_of March 1 deploys from main; runs on Postgres 16
as_believed May 1 deploys from main; deploys from trunk; runs on Postgres 15
as_of May 1 and as_believed May 1 deploys from trunk; runs on Postgres 15

Two rows are worth reading slowly. "Runs on Postgres 15" is missing from the as_of March 1 row because it was never true on March 1 or any other date; it comes back only when you ask what was believed, which the "point-in-time belief query includes since-corrected facts" test pins. And as_believed alone ignores the world axis, which is why it still lists "deploys from main": pass both parameters to ask what was believed and true on the same day. A test named "as_of and as_believed together apply BOTH axes" pins the last row of the table.

This is old, and Postgres doesn't do it for you

None of this is new. The temporal-database community's consensus glossary (the 1998 version) defines both timelines: valid time is "usually supplied by the user", transaction time is "system-generated and -supplied", and past transaction times "cannot be changed". Snodgrass's book on time-oriented SQL (Morgan Kaufmann, ©2000, freely available from the author's site) describes the split this post is about: archival rows "originate from two separate processes", an end time that later "becomes known", and the case "when erroneous data was discovered and corrected; the data in error is retained" (p. 338). Fowler's 2021 article calls the two timelines actual history and record history, and says "record history itself is append only".

SQL:2011 standardized the pieces, as Kulkarni and Michels describe: system-versioned tables for transaction time and application-time period tables for valid time. It has no term for a table that is both; "bitemporal tables" is the paper's word, not the standard's.

PostgreSQL 16, which lontar runs on, has neither table type: T180 and T181 are on its unsupported-features list. PostgreSQL 18 added WITHOUT OVERLAPS for primary keys and unique constraints and PERIOD for foreign keys (release notes), not system-versioned tables. The machinery is yours to build.

What lontar adds is small. A required discriminator: every revision says ended or corrected. A correction's successor inherits both bounds of the original's window, which is my design choice; I haven't found it stated as a rule anywhere. And guards for a writer that retries, which the next three sections cover.

Ended

ended is the ordinary case: the fact was true, and the world moved on. The revision sets the fact's valid_to to at, which is now if the caller leaves it out, and a replacement, if there is one, becomes a new row that starts at at and has no end. In the running example, "deploys from main" gets a valid_to of April 14, and "deploys from trunk" starts the same day.

Two guards sit in front of that write.

The first is range. at comes from a model, so nothing upstream stops it from preceding the fact's own valid_from. The database CHECK would catch that, and what the model would get back is an opaque SQL error it can't act on. So the application checks first and returns a typed InvalidTemporalRange saying valid_to precedes valid_from, and the transaction rolls back. The test "refuses to end a fact before it began, with the typed range error" pins it. Ending a fact at exactly its valid_from is allowed.

The second is state. A fact that has already ended can't end again: the call fails with FactAlreadyRetired, whose message ends "re-read current state before revising". It is a different error from "not found" on purpose, because "not found" would send the model hunting for another id when it should re-read. The test "refuses to end a fact that has already ended" pins it.

The second guard exists because of retries. A model keeps ids it saw in an earlier recall, and revise_fact is annotated idempotentHint: false, so a retried call can land on a fact that is already retired. Without the guard, the UPDATE runs again and a second successor is inserted. The store then holds two current "deploys from trunk" rows, or two contradictory ones if the retry worded the replacement differently, and the next session's recall hands both to the model.

Corrected, and the window the successor inherits

corrected is the other half, and the tool's own wording for it is "the fact was NEVER true and we were wrong". The revision sets invalidated_at to at, which is the belief axis, and leaves valid_to alone. A replacement, if there is one, is inserted in the same transaction.

A corrected fact is terminal. Any later revision, of either reason, fails with FactAlreadyRetired and retiredBy: "corrected". The test "a corrected fact is terminal; any later revision fails FactAlreadyRetired" pins it. The guard has to be separate from the one for ended facts, because a corrected fact keeps an empty valid_to: an ended revision would walk straight past a valid_to check, and either reason would insert a second successor beside the first.

The successor inherits both bounds of the original's window, valid_from and valid_to, plus its subject, and supersedes points back at the original. Both bounds, because of a bug I wrote. My first version carried only valid_from. Take "deploys from main", ended April 14, and suppose someone corrects its wording to "deploys from the main branch". With only valid_from carried, the new row has no valid_to, and an empty valid_to is exactly what the current filter matches, so the dead branch comes back as current beside "deploys from trunk".

The suite didn't catch it, and the reason is the instructive part. Every corrected test started from a fact with no end, so the buggy value and the expected value were both empty. Review caught it before the code first merged. The test "'corrected' carries the original's valid_to to the successor" now starts from an ended fact.

Correcting an ended fact is legal on purpose. Fixing the wording of something that has already ended is ordinary, and the successor carries the end forward instead of reopening it.

Because a correction lives on the belief axis, the wrong fact isn't deleted. as_of never returns it, and as_believed does, which the remember skill calls "how the record of having been wrong is preserved". In the table under "Two timelines", "runs on Postgres 15" is that row: absent for every world date, present when you ask what was believed on May 1.

Without terminality, a model that retries a correction, or tries to end a fact it has just corrected, would leave two replacements both current, "runs on Postgres 16" and a second take on it, and two rows that each claim to have replaced the same fact.

Making the distinction impossible to skip

A rule is only as good as the paths around it, so the paths are closed.

revise_fact is the only way to retire a fact. remember_fact inserts and nothing else, and it has no supersedes parameter, because one would let a caller retire a fact without saying which way, and that is the one distinction the whole model exists to preserve. On revise_fact the reason is required, and the wire schema accepts exactly corrected or ended.

Requiring a reason doesn't make a model choose well, so the remember skill gives it a test question: "if someone asked what was true last month, should this fact be in the answer?" Yes means ended, no means corrected. It also has a rule for when the test doesn't settle it: "When you cannot tell which applies, ask. Never guess."

The retirement and the successor's insert happen in one transaction. The test "revise_fact with a replacement retires and inserts in ONE transaction" breaks the insert with a 501-character replacement and checks that the original is still current, so the retirement rolled back with it.

Two sessions can race. Each Claude Code session runs its own MCP server against the same database, and the target row is read with SELECT … FOR UPDATE. Without that lock, both revisions would pass the guards and both would insert a successor. The test "concurrent revisions of one fact leave exactly one successor" holds the row, parks both revisions behind it, and expects one accepted and one FactAlreadyRetired. With the lock removed, it fails with both accepted.

Spaces get the same treatment. The lookup is scoped to the session's write space, so a work session can't even see a personal fact to revise ("a fact cannot supersede a fact in another space"), and a composite foreign key from (supersedes, space_id) to (id, space_id) rejects it in the schema ("refuses a fact superseding a fact in another space", a raw insert).

For a retrying writer, that adds up to one thing: a duplicate, late or crossed revision ends as a typed refusal, and the store never holds two answers to what is current.

What it bends and leaves out

This is where the model bends, and what it leaves out.

"Current" means the end of the chain, not "true now". The filter is invalidated_at IS NULL AND valid_to IS NULL, with no comparison against valid_from, so a fact dated for next month counts as current today.

The correction time is the caller's. SQL:2011 makes system-time columns GENERATED ALWAYS, and users "are not allowed to assign or change" them. Here the database assigns recorded_at, but invalidated_at is whatever at the caller passes, and the test "does not range-check a 'corrected' revision" pins that nothing checks it.

ended is an in-place update. In Snodgrass's first process the previous row moves to the archival store when an end time becomes known. Mine overwrites valid_to, so only corrections leave a trace on the belief axis. Ask what the store believed on March 1 about May 1, and "deploys from main" is missing, because its end is now written into the only row it has. No test pins this, or the future-dated case above.

There are no period keys and no overlap checks. Nothing stops two current facts about one subject from disagreeing, and PostgreSQL 16 has no WITHOUT OVERLAPS to ask for; 18 added it for keys. Keeping facts from colliding is the writer's job.

Where this stands

Everything above is in the code today, and it runs for one user: me. It is a stdio MCP server over a Postgres I run myself, installed as a Claude Code plugin. The repository is private for now. It will be open source (AGPL-3.0), whether or not a hosted version gets built.

The hosted version does not exist. Its plan is the same six tools with today's behavior, so these rules would carry over (designed). Whether it gets built depends on a waitlist: if 280 people confirm a signup at lontar.dev before the window closes, I build it, and if not, lontar stays a personal tool and the list is deleted. The page has the date and the details.

If you take one thing from this post, it isn't lontar. Treat the two ways a fact stops being true as two operations, and make the writer say which one. A writer that is often wrong and retries will take the easiest path, so the easiest path has to keep the difference. If you've modeled this another way, or found a case where ended and corrected aren't enough, I would like to compare notes.