ADR-032: Temporal retrieval is one overlap rule over validity time, and history walks explicit supersession links¶
Status¶
Accepted.
Amends ADR-021: current and as_of keep their meaning and become two cases of one rule,
which also gives an interval, and a superseded drawer now records what replaced it.
Builds on ADR-005 (the string form valid_to is compared in)
and ADR-007 (history is a read).
Context¶
ADR-021 made point-in-time search work: current, as_of and include_historical.
Issue #162 asks for the rest of what a memory that changes over time needs.
An agent should be able to ask what was true during a period, and how a piece of knowledge evolved,
without rebuilding the answer from a pile of search hits.
Two gaps stood in the way.
- There was no interval. "What did we believe during January?" has no spelling as a list of points, and a client that sampled points would miss a fact that began and ended between two of them.
- Nothing recorded which drawer replaced which.
supersede_drawerset the old drawer'svalid_to, handed its name to the replacement and stopped. A history could only be guessed from a room, a name that the old drawer no longer has, a mining origin, or the oldvalid_toequalling the newvalid_from, and the last was not even true of mined drawers: the pipeline built the replacement, then read the clock again to close the old one, leaving a few milliseconds in which both were valid.
The same issue forbids a temporal index or datastore, and requires lexical, semantic, hybrid and graph retrieval to agree.
Decision¶
- Time means validity time.
valid_fromandvalid_tosay when the knowledge was true.created_atandupdated_atsay when MemCastle recorded it, and are never searched. A memory discovered long after the period it describes is found by asking about that period. - One rule, an overlap.
Validity is the half-open range
[valid_from, valid_to), and novalid_tomeans open-ended. A record overlaps the window[from, until)whenvalid_from < untiland it has novalid_toorvalid_to > from. Touching is not overlapping: a record closed exactly atfrom, or opened exactly atuntil, is outside. A point t is the window[t, t + 1ns), socurrent(t = now) andas_ofrun the very same clause as an interval, and a supersession at t leaves exactly one of the two records valid at t.include_historicalis no clause at all. - The clause lives in one place.
store::retrieval::VALIDITY_WHEREis spliced into the shared scope every leg applies, so lexical, semantic and hybrid search cannot disagree, and into graph expansion'srelates_tohops, so an edge is judged exactly as a drawer is. It is a predicate beside the full-text and vector lookups: no index, no second structure. TemporalgainsBetween { from, until }. The wire form is{"between": {"from": ..., "until": ...}}onPOST /api/search. An empty or reversed window (from >= until) is refused where it enters,memcastle::input::invalid, because it contains no instant and would otherwise silently match nothing.- The options are
fromanduntil, and the instants may be dates.as_of,fromwithuntil, andinclude_historicalare mutually exclusive, andfromwithoutuntilis an error naming the missing end. Both ends are required: an open end is a different question (a lower bound alone is "since", an upper bound alone is "ever before"), better added deliberately than inferred. An instant is RFC 3339 or aYYYY-MM-DDdate, read as midnight UTC at the start of that day, so--as-of 2026-01-01works as written and--from 2026-01-01 --until 2026-02-01is exactly January. One conversion,SearchOptions::into_query, serves REST, MCP and the CLI. - Supersession is recorded.
drawergainssupersedesandsuperseded_by, plain drawer ids like every foreign key, andsupersede_drawersets both inside the transaction that closes the old drawer and opens the new one. The link is written by the store, not by a caller, so a pair cannot be half-linked and no writer can forget it. A drawer closed without a replacement has nosuperseded_by: that is an invalidation, not a missing link. - A migration pairs history that already exists.
Data migration 3 (
since-0.2, piecesupersession-lineage) links a closed drawer to a successor in the same room that opened at the instant it closed, or that came from the same mined document chunk and opened within ten seconds before. A link is written only when exactly one drawer qualifies, and an ambiguous or unmatched close is left alone. - Mining opens the replacement at the instant it closes the old drawer.
The pipeline sets the replacement's
valid_fromto the instant it passes tosupersede_drawer, as the manual path already did, so a point-in-time search lands on one version and never two. - History is a read of the chain, by drawer id.
drawer_historywalks the links both ways from any member and returns every version oldest first, each verbatim: identity, validity period, provenance and content, without embeddings. The walk is bounded and remembers where it has been, so a corrupt link ends it rather than looping, and a link to a deleted drawer ends it too. It is exposed asGET /api/drawers/{id}/history,memcastle drawer history, andmemcastle_history. It is a read like search (ADR-007), so a read-only session may use it and a disabled one may not. The MCP tool is read-only and takes an id, which every search hit carries, so an agent searches (withinclude_historicaloras_of) and then follows a hit, with nothing to reconstruct.
Alternatives rejected¶
- A temporal index or a table of versions. The issue forbids a second structure, and validity is already on the drawer. A predicate beside the existing lookups costs a comparison per candidate, which the prefilter already pays for the rest of the scope.
- Inferring history at read time.
The old drawer gives up its name on supersession and many drawers have none, a mining origin only identifies mined chunks,
and
old.valid_to == new.valid_fromwas false for mined drawers until now. A chain that is right for some drawers is worse than one that is explicit for all of them. - A
supersedesedge table instead of two fields. It would be a third shape for a one-to-one relation that a drawer can carry in two columns and that history reads by id. It would also make the walk a graph query for no gain. - Separate point and interval constructs in the query. Two predicates that are meant to agree would eventually differ at a boundary. Treating a point as a one-nanosecond window makes that impossible by construction.
- A single
--between A Bflag. It is awkward to spell as a query-string parameter and as an MCP argument, which would then not match the CLI. --valid-fromand--valid-toas the names. They echo the drawer's fields, but a drawer'svalid_tois an exclusive end that can be open, and a search window is neither. They would also read as record fields in the output.- Accepting a half-open interval (
--fromalone). See above: it is a different question, and the answer can still be had with a far bound until a use case argues for it. - A history addressed by search text. It would put ranking decisions (which hits, how many chains) into an operation whose point is to be exact. Search finds the drawer, history explains it.
- Letting an MCP caller write a back-dated drawer to test the model. Writing validity time is a separate decision with its own consequences for provenance; nothing here needs it.
Consequences¶
- An agent can ask what is true now, what was true on a date, what was true across a period, and how a decision evolved, with the same options on REST, MCP and the CLI and the same answer from every ranking.
currentandas_ofresults are unchanged, except that a mined drawer's predecessor no longer overlaps it by milliseconds.- Every drawer row gains two optional columns, carried in each projection, and
Drawergains two optional fields that are absent from the JSON when unset, so existing clients and the fixtures are unaffected. - A palace written before this release has its mined and manual supersessions linked on the next start, and any it could not pair unambiguously read as one-version histories.
- The window's end is exclusive on both sides,
which is the convention that makes
--from 2026-01-01 --until 2026-02-01mean January, and the one thing a user used to inclusive ranges has to learn. - There is still no way to write a drawer with a back-dated
valid_from, so validity and record time differ today only for mined content and for corrections, which is where the distinction matters.