Four SQLite files, node:sqlite — stdlib on Node 26, so no dependency, no process,
no port. Why they are four and not one is in What is stored.
node:sqlite is fully synchronous, so every write blocks the event loop. Batch
inserts in a transaction and keep writes off the streaming path — persistence
happens on finish, not per delta. Pragmas are applied on every open: WAL persists,
the rest do not, and foreign_keys silently defaults to off.
The field that makes this worth querying is failure_kind. It separates Nextcloud
refused — the system working exactly as designed — from the model could not use the
tool, which means the ACI is wrong and should change.
Read it this way:
failure_kind | Means | Action |
|---|---|---|
permission | Nextcloud said no | nothing — this is the invariant working |
not_found | the model guessed a path | tighten the tool description or the hint |
invalid_input | it could not build valid arguments | the schema or .describe() is unclear |
upstream | Nextcloud was unreachable | infrastructure |
internal | our bug | fix it |
Useful queries:
approved_by_user is derived, not parsed: it is true exactly when G3 gated the
call. The reasoning is that the SDK refuses to execute a needsApproval tool
without an approval response it has HMAC-verified against that exact call — so for
such a tool, reaching the audit hook is the user having confirmed it. Reading the
approval part back out of the message history would be two hops through
ModelMessages for a fact the structure already guarantees.
Read the query above accordingly: a 0 on an action means the tool was
policy-exempt (annotated idempotentHint, so G3 let it through), not that a
confirmation was skipped. An unknown tool defaults to 1, so a gap in the meta map
never shows up as a missing confirmation.
turns records tokens, steps, duration, finish reason, and whether the turn was
compacted or pruned. tool_fingerprints stores a digest per tool definition, so a
definition that changes under us forces needsApproval: true and logs loudly — a
tool contract moving silently is a supply-chain problem.
Two rules keep this honest.
parts_json holds UIMessage.parts verbatim. The UI form is the stored form;
ModelMessage is derived on the way out, never the other way round. Converting on
write loses information you cannot get back.
pruned is a display hint only. What reaches the model is decided by
pruneMessages inside prepareStep, recomputed every turn so it stays
deterministic. Letting a stored flag drive the model context would create two
sources of truth that disagree.
The unique index on (conversation_id, up_to_seq) is what makes a summary reused
rather than regenerated — the whole point of rung 2 paying once per compaction event.
The title is the first user message, truncated: cheap, and no extra model call.
⚠️ A conversation id comes from the client. Every read and write filters on uid.
ensureConversation throws when the id exists under another user — a loud refusal
rather than a silent 404, because that is the difference between a bug and an IDOR.
Read by the model on every turn, which makes it a persistent instruction
channel. See the agent for why memory_write is annotated
destructive.
Two traps, both tested, both capable of doing real damage:
FTS5 cannot filter by uid. Every search must join back to facts and filter
there:
Forget the join and one user reads another's memory. The test asserts both directions, so a passing result cannot be an accident.
User text is FTS5 query syntax. A stray ", -, * or NEAR throws or
silently changes the query. toMatchQuery() tokenises and re-quotes each term,
OR-joined, so no operator survives. Terms of one character are dropped — they match
almost everything.
source exists so everything the agent attributed to itself can be purged
separately. superseded_by supersedes rather than deletes, so "my office moved to
the 3rd floor" hides the old fact without losing the history.
-wal and -shm siblings next to each .db are normal. A clean shutdown
checkpoints them — index.ts closes the databases on SIGTERM, which is why killing
the process mid-write leaves a -wal to recover from.
node:sqlite also exposes Session and backup, so hot backup needs no external
tool. Not wired up yet.