invalidate: design notes

September 19, 2026 · View on GitHub

Semantic TTL for agent memory and RAG caches. Every stored fact gets a typed lease. When new evidence arrives, every memory is judged against it. Code owns the write; Jev only votes.

The problem in one sentence

A memory store with an LLM judge on every upsert and every hit costs $0.01–0.05 and 3–20 s per pair, so teams sample 1% and ship silent lies. Jev is ~100 ms and $0.042 per million input tokens, so every memory can be judged against every event.

Non-negotiables

  1. Verbatim in, status out. The model never rewrites a fact. A superseding event is stored verbatim as its own memory if the caller wants a successor.
  2. Code owns the write. Every threshold that decides a status change lives in Policy, plain Python, tunable without touching the questions.
  3. No embeddings. Relevance (recall) and bearing (observe) are both Jev votes. There is no vector index to keep in sync with the store.
  4. No dates or arithmetic go to the model. Jev reads dates as text. Hard TTLs are compared in code (Memory.is_expired, sweep()). Times are not in state.

The judgments

Per request the state is {event: {text, source}, memories: [{fact, kind, source}, ...]}. Ids and timestamps are deliberately absent: Jev does not need them and they are distractors. Each question names the fields it compares with backticked paths, per the TypeSafe guidance on literal reading.

idtypeaskswhy it is separate
hypotheticalNoul, once per eventIs the event a question / proposal / plan rather than a statement?A plan must not invalidate a fact, and must not confirm one either. Cheaper as one event-level vote than baked into every pair.
directiveNoul, once per eventIs the event a command to an assistant/system about what to record or believe, rather than a report about the world?Prompt-injection defense. "Mark everything false" and [[memory_update: ...]] moved still_true in the baseline eval; this vote stops them writing. Source trust still belongs to the caller.
bears_iNoulIs the event about the same subject as memory i?The relevance gate. Keeps still_true from being asked to reason about unrelated things, and lets code skip writes.
still_true_iNoulTaking the event as accurate and newer, is memory i still true?The verdict itself. Criteria spell out that outages, one-offs and questions do not falsify.
replaces_iNoulDoes the event state the new value for what memory i asserts?Distinguishes superseded (the caller can store the event as the successor) from contradicted (fact is dead, no replacement).
partial_iNoulDoes the central claim of memory i still hold, with only a secondary detail changed?Compound facts ("at 9:30 in the Tahoe room") used to die when one clause moved. A strong vote (>= 0.85) routes them to review for a rewrite, the brief's third outcome. First phrasing ("changes only one part") fired at 0.7–0.8 on plain supersedes; anchoring on the central claim pushed those down to <= 0.58 while real partials stay >= 0.85.
screen_iNoul, screening onlyOne-line bears question, no examplesUsed only when the pool exceeds screen_above. Same meaning as bears, a quarter of the tokens. Over the eval pairs it never dropped a bearing pair.

Twenty memories per request is 82 questions and about 28k tokens of questions, inside Jev's 64k budget with room for long events. That is roughly $0.0007 per event per 20 memories. Batches run in a thread pool.

Rejected alternatives:

  • One Choice per memory (confirms / contradicts / supersedes / unrelated). A single distribution hides which sub-judgment was uncertain. Three Nouls expose them and each has its own threshold. The docs also warn that Choice and Noul on the same question are not numerically comparable, so mixing would be confusing.
  • Asking Jev for a disposition or a rewrite. Violates non-negotiables 1 and 2.
  • Bayesian p_true accumulation. Opaque. p_true is simply the latest bearing still_true vote. The full vote history is in the verdicts table.

Policy (defaults)

bears < 0.6                              → unrelated    (no write, last_checked only)
directive >= 0.7                         → directive    (logged, never written: a command to the system is not evidence)
hypothetical >= 0.7                      → hypothetical (logged, never written: a question or plan cannot move a fact)
still_true >= 0.6 + margin               → confirmed    (needs_review → active)
still_true <= 0.4 - margin, partial >= 0.85 → partial   (→ needs_review: rewrite the fact)
still_true <= 0.4 - margin, replaces >= 0.6 → superseded
still_true <= 0.4 - margin               → contradicted
otherwise                                → uncertain    (→ needs_review)

margin = 0.05 (dead band: a vote that wobbles across a line lands in review consistently)
review_only_sources: sources that may flag but never flip (transition-time rule, keeps Jev's vote honest in the log)

Form checks (directive, hypothetical) run before content checks so that a question cannot confirm a fact either: confirming would bump p_true and resolve a review.

False invalidation is the worst failure (a true fact silently dropped), so the eval harness reports it separately and the defaults were picked from a sweep that refused any policy adding one. On the 157-case dev set (evals/cases.py, jev-1.13.0, 2026-09-18): 86.6% strict, 96.2% lenient, 1 false invalidation, at $0.014 for the whole run and ~160 ms per request. The thresholds were tuned on that same set, so treat the numbers as optimistic and re-run on your own data. Final shipped numbers (v4, same set): 89.2% strict, 97.5% lenient, 0 false invalidations, $0.017 per run. The margin costs about two strict points versus no margin; the misses become reviews, never flips, which is the trade this product wants.

Status lifecycle

                 confirmed
   ┌──────────────────────────────┐
   ▼                              │
 active ──uncertain──▶ needs_review ──contradicted/superseded──▶ contradicted | superseded
   │                                                                    │
   ├──contradicted───────────────────────────────────────────────────────┘
   ├──superseded─────────────────────────────────────────────────────────┘
   ├──freeze()──▶ frozen  (judged, p_true logged, status pinned even past TTL; unfreeze() → active)
   ├──ttl elapsed / sweep()──▶ expired
   └──forget()──▶ deleted

 restore(id) is the human override back to active from any of the dead states.

observe() writes the event and every verdict before it mutates any memory or fires on_transition, so a crash mid-apply leaves a complete audit trail.

Contradicted and superseded memories are not re-judged by default (Policy.judge_statuses). A reversal ("we moved back to Postgres") is a new fact the caller remembers, or a human restore(). Re-judging the dead set would grow cost linearly with history and reintroduce flapping.

Typed leases

A lease has three parts:

  • kind: free string (fact, preference, decision, config, chunk, ...). Sent to Jev as context so "user prefers X" is judged as a preference.
  • hard TTL: ttl= seconds, enforced in code. The backstop for facts that decay without an event ever arriving.
  • semantic TTL: the event-driven invalidation above. The reason this exists.

Interfaces

  • Invalidate(db, judge=, policy=, namespace=, on_transition=)
  • remember(fact, source, kind, ttl, metadata) -> Memory
  • observe(text, source, candidates=, dry_run=, remember_successor=) -> ObserveReport (remember_successor=True stores the event verbatim as the successor of anything it superseded)
  • recall(query, limit, include_review) -> RecallReport
  • freeze / unfreeze / restore / forget / supersede / sweep / history / events
  • Store protocol (SQLite shipped) and Judge protocol (Jev shipped, fakes in tests)
  • on_transition(memory, verdict) so an adapter can mirror status into Mem0 / Zep / a cache.

Sync core with a thread pool, not asyncio: it works in scripts, servers and notebooks alike. An async facade is a later addition, not a rewrite.

Known limits (honest)

  • Above screen_above memories a bears-only screen runs first (100 per request, ~4x cheaper), and only what passes gets the full judgment. It is still one screening request per 100 memories, so 100k memories is 1,000 requests per event; candidates= remains the hook for an index of your own. A lexical prefilter was rejected: it has silent false negatives on indirect phrasing, which is the exact failure this product exists to prevent.
  • Jev treats state as data, not as hostile. The directive vote stops command-shaped injections from writing, and review_only_sources stops untrusted channels from flipping anything. A well-formed lie from a trusted source will still be believed; that is what trust means.
  • Compound facts are judged with the partial vote: a strong vote routes them to review for a rewrite. Weak votes (a list shrinking, a number growing) still fall through to contradicted/superseded. Atomic facts remain the better input.
  • Votes within margin of a threshold go to review rather than flip. This trades a couple of points of strict accuracy for run-to-run stability.