The branch opened with a clear goal: build a REST client adapter for Memori, the SQL-native AI memory engine. Tier 1 of the CaseMemoryStore adapter ladder. By the time we were done, there was no Memori REST adapter. There were two others.

What I Thought Memori Was

The research spec described Memori as self-hosted, SQL-native, Postgres-backed, Apache 2.0, LoCoMo 81.95%. The “zero extra infra” case was compelling: if you already run PostgreSQL, Memori uses it — no new database to manage.

Before writing a line of code I went to verify the API. Memori’s dedicated REST API is on the roadmap, not shipped. The BYODB option — self-hosted, using your own Postgres — requires a running Python service alongside your JVM application. That’s not zero extra infra for a Quarkus deployment. It’s a Python sidecar.

The REST client had nothing stable to target, and the “zero infra” premise was wrong.

A Design Smell in the SPI

Before pivoting, Claude and I did the architectural review and hit something worth naming.

The original EraseRequest had domain as a nullable field — null meant “erase across ALL domains” for GDPR Art.17. Compact, but when you’re building a backend adapter, null-domain erase and domain-scoped erase are completely different API calls. There’s no clean way to implement both through one method when your backend uses domain as part of the namespace key. We’d have to track which domains an entity has ever used, which requires state the adapter doesn’t have.

The right shape is two methods: erase(EraseRequest) for operational domain-scoped deletion — domain required — and eraseEntity(String entityId, String tenantId) for GDPR cross-entity wipe. The null-domain pattern was encoding a semantic distinction as a nullable field on a record. We broke it out.

EraseRequest.domain is now required — a compile error if you construct one without it. eraseEntity() is a new default throw on CaseMemoryStore, consistent with the existing eraseById() pattern. The SPI has no external consumer implementations yet, so the blast radius was exactly the three classes that implement it in this repo.

Two Adapters Instead of One

memory-inmem/ — pure Java, ConcurrentHashMap keyed by a typed BucketKey(tenantId, entityId, domain) record. Volatile: data lives and dies with the JVM. Used for @QuarkusTest isolation and ephemeral installs. Constructor injection makes the tests completely framework-free — no CDI container, plain JUnit 5.

memory-jpa/ — JPA/Panache, Flyway V1000 in classpath:db/memory/migration, PostgreSQL with FTS via websearch_to_tsquery when a question is provided, chronological otherwise. The FTS language is configurable (casehub.memory.jpa.fts.language=english). H2 in tests with FTS disabled — H2 doesn’t support to_tsvector.

Tenant isolation is structural in both. Every JPA query includes WHERE tenant_id = :tenantId as a non-optional predicate. MemoryPermissions.assertTenant() fires before any backend call. eraseById() also includes AND tenant_id = :t in the SQL — even if you know a memory UUID, you can’t delete another tenant’s record.

em.clear() after every bulk DELETE. The JPA spec says bulk DML leaves the first-level cache indeterminate. Tests would pass without it, but they’d be testing cached state, not database state.

Three Things H2 Got Wrong

Building against H2 in PostgreSQL mode surfaces the edges of that mode.

TIMESTAMPTZ isn’t recognised — use TIMESTAMP WITH TIME ZONE. quarkus.flyway.migrate-at-start defaults to false, which means Flyway starts but doesn’t run; the schema never exists. And @jakarta.transaction.Transactional on a @QuarkusTest method commits — data leaks between tests. Quarkus ships @io.quarkus.test.TestTransaction specifically for this, which always rolls back.

None of these are in the documentation you find first.

What’s Next in the Ladder

The Memori REST adapter isn’t dead — it’s waiting. When their API stabilises and ships a Java-accessible self-hosted option, memory-memori/ gets built using the mapping we worked out: namespace → tenantId, entity_id → entityId, process_id → domain.name(). The vector and temporal tiers (Mem0, Graphiti) follow the same CDI priority ladder as what’s here — drop in the dep, it displaces what’s below it.


<
Previous Post
The Case That Completed Silently
>
Next Post
The seam that was half-closed