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
speakerandconversationas 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_idsname 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.
$ 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.
| time | kind | id | text | |
|---|---|---|---|---|
| 14:02:10 | turn | 0671c55d | Customer: order 88213 shows delivered on 1 October but nothing arrived | |
| 14:02:13 | tool_call | 564ac64f | get_order(88213) -> 1 item, shipped 2026-09-29, carrier ref PX-55790112 | |
| 14:02:15 | tool_call | 354192b1 | track_parcel(PX-55790112) -> delivered, signed for at no. 16, 2026-10-01 14:12 | |
| 14:02:19 | turn | 009cfbc7 | Agent: the carrier shows it signed for at number 16, on 1 October at 14:12. Please check therefrom 0671c55d, 564ac64f, 354192b1 | |
| 14:03:02 | turn | 31c5a743 | Customer: there is no number 16 on my street. I want a refund | |
| 14:03:05 | tool_call | a408d2e9 | open_case(order=88213, reason=delivery disputed) -> case C-5117, assigned to a person | |
| 14:03:06 | fact | 389884bc | Order 88213: carrier says delivered at no. 16, customer says there is no no. 16; case C-5117 is openfrom 354192b1, 31c5a743, a408d2e9 | |
| 14:04:40 | turn | 512dbc6d | Customer: the courier can use door code 4471 next time and leave the parcel in the hall | |
| 14:04:42 | fact | a525f2aa | Delivery instruction: door code 4471, leave parcels in the hallfrom 512dbc6d | |
| 16:31:07 | turn | 10dce972 | Customer: 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.
$ 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.
| time | kind | id | text | recall | |
|---|---|---|---|---|---|
| 14:02:10 | turn | 0671c55d | Customer: order 88213 shows delivered on 1 October but nothing arrived | what it was told | |
| 14:02:13 | tool_call | 564ac64f | get_order(88213) -> 1 item, shipped 2026-09-29, carrier ref PX-55790112 | what it looked up | |
| 14:02:15 | tool_call | 354192b1 | track_parcel(PX-55790112) -> delivered, signed for at no. 16, 2026-10-01 14:12 | what it looked up | |
| 14:02:19 | turn | 009cfbc7 | Agent: the carrier shows it signed for at number 16, on 1 October at 14:12. Please check therefrom 0671c55d, 564ac64f, 354192b1 | the answer | |
| 14:03:02 | turn | 31c5a743 | Customer: there is no number 16 on my street. I want a refund | also matched | |
| 14:03:05 | tool_call | a408d2e9 | open_case(order=88213, reason=delivery disputed) -> case C-5117, assigned to a person | ||
| 14:03:06 | fact | 389884bc | Order 88213: carrier says delivered at no. 16, customer says there is no no. 16; case C-5117 is openfrom 354192b1, 31c5a743, a408d2e9 | reached, left out | |
| 14:04:40 | turn | 512dbc6d | Customer: the courier can use door code 4471 next time and leave the parcel in the hall | ||
| 14:04:42 | fact | a525f2aa | Delivery instruction: door code 4471, leave parcels in the hallfrom 512dbc6d | ||
| 16:31:07 | turn | 10dce972 | Customer: 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.
$ 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.
$ 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.
$ 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
readfor a lookup,writefor 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.
$ 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.
| time | kind | id | text | |
|---|---|---|---|---|
| 09:12:04 | turn | 3eb19cfb | Priya: move Northfield Fasteners to net 45 from November, the amendment is signed | |
| 09:12:07 | tool_call | 39567389 | get_supplier(SUP-2207) -> Northfield Fasteners, terms net 30, 3 open POs | |
| 09:12:09 | tool_call | 011a6a8f | get_contract(SUP-2207) -> amendment 3, signed 2026-10-02: net 45 from 2026-11-01 | |
| 09:12:12 | tool_call | 0c200f82 | set_payment_terms(SUP-2207, net 45, from 2026-11-01) -> ok, change CHG-9031from 3eb19cfb, 39567389, 011a6a8f | |
| 09:12:13 | fact | ee660d62 | Northfield Fasteners (SUP-2207) is on net 45 from 2026-11-01, per amendment 3from 011a6a8f, 0c200f82 | |
| 11:40:22 | turn | e6ed335c | Daniel: release PO-7741 for Northfield, the quality hold was cleared this morning | |
| 11:40:25 | tool_call | 69d26777 | get_po(PO-7741) -> SUP-2207, 18,400.00 EUR, on hold: incoming inspection | |
| 11:40:27 | tool_call | 94531a1f | get_inspection(PO-7741) -> lot 44 passed 2026-10-06 08:55 | |
| 11:40:30 | tool_call | b1ce4a3a | release_po(PO-7741) -> released, change CHG-9038from e6ed335c, 69d26777, 94531a1f | |
| 15:03:51 | turn | 8155e40d | Priya: put Calder Packaging on hold until their insurance certificate arrives | |
| 15:03:54 | tool_call | 237aa28b | get_supplier(SUP-3310) -> Calder Packaging, active, insurance certificate expired 2026-09-30 | |
| 15:03:57 | tool_call | a896abfe | set_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.
$ 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"]
| time | kind | id | text | audit | |
|---|---|---|---|---|---|
| 09:12:04 | turn | 3eb19cfb | Priya: move Northfield Fasteners to net 45 from November, the amendment is signed | asked by priya.n | |
| 09:12:07 | tool_call | 39567389 | get_supplier(SUP-2207) -> Northfield Fasteners, terms net 30, 3 open POs | ||
| 09:12:09 | tool_call | 011a6a8f | get_contract(SUP-2207) -> amendment 3, signed 2026-10-02: net 45 from 2026-11-01 | ||
| 09:12:12 | tool_call | 0c200f82 | set_payment_terms(SUP-2207, net 45, from 2026-11-01) -> ok, change CHG-9031from 3eb19cfb, 39567389, 011a6a8f | CHG-9031 | |
| 09:12:13 | fact | ee660d62 | Northfield Fasteners (SUP-2207) is on net 45 from 2026-11-01, per amendment 3from 011a6a8f, 0c200f82 | ||
| 11:40:22 | turn | e6ed335c | Daniel: release PO-7741 for Northfield, the quality hold was cleared this morning | asked by daniel.o | |
| 11:40:25 | tool_call | 69d26777 | get_po(PO-7741) -> SUP-2207, 18,400.00 EUR, on hold: incoming inspection | ||
| 11:40:27 | tool_call | 94531a1f | get_inspection(PO-7741) -> lot 44 passed 2026-10-06 08:55 | ||
| 11:40:30 | tool_call | b1ce4a3a | release_po(PO-7741) -> released, change CHG-9038from e6ed335c, 69d26777, 94531a1f | CHG-9038 | |
| 15:03:51 | turn | 8155e40d | Priya: put Calder Packaging on hold until their insurance certificate arrives | ||
| 15:03:54 | tool_call | 237aa28b | get_supplier(SUP-3310) -> Calder Packaging, active, insurance certificate expired 2026-09-30 | ||
| 15:03:57 | tool_call | a896abfe | set_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:
$ 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.
$ 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:
$ 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:
$ 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.
| time | kind | id | text | |
|---|---|---|---|---|
| Mon 09:10 | turn | 3bd0e2ae | Lena: we are moving billing to the new ledger, cutover on Saturday 10 October | |
| Mon 09:10 | fact | 0d25d7f4 | Billing cutover to the new ledger is planned for Saturday 2026-10-10from 3bd0e2ae | |
| Mon 09:11 | turn | c7aaeec6 | Lena: keep status notes to three bullets, no tables | |
| Mon 09:11 | fact | 1ecda315 | Lena wants status notes as three bullets, no tablesfrom c7aaeec6 | |
| Mon 09:15 | tool_call | b9a3cecc | count_rows(invoices) -> 41,206,118 rows, 212 GB | |
| Mon 09:15 | fact | aaaa45db | The invoices table holds 41.2 million rows, 212 GBfrom b9a3cecc | |
| Tue 14:30 | tool_call | 440ef13c | dry_run(copy invoices) -> finished in 6 h 40 min, 0 rows rejected | |
| Tue 14:30 | fact | ead68b41 | A full copy of invoices took 6 h 40 min in the dry runfrom 440ef13c | |
| Tue 14:32 | turn | c6ae90a3 | Lena: finance closes September on the 12th, so no freeze before that. Move the cutover to Saturday 17 October | |
| Tue 14:32 | fact | 675aa81d | Billing cutover moved to Saturday 2026-10-17: finance closes September on 12 Octoberfrom c6ae90a3, 0d25d7f4 | |
| Tue 14:36 | turn | 74edb4ba | Lena: freeze writes during the copy, I do not want dual-write | |
| Tue 14:36 | fact | bbb49487 | Decision: writes to billing are frozen during the copy; dual-write was rejectedfrom 74edb4ba | |
| Wed 10:05 | turn | 39552bf1 | Lena: 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.
$ 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:
$ 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:
$ 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
| time | kind | id | text | recall | |
|---|---|---|---|---|---|
| Mon 09:10 | turn | 3bd0e2ae | Lena: we are moving billing to the new ledger, cutover on Saturday 10 October | ||
| Mon 09:10 | fact | 0d25d7f4 | Billing cutover to the new ledger is planned for Saturday 2026-10-10 | forgotten | |
| Mon 09:11 | turn | c7aaeec6 | Lena: keep status notes to three bullets, no tables | ||
| Mon 09:11 | fact | 1ecda315 | Lena wants status notes as three bullets, no tablesfrom c7aaeec6 | ||
| Mon 09:15 | tool_call | b9a3cecc | count_rows(invoices) -> 41,206,118 rows, 212 GB | ||
| Mon 09:15 | fact | aaaa45db | The invoices table holds 41.2 million rows, 212 GBfrom b9a3cecc | ||
| Tue 14:30 | tool_call | 440ef13c | dry_run(copy invoices) -> finished in 6 h 40 min, 0 rows rejected | ||
| Tue 14:30 | fact | ead68b41 | A full copy of invoices took 6 h 40 min in the dry runfrom 440ef13c | third | |
| Tue 14:32 | turn | c6ae90a3 | Lena: finance closes September on the 12th, so no freeze before that. Move the cutover to Saturday 17 October | ||
| Tue 14:32 | fact | 675aa81d | Billing cutover moved to Saturday 2026-10-17: finance closes September on 12 Octoberfrom c6ae90a3 | first | |
| Tue 14:36 | turn | 74edb4ba | Lena: freeze writes during the copy, I do not want dual-write | ||
| Tue 14:36 | fact | bbb49487 | Decision: writes to billing are frozen during the copy; dual-write was rejectedfrom 74edb4ba | second | |
| Wed 10:05 | turn | 39552bf1 | Lena: 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:
$ 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.
$ 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_TENANTSsays 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
hopsand 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.