feat(state): give memory an expiry and multi-source provenance - #440
Merged
Conversation
Memory items could not express two things a durable store has to be able to say, and the contract was silent on a third. Expiry. A fact with a known death date, a credential that rotates on Friday, a plan void at the end of the month, had nowhere to record it. The date is known at write time and nowhere else, so a host sweeping the store later can only guess. MemoryItem.ExpiresAt carries it, the zero value never expires, and Recall omits an expired item on every backend. Expiry is half-open like the Since/Until window, so an item expiring at T is recalled at T-1ns and not at T, and it hides a fact from retrieval rather than deleting it: the record is still on the event stream and Delete still tombstones it. Provenance. Source was one string, so an item distilled from several inputs could record only one of them. That makes a purge approximate exactly where it needs to be exact: retiring everything a compromised source contributed to has to find the items where it was one contributor among many. Sources is a list, order preserved, and no provenance stays distinguishable from provenance that is the empty string. The guard grades a multi-source item by its weakest source, so mixing one trusted input into a poisoned item does not buy it past the write gate. Dedup and retention. Memory is append-only, so contradictory facts accumulate and nothing links a correction to what it corrects. That stays the host's to resolve, because the rule is domain knowledge a store cannot infer, but the contract now says so rather than leaving each backend to invent an answer, and says what a backend may not do: drop a write it judges duplicate, or collect old items on its own. Both fields flow through the shared selector and one clock reading per recall, so backends cannot drift. SQLite stores expiry as unix nanoseconds rather than the RFC3339 text created_at uses, which is not fixed-width and does not compare correctly; that lets the predicate stay in SQL, including on the prepared statement behind the scoped read an agent issues at startup. Items written under the old single-source shape decode into a one-element list, on the event stream and in the resource store alike, so a rebuild reproduces the state those events originally produced. The conformance suite covers both across every recall shape, and a driven clock pins the expiry instant itself on both the prepared and the built query. Signed-off-by: Ion Alpha <contact@ionalpha.io>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Four issues, all mechanical except the first: The conformance suite read its own clock to build the expiry times. Recall judges expiry against the backend's clock, so a suite reading this process's time was asserting the two clocks agree rather than asserting the contract. The times now anchor to a stamp the store itself assigned, which is also what the forbidigo rule is there to enforce. The rest: a fixed far-future date instead of time.Now in the boundary test, gofumpt on the memory row scanner, and TrustOf's doc comment left stranded above TrustOfAll when the new function was inserted before it. Signed-off-by: Ion Alpha <contact@ionalpha.io>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Three changes to the
state.MemoryStorecontract, all of them about what a memory item is allowed to say about itself:MemoryItem.ExpiresAt. An item can now carry the instant it stops being recallable. The zero value never expires.Recallomits an expired item on every backend.MemoryItem.Sources []stringreplaces the single-valuedSource. An item distilled from several inputs can record all of them, in the writer's order.MemoryStoreinterface, along with what a backend may not do about them.Why
The first two are things the contract could not express, and both are cheap now and breaking later: once other implementations exist, changing the shape of
MemoryItembreaks all of them at once.Expiry. A fact with a known death date, a credential that rotates on Friday, a plan void at the end of the month, had nowhere to record it. That date is known at write time and nowhere else, so a host sweeping the store afterwards can only guess at it.
Provenance. A single source string makes a purge approximate exactly where it needs to be exact. Retiring everything one compromised source contributed to means finding the items where it was one contributor among several, and a single field silently under-reports precisely those. It also lets the write gate be graded correctly: a multi-source item now takes the trust of its weakest source, so mixing one trusted input into a poisoned item no longer buys it through.
Dedup and retention. Memory is append-only, so contradictory facts accumulate and nothing links a correction to what it corrects. Resolving that stays the host's job, because the rule is domain knowledge a store cannot infer (one preference per key, but every observation kept). The defect was that the contract never said so, leaving each backend to invent its own answer. It now states the ownership, and states that a backend may not silently drop a write it judges duplicate or garbage-collect old items on its own.
How to verify
go test ./...The behaviour is pinned in the shared conformance suite, so both backends are held to it:
memorytest.RunSuitegainsProvenanceandExpiry. The expiry case runs every recall shape a backend implements: scoped and query-less, full-text, unfiltered, kind-filtered, widened over a scope chain, relevance-ordered, and capped. That matters because SQLite answers the scoped query-less read from a prepared statement and everything else from a query built per call; a backend that put the expiry predicate in only one of them would serve expired memory from the other and still pass a suite that tested one shape.TestProviderMemoryExpiryIsExactInBothQueryShapesdrives a manual clock across the expiry instant and asserts both SQLite query shapes agree on it, which is the part a suite running on the wall clock cannot reach.TestMemoryItemExpiredAtBoundaryandTestRecallQuerySelectsRejectsExpiredpin the half-open boundary and that expiry outranks every other selector.TestMemoryItemUnmarshalAcceptsLegacySourceandTestRecallReadsLegacySingleSourceResourcescover reading data written under the old shape.Patch coverage is 96.8%; the uncovered lines are the failure branches of the conformance suite's shared assertion helpers, which by construction never run on a green build.
Notes for reviewers
Compatibility. Items written under the old single-source shape are still on the spine and in the resource store, and both read paths accept them:
MemoryItem.UnmarshalJSONfolds a legacySourceinto a one-elementSources, and the resource spec does the same for a storedsource. Without that, a rebuild would decode those events into items with no provenance and quietly rewrite the history the replay exists to reproduce. Nothing writes the old field any more.Migration 0021 adds
sources(a JSON array) andexpires_at, backfills existing rows into one-element arrays so no row loses provenance, and dropssource.Expiry is stored as unix nanoseconds, not RFC3339 text.
created_atis RFC3339Nano, which drops trailing zeros from the fractional second and so is not fixed-width and does not compare correctly as text. Expiry is evaluated on every recall including the prepared statement, so it has to be a predicate SQLite can answer exactly rather than one the Go side re-checks afterwards. (Thecreated_atordering defect itself is separate and untouched here.)RecallQuery.Selectstakes anowargument now. Expiry rides in the shared selector rather than in a separate pass because a backend that forgot the separate pass would serve expired memory and pass every other test. Each backend reads its clock once per recall, so one result set is judged against one instant.Deliberately not included: a
Supersedesfield. Linking a correction to what it corrects would need a reverse lookup in every backend and makes a recall's result set depend on writes it did not read; that is worth doing on its own evidence, not folded in here. The contract now names the gap instead of being silent about it.