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.
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.
| Holds | Lifecycle | |
|---|---|---|
audit.db | what the agent did, for whom, and why it failed | append-mostly, kept |
chat.db | the conversation | mutable, user-erasable |
memory.db | facts the user asked to be remembered | read by the model on every turn ⚠ |
index.db | the extracted text of documents | rebuildable, and not in the same service |
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 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:
| Kind | Means | What to do |
|---|---|---|
permission | Nextcloud said no | nothing — this is the invariant working |
not_found | the model guessed a path | tighten the tool description or its hint |
invalid_input | it could not build valid arguments | the schema or a field description is unclear |
upstream | Nextcloud was unreachable | infrastructure |
internal | our bug | fix 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.
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.
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.
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.