memspine

Technical reference

An event log for agents, with recall and provenance

memspine is an HTTP server that stores an agent's events per tenant and returns them by embedding, by keyword, or by the links between them. The caller supplies the embeddings; the server never calls a model. This page states what each operation guarantees, what was measured and how, and where the service stops.

Version 0.2.2, released 2026-10-08. Linux x86_64 (glibc 2.17 or newer) and macOS arm64.

curl -fsSL https://memspine.com/install.sh | sh

Installs two binaries into ~/.local/bin, checked against a published checksum. No root. First session.

Storestyped events: kind, time, text, embedding, JSON properties, links to source events
Durableafter one fsync of the tenant's log, before the response
Retrievalexact cosine, BM25, or both fused by reciprocal rank
Evidence recall@20, LoCoMo0.889 with fact extraction and query expansion done by the client; 0.794 on raw turns
Search, p502.8 ms at 10,000 events, 35 ms at 100,000; linear in tenant size
Memory1.9 GiB peak resident for a run that stored and queried 100,000 events of 384 dimensions
Forgettingunreadable when DELETE returns; bytes off disk within 60 s by default
Does notcall a model, replicate, terminate TLS, or bound the cost of a Cypher query

Each row links to the section that gives its conditions.

One tenant's log

Everything below is shown on this log: ten events from a short session between a user and an agent. A turn is something said, a tool_call is something the agent ran, and a fact is something the agent derived and wrote back. The lines at the left are SOURCE_FROM edges. Each fact points at the events it was derived from.

timekindidtext
09:14:02turn75173310Ana: we adopted a greyhound called Pixel last weekend
09:14:31turn9509bd23Ana: she needs a vet, somewhere open on Saturdays
09:14:33tool_call697ea0fafind_vets(city=Lisbon, open=saturday) -> Clinica Arroios, VetLuz
09:15:10turnb8533f46Ana: book Clinica Arroios for this Saturday at ten
09:15:12tool_call740c7d03book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291
09:15:13factd53b6b8bAna owns a greyhound named Pixel
09:15:14factb41bdb35Ana prefers Saturday appointments
09:15:15fact3dce87e5Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291
09:41:50turnaf2d5cc8Ana: my number is 912 555 019, text me the day before
09:41:52factc3d7a7afText Ana the day before the vet appointment

Output of GET /v1/memory/events on a local 0.2.2 server, oldest first. Embeddings have four dimensions and were chosen by hand so the rankings below can be followed; the two tool_call events were stored without one. Every request and response of the session is in session.txt.

Quickstart

$ curl -fsSL https://memspine.com/install.sh | sh
Downloading memspine-macos-arm64.tar.gz ...
Checksum OK.
Installed memspine-server and memspine-admin to ~/.local/bin

Start the server:
  MEMSPINE_BIND=127.0.0.1:7777 ~/.local/bin/memspine-server
$ MEMSPINE_BIND=127.0.0.1:7777 MEMSPINE_EMBEDDING_DIM=4 \
    ~/.local/bin/memspine-server > memspine.log 2>&1 &
$ sleep 1; curl localhost:7777/health
{"service":"memspine-server","status":"ok","version":"0.2.2"}

$ K="Authorization: Bearer 00000000-0000-0000-0000-000000000042"
$ J="Content-Type: application/json"

$ TURN=$(curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" \
  -d '{"kind":"turn","embedding":[0.9,0.1,0.2,0.1],
    "text":"Ana: we adopted a greyhound called Pixel last weekend"
    }' | jq -r .event_id)

$ curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" \
  -d '{"kind":"fact","embedding":[0.8,0.2,0.1,0.1],
    "text":"Ana owns a dog named Pixel","properties":{
    "source_event_ids":["'"$TURN"'"]}}'
{"event_id":"b5ea3003-7681-4632-a015-5ec4fdec7595"}

$ curl -s localhost:7777/v1/memory/recall -H "$K" -H "$J" \
  -d '{"text":"which dog does Ana have","k":1,"hops":1}' \
  | jq -c '.hits[] | [.kind, .text]'
["fact","Ana owns a dog named Pixel"]
["turn","Ana: we adopted a greyhound called Pixel last weekend"]

Run on macOS arm64 against the published binaries, 2026-10-08. The server takes 384-dimension embeddings unless MEMSPINE_EMBEDDING_DIM says otherwise; this one was started with 4. The last two commands need jq.

The bearer token above is a bare UUID. With no control database, any UUID is a tenant and is also that tenant's key, with read and write access. That mode has no secret and is for local use. memspine-admin init creates the control database, after which only issued keys are accepted.

What an event is

fieldtypeused forlimit
kindstring, requireda filter in list, search and recall1 to 256 bytes
textstringkeyword recall; returned with the eventthe request body
embeddingarray of numberscosine searchexactly the server's dimension; finite, not all zero
propertiesJSON objectreturned as sentthe request body
properties.source_event_idsarray of event idsone SOURCE_FROM edge per id that exists in the tenantunknown ids are skipped
tsinteger, microsecondsorder, and the since and until filtersdefaults to arrival time; not checked against the server clock
event_idUUID, in the responseget, forget, lineagechosen by the server, or derived from an Idempotency-Key

A request body may be 2 MiB for one event and 32 MiB for a batch of up to 1,000.

Events are immutable. To correct one, append the correction and forget the original.

Text and properties of any size within the body limit are stored and read back as sent. From Cypher, up to 16 top-level properties per event can be filtered on by name: booleans, numbers and strings of at most 256 bytes, taken in key order. n.text is null there for text over 4,096 bytes; returning the node gives the whole event.

The server records each tenant's embedding dimension and refuses to open the tenant under a different one once it holds embeddings. It does not record which model produced the vectors, so two models of the same dimension would be mixed without warning.

$ curl -s localhost:7777/v1/memory/event/$F8 -H "$K" | jq .
{
  "event_id": "3dce87e5-4b2c-4cc0-96bc-338ce95f158f",
  "kind": "fact",
  "ts": 1791364515000000,
  "text": "Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291",
  "properties": {
    "source_event_ids": [
      "b8533f46-b491-4c20-a078-b0ca2e039926",
      "740c7d03-9b97-4854-8799-6d66524cf3fe"
    ]
  }
}

Operations and what each guarantees

Auth is Authorization: Bearer <key>. The tenant comes from the key and never from the request body. Every response carries x-memspine-trace-id, and the lines the server logs for that request carry the same id.

route, scopetrue when it returnsnot promised
POST /v1/memory/event
write
The event, its embedding, its text and its edges are in the tenant's log and the log has been fsynced. A killed server restarts with it. Under an Idempotency-Key the id is derived from tenant and key, so a repeat returns the first id and stores nothing, with no expiry.A second copy on another disk. A repeat's body is not compared with the first.
POST /v1/memory/events
write
Up to 1,000 events stored by one batch and one fsync: all of them or none. Events without a ts get consecutive timestamps one microsecond apart.A keyed batch replayed with the same number of events or fewer returns the first ids without comparing bodies; with more events it gets a 400.
GET /v1/memory/event/<id>
read
The event as stored. 404 for an id that belongs to another tenant or was forgotten.
GET /v1/memory/events
read
Events newest first by (ts, event_id), filtered by kind, since, until, in pages that follow a cursor.
POST /v1/memory/search
read
The k nearest events by exact cosine similarity among those that pass the filters. k is at most 1,000.Sub-linear time: every embedding in the tenant is compared.
POST /v1/memory/recall
read
A ranking by keyword, by embedding, or by both fused, then up to k events reached over SOURCE_FROM within hops steps. The same request over the same data returns the same order. How it ranks.Keyword matches in scripts other than ASCII letters and digits.
POST /v1/memory/cypher
read
Columns and rows of a read-only query over (:Event) nodes and [:SOURCE_FROM] edges. Writes get a 400.A bound on what the query costs. The screen refuses known explosive shapes only.
DELETE /v1/memory/event/<id>
write
Get, list, search, recall and Cypher no longer return the event, its edges are gone, and that is durable. With ?purge=true its bytes have also left the tenant's files.Without ?purge=true the bytes stay in the log until the next purge. What remains.
GET /v1/whoami
any key
The tenant and scopes behind the key.
GET /health, GET /metrics
no key
Liveness, and Prometheus counters and latency histograms for each data-plane handler. No metric is labelled with a tenant id./metrics is unauthenticated; move it with MEMSPINE_METRICS_BIND.
/v1/admin/tenants, /keys, /audit
admin key
Tenants and keys created, listed and revoked; each change recorded in an audit log. These take the server's MEMSPINE_ADMIN_KEY and answer 503 while it is unset.

Errors are JSON, {"error": code, "detail": text}: 400 invalid_request, 401 unauthorized, 403 forbidden, 404 not_found, 429 rate_limited, 500 internal_error, 503 unavailable.

The write path, and what a crash leaves

stepif the process dies here
The key is resolved to a tenant and a scope. The tenant's rate limit and its bound on calls in flight are checked.Nothing was written.
Under the tenant's write lock, one FFS batch applies the node, the embedding, the text and the edges.The append was never acknowledged. It may or may not be there after a restart.
The tenant's log is fsynced.The same.
The response is sent with the event id.The event is there after a restart.

Appends within a tenant are serialised; reads run concurrently and do not wait for them. A read can therefore return an event during the moment between its batch and its fsync, before the append has been acknowledged.

$ curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" -d @last.json; echo
{"event_id":"1eb38657-2649-47e6-b082-bf90e6da5c72"}

$ kill -9 $(lsof -tiTCP:7777 -sTCP:LISTEN); sleep 0.3; curl -s -m 2 localhost:7777/health; echo "curl exit $?"
curl exit 7

$ MEMSPINE_BIND=127.0.0.1:7777 MEMSPINE_EMBEDDING_DIM=4 MEMSPINE_DATA_DIR=./data \
    memspine-server > memspine.log 2>&1 &

$ curl -s localhost:7777/v1/memory/event/$E11 -H "$K"; echo
{"event_id":"1eb38657-2649-47e6-b082-bf90e6da5c72","kind":"turn","ts":1791366150000000,"text":"Ana: thanks, that is all for today","properties":{}}

An acknowledged append, kill -9, a restart, and the event read back. Durability was tested this way, by killing the process. It has not been tested by cutting power, and there is one copy on one disk.

A retry

$ curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" \
  -H "Idempotency-Key: turn-0941" -d @turn.json; echo
{"event_id":"af2d5cc8-2a80-583b-b7ed-941419d7a27f"}

$ curl -s localhost:7777/v1/memory/event -H "$K" -H "$J" \
  -H "Idempotency-Key: turn-0941" -d @turn.json; echo
{"event_id":"af2d5cc8-2a80-583b-b7ed-941419d7a27f"}

The same request twice under one Idempotency-Key. The id is a version-5 UUID of the tenant and the key, so both calls return it and one event is stored.

Between tenants

Each tenant is its own database directory, opened on first request. Another tenant's event id returns 404. Each tenant has a token bucket (1,000 requests per second, burst 100) and may have 32 engine calls running or queued; past either it gets a 429. What tenants share is one process, one worker pool and one heap, so a large tenant's scan uses the same cores and memory as its neighbours.

How recall ranks

Given an embedding, events are ranked by cosine similarity. Given text, they are ranked by BM25 over text. Given both, the two rankings are fused by reciprocal rank (Cormack, Clarke and Büttcher, 2009), with ranks counted from 1:

score = 1 / (60 + rankembedding) + 0.5 / (60 + rankkeyword)

On the log

One request, text when is the vet appointment for Pixel and embedding [0.3, 0.9, 0.3, 0.0], asked three ways. Greyed keyword rows fall under the floor.

rankcosinekindtext
10.9957factPixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291
20.9908turnAna: she needs a vet, somewhere open on Saturdays
30.9535turnAna: book Clinica Arroios for this Saturday at ten
40.5721factAna prefers Saturday appointments
50.5360factText Ana the day before the vet appointment
60.3996turnAna: we adopted a greyhound called Pixel last weekend
70.3015factAna owns a greyhound named Pixel
80.1626turnAna: my number is 912 555 019, text me the day before

Embedding only.

rankBM25kindtext
14.6866factText Ana the day before the vet appointment
23.1517turnAna: my number is 912 555 019, text me the day before
32.5925factPixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291
42.0447turnAna: book Clinica Arroios for this Saturday at ten
51.3526factAna owns a greyhound named Pixel
61.1752turnAna: we adopted a greyhound called Pixel last weekend
70.9173turnAna: she needs a vet, somewhere open on Saturdays
80.8109tool_callbook_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291

Text only. The second row matches on is and the.

rankscoreemb.kw.kindtext
10.024313factPixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291
20.023651factText Ana the day before the vet appointment
30.022882turnAna: my number is 912 555 019, text me the day before
40.01612–turnAna: she needs a vet, somewhere open on Saturdays
50.01593–turnAna: book Clinica Arroios for this Saturday at ten
60.01564–factAna prefers Saturday appointments
70.01526–turnAna: we adopted a greyhound called Pixel last weekend
80.01497–factAna owns a greyhound named Pixel

At these settings the table is the server's own response to the request above. The sliders recompute the fusion in the page with the server's formula. Set the floor to 0 and the weak keyword matches take ranks, including a tool_call that has no embedding.

Hops

After ranking, hops follows SOURCE_FROM edges out from the hits, in both directions, at most 3 steps. It adds at most k events, examines at most 8k, and marks each addition with via, the hit whose walk reached it. The time filters apply to what is reached; the kind filter does not, since the point is to cross from a fact to the turns behind it.

$ curl -s localhost:7777/v1/memory/recall -H "$K" -H "$J" \
  -d '{"text":"vet appointment booking","k":2,"hops":1}' \
  | jq -r '.hits[] | [.kind, .event_id[0:8], (.via // "-")[0:8], .text[0:52]] | @tsv'
fact	3dce87e5	-	Pixel has a vet appointment at Clinica Arroios on 20
fact	c3d7a7af	-	Text Ana the day before the vet appointment
turn	b8533f46	3dce87e5	Ana: book Clinica Arroios for this Saturday at ten
tool_call	740c7d03	3dce87e5	book_vet(Clinica Arroios, 2026-10-10 10:00) -> confi
timekindidtextrecall
09:14:02turn75173310Ana: we adopted a greyhound called Pixel last weekend
09:14:31turn9509bd23Ana: she needs a vet, somewhere open on Saturdays
09:14:33tool_call697ea0fafind_vets(city=Lisbon, open=saturday) -> Clinica Arroios, VetLuz
09:15:10turnb8533f46Ana: book Clinica Arroios for this Saturday at tenvia hit 1
09:15:12tool_call740c7d03book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291via hit 1
09:15:13factd53b6b8bAna owns a greyhound named Pixel
09:15:14factb41bdb35Ana prefers Saturday appointments
09:15:15fact3dce87e5Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291hit 1
09:41:50turnaf2d5cc8Ana: my number is 912 555 019, text me the day before
09:41:52factc3d7a7afText Ana the day before the vet appointmenthit 2

Two ranked hits and the two sources of the first. The second hit's sources are one hop away too; the two additions allowed by k were already taken.

Cypher over the same edges

$ curl -s localhost:7777/v1/memory/cypher -H "$K" -H "$J" -d '{"query":
  "MATCH (f:Event)-[:SOURCE_FROM]->(s:Event) WHERE f.kind = $kind RETURN f.text, s.kind, s.text",
  "params":{"kind":"fact"}}' | jq -c '.rows[]'
["Ana owns a greyhound named Pixel","turn","Ana: we adopted a greyhound called Pixel last weekend"]
["Ana prefers Saturday appointments","turn","Ana: she needs a vet, somewhere open on Saturdays"]
["Ana prefers Saturday appointments","turn","Ana: book Clinica Arroios for this Saturday at ten"]
["Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291","tool_call","book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291"]
["Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291","turn","Ana: book Clinica Arroios for this Saturday at ten"]
["Text Ana the day before the vet appointment","fact","Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291"]
["Text Ana the day before the vet appointment","turn","Ana: my number is 912 555 019, text me the day before"]

What was measured

Retrieval was scored on LoCoMo (Maharana et al., 2024): 10 long conversations, 1,540 questions in categories 1 to 4. The metric is the fraction of a question's gold evidence turns among the 20 events retrieved per query. 1,536 questions are scored, because four open-domain questions name no evidence. Embeddings are text-embedding-3-small at 384 dimensions.

The server does not extract facts or rewrite queries. In the upper half of the table the benchmark harness does both with gpt-4o-mini, as a client would: it appends dated facts linked to their source turns, and expands each question into subqueries. The lower half stores raw turns and sends the question as the only query. The difference between the halves is the client's work.

retrievalmulti-hop
n=282
temporal
321
open-domain
92
single-hop
841
all
1,536
facts and query expansion by the client
keyword0.5850.9020.5320.8850.812
embedding0.7340.9400.5500.9210.869
fused0.7460.9460.6080.9460.889
raw turns, the question as the only query
keyword0.2790.6700.3100.6890.587
embedding0.6090.8350.4840.8610.787
fused0.5900.8440.5310.8730.794

Evidence recall@20, LoCoMo, 10 conversations, run 2026-10-07 on the 0.2.1 engine with embeddings and model calls replayed from a cache recorded in June. Marked: fusion's largest gain, the keyword-only collapse on multi-hop questions without query rewriting, and fusion's loss on multi-hop for raw turns.

Is the fused gain real

The keyword weight and the floor were chosen on the first five conversations and checked on the other five.

weight / floortuned onheld out
embedding only0.8590.878
0.25 / 0.50.8740.898
0.5 / 0.5, the default0.8820.896
0.75 / 0.50.8810.896
1.0 / 0.50.8790.887
0.5 / 0.30.8810.892
0.5 / 0.70.8690.897

Recall@20 on each half. With no floor at all, fusion scored 0.704 on multi-hop over all ten conversations, against 0.734 for embeddings alone.

fused against embeddingbettersameworse
multi-hop3023517
temporal33180
open-domain10802
single-hop278095
all701,44224

Questions, by whether fusion retrieved more of their evidence. Questions with all evidence retrieved rose from 1,238 to 1,270; with none, fell from 117 to 88.

End-to-end answers

An answer model reads the retrieved events and a judge model marks its answer. This was scored once, on 2026-06-12, on the engine before 0.1.0, with embedding retrieval, gpt-4o-mini as answerer and judge, and about 1,150 answer tokens per question. It has not been re-run on the current engine, where the retrieved context is identical for 93% of questions, and fused retrieval has no such score yet. There is no error bar.

categorynF1J
multi-hop2820.4330.762
temporal3210.5860.760
open-domain960.2110.375
single-hop8410.6060.851
all1,5400.5460.786

J is the fraction of answers the judge accepted, under a published rubric used verbatim (the ACCURACY_PROMPT of the mem0 evaluation code).

judge promptJ, same answers
"matches in meaning"0.631
the published rubric0.786

The same 1,540 answers under two judges. The rubric alone moves J by 15.5 points, so a J figure says little without its judge prompt. Under that rubric, answer model and judge model, the mem0 paper reports 0.669.

Where it fails: open-domain questions are the weakest category in every row above, and a caller with no embedding model retrieves 0.279 of multi-hop evidence unless it rewrites its queries.

Scale and cost

one tenant, 384 dimensions10,000 events100,000 events
batch ingest, 1,000 per request17,304 /s9,788 /s
single append, p50 / p9916 ms / 20 ms16 ms / 20 ms
search, k=10, p50 / p992.8 ms / 6.3 ms35 ms / 52 ms
fused recall, p50 / p993.7 ms / 7.5 ms45 ms / 50 ms
forget, p5020 ms24 ms
purge, p50 / max3.4 s / 4.5 s25 s / 39 s
cold open437 ms3.3 s
on disk27.2 MiB269.7 MiB
peak resident memory of the run214 MiB1.9 GiB

memspine-bench --mode scale on the 0.2.1 engine, 2026-10-07, an Apple M-series laptop that was not otherwise idle. The engine is called in process, with no HTTP or JSON. Random unit vectors, eight-word texts, two short properties per event; 100 queries, 50 single appends. The sizes in between were not run. The rows bound by fsync (ingest, append, forget, purge) varied several-fold between runs on this machine while other builds were using its disk: a second run at 10,000 events gave a single append of 8.0 ms and a purge of 2.2 s. The search rows did not move.

Forgetting

momentget, list, search, recall, Cyphertext and embedding bytes on disk
before DELETEreturnedin the tenant's log
DELETE returns
one batch and one fsync
not returned; edges gone; durablestill in the log
purge starts
within 60 s by default, or before the response with ?purge=true
not returnedlog being rewritten; the tenant waits
purge endsnot returnedin no file of the tenant's directory

A marker file is written before the delete, so a crash between the delete and the purge still purges after restart, whether or not the tenant gets another request.

$ grep -rl "912 555 019" data
data/00000000-0000-0000-0000-000000000042/events.ffs.qlog

$ curl -s -X DELETE "localhost:7777/v1/memory/event/$E9?purge=true" -H "$K"; echo
{"deleted":true,"purged":true}

$ grep -rl "912 555 019" data; echo "grep exit $?"
grep exit 1

$ curl -s -o /dev/null -w "%{http_code}\n" localhost:7777/v1/memory/event/$E9 -H "$K"
404

$ curl -s localhost:7777/v1/memory/event/$F10 -H "$K" | jq -c '.properties'
{"source_event_ids":["af2d5cc8-2a80-583b-b7ed-941419d7a27f","3dce87e5-4b2c-4cc0-96bc-338ce95f158f"]}

$ curl -s localhost:7777/v1/memory/cypher -H "$K" -H "$J" -d '{"query":
  "MATCH (f:Event)-[:SOURCE_FROM]->(s:Event) WHERE f.event_id = $id RETURN s.kind, s.text",
  "params":{"id":"'$F10'"}}' | jq -c '.rows[]'
["fact","Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291"]
timekindidtextafter the forget
09:14:02turn75173310Ana: we adopted a greyhound called Pixel last weekend
09:14:31turn9509bd23Ana: she needs a vet, somewhere open on Saturdays
09:14:33tool_call697ea0fafind_vets(city=Lisbon, open=saturday) -> Clinica Arroios, VetLuz
09:15:10turnb8533f46Ana: book Clinica Arroios for this Saturday at ten
09:15:12tool_call740c7d03book_vet(Clinica Arroios, 2026-10-10 10:00) -> confirmed A-2291
09:15:13factd53b6b8bAna owns a greyhound named Pixel
09:15:14factb41bdb35Ana prefers Saturday appointments
09:15:15fact3dce87e5Pixel has a vet appointment at Clinica Arroios on 2026-10-10 at 10:00, booking A-2291
09:41:50turnaf2d5cc8Ana: my number is 912 555 019, text me the day beforeforgotten
09:41:52factc3d7a7afText Ana the day before the vet appointmentkeeps the id in its properties; the edge is gone

What remains after a purge:

Limits

FFS, the storage engine, has no row or time budget for a query and cannot stop one that has started. A query is therefore screened before it runs.

refused with a 400because
over 16 KB or 512 tokensThe parser and evaluator recurse without a limit. Deep nesting, or a chain such as 1+1+1+… a few thousand links long, overflowed the stack and aborted the process.
brackets or CASE nested over 64 deep
UNWINDIt multiplies every row by the length of a list. Filter with IN $list.
a pattern sharing no variable with what is in scopeIt pairs every row of one pattern with every row of the other. Two thousand events made four million rows and 2.7 GB.
a variable-length path over 8 hopsPath count grows with every hop.
parameters over 10,000 values, results over 10,000 rowsBoth are held in memory whole.
CREATE, MERGE, SET, DELETEQueries are read-only. Write through the event routes.
$ curl -s localhost:7777/v1/memory/cypher -H "$K" -H "$J" \
  -d '{"query":"MATCH (a:Event), (b:Event) RETURN count(*)"}'; echo
{"error":"invalid_request","detail":"the query matches two patterns that share no variable, which pairs every row of one with every row of the other. Connect them through a shared variable"}

The screen removes the cheap ways to exhaust the server. It does not bound a query's cost: a pattern through a heavily connected event can still be expensive. Give Cypher only to callers you would let run a full scan.

Use something else if

Running it

keyswhat is accepted
no control databaseAny UUID as the bearer token. It names the tenant and has read and write access. No secret is involved; local use only.
after memspine-admin initIssued keys only, stored hashed, each with scopes (read, write), an optional expiry, and revocation. Created with the CLI or the admin routes; both write the audit log. If the control database later goes missing, while the server runs or across a restart, requests get a 503 and the UUID mode does not come back.
variabledefaultmeaning
MEMSPINE_BIND0.0.0.0:7777listener for the API
MEMSPINE_DATA_DIR./.memspine-dataone directory per tenant lives here
MEMSPINE_EMBEDDING_DIM384embedding dimension, fixed per process
MEMSPINE_ADMIN_KEYunsetbearer for the admin routes; unset disables them
MEMSPINE_METRICS_BINDunsetserve /metrics here and not on the API listener
MEMSPINE_PURGE_INTERVAL_SECS60how often tenants that owe a purge are compacted
MEMSPINE_TENANT_IDLE_TTL_SECS3600close a tenant after this long without a request
MEMSPINE_TENANT_MAX_IN_FLIGHT32engine calls one tenant may have running or queued
MEMSPINE_MAX_OPEN_TENANTS(fd limit − 64) / 12tenants open at once; the least recently used idle one closes first
MEMSPINE_RATE_LIMIT_RPS, _BURST1000, 100per-tenant token bucket

What has broken

0.1.0, 0.2.0 and 0.2.1 were all released on 2026-10-07, and 0.2.2 a day later. After 0.2.0, two independent reviews went looking for defects, and each claim was reproduced by a second reviewer before it counted. 0.2.1 and 0.2.2 are the fixes; the last part of the review finished after 0.2.1 had gone out, and found a defect that 0.2.1 itself had introduced. The ones a caller could have met:

defecttriggernow
in 0.2.1 only: tenants damaged, acknowledged appends losta second server started on a data directory already in use, even with no request sent to itthe server locks its data directory and each open tenant; a second one refuses to start
tenant UUIDs accepted as keysa restart while the control database was missingthe data directory records that it has a control plane; requests get a 503
a tenant could not be opened againany string over about 8 KB, then two purgeslong values are stored where the engine does not index them; a tenant already in that state is rebuilt from its log on first open
appends acknowledged, then lost at restartthe tenant's log could not be opened for writing, for instance no file descriptors leftopening a tenant writes a record and checks the log grew
one query aborted the serverabout a thousand nested brackets, or a long operator chainscreened on the engine's own tokens; each query runs on its own 16 MB stack
one query grew the server by gigabytesUNWIND, or two unconnected patternsrefused
one tenant stalled the othersmany slow calls inside its rate limit32 engine calls per tenant, then 429
a retry restored a forgotten eventreplaying its Idempotency-Keythe id is kept and the replay stores nothing
fused recall could not return a keyword match with no embeddingk of 61 or lessit scores as an embedding-only match at the same rank
search returned fewer than k eventsafter a forget, until the tenant was reopenedthe deleted event leaves the vector index at once
forgotten bytes stayed on diska crash before the purge, and no later request to that tenantpurges owed are found at startup and on every pass

The whole list is in changelog.txt. Events forgotten under 0.2.0 left no record of their id, so a retry of one of those keys after upgrading still stores the event.

Tested by 162 Rust tests, 29 in the Python binding's suite and 21 in the TypeScript one. Each change runs formatting, lints with warnings denied, the tests, and a container image that is started and written through. After each publish of this site, the install command above is run on Ubuntu, AlmaLinux 8 and macOS, and the installed binaries append, recall, list and forget an event. The largest tenant measured so far is 100,000 synthetic events.