What is stored, and where the lines are

Four SQLite files. What matters is not that they are SQLite — it is that they are four, that three of them belong to one service and one belongs to a different one, and that each boundary answers a question about who is allowed to hold what.

Schemas and queries are in Reference: databases. This page is the reasoning.

Why not one file

Their lifecycles differ, and merging them would make one lifecycle destroy another.

Erasing a user's conversations must not erase the record of what the agent did on their behalf. The audit trail has to be independently backupable and, ideally, independently readable by someone who has no business reading anyone's chat. A single file makes "delete my history" and "keep the accountability record" contradictory.

HoldsLifecycle
audit.dbwhat the agent did, for whom, and why it failedappend-mostly, kept
chat.dbthe conversationmutable, user-erasable
memory.dbfacts the user asked to be rememberedread by the model on every turn ⚠
index.dbthe extracted text of documentsrebuildable, and not in the same service

Why the fourth one lives somewhere else

index.db holds the extracted text of every document a search has reached. That makes it exactly as sensitive as the drive it mirrors — arguably more, because it is the readable parts of it, concentrated.

The orchestrator holds no Nextcloud data of any kind. An index of the company's documents would be the largest possible violation of that, so it sits on the credential side of the boundary, inside the tool server.

It also stores no ACL, which is what lets it be a shared index at all: it has no user id to filter on, and every hit is re-checked against the caller's own token before it leaves the process. → Searching inside documents

Of the four, it is the only one that can be deleted with no loss but a re-crawl.

The feedback loop on tool design

The audit trail is not a compliance artefact that nobody reads. Its most useful field is a classification of why a call failed, and the distinction it draws is the one that tells you whether to change anything:

KindMeansWhat to do
permissionNextcloud said nonothing — this is the invariant working
not_foundthe model guessed a pathtighten the tool description or its hint
invalid_inputit could not build valid argumentsthe schema or a field description is unclear
upstreamNextcloud was unreachableinfrastructure
internalour bugfix it

A rising permission count means the system is behaving. A rising invalid_input count on one tool is a design defect in that tool, not a model problem.

One field is worth understanding because it looks like a gap and is not. approved_by_user is derived, not parsed out of the message history: the SDK refuses to execute a tool that needs approval without a signed approval matching that exact call, so for such a tool, reaching the audit hook at all is the user having confirmed it. A 0 on an action therefore means the tool was policy-exempt — annotated idempotent, so the gate let it through — never that a confirmation was skipped.

Two rules that keep the conversation honest

The stored form is the UI form. Messages are stored exactly as the interface produced them, and the model's view is derived on the way out — never the reverse. Converting on write loses information that cannot be recovered, and a tool call whose result was pruned for the model still has to render in the transcript.

Pruning is recomputed, never replayed. What reaches the model is decided fresh every turn. A stored "this was pruned" flag drives display only. Letting it drive the model's context would create two sources of truth that disagree within a week.

Memory is an instruction channel, and is treated as one

Everything in memory.db is read by the model on later turns. That makes it the one store where a write is more dangerous than a delete: a fact written silently today shapes every answer from now on.

Hence memory_write needs a confirmation despite destroying nothing, hence the source of every fact is recorded so the agent's own attributions can be purged separately, and hence facts are superseded rather than overwritten — "my office moved to the 3rd floor" hides the old fact without losing that it was once true.

Two traps live here, both tested, both capable of real damage:

Full-text search cannot filter by user. The index is a virtual table with no user column, so every search must join back and filter there. Forget it and one user reads another's memory. The test asserts both directions, so passing cannot be an accident.

User text is query syntax. A stray quote, dash, asterisk or NEAR either throws or silently changes the meaning of the search. Every term is tokenised and re-quoted before it reaches the engine.

The same two rules apply, for the same reasons, to the document index and to conversation ids — an id that comes from the client is checked against the uid on every read and every write, because a guessable id would otherwise be an IDOR.

What is deliberately absent

No vector index. If keyword search plateaus, an extension attaches to the same file with no migration; until the measurements say so, it is work done in advance. → why, with the trigger written down

No episodic memory, no automatic compaction of memory itself, no model-generated conversation titles — the first user message, truncated, costs nothing and is usually better.