A support agent whose answer is disputed

A shop runs an agent on its support chat. A customer writes that an order marked as delivered never arrived. The agent looks up the order, tracks the parcel and answers that the carrier has a signature at number 16. The customer replies that the street has no number 16, the agent opens a case for a person, and before the conversation ends the customer gives a door code for the next delivery.

What the agent writes

Each customer account is one tenant. The agent works with a key for that tenant, which reads and writes this customer's events and no others.

turn
Each message, with speaker and conversation as properties and an embedding.
tool_call
Each call the agent makes, written as the call and its result, with the tool's name as a property. Stored without an embedding here; recall still reaches these by keyword and over links.
the agent's reply
A turn whose source_event_ids name the message it answers and every lookup made since that message.
fact
What the agent concluded and will want to find again: the state of the case, the delivery instruction. Each names the events it was derived from.
the agent's reply, as sent
$ cat reply.json
{
  "kind": "turn",
  "ts": 1791208939000000,
  "text": "Agent: the carrier shows it signed for at number 16, on 1 October at 14:12. Please check there",
  "embedding": [0.9, 0.1, 0.0, 0.0],
  "properties": {
    "speaker": "agent",
    "conversation": "c-1005-01",
    "source_event_ids": [
      "0671c55d-8860-4a40-b6a1-9784bd9c5367",
      "564ac64f-925b-4d8c-8997-eae3347d517b",
      "354192b1-c84e-4f91-9374-7296fa56b173"
    ]
  }
}

$ curl -s localhost:7781/v1/memory/event -H "$K" -H "$J" -d @reply.json; echo
{"event_id":"009cfbc7-bc4d-48c4-9a4a-7ad5f4cc17da"}

Recorded against the released 0.2.2 binary on an empty data directory. How the sessions were recorded is below.

The server turns each id in source_event_ids into a SOURCE_FROM link when it stores the event. An id that does not exist in the tenant is skipped without an error, so the application sends ids it has just received from an append.

timekindidtext
14:02:10turn0671c55dCustomer: order 88213 shows delivered on 1 October but nothing arrived
14:02:13tool_call564ac64fget_order(88213) -> 1 item, shipped 2026-09-29, carrier ref PX-55790112
14:02:15tool_call354192b1track_parcel(PX-55790112) -> delivered, signed for at no. 16, 2026-10-01 14:12
14:02:19turn009cfbc7Agent: the carrier shows it signed for at number 16, on 1 October at 14:12. Please check therefrom 0671c55d, 564ac64f, 354192b1
14:03:02turn31c5a743Customer: there is no number 16 on my street. I want a refund
14:03:05tool_calla408d2e9open_case(order=88213, reason=delivery disputed) -> case C-5117, assigned to a person
14:03:06fact389884bcOrder 88213: carrier says delivered at no. 16, customer says there is no no. 16; case C-5117 is openfrom 354192b1, 31c5a743, a408d2e9
14:04:40turn512dbc6dCustomer: the courier can use door code 4471 next time and leave the parcel in the hall
14:04:42facta525f2aaDelivery instruction: door code 4471, leave parcels in the hallfrom 512dbc6d
16:31:07turn10dce972Customer: please delete my door code from your records

The customer's tenant after the last message and before the erasure below, oldest first: ten events. Blue dots are turns, green are tool calls, yellow are facts. A line joins an event to each event it names as a source. Links: the reply at 14:02:19 to the three events before it; the case fact at 14:03:06 to the tracking result, the objection and the opened case; the fact at 14:04:42 to the message before it.

What was the agent told, and what did it look up, before it said that?

Asked by the person who picks up case C-5117.

The reviewer quotes the agent's own words, so the request sends text and no embedding. It is sent with the reviewer's own key, which can read this tenant and do nothing else. kind restricts the ranked hits to turns. hops: 1 then follows the links one step from the hits, in both directions, and adds up to k of the events it reaches, of any kind, each marked with via.

recall with one hop
$ curl -s localhost:7781/v1/memory/recall -H "$REVIEW" -H "$J" \
  -d '{"text":"signed for at number 16","kind":"turn","k":3,"hops":1}' \
  | jq -r '.hits[] | [.kind, .event_id[0:8], (.via // "-")[0:8], .text[0:60]] | @tsv' \
  | column -ts $'\t'
turn       009cfbc7  -         Agent: the carrier shows it signed for at number 16, on 1 Oc
turn       31c5a743  -         Customer: there is no number 16 on my street. I want a refun
turn       0671c55d  009cfbc7  Customer: order 88213 shows delivered on 1 October but nothi
tool_call  564ac64f  009cfbc7  get_order(88213) -> 1 item, shipped 2026-09-29, carrier ref 
tool_call  354192b1  009cfbc7  track_parcel(PX-55790112) -> delivered, signed for at no. 16

In the response, the first row is the reply. The three rows carrying its id are the customer's message and the two lookups, among them the tracking result that names number 16. The second ranked row is the customer's objection, which shares two words with the query.

timekindidtextrecall
14:02:10turn0671c55dCustomer: order 88213 shows delivered on 1 October but nothing arrivedwhat it was told
14:02:13tool_call564ac64fget_order(88213) -> 1 item, shipped 2026-09-29, carrier ref PX-55790112what it looked up
14:02:15tool_call354192b1track_parcel(PX-55790112) -> delivered, signed for at no. 16, 2026-10-01 14:12what it looked up
14:02:19turn009cfbc7Agent: the carrier shows it signed for at number 16, on 1 October at 14:12. Please check therefrom 0671c55d, 564ac64f, 354192b1the answer
14:03:02turn31c5a743Customer: there is no number 16 on my street. I want a refundalso matched
14:03:05tool_calla408d2e9open_case(order=88213, reason=delivery disputed) -> case C-5117, assigned to a person
14:03:06fact389884bcOrder 88213: carrier says delivered at no. 16, customer says there is no no. 16; case C-5117 is openfrom 354192b1, 31c5a743, a408d2e9reached, left out
14:04:40turn512dbc6dCustomer: the courier can use door code 4471 next time and leave the parcel in the hall
14:04:42facta525f2aaDelivery instruction: door code 4471, leave parcels in the hallfrom 512dbc6d
16:31:07turn10dce972Customer: please delete my door code from your records

k bounds the ranked hits and, separately, what the walk adds: at most k events in total, shared by all the hits. Here the reply's three sources used the whole allowance. The case fact names the objection among its sources, so it is one link from the second hit. The walk reached it and left it out, and the response does not say so. A reply with more than k sources comes back with some missing in the same way. Its source_event_ids property, returned with the hit, lists them all.

Keys for the agent and for the reviewer

The shop's backend created the tenant and the agent's key when the customer opened an account, and the reviewer's key when the case was opened. Tenants and keys are created through the admin routes, which take a separate key that the server reads from MEMSPINE_ADMIN_KEY at start.

a tenant and two keys
$ curl -s localhost:7781/v1/admin/tenants -H "Authorization: Bearer $ADMIN_KEY" -H "$J" \
  -d '{"name":"customer-4417"}'; echo
{"tenant_id":"4a2cd816-0f6f-4f89-ac26-41ef06c06e47","name":"customer-4417"}

$ curl -s localhost:7781/v1/admin/keys -H "Authorization: Bearer $ADMIN_KEY" -H "$J" \
  -d '{"tenant_id":"'$T'","name":"support-agent"}' \
  | tee agent-key.json | jq -c '{api_key: (.api_key[0:15] + "..."), scopes}'
{"api_key":"smk_live_kQ_dfT...","scopes":["read","write"]}

$ curl -s localhost:7781/v1/admin/keys -H "Authorization: Bearer $ADMIN_KEY" -H "$J" \
  -d '{"tenant_id":"'$T'","name":"case-C-5117","scopes":["read"],"ttl_secs":86400}' \
  | tee review-key.json | jq -c '{api_key: (.api_key[0:15] + "..."), scopes}'
{"api_key":"smk_live_hq1t5d...","scopes":["read"]}

A key belongs to one tenant and carries the read scope, the write scope or both. The reviewer's key has the read scope and expires after a day, which is enough for the recall above. The next transcript tries it on a delete, then sends two requests with the key of a second customer, whose tenant was created the same way.

what the other keys can do
$ curl -s -w "  HTTP %{http_code}\n" -X DELETE localhost:7781/v1/memory/event/$REPLY -H "$REVIEW"
{"error":"forbidden","detail":"api key is missing the 'write' scope"}  HTTP 403

$ curl -s -w "  HTTP %{http_code}\n" localhost:7781/v1/memory/event/$REPLY -H "$OTHER"
{"error":"not_found","detail":"no event with that id"}  HTTP 404

$ curl -s localhost:7781/v1/memory/recall -H "$OTHER" -H "$J" -d '{"text":"order 88213 delivered","k":5}'; echo
{"hits":[]}

The delete is refused for the missing scope. The second customer's key asks for the reply by its id and gets the 404 an unknown id would get, then searches for the order number and gets nothing. The server takes the tenant from the key, and each tenant is a separate database directory on disk.

The customer asks for the door code to be erased

The code is in two events: the customer's message and the fact derived from it. memspine does not delete derived events with their source, and a deleted event takes its links with it. So the application asks what was derived from the message before it deletes anything.

erase the door code
$ curl -s localhost:7781/v1/memory/cypher -H "$K" -H "$J" -d '{"query":
  "MATCH (d:Event)-[:SOURCE_FROM]->(s:Event) WHERE s.event_id = $id RETURN d.event_id, d.kind, d.text",
  "params":{"id":"'$DOOR_TURN'"}}' | jq -c '.rows[]'
["a525f2aa-c37e-4935-9e17-f4afbee09ed3","fact","Delivery instruction: door code 4471, leave parcels in the hall"]

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

$ grep -rl "door code 4471" data; echo "grep exit $?"
data/4a2cd816-0f6f-4f89-ac26-41ef06c06e47/events.ffs.qlog
data/4a2cd816-0f6f-4f89-ac26-41ef06c06e47/events.ffs.nodedata.gx1
data/4a2cd816-0f6f-4f89-ac26-41ef06c06e47/events.ffs.propidx.gx1
grep exit 0

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

$ grep -rl "door code 4471" data; echo "grep exit $?"
grep exit 1

$ curl -s -w "  HTTP %{http_code}\n" localhost:7781/v1/memory/event/$DOOR_TURN -H "$K"
{"error":"not_found","detail":"no event with that id"}  HTTP 404

After the first delete and its rewrite of the tenant's files, grep still finds the code, in the fact. After the second, no file under the data directory contains it.

purge=true makes the request wait for the rewrite, which blocks that tenant while it runs. Without it the event is unreadable when the delete returns, and its bytes leave the disk at the next purge, which starts within 60 seconds by default.

Left to the application. Knowing which events hold personal data, and finding what was derived from them before the delete. The same data in the application's own systems, and in backups taken before the purge, is outside what a delete here reaches.

An operations agent whose actions are audited

A company lets an agent act in its purchasing system. On one Tuesday an accounts payable manager asks it to move a supplier to net 45 payment terms, a buyer asks it to release a held purchase order for the same supplier, and the manager asks it to put a second supplier on hold. Before each change the agent reads the supplier record, the contract or the inspection result.

What the agent writes

The tenant is the company. Every call into the purchasing system is a tool_call event, and the audit rests on its properties:

effect
read for a lookup, write for a call that changed something.
supplier
The supplier the call concerns. Instructions carry it too.
change
The id the purchasing system returned for a write. The append is sent with the same value as its Idempotency-Key.
source_event_ids
On a write: the instruction, and the lookups made for it.
actor, role
On an instruction: who gave it.
one action, sent twice
$ cat release.json
{
  "kind": "tool_call",
  "ts": 1791286830000000,
  "text": "release_po(PO-7741) -> released, change CHG-9038",
  "properties": {
    "tool": "release_po",
    "supplier": "SUP-2207",
    "effect": "write",
    "change": "CHG-9038",
    "source_event_ids": [
      "e6ed335c-ec81-4e64-b74c-18e807d35ad6",
      "69d26777-6d8c-45e1-adeb-40b736fa797e",
      "94531a1f-68e3-4017-8dff-212a89976207"
    ]
  }
}

$ curl -s localhost:7782/v1/memory/event -H "$K" -H "$J" \
  -H "Idempotency-Key: CHG-9038" -d @release.json; echo
{"event_id":"b1ce4a3a-3046-59f6-8137-4d20a340de8f"}

$ curl -s localhost:7782/v1/memory/event -H "$K" -H "$J" \
  -H "Idempotency-Key: CHG-9038" -d @release.json; echo
{"event_id":"b1ce4a3a-3046-59f6-8137-4d20a340de8f"}

The second request is the agent retrying because it did not see the first reply. Under an Idempotency-Key the event id is derived from the tenant and the key, so the retry returns the first id and stores nothing. The server does not compare the two bodies.

timekindidtext
09:12:04turn3eb19cfbPriya: move Northfield Fasteners to net 45 from November, the amendment is signed
09:12:07tool_call39567389get_supplier(SUP-2207) -> Northfield Fasteners, terms net 30, 3 open POs
09:12:09tool_call011a6a8fget_contract(SUP-2207) -> amendment 3, signed 2026-10-02: net 45 from 2026-11-01
09:12:12tool_call0c200f82set_payment_terms(SUP-2207, net 45, from 2026-11-01) -> ok, change CHG-9031from 3eb19cfb, 39567389, 011a6a8f
09:12:13factee660d62Northfield Fasteners (SUP-2207) is on net 45 from 2026-11-01, per amendment 3from 011a6a8f, 0c200f82
11:40:22turne6ed335cDaniel: release PO-7741 for Northfield, the quality hold was cleared this morning
11:40:25tool_call69d26777get_po(PO-7741) -> SUP-2207, 18,400.00 EUR, on hold: incoming inspection
11:40:27tool_call94531a1fget_inspection(PO-7741) -> lot 44 passed 2026-10-06 08:55
11:40:30tool_callb1ce4a3arelease_po(PO-7741) -> released, change CHG-9038from e6ed335c, 69d26777, 94531a1f
15:03:51turn8155e40dPriya: put Calder Packaging on hold until their insurance certificate arrives
15:03:54tool_call237aa28bget_supplier(SUP-3310) -> Calder Packaging, active, insurance certificate expired 2026-09-30
15:03:57tool_calla896abfeset_supplier_status(SUP-3310, on hold) -> ok, change CHG-9044from 8155e40d, 237aa28b

The company's tenant at the end of the day: twelve events, three of them writes. Links: each of the three writes, at 09:12:12, 11:40:30 and 15:03:57, to the instruction and each lookup made for it; the fact at 09:12:13 to the contract lookup and the change of terms.

Which actions did the agent take on this supplier, and on whose instruction?

Asked by an auditor, about supplier SUP-2207.

The question is about properties and links and has nothing to rank, so it goes to the read-only Cypher route. Events are (:Event) nodes, and a link is a [:SOURCE_FROM] relationship from an event to its source.

who asked for each write
$ cat who.cypher
MATCH (a:Event)-[:SOURCE_FROM]->(i:Event)
WHERE a.supplier = $supplier AND a.effect = $effect AND i.kind = $kind
RETURN a.change, a.tool, i.actor, i.role
ORDER BY a.ts

$ jq -n --rawfile query who.cypher '{$query, params: {supplier: "SUP-2207", effect: "write", kind: "turn"}}' \
  | curl -s localhost:7782/v1/memory/cypher -H "$K" -H "$J" -d @- | jq -c '.rows[]'
["CHG-9031","set_payment_terms","priya.n","ap_manager"]
["CHG-9038","release_po","daniel.o","buyer"]
timekindidtextaudit
09:12:04turn3eb19cfbPriya: move Northfield Fasteners to net 45 from November, the amendment is signedasked by priya.n
09:12:07tool_call39567389get_supplier(SUP-2207) -> Northfield Fasteners, terms net 30, 3 open POs
09:12:09tool_call011a6a8fget_contract(SUP-2207) -> amendment 3, signed 2026-10-02: net 45 from 2026-11-01
09:12:12tool_call0c200f82set_payment_terms(SUP-2207, net 45, from 2026-11-01) -> ok, change CHG-9031from 3eb19cfb, 39567389, 011a6a8fCHG-9031
09:12:13factee660d62Northfield Fasteners (SUP-2207) is on net 45 from 2026-11-01, per amendment 3from 011a6a8f, 0c200f82
11:40:22turne6ed335cDaniel: release PO-7741 for Northfield, the quality hold was cleared this morningasked by daniel.o
11:40:25tool_call69d26777get_po(PO-7741) -> SUP-2207, 18,400.00 EUR, on hold: incoming inspection
11:40:27tool_call94531a1fget_inspection(PO-7741) -> lot 44 passed 2026-10-06 08:55
11:40:30tool_callb1ce4a3arelease_po(PO-7741) -> released, change CHG-9038from e6ed335c, 69d26777, 94531a1fCHG-9038
15:03:51turn8155e40dPriya: put Calder Packaging on hold until their insurance certificate arrives
15:03:54tool_call237aa28bget_supplier(SUP-3310) -> Calder Packaging, active, insurance certificate expired 2026-09-30
15:03:57tool_calla896abfeset_supplier_status(SUP-3310, on hold) -> ok, change CHG-9044from 8155e40d, 237aa28b

Two writes, each with the person who asked for it. The write on the other supplier, at 15:03, is absent. The same links give what a write had in front of it:

what the release relied on
$ curl -s localhost:7782/v1/memory/cypher -H "$K" -H "$J" -d '{"query":
  "MATCH (a:Event)-[:SOURCE_FROM]->(s:Event) WHERE a.change = $change RETURN s.kind, s.text ORDER BY s.ts",
  "params":{"change":"CHG-9038"}}' | jq -c '.rows[]'
["turn","Daniel: release PO-7741 for Northfield, the quality hold was cleared this morning"]
["tool_call","get_po(PO-7741) -> SUP-2207, 18,400.00 EUR, on hold: incoming inspection"]
["tool_call","get_inspection(PO-7741) -> lot 44 passed 2026-10-06 08:55"]

A listing answers the plainer question of what the agent ran in a period. It filters on kind and on time and on nothing else, so it returns lookups and writes for every supplier. A query parameter it does not know, such as supplier, is ignored without an error.

tool calls between 11:00 and 16:00
$ curl -s "localhost:7782/v1/memory/events?kind=tool_call&since=$T1100&until=$T1600" -H "$K" \
  | jq -r '.events[] | [(.ts/1e6|strftime("%H:%M:%S")), .properties.effect, .text[0:72]] | @tsv' \
  | column -ts $'\t'
15:03:57  write  set_supplier_status(SUP-3310, on hold) -> ok, change CHG-9044
15:03:54  read   get_supplier(SUP-3310) -> Calder Packaging, active, insurance certificat
11:40:30  write  release_po(PO-7741) -> released, change CHG-9038
11:40:27  read   get_inspection(PO-7741) -> lot 44 passed 2026-10-06 08:55
11:40:25  read   get_po(PO-7741) -> SUP-2207, 18,400.00 EUR, on hold: incoming inspection

A stored event cannot be edited. There is no update route, and the query route refuses a write:

a write through the query route
$ curl -s localhost:7782/v1/memory/cypher -H "$K" -H "$J" -d '{"query":
  "MATCH (a:Event) WHERE a.change = $change SET a.text = $text RETURN a",
  "params":{"change":"CHG-9038","text":"release_po(PO-7741) -> not released"}}' | jq -r '.error, .detail'
invalid_request
only read queries are accepted (MATCH, WITH, RETURN); write through POST /v1/memory/event

Left to the application. Recording the action in the same code path that performs it: a link exists only if the application sent the id, and an action stored without one has no instruction. The timestamp is the one the application sent, and the server neither checks it against its clock nor keeps a second one. An auditor's key can be issued with the read scope alone, as the reviewer's was in the first example. The agent's key cannot be narrowed the same way: a key with the write scope can delete an event, and no scope allows appending without deleting.

An assistant on work that runs for three days

An assistant helps an operations lead move billing to a new ledger. On Monday she states the plan and a preference, and the assistant measures the table it has to copy. On Tuesday a dry run finishes, the cutover date moves by a week and a design decision is made. On Wednesday she asks for a status note, and the assistant starts that session with none of the earlier conversation in its prompt.

What the assistant writes

The tenant is the project. The assistant stores every turn and tool call, and after each exchange its model writes down what should outlive the conversation as fact events: one sentence each, with an embedding, a topic property and links to the events the sentence came from. When the date changes on Tuesday, the new fact also names the one it replaces:

the fact that replaces Monday's
$ cat moved.json
{
  "kind": "fact",
  "ts": 1791297160000000,
  "text": "Billing cutover moved to Saturday 2026-10-17: finance closes September on 12 October",
  "embedding": [1.0, 0.0, 0.0, 0.2],
  "properties": {
    "topic": "cutover_date",
    "supersedes": "0d25d7f4-0f30-432b-8d5e-ee1b12d6785e",
    "source_event_ids": [
      "c6ae90a3-8a80-4fed-b130-7bd250bc3144",
      "0d25d7f4-0f30-432b-8d5e-ee1b12d6785e"
    ]
  }
}

$ curl -s localhost:7783/v1/memory/event -H "$K" -H "$J" -d @moved.json; echo
{"event_id":"675aa81d-dc63-49b8-943d-4e24b16d4091"}

topic and supersedes are this application's own properties. memspine stores them and lets Cypher filter on them. It attaches no meaning to either.

timekindidtext
Mon 09:10turn3bd0e2aeLena: we are moving billing to the new ledger, cutover on Saturday 10 October
Mon 09:10fact0d25d7f4Billing cutover to the new ledger is planned for Saturday 2026-10-10from 3bd0e2ae
Mon 09:11turnc7aaeec6Lena: keep status notes to three bullets, no tables
Mon 09:11fact1ecda315Lena wants status notes as three bullets, no tablesfrom c7aaeec6
Mon 09:15tool_callb9a3cecccount_rows(invoices) -> 41,206,118 rows, 212 GB
Mon 09:15factaaaa45dbThe invoices table holds 41.2 million rows, 212 GBfrom b9a3cecc
Tue 14:30tool_call440ef13cdry_run(copy invoices) -> finished in 6 h 40 min, 0 rows rejected
Tue 14:30factead68b41A full copy of invoices took 6 h 40 min in the dry runfrom 440ef13c
Tue 14:32turnc6ae90a3Lena: finance closes September on the 12th, so no freeze before that. Move the cutover to Saturday 17 October
Tue 14:32fact675aa81dBilling cutover moved to Saturday 2026-10-17: finance closes September on 12 Octoberfrom c6ae90a3, 0d25d7f4
Tue 14:36turn74edb4baLena: freeze writes during the copy, I do not want dual-write
Tue 14:36factbbb49487Decision: writes to billing are frozen during the copy; dual-write was rejectedfrom 74edb4ba
Wed 10:05turn39552bf1Lena: draft the status note for the steering group

The project's tenant on Wednesday morning: thirteen events over three days, six of them facts. Links: each fact to the turn or tool call before it; Tuesday's date fact also to Monday's.

What has been settled about the cutover?

Asked by the assistant on Wednesday, before it drafts the note.

The request sends a query the assistant wrote for the question, as text and as an embedding, restricted to facts, and asks for three events. Given both, memspine ranks by keyword and by embedding separately and fuses the two rankings.

recall on Wednesday
$ curl -s localhost:7783/v1/memory/recall -H "$K" -H "$J" \
  -d '{"text":"cutover plan for billing: date, freeze, copy time",
       "embedding":[0.7,0.4,0.0,0.6],"kind":"fact","k":3}' \
  | jq -r '.hits[] | [(.score*1e4|round/1e4), (.ts/1e6|strftime("%a")), .event_id[0:8], .text[0:68]] | @tsv' \
  | column -ts $'\t'
0.0238  Mon  0d25d7f4  Billing cutover to the new ledger is planned for Saturday 2026-10-10
0.0234  Tue  675aa81d  Billing cutover moved to Saturday 2026-10-17: finance closes Septemb
0.023   Tue  bbb49487  Decision: writes to billing are frozen during the copy; dual-write w

Three of the six facts come back, and two of them are the same fact in two versions, with Monday's date ranked first. Ranking uses the keyword and embedding scores only. It has no term for recency, and the server does not know that one fact replaces another. The copy time, which the note needs, did not make the list.

A fact that has been replaced

The application can keep both facts or forget the old one. Keeping both, it reads the current one by property, as the newest fact on that topic:

the newest fact on a topic
$ curl -s localhost:7783/v1/memory/cypher -H "$K" -H "$J" -d '{"query":
  "MATCH (f:Event) WHERE f.kind = $kind AND f.topic = $topic RETURN f.text ORDER BY f.ts DESC LIMIT 1",
  "params":{"kind":"fact","topic":"cutover_date"}}' | jq -c '.rows[]'
["Billing cutover moved to Saturday 2026-10-17: finance closes September on 12 October"]

That works for a value the application knows to ask for by name. A recall by meaning still returns both versions. Forgetting the old fact once the new one is stored changes what recall returns:

forget Monday's date, ask again
$ curl -s -X DELETE localhost:7783/v1/memory/event/$OLD_DATE -H "$K"; echo
{"deleted":true,"purged":false}

$ curl -s localhost:7783/v1/memory/recall -H "$K" -H "$J" \
  -d '{"text":"cutover plan for billing: date, freeze, copy time",
       "embedding":[0.7,0.4,0.0,0.6],"kind":"fact","k":3}' \
  | jq -r '.hits[] | [(.score*1e4|round/1e4), (.ts/1e6|strftime("%a")), .event_id[0:8], .text[0:68]] | @tsv' \
  | column -ts $'\t'
0.0238  Tue  675aa81d  Billing cutover moved to Saturday 2026-10-17: finance closes Septemb
0.023   Tue  bbb49487  Decision: writes to billing are frozen during the copy; dual-write w
0.0152  Tue  ead68b41  A full copy of invoices took 6 h 40 min in the dry run
timekindidtextrecall
Mon 09:10turn3bd0e2aeLena: we are moving billing to the new ledger, cutover on Saturday 10 October
Mon 09:10fact0d25d7f4Billing cutover to the new ledger is planned for Saturday 2026-10-10forgotten
Mon 09:11turnc7aaeec6Lena: keep status notes to three bullets, no tables
Mon 09:11fact1ecda315Lena wants status notes as three bullets, no tablesfrom c7aaeec6
Mon 09:15tool_callb9a3cecccount_rows(invoices) -> 41,206,118 rows, 212 GB
Mon 09:15factaaaa45dbThe invoices table holds 41.2 million rows, 212 GBfrom b9a3cecc
Tue 14:30tool_call440ef13cdry_run(copy invoices) -> finished in 6 h 40 min, 0 rows rejected
Tue 14:30factead68b41A full copy of invoices took 6 h 40 min in the dry runfrom 440ef13cthird
Tue 14:32turnc6ae90a3Lena: finance closes September on the 12th, so no freeze before that. Move the cutover to Saturday 17 October
Tue 14:32fact675aa81dBilling cutover moved to Saturday 2026-10-17: finance closes September on 12 Octoberfrom c6ae90a3first
Tue 14:36turn74edb4baLena: freeze writes during the copy, I do not want dual-write
Tue 14:36factbbb49487Decision: writes to billing are frozen during the copy; dual-write was rejectedfrom 74edb4basecond
Wed 10:05turn39552bf1Lena: draft the status note for the steering group

The same request now returns the current date, the decision and the copy time. This delete did not ask for a purge, so purged is false: the old sentence is unreadable at once and leaves the files at the next purge. What is lost is the old fact as an event. The change stays on record in Tuesday's message and in the wording of the new fact, which also keeps the old id in its properties although the link is gone.

The note also needs the preference stated on Monday, and a second request finds it:

a preference from two days earlier
$ curl -s localhost:7783/v1/memory/recall -H "$K" -H "$J" \
  -d '{"text":"how Lena wants status notes written","embedding":[0.0,0.0,1.0,0.1],"kind":"fact","k":1}' \
  | jq -r '.hits[] | [(.ts/1e6|strftime("%a %H:%M")), .event_id[0:8], .text] | @tsv' | column -ts $'\t'
Mon 09:11  1ecda315  Lena wants status notes as three bullets, no tables

Left to the application. Writing the facts takes a model, and the server never calls one. Noticing that a new fact replaces an old one, and choosing between keeping and forgetting, are the application's work too. The effect of the first of these has been measured, together with query rewriting: on LoCoMo, the benchmark on the results page, a client that extracts facts and rewrites its queries finds 0.889 of the evidence turns among the 20 events retrieved per query, against 0.794 for raw turns and the question as asked, both with fused recall.

How the sessions were recorded

Each example ran against its own server: the released 0.2.2 binary, started on an empty data directory. The site's build pastes the commands and their output into this page from those runs. The page shows the requests that answer each question and leaves out most of the appends that stored the events.

start of the first session
$ MEMSPINE_BIND=127.0.0.1:7781 MEMSPINE_EMBEDDING_DIM=4 MEMSPINE_DATA_DIR=./data \
    MEMSPINE_ADMIN_KEY="$ADMIN_KEY" memspine-server > memspine.log 2>&1 &

$ curl -s localhost:7781/health; echo
{"service":"memspine-server","status":"ok","version":"0.2.2"}

MEMSPINE_EMBEDDING_DIM=4 keeps the request bodies readable: the four numbers in each embedding were set by hand, where a deployment sends vectors from an embedding model, 384 numbers long by default. A recall that sends only text needs no embedding.

The first server was given an admin key and a control database, created with memspine-admin init, after which it accepts only issued keys. In its transcripts $K is the support agent's key, $REVIEW the reviewer's and $OTHER the second customer's. The other two servers had no control database, so their bearer token is the tenant id itself, a mode meant for local use.

What the application decides

Each of the three sessions settled the following in its own code.

the tenant
The unit whose events may appear together in one answer. Neither recall nor listing can be narrowed to one customer inside a shared tenant. Above: the customer account, the company, the project.
tenants open at once
Follows from the choice of tenant. An open tenant holds its indexes in memory and about eight file descriptors, and one with no request for an hour, the default, is closed until its next request. The server keeps at most (file-descriptor limit − 64) / 12 tenants open, which is 80 at a limit of 1,024, unless MEMSPINE_MAX_OPEN_TENANTS says otherwise. Past that it closes the least recently used idle tenant, and answers 503 when every open tenant has a request in flight. Reopening one took 437 ms at 10,000 events and 3.3 s at 100,000 in the scale run on the results page.
kind
The one categorical filter recall and listing have. The operations session stored lookups and writes under one kind, so only Cypher could tell them apart.
properties
Up to 16 top-level properties per event, taken in key order, can be filtered on from Cypher: booleans, numbers and strings of at most 256 bytes, under names that begin with a letter and use only ASCII letters, digits and underscores. Arrays, nested values, other names and further properties are stored and returned as sent.
facts
What to derive, when, and in which words.
embedding
Which events get one and which model makes it. The server checks the length and does not record the model, so vectors from two models of the same length would be mixed without warning.
source_event_ids
Which links exist. Recall with hops and Cypher follow these and nothing else.
ts
When the event happened, in microseconds. It defaults to the time of arrival.
replacement, erasure
Whether a replaced fact is kept or forgotten, and which derived events go with a deleted one.

When memspine is the wrong tool

Each of these is a reason to use something else, or to run something beside it.

One customer, company or project will hold far more than 100,000 events. Search compares the query with every stored embedding: a median of 2.8 ms at 10,000 events and 35 ms at 100,000, for 384-dimension embeddings with the engine called in process on a laptop, and nothing larger has been measured.

A tenant cannot wait while an erasure is purged. A purge rewrites that tenant's whole log; at 100,000 events the median of three purges in one run on a laptop that was not otherwise idle was 25 s and the longest 39 s, and earlier runs took 4 to 12 s.

You need a second copy of the data on another machine. There is no replication.

You want the service to embed text, extract facts or summarise. It stores and returns what the application sends.

The audit trail has to hold against the party that writes it. A write key can delete, and the timestamp is the writer's.

Facts need versions, validity periods or a newest-first ranking from the service. Recall ranks on keyword and embedding match alone.

Your text is not in ASCII letters and digits and you have no embedding model. Keyword recall will not match it.

You would give Cypher to callers you do not trust. A query is screened for the shapes known to exhaust the server, and its cost is otherwise unbounded.

Running agents for your customers?

memspine is built by ERP.AI. If you are putting memory under agents that serve your customers, talk to us.