Skip to content

feat(state): give memory an expiry and multi-source provenance - #440

Merged
ion-alpha-dev merged 3 commits into
mainfrom
memory-contract-expiry-provenance
Jul 31, 2026
Merged

feat(state): give memory an expiry and multi-source provenance#440
ion-alpha-dev merged 3 commits into
mainfrom
memory-contract-expiry-provenance

Conversation

@ion-alpha-dev

Copy link
Copy Markdown
Collaborator

What

Three changes to the state.MemoryStore contract, 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. Recall omits an expired item on every backend.
  • MemoryItem.Sources []string replaces the single-valued Source. An item distilled from several inputs can record all of them, in the writer's order.
  • Dedup and retention are documented as host policy on the MemoryStore interface, 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 MemoryItem breaks 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.RunSuite gains Provenance and Expiry. 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.
  • TestProviderMemoryExpiryIsExactInBothQueryShapes drives 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.
  • TestMemoryItemExpiredAtBoundary and TestRecallQuerySelectsRejectsExpired pin the half-open boundary and that expiry outranks every other selector.
  • TestMemoryItemUnmarshalAcceptsLegacySource and TestRecallReadsLegacySingleSourceResources cover 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.UnmarshalJSON folds a legacy Source into a one-element Sources, and the resource spec does the same for a stored source. 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) and expires_at, backfills existing rows into one-element arrays so no row loses provenance, and drops source.

Expiry is stored as unix nanoseconds, not RFC3339 text. created_at is 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. (The created_at ordering defect itself is separate and untouched here.)

RecallQuery.Selects takes a now argument 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 Supersedes field. 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.

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

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.92265% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
memory/memorytest/memorytest.go 83.07% 5 Missing and 6 partials ⚠️

📢 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>
@ion-alpha-dev
ion-alpha-dev merged commit 33d098b into main Jul 31, 2026
26 checks passed
@ion-alpha-dev
ion-alpha-dev deleted the memory-contract-expiry-provenance branch July 31, 2026 19:38
@github-actions github-actions Bot locked and limited conversation to collaborators Jul 31, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant