NAVIGATION
ELOWEN DOCUMENTATION

Last updated: 10 October 2026

Browse documentation · Persistence and store ownership
Developer reference

Persistence and store ownership

Persistence and the store

brain_session_entries is the sole durable PI transcript. Native entry IDs and parent links form the branch tree, brain_session_journal_state.active_leaf_id selects the branch, and sequence numbers record append order. journalWriter.ts stores the native diff; persistence.ts::rehydrate restores PI entries and leaf selection rather than rebuilding provider history from chat text. Hidden elowen.turn-context records and providerText belong to this same journal. The derived branch-state and usage projections are read indexes, not additional transcript authorities. With no journal entries a session restores empty native history; malformed persisted entries fail preparation. Restoration and forks read stored native entries without a provider request.

createSessionPersistenceProjector checks the durable session owner only at PI's post-append barrier and the agent_end / agent_settled settlement barriers. Non-persisting PI events update only in-memory clocks and do no database work. Every event still respects the in-memory persistence-failure latch; a failed session cannot commit again. Late appends and settlement after deletion remain inert, and the journal's synchronous durable append, ordering, recovery and provider-visible message bytes are unchanged.

src/shared/instancePaths.ts derives all database-adjacent paths once: plugin/update state, licence identity, avatars and chat images, themes, skins, agents, brain credentials and boot markers. Use instancePaths(dbPath).licencePath in the installer or licence store rather than deriving another path. The function only derives names and never creates directories. Callers explicitly disable persistent surfaces for :memory:; buildBrainCore also skips credential-directory creation and uses its in-memory credential store. File-backed credential directories are born with mode 0700. A relocated database keeps these paths beside that database without changing HOME-based log defaults or the existing vault-key policy. Derivation is constant-cost and adds no disk or network reads. createRouteContext resolves updateDir once per server from the injected override or instancePaths(dbPath(process.env)). Config and plugin routes share this namespace for queue cancellation, mutation locks and update status.

The licence reader uses readJsonStrict(path, parser) from the existing atomic JSON module (packages/plugin-shared/atomicJson.mjs, imported by src/licence/store.ts). Only ENOENT means absent; invalid JSON, invalid domain shape and other filesystem errors remain failures. The installer retains parseLicenceRecordText(raw, path) for its injected text transport, with the same domain validator as the file reader. Writes retain compact newline-terminated bytes, forced mode 0600 and durable file/directory synchronization.

Conversation display and answer readers share messageView: ordered text-block projection preserves text verbatim, including literal <think> and <thinking> tags, without losing whitespace or moving tool positions. Reasoning is carried by PI's native thinking blocks and separate events, not inferred from answer text. Current models use the API's separate reasoning channel, and the production-history audit found no inline reasoning leaks, so neither core nor chat adapters carry an inline-tag filter or stream conversion. extractText joins only text blocks; the shared live-message engine opens an answer only when accumulated text is non-empty. Historical journal entries are never rewritten for display, preserving restored prompt-cache bytes. transcriptPageRows counts complete call/result units through shapeBrainMessages, including its lookahead, so hidden wrapper results cannot create false pages. lastSettledAssistantText reads retained, settled assistants in bounded batches of 32 (src/brain/conversationRead.ts:18) until a non-empty answer or exhaustion; plugin reads and delegated recovery use it without a fixed empty-tail cutoff.

ProviderRequestStore prepares canonical payload segments, display metadata, and segment and chain hashes before acquiring the database write lock. Correlation validation, previous-manifest selection, collision validation, persistence, and summary accounting remain inside one IMMEDIATE transaction. The existing ProviderRequestRecorder boundary uses this preparation for starts, terminal responses and attached compaction responses; there is no second capture authority.

The store keeps its capture, lifecycle and reconstruction API. Its diagnostic methods delegate to src/store/providerRequestDebugReads.ts, which owns filters, cursors, session/request projections, metadata batching and bounded payload reads. Session list and detail use one local SELECT/JOIN fragment. The reader receives the store's manifest-reference reader, segment loader and canonical reconstruction with its UTF-8 byte count; it never implements a second chain walk or payload reconstruction. pruneDiagnostics deletes at most one eligible session per IMMEDIATE transaction, releasing the writer lock between sessions within the existing sweep limit. Selection, pending-request exclusion and byte-budget checks run under each transaction's lock; a failed deletion rolls back that session only. src/store/providerRequestDisplay.ts supplies capture-time roles, labels and 240-character previews. BrainService.debugRequest and the other diagnostic methods continue through this facade: unknown or foreign-session requests remain absent, page sizes remain capped at 100, and payload reads retain the 256 KiB default and 4 MiB maximum with an explicit error for oversized items. Diagnostic reads acquire no write lock; raw reads check segment metadata before loading payloads and also check the reconstructed body's canonical byte size. Without captured requests, the existing session projection and legacy-transcript path remain unchanged.

ProviderRequestRecorder opens one isolated cost meter for every provider request, independent of debug capture, and reconciles terminal messages before PI persists them or usage consumers observe them. A provider-reported zero replaces that request's list-price estimate; requests with no report retain their own estimate. Repeated cost snapshots within one HTTP response count once. Sessionless piInferenceClient completions use the same reconciliation and cannot charge an ambient parent meter. API-key endpoint overrides create transient OpenAI-compatible descriptors through the existing modelEntry and openAiCompatibilityFor projection, including models absent from the original catalog, and never overwrite the registered conversation route or receive OAuth credentials. Failed, aborted, empty and transport-thrown paid secondary completions carry InferenceFailure.usage; withOriginUsage records it once before rethrowing, without consuming the parent origin pin. A transport throw with only a known charge reports no invented tokens. The public RelayClient and memory-categorization overrides consume this route rather than parsing raw chat responses separately.

Web and platform file ingress share resolveUploadProject (src/brain/chatUploads.ts) and one write path, openProjectUpload in src/brain/managedArtifacts.ts. It returns a ProjectUpload (append, commit, abort, received) for either filesystem. The conversation's Project identity takes precedence over workspace defaults; current project rows determine execution kind, and current writer membership gates placement. Host uploads write into <name>.part, claimed create-only (wx) beside the target with the usual containment checks, through openHostUpload in src/plugins/hostUpload.ts, a module published for plugins (seam catalog row 62). Each append opens the part with O_NOFOLLOW, writes at explicit offsets and closes it, so no descriptor outlives a request; the part's device and inode are recorded when it is claimed, and an append that opens a different file breaks the upload. The commit publishes the part in create mode through the same createUploadTarget collision walk (link refuses an existing name like wx), checks through a descriptor on the published name that it is still that file and holds exactly the bytes written (otherwise it unlinks the name again and fails), and removes the part, so an unfinished or substituted file never carries a complete-looking name. Managed uploads resolve the live Sandbox projectFiles control, require a declared size, and refuse unavailable providers without touching host paths. Every chunked write into a guest, these uploads, ctx.projectImageFiles() writes and streamed tool output alike, goes through beginGuestUpload / uploadGuestFile in src/plugins/guestUpload.ts, a module published for plugins (seam catalog row 61) that sequences operations over the caller's own scoped file call. Registry Editor and OneDrive use the same guest helpers, and Editor uses the host helper for host uploads. The transport admits a chunk only at its own boundary (exactly chunkSize, except the last ending at size), so append regroups its stream into those chunks and drops a trailing partial chunk; received() counts only bytes the destination acknowledged, and the next append resumes there. write-begin builds missing parent directories inside the guest, so no caller walks them first; the managed upload walks only the collision suffixes, bounded by the same MAX_COLLISIONS as the host. storeProjectUpload is the one-request shape (append the whole stream, commit, abort on failure), used by POST /brain/uploads and platform ingress, which keeps its existing attachment count and byte limits. The part protocol is POST /brain/uploads/sessions to open an upload, PUT /brain/uploads/sessions/:id for each part, POST /brain/uploads/sessions/:id/commit to finish it, and DELETE /brain/uploads/sessions/:id to cancel it (src/api/routes/brainUploads.ts:70-101). The browser uses UploadSessions (src/brain/uploadSessions.ts): an in-memory table of open uploads, one append per PUT part of UPLOAD_PART_BYTES (16 MiB, 32 guest chunks of 512 KiB, src/brain/uploadSessions.ts:10, src/plugins/environmentTypes.ts:51), swept after an hour idle on the next upload activity without blocking that request. Every part and commit reauthorizes the stored project identity through the same uploadCandidates / resolveUploadProject rules, after upload-account ownership is checked. A changed execution kind, root or slug invalidates the captured destination; request query values cannot redirect an open upload. Refusal discards staging through the existing cancel path, which remains available after access loss. A part runs under the request's abort signal; a cancel while a part is writing aborts it and the part discards the upload once it lets go. A destination write failure marks the upload broken() and ends it, because the destination may hold bytes nobody acknowledged; a stream that broke off resumes from received(). Parts exist because a proxy in front of an instance caps one request and every hop ends a request after minutes. A restart forgets open uploads: managed staging expires on the Sandbox TTL, and a host upload leaves its .part file. The web proxy.ts (Next middleware, whose matcher excludes api/ at web/proxy.ts:21) must not match /api/*, because Next clones and cuts the body of a matched request at 10 MB.

Tool-result clearing has three owners in src/brain/session/: toolResultPlaceholder.ts owns the unchanged v1 placeholder bytes, structural marker, content projection and cold selection; toolResultSpillStore.ts owns spill names, host/guest writes and the live Sandbox resolver; deliverySpill.ts owns live size/group thresholds and the serialized per-batch delivery hook. The existing coldToolResultClearing.ts still gates historical rewrites on coldness and quiescence. Delivery selects whether to spill before resolving a guest, renders the actual destination once, then projects content once after a successful write. Inline results do no guest I/O. Host cold-write failures and delivery failures retain full results; inner PI hook exceptions still propagate.

Managed delivery-time and cold clearing share managedSpillSinkFor(resolved, sessionId) in src/brain/session/toolResultSpillStore.ts. The returned { dir, store } binds the already-resolved guest access to the caller's immutable session namespace. Delivery resolves ambient turn identity; cold clearing resolves the stored session owner before creating the sink. store(path, text, toolCallId) normalizes and confines the destination, then uses publishGuestArtifact through account/Project-bound create-only uploads bounded by MAX_TOOL_OUTPUT_BYTES (8,000,000 bytes, src/shared/toolOutput.ts:3). Only a typed already_exists collision permits adoption; new and adopted files pass complete version-checked byte comparison before a placeholder replaces inline text. The sink itself performs no I/O; invoking store incurs the upload and verification read. Without a store call, no file is created; a missing provider or publication failure retains the full result without host fallback. One real consumer is runColdHistoryPasses.

workflowArtifacts.ts owns publishWorkflowArtifact, the single immutable workflow-result writer. persistSharedWorkflowArtifact passes actual UTF-8 bytes; shareToolOutput passes captured source chunks only after the caller checks the durable child/DAG selection and the child's own capture namespace. Host publication stages a private file and links it without replacement; guest publication uses the existing create-only chunked upload. Both routes verify regular-file type, exact size and SHA-256 before registration, including collision adoption. Host reads use O_NOFOLLOW; guest reads pin the provider version. Registration failure removes only a new file created by that call, against its inode/device or guest version, and preserves adopted files. The text and captured-output PluginContext signatures and durable grant lifecycle are unchanged. Without a writer call, no file or registration is created. Streaming retains one bounded read chunk in memory; publication verifies the source stream and reads the final file once. Workflow files remain outside conversation spill trees so existing recipient grants can outlive history.

Owner and channel recovery share MAX_PARK_RESUME_ATTEMPTS (3, src/brain/recovery/types.ts:17) and parkResumeEligible; the durable atomic claim enforces the same cap before any continuation, including a delegation wait. The cap counts resumes that never got the turn running again: a pause that re-parks a turn whose agent run is active resets the counter (resetParkAttempts; a held lock alone, such as a spawn still in progress, does not), so planned restarts during a long resumed turn do not use it up, while a crash loop still does. Stopping a parked turn, live or not, releases the marker and the activity fence together, and a boot resume the user stops is released rather than counted as a failed resume. Pending result delivery has its separate retry budget. turnActivityOutcome classifies owner completions consistently: cancellation is idle, a provider or thrown failure is failed, and successful completion is done. Native compaction preflight and turn admission raise the typed SessionWorkAbortedError with the standard AbortError identity. Result-triggered parent turns use the same classifier and do not attribute a previous assistant's outcome to a new preflight failure.

Interactive journal readers use the existing active-branch seam with a retained-context scope by default. brain_journal_entry_state is a body-free, branch-local projection of native parent links, the latest compaction cut, inherited model/thinking settings and turn-state metadata. Journal insert/update/delete triggers keep it derived from the sole transcript, including branch moves and ancestor rewrites. Whole-history deletion through BrainStore clears the session-scoped projection first in the existing write transaction, so row deletion triggers skip descendant repair when no projected row remains. Conversation expiry, /clear, and account deletion share this path: three set-based journal deletes, no rebuilding descendants that will also be deleted. Partial entry deletion still repairs surviving branches; failures roll projection, journal, session state and artifact-deletion intents back together. The retained walk stops at the latest native cut and fetches bodies by entry-id index, avoiding a session-wide ordered JSON scan. Missing, future or sibling cut ids retain no pre-summary messages, matching PI. restoreJournalSession derives the PI session id itself as the sha256 of the durable id, because PI refuses the # and : in channel ids and forwards the id as a 64-character prompt cache key; callers never pass one. It supplies at most three non-message ancestry seeds under existing durable ids so archived native settings remain available without loading archived messages. After each durable compaction, its stable manager exchanges the resident native context and reinstalls the same post-append persistence barrier; provider-visible messages and durable history are unchanged. Turn baselines read inherited metadata in constant space, while memory-delivery, recovery and status readers inspect only retained context. Boot prepares old journals in indexed 128-entry batches with yields and atomic readiness before publishing the core. A failed rebuild rolls back and readers refuse unready metadata. transcriptPageRows remains the older-history scroll seam; explicit full exports opt into the history scope. Sessions with no compaction retain their whole active context, as PI does. One real consumer is a delegated child status update: its parent reads reduced usage totals and retained context, never archived message bodies.

Workflow artifact deletion intents are recorded by the existing history/session removal lifecycle through BrainDelegationStore.recordArtifactDeletion, in the same transaction that relinquishes the last reference. BrainStore exposes pending, defer and settle operations, without a separate public intent-recording facade. Repeated removal of the same content-addressed target keeps one pending intent.

SQLite is opened by src/store/db.ts with WAL mode, foreign keys, and the configured synchronous setting. The daemon prepares WAL, migrates the schema, and ensures the host Project row. Forked runners pass migrate: false and preparedByDaemon: true into the shared brain core: they inherit WAL without reissuing the journal-mode switch, skip migrations and the host-row upsert, but keep connection-local busy timeout, synchronous mode, and foreign keys. Without that option, an ordinary open prepares WAL and the host row. Because the daemon and its runners write the same file, every store transaction that writes goes through withWriteLock (src/store/writeLock.ts, its own module so stores that db.ts imports for migrations can use it without an import cycle): it begins IMMEDIATE before the first read and makes at most five attempts on SQLITE_BUSY* (src/store/writeLock.ts:124, :131, :156). A DEFERRED transaction that reads and then writes fails at once with SQLITE_BUSY_SNAPSHOT ("database is locked", no busy wait) when another process commits in between, so plain db.transaction(fn)() and ad hoc .immediate() are not used for writes. The connection's five-second busy timeout bounds synchronous writer acquisition, not a fairness or progress guarantee: back-to-back short commits can starve a waiter even though each writer releases its lock. A full timeout surfaces rather than starting another five-second event-loop freeze; bounded retries handle earlier busy answers. tests/store/crossProcessWriteLocks.test.ts proves ownership with a real second process attempting a commit immediately after each method's first read, and proves durability with acknowledged runner commits ordered inside each turn or delegation iteration. It does not infer overlap from a wall-clock start or a minimum commit rate, and its runner waits for work rather than flooding SQLite. Transactions that only read, such as config snapshots and descendant usage, stay deferred and take no writer lock. Nested calls become savepoints. The callback is synchronous and executes only after writer acquisition; a failure after entering it rolls back and surfaces without rerunning its side effects. Returning a thenable is rejected before commit. The two async boot rebuilds (prepareBrainUsageRollup, prepareBrainJournalProjection) yield between batches and therefore open BEGIN IMMEDIATE themselves. src/store/schema.sql defines the core shape, while db.ts applies additive and versioned migrations. Plugin-owned tables and migrations run through the plugin database capability.

File-backed WAL initialization is serialized by the existing descriptor-based withKernelLockSync at the canonical database pathname plus .wal.lock. This prevents concurrent journal-mode lock upgrades that SQLite's busy handler cannot serialize. The connection timeout remains five seconds and migration locking is unchanged; no SQLite operation is retried by this initialization path. In-memory opens and preparedByDaemon attachments take no initialization lock. The persistent lock pathname must not be unlinked while the database is in service, because unlinking can split contenders across different inodes. Lock failures remain failures.

Core migration ordering stays explicit in db.ts::migrate, without a registry. Historical bodies live in store/migrations/toolNames.ts, journal.ts, toolResults.ts and grants.ts; migrations/common.ts owns only the shared shape probes, historical JSON rewriting and transactional runOnce gate. Every numbered step rechecks and advances user_version inside its own immediate transaction. SQL, historical formats and versions remain frozen. The two grant manifest readers remain distinct: v36 enumerates the legacy wildcard catalogue, while v37 reads administrator plugin grants. db.ts passes its original bundled root to both, retaining bundled-first precedence and explicit pluginDirs overrides after the module move. The real-daemon migration fixture derives its expected version with TypeScript's parser from db.ts, workingSetMigration.ts and every migrations/*.ts owner, independently of execution. This keeps an omitted ladder call detectable after historical bodies move between modules.

store/workingSetMigration.ts adds v56 after v55. It removes exactly details.elowen.workingSet on every physical compaction entry, including inactive branches and fork copies, preserving PI summaries, boundaries, journal links, active leaves and other fields. Missing keys and foreign details require no rewrite. Malformed Elowen metadata identifies the session and sequence and rolls the entire step back, including its version. The pass loads only compaction rows and performs no filesystem or provider I/O. buildPostCompactionContext(sessionId, liveMessages, sandboxResolver) now reads the agreed plan only; the owner turn builder and platform channels retain the durable one-shot decision, visible-plan suppression and explicit managed-plan failure. The journal remains the original file evidence, without a second touched-file reader.

Historical delegated-child migration v42 imports resolveSubagentName directly from elowen-plugin-shared/subagentName. The shared rule is byte-identical; adoption adds no name rewrite.

sqliteError.isBusyError shares typed code extraction with constraint errors and preserves the existing SQLITE_BUSY prefix test for both lock modes; retry counts, busy-timeout budgets and callback failure handling stay in writeLock.ts.

Awaitable hot paths use the same withWriteLock(db, apply, { wait: 'async', operation, context }) boundary through BrainStore.atomically or the existing provider-request methods. Acquisition tries BEGIN IMMEDIATE with the busy handler disabled only for that uninterrupted attempt, restores the connection's original busy timeout before every yield, and retries on a timer within that original acquisition budget. It does not hold a read snapshot, transaction, or altered pragma across an await. Async submissions on one connection run in submission order, including after a rejection. Their promises resolve only after the callback and COMMIT complete. Nested synchronous store writes remain savepoints; async acquisition inside a transaction is refused. Unrelated synchronous callers can overtake pending async callers and retain their synchronous commit contract. There is no persistence worker or detached write acknowledgement. Session start rechecks the client generation after acquiring the writer before pruning or replacing the active selection; session preparation and recovery complete before rehydration. Provider payload and response hooks, HTTP retry reconciliation, stream error/compaction terminals, and the stream-owned summarization usage callback await this acquisition. The usage callback retains its existing atomic journal append and settlement counters and completes before exposing the terminal. The immutable payload preparation introduced earlier remains outside the lock.

PI's post-append journal barrier and its synchronous lifecycle listeners cannot await. Journal appends, chat message-end capture, and synchronous plugin db.transaction therefore keep their existing immediate durability contract. These paths can still block behind an external writer. Boot migrations and synchronous workflow snapshot publication also remain synchronous. The async boundary removes the measured payload-hook and conversation-start stalls, not every possible SQLite stall. The WAL/NORMAL durability policy is unchanged.

Each owning process measures writer wait and hold time, including commit/rollback, in at most 64 operation aggregates. writerLockTimings() returns detached counts, failures, totals, and maxima with the owning pid; daemon /health.writerLock exposes those bounded, body-free aggregates. Hot provider, journal, workflow snapshot, and grouped brain writes have explicit operation attribution. Unnamed synchronous callers share a bucket and retain stack attribution only for slow intervals, avoiding stack capture on fast writes. Async slow intervals log operation, pid, and bounded opaque correlation locally; they do not enter the synchronous-stall watchdog history. Runner diagnostics are measured and logged inside the runner, never inferred from daemon CPU. Aggregate nested holds overlap and are not additive.

The principal durable records include:

  • accounts, Projects, memberships, configuration, prompts, and external identities;
  • brain sessions, messages, pending messages, activity, recovery envelopes, and delegated runs;
  • memories, categories, embeddings, usage events, origin rollups, and provider request records;
  • plugin configuration, encrypted secret references, push subscriptions, search data, and durable per-recipient alerts.

Alerts are stored in the core alerts table with one unique row per (user_id, source, key). The producer owns cleared_at; the reader owns read_at and dismissed_at. A read acknowledgement leaves a standing condition visible until its producer clears it, while dismissal hides that occurrence. Open listings are bounded to 200 rows (src/store/alertStore.ts:218) and retention removes cleared or dismissed history after 30 days, acknowledged info after 14 days, and acknowledged warning or critical rows after 30 days (src/store/alertStore.ts:31-33, run by src/daemon/maintenance.ts). On upgrade, migration 22 rebuilds legacy alert rows to remove the retired kind column; the post-rebuild phase adds title_key, body_key, and params, and repairs the open-alert index.

The PI journal is authoritative for conversation accounting. usageSql owns normalization and usage epochs for the write-time brain_usage_rows projection and its retained session/tree/compaction readers: BrainStore.tokenTotals, tokenTotalsAll, BrainUsageStore.descendantUsage and compactionUsage. The projection, triggers, reduced totals, boot rebuild and offline rebuild CLI remain necessary because the complete daily counter cannot answer those conversation-specific queries. Global model/day statistics have no journal fallback or projection cache: UsageProviderStore.byModel and byDay read only usage_by_provider_day, the same source as provider reports and account spend limits. It includes all recorded chat, sessionless inference, embedding and voice requests, with whole UTC day bounds, caller scope and explicit administrator instance scope. Known costs are summed; no known prices yields null, a known free price yields zero, and requests/costedRequests retain partial-price coverage. The counter stores neither price provenance nor output timing, so aggregate views expose neither.

Summarization spend uses the same journal bucket path, not the optional provider-request debugger. The session-scoped ProviderRequestRecorder observes every compaction stream terminal, including refused, aborted, retried and native split-summary requests even with capture disabled. The factory calls recordCompactionUsage, which appends one native hidden custom entry of type elowen.compaction_usage, with one data.elowen.usageRollup bucket carrying the actual request model and configured provider, terminal timestamp, tokens, nullable cost and calls: 1. The provider id comes from the resolved session or distinct compaction route, including OAuth, never from the registry provider name. This is final paid usage even while the surrounding turn is provisional. Abort-before-output removes the cancelled turn's content but preserves its summary accounting ids, reconnecting their parent links to the surviving branch before restoring the leaf. Journal append, origin billing and API-token usage commit in one transaction; failure sets the existing persistence latch and refuses further requests. Provider-counter writes through billSettledTurn are required and fail the accounting transaction. The bucket feeds the write-time projection and its reduced session totals, conversation-list token totals and descendant usage. sessionUsageSnapshot reads lifetime hidden summarization totals from that reduced projection, not SessionManager.getEntries; manual compaction callers use the same snapshot after runCompaction returns its outcome. Chat cost adds these attempts to resident message spend without changing context occupancy or effective chat speed. PI's successful compaction result can aggregate retries and contains no model identity; its own usage is not billed again. Each summary has an isolated OpenRouter meter, so its provider-reported cost, including a real zero, cannot leak onto the next chat assistant. Without a terminal report, no unreported tokens or price are invented. A synthetic pre-dispatch failure adds no request count. Manual owner and platform compaction open and close the existing origin pin inside the session lock, attributing summary attempts to the requesting account and client origin without stranding a pin for the next writer. When no summary request runs, no usage entry is added. One consumer is the Statistics plugin page at /p/stats (served by the plugin host route web/app/p/[plugin]), which receives the producing summary model through /usage/by-model and the completion day through /usage/by-day.

Historical compactions are not automatically backfilled. Old successful entries contain aggregate usage without the producing model/provider or an exact per-attempt time; refused attempts may have no journal entry. Optional request capture does not persist the requesting origin or API-token attribution and may be absent, incomplete or already removed. Therefore no complete exact migration across the independent spend counters is possible without inventing attribution. Existing historical counters remain untouched; the fix records future attempts.

A turn's origin pin lives in memory (UsageOriginStore), so the pause-for-restart writes it beside the park marker (brain_sessions.parked_origin, the SpawnOrigin shape) in the same write. The owner boot resume pins it again through openTurn for the continued run and for the messages queued behind it, so both settle under the request that ordered the run instead of internal. A parked platform room re-pins platform:<platform> from its resume envelope; a cron or scheduled run never parks.

Usage statistics differ in what deleting a conversation does to them:

SourceRead byConversation deleted, /clear, session retention
brain_usage_rows (projection of the journal)Conversation token totals, descendant usage and compaction usageShrinks: the rows of each deleted entry go with it.
usage_by_origin/usage/by-origin, pulse spendSurvives; removed by /users/:id/usage/reset, the activity retention horizon, IP redaction (folds, keeps spend) and account deletion.
usage_by_provider_dayGET /usage/by-model, GET /usage/by-day, GET /usage/by-provider, account monthly spend limitSurvives; removed by /users/:id/usage/reset (the target account's rows), the activity retention horizon and account deletion.
provider_quota_samplesGET /usage/by-provider (quota), the provider statistics drawerSurvives; not user data and not per account, so /users/:id/usage/reset, account deletion, provider removal and credential changes leave it alone. Removed only by the hourly 120-day purge.

usage_by_provider_day is the durable per-provider counter: one row per UTC day, requesting user, configured provider id and model, written as turns settle and as embedding requests are answered (see "Embedding usage" below). It exists because the conversation delete path, and this instance's hourly session retention, would otherwise erase provider history within days. Its chat writer is billSettledTurn (usageOriginStore.ts), the same settle seam that feeds usage_by_origin: the function resolves the account once (turn pin first, conversation owner otherwise) and writes both counters, so the requester rule has a single implementation. billSettledTurn(db, origins, sessionOwnerUserId, sessionId, usage, atMs, providers) requires the same database connection that backs both stores. Its synchronous withWriteLock transaction commits both counters together or rolls both back on either write failure; the stores' own write boundaries nest as savepoints. Without provider groups it commits the origin counter alone; an unknown session with no payer writes neither counter. The daemon retains the account-health provider wrapper and independently calls apiTokens.noteTurnUsage in finally, even when the counter transaction fails. settledTurnUsage (src/brain/settledUsage.ts, called from src/brain/persistence.ts) splits the settled run by the provider and model each assistant message names, with the same elowen- normalization usageSql.markedProvider applies to stored rows, so a model switch inside one run yields two buckets and one provider's history is never split between the counter and the projection. A requests unit is one assistant message, which is one provider request. cost is nullable and stays null until some request of the bucket reports a price; for a subscription account it is the list-price equivalent of the tokens, not a charge. Messages that name no provider or model are left out of the counter (they still count in usage_by_origin), sessionless chat inference writes its resolved provider and model at its request boundary, including paid failures. Voice writes its priced or unpriced provider/model request through the existing voice accounting seam, including cost-only requests. Embedding requests are counted here and NOT in usage_by_origin, which stays the only origin-attributed spend and knows nothing of them, so the provider sums can exceed the origin totals. The two counters are not a check on each other.

Embedding usage. An embeddings request is billed by its provider like a chat request, and a provider used only for embeddings (memory vectors through OpenRouter, say) would otherwise show an empty Statistics drawer. EmbeddingService (embeddings/embeddingService.ts) therefore takes an optional usage sink, onUsage, called once per successful HTTP request with { providerId, userId, model, tokens, cost }. tokens is usage.prompt_tokens (else total_tokens) of the OpenAI-compatible response and cost is a numeric usage.cost when the endpoint states one (OpenRouter does); a missing or malformed figure means "not reported": zero tokens and a null cost, never an invented number and never $0. The request is counted once the endpoint has answered, before its vectors are validated, because it was billed either way. A sink that throws propagates through reportUsage; required provider-counter failures block further paid requests. The daemon's sink (countEmbeddingUsage, embeddings/embeddingUsage.ts) writes one group through UsageProviderStore.addTurn with the same helper as sessionless inference (recordInferenceProviderUsage: one request, tokens as input and total, output 0, costedRequests 1 only when a cost was stated) and books the row on the day of the answer, under the embedding model as an ordinary model row. So retention, /users/:id/usage/reset, account deletion and the read path treat it exactly like a chat row, and the drawer needs no embedding-specific code. Attribution is the account the work was FOR: callers pass { userId } per call (memory retrieval, duplicate check and search of that user, the reindex job of the caller, POST /search/rank of the caller, the embedding probe of the admin who ran it), and the drain sends one batch per memory owner. The background worker has no database, so its reply carries the usage of each request and the daemon reports it through the same reportUsage gate (embedBatchThroughWorker, brain/embedBatchWorker.ts), with the provider taken from embeddingProviderId, the one definition of "which configured provider did this request go to". Requests to an explicit local baseUrl have no configured provider counter. Plugin embedding calls pass their contribution account explicitly. With admission configured, a request with no attribution at all is refused before reaching the provider; an explicit null account is instance work, admitted and counted to no account. A batch whose only request returned a 200 with unusable vectors, sent through the worker as a single body, is not reported because the child then answers with the failure alone.

Installation and rollout: migration v39 only creates the one-row usage_by_provider_state (since_ms = 0 meaning "not begun", backfilled). The DAEMON's boot path (startUsageProviderCounter, called in brainCore.ts before any turn can settle) stamps since_ms with its own start time, once, and, when the usage projection is ready, copies brain_usage_rows older than since_ms into the counter in one grouped INSERT (the current usage epoch of each owner only, so spend hidden by /users/:id/usage/reset does not return). Stamping in the migration would be wrong because the migration runs in whichever process opens the database first on the new build, possibly a CLI command while the previous daemon is still settling turns it never feeds into the counter: those turns would fall after the stamp and be neither copied nor fed. With the daemon stamping, the deploy order is unconstrained: migrate whenever, restart the daemon last. An install whose projection is not ready yet is seeded by rebuildBrainUsageRollup at the end of its offline transaction instead (a no-op until the daemon has stamped, then finishing the copy), and backfilled makes either path run once. The counter writes inside billSettledTurn are required: a failure propagates to the persistence discipline. Both writes share its transaction, so a provider failure also rolls back the already-written origin row. Copied days keep the projection's attribution (the conversation owner, one row per physical fork copy, requests from calls, which is 0 for history folded by a legacy compaction); days fed live use the requester and count a generation once, so the difference is visible only on days before since_ms. Known drift: a message with no model id is counted by the projection under an empty model but has no provider group here, so conversation totals can differ from the complete daily counter by such messages. The history before the copy is gone with the conversations that held it and cannot be reconstructed, which is why the statistics show "tracked since". Retention shares the origin sweep's cutoff (runOriginRetentionSweep), and the table is in USER_REFERENCE_COLUMNS so a recycled account id inherits nothing. The provider statistics are a plain read of this table through its (provider, day) index, microseconds at these row counts, so the background worker (a network-only child with no database) is not involved; add a worker job only if a synchronous read ever measures above roughly 50 ms.

Counter arithmetic is shared in src/store/usageSql.ts: USAGE_COUNTER_ADDITIONS and USAGE_COUNTER_SUMS cover only common tokens, nullable cost and timestamps; each store still owns its grain and distinct counts. Origin writes and redaction retain MIN(trusted), turns and costed turns; provider writes retain requests, reasoning and costed requests. Live inputs use the same finite-number and nullable-cost normalization. The atomic billSettledTurn boundary and UsageProviderStore.spentBetween remain the accounting and admission owners. brainUsageRollup.ts shares projection reset and explicit FROM pieces between yielding boot and synchronous offline rebuilds; boot still holds its IMMEDIATE transaction across bounded batches, while offline rebuild remains one synchronous write-lock transaction. Neither mode acknowledges readiness before rows, triggers, reduced totals and provider backfill commit.

MemoryStore remains the single caller-facing memory persistence facade. It creates MemoryEmbeddingStore and MemoryUsageStore on its existing DB connection: vector selection/packing/freshness checks live in the former, recall counters and reader-keyed history in the latter. Calls such as MemoryService.markRecalled still reach MemoryStore.markUsed; its counter updates and history inserts commit together, and foreign or missing rows log nothing. The owners perform no network inference and create no new database or cache. Embedding row/input and recall-context shapes belong to the concrete owners; the facade derives its existing public types from their method signatures, so the owners never import back from the facade. sharedMemoryAccess.ts owns both memoryReaderScope, which returns the ordered SQL predicate/binds for own rows plus supplied pools, and canUseCategoryRow, reused by category lookup and the already-read categorization CAS target. An empty pool set reads only owned rows; pool membership is resolved fresh per operation. Categorization keeps author-keyed revisions and prevents a member from moving another author's shared row out of its pool. Timestamp bounds reuse toDbTs, and the icon suggestion route reuses the category store's DEFAULT_ICON.

Domain stores own their tables. BrainStore is the brain-facing facade for conversation persistence and related brain records. It constructs BrainGoalStore, BrainAttachmentReads and BrainTranscriptReads on the same database connection. Goals and normalized subgoals belong to the goal store; transcript projection, paging, diagnostic byte budgets, search and bounded journal reads belong to the transcript reader; attachment ownership, latest-image selection and sweep references belong to the attachment reader. brainBranchSql.ts owns the active-history and retained-context ancestry SQL shared by transcript reads and fork copying. attachmentSql.ts owns attachment source projection, the off-branch ownership-entry marker, the removable-branch predicate and the partial-index predicates. Attachment reads retain their distinct scopes: per-user ownership and sweeps include discarded references, while session share reads and latest-tool-image selection exclude discarded rows; off-branch share ownership remains readable. BrainStore.recordSharedAttachment remains the actual ownership writer, and journal mutation, session deletion and rekey transactions remain in the facade. Session model setup uses setSessionModel; only the journal writer needs to stamp persisted message activity. ProjectStore.ensureHostRow({ id, slug, path }) is the idempotent seam for ensuring the daemon's own host Project row; it runs under the store write lock and leaves an existing row unchanged. The daemon bootstrap uses it instead of issuing SQL directly. If another caller has no host row to ensure, it does not call this method. Core does not query plugin-owned tables directly. Each account's own Project order lives in user_project_order (src/store/projectOrder.ts, reached through UserProjectStore.ordered() / saveOrder()), and the first project of that order IS the account's default project: there is no separate default column. Membership stays in user_projects and the administrator's all-access, so the order is always read against the live set of visible projects: a project the account lost is skipped, one it gained is appended in id order, and a write replaces the whole order and prunes the rest. The default is where a conversation with no project chosen opens (brain/service/personalProject.ts): every new conversation, an administrator's included, plus an existing platform or member conversation that never got one. An administrator's existing host conversation stays on the host, a CLI conversation answers with its launch directory, and an administrator with no usable project keeps the host. ProjectStore.defaultProjectRef(userId, canUse) is the single candidate order for new conversations, opening ordinary unbound conversations and deletion rebinds: it selects the first active Project in the owner's order within the caller's live execution boundary without writing anything. The first active card is the UI's default designation. Personal account creation places its Project first; an account created with existing Projects initially uses their id order until its order is explicitly arranged. No users.default_project_id exists after v41, and membership removal excludes the former default before choosing the next one. preparePersonalProject persists this default when an ordinary existing row has no execution_ref, including human and chatbot platform rooms and direct chats. Admin platform rooms follow the same default rule; admin web/CLI null refs remain null. A directory alone does not select a Project for a non-admin conversation. Every non-admin account must have at least one active Project. UserStore.create and linkExternalIdentity create the account and the administrator's explicit projects choice in one IMMEDIATE transaction: { kind: 'existing', projectIds: [...] } assigns existing active Projects; { kind: 'personal' } uses ProjectStore.createManaged and its creation ceiling. Only the first bootstrap administrator is exempt from choosing Projects; administrators may retain zero Projects. Microsoft web SSO and Teams onboarding use the same provisioner and its configured active Project selection, or the explicit ssoNewPersonalProject choice. Generic plugin external provisioning accepts the same projects input. No chat path provisions Projects. store/accountProjectInvariant.ts owns the typed refusal and the active-membership checks used by demotion, unassignment and project deletion; each mutation rechecks inside its write transaction, and the project service checks before invoking runtime teardown. A deletion that would strand any non-admin account returns last_active_project with the affected account identities. Beginning managed deletion takes the Project out of the active set, so concurrent deletion of the remaining last Project is refused. At verified deletion completion, ProjectStore.removeRows removes memberships and order entries and rebinds ordinary conversations whose stored execution_ref names the deleted Project to each non-admin owner's current default Project in the same transaction; administrators' refs are cleared. ProjectStore.onExecutionRebound notifies BrainService for each completed ordinary rebind. The service refreshes the live policy and directory through commitProjectExecution, the same metadata commit used by an explicit Project switch, with current account permissions. Cold conversations need no live update. Sandbox finalization owns an outer synchronous transaction, so its notification is deferred until that transaction finishes and the stored target is rechecked; rollback or a newer selection cannot refresh the live context to an abandoned binding. Notifications create no environment or user-visible switch event. Delegated execution scopes and their execution bindings remain immutable and a missing target throws ProjectExecutionInvariantError, as does a non-admin conversation with no usable Project; neither case falls back to host execution. turnWorkDir enforces the directory invariant when the daemon supplies its Project catalog; embedded brains without that catalog retain their configured working-directory contract. Account membership writes stay in UserStore and ProjectStore, so Microsoft provisioners no longer accept separate Project-store dependencies. The web creates accounts using ChoiceField, SelectionSummary and ManageSelectionModal, renders deletion refusal identities inside ConfirmDialog, and grants replacement memberships before revoking old ones. The checks use database reads; deletion reads account order for each matching conversation and invokes no network or environment startup. There is no boot-time repair or migration. Migration v41 seeded every account's order from its former users.default_project_id (when that project still existed, was active and visible) followed by the remaining visible projects in id order, and dropped the column.

The /activity team-feed tool column consumes BrainStore.recentToolCalls through a per-server snapshot in registerActivityRoutes. Registration queues one warm-up on the next event-loop turn; an earlier request sees empty tool lists, and later requests see the last complete map. A changed journal rowid queues one deferred refresh, no more often than every 10 seconds; with no changed rows there is no periodic scan. Deferred still means synchronous work on the main thread, so the refresh uses slowSync('activity tool snapshot refresh', ...). Failed scans keep the last map and log a warning. The six-hour range seeks through idx_brain_session_entries_assistant_time, a partial created_at index whose predicate comes from the same usageSql.assistantEntry as the reader. SQLite extracts tool names only inside that indexed window; the existing rowid/content-key tiebreak and tool-call cap are unchanged. db.ts installs this index additively in the final migration write transaction after historical journal conversions and before projection triggers, for both fresh and existing databases. Its first build reads the journal once and logs start and elapsed milliseconds; subsequent opens compare its stored SQL with the current DDL derived from assistantEntry and atomically replace a stale definition. An unchanged index is not rebuilt, and attached non-migrating runners never install it. There is no new cache or transcript projection. This decorative six-hour projection may lag behind the journal, while tenancy filtering and the event feed remain live.

Daemon maintenance keeps scheduling and lifecycle in daemon/maintenance.ts, using the shared clock and tracking in-flight work before shutdown. Its collaborators are ordinary functions, not schedulers: daemon/retentionSweeps.ts owns memory eviction, provider diagnostic pruning and origin/provider/quota retention; daemon/resourceAlerts.ts owns subscription-window and host-disk alert thresholds and recovery. Host disk warning and critical transitions explicitly report alert.host_disk through AlertService's host-disk problem category; repeated unchanged conditions produce no additional report. Only the code and severity leave the instance, not paths or filesystem measurements. The retention functions read live policy and the supplied time for each invocation. Origin/provider purge never enters the current UTC month; memory scans page 1,000 rows and delegate rolling per-account deletion admission to MemoryStore.autoEvict. Resource alerts retain their hysteresis and admin scope; stale provider samples leave existing alerts in place, non-OAuth providers clear their limit alert, and an in-memory database skips host-disk measurements. createMaintenanceLoops is the production consumer, preserving boot ordering and hourly/daily cadence; when an optional alert sink or usage store is absent, its existing scheduling closure performs no work. The helpers add no timer or independent persistence authority: subscription usage comes through the existing usage services and alert writes go through AlertService.

New user image uploads use storeChatImages through the existing storeImageByContent/content-addressed writer, just like tool images: repeated equal bytes and MIME share one SHA-256 file while the user row retains each attachment reference. A failed write keeps the turn and omits that thumbnail, with the writer's existing failure diagnostic. Stored UUID references remain readable and sweepable. readChatImage validates the stored name before using the shared MIME resolver. Direct ShareImage/ShareFile events use parseSharedChatImage/parseSharedChatFile, the same parsers as durable ownership and hydration; malformed shares settle through normal tool completion without an attachment event.

The chat attachment sweep (maintenance.ts, at boot and daily, synchronous on the daemon's event loop) asks BrainStore.referencedChatImages and referencedChatFiles which stored files are still referenced and deletes the rest past a one-hour grace. Both reads select through partial indexes, idx_brain_session_entries_image_refs and idx_brain_session_entries_file_refs, which hold only the journal entries whose JSON contains a reference key (images, ref, sharedImage; sharedFile). The predicates live once in src/store/attachmentSql.ts: SQLite serves a query from a partial index only when the query repeats the index predicate, so a reader that spells it differently silently falls back to scanning the whole journal (hundreds of MB, seconds of blocked loop on a long-lived instance). The predicates only narrow the read; file names still come from parsing each candidate with collectImageFiles / collectChatFiles. db.ts installs both indexes with the same compare-and-replace installer as the assistant time index above, so the first open on a new build reads the journal once and logs the build time. Writes pay one instr check per inserted entry.

UserSettingStore owns per-account key/value settings. Global agent instructions have one key, userInstructions, read through cliSettings() and patched through authenticated PATCH /auth/me/cli-settings; a missing key defaults to an empty string, and empty clears it. The route clamps the body to 100,000 characters and uses the CLI settings revision. PersonalitySection is the web consumer; the daemon reads the same typed property when composing prompts. Migration v34 renames the prior stored key without changing its value or revision history, and refuses a collision rather than overwriting either row.

The terminal, permissions, navigation, voice and alert-push JSON resources use UserSettingStore.readBlob/updateBlob. Each typed setter supplies its existing sanitizer and merge policy; revision checking, reading, merging, full-blob writing and revision advancement share one immediate transaction. An absent row supplies defaults, and valid partial JSON is sanitized as before. Any present malformed JSON, including an empty string, throws before a patch can overwrite it; failed persistence or revision writes roll back both value and revision. The approval prompt's addPermissionAllowRule uses the same transaction and retains last-match-wins insertion order. These account blobs are small synchronous reads/writes; they add no registry or background job.

Microsoft SSO resolves the unique verified email account through one callback-local reader and uses one local existing-identity linker for both Teams TOFU promotion and configured email linking (src/auth/msSso.ts:214, which stores through UserStore.linkExistingExternalIdentity in src/store/userStore.ts:233). Subject bindings still win, TOFU still requires the same account's email, ambiguous emails are refused, and directory checks precede linking when the token has no account-kind claim. Linking conflicts retain their denial audit and typed error; provisioning stays separate. Project commit and range file diffs share one private execution helper in projectFiles.ts: refs are validated by each caller, canonical path validation still throws before the git-error catch, and only git execution errors become an empty diff. Both callers retain the same pathspec and 4 MiB buffer bound.

Brain goal subgoals are stored only in brain_goal_subgoals, addressed by stable row ids and ordered by position; BrainStore.getGoal() and activeGoals() project them into the existing public subgoals JSON field. Add goal reads or writes through BrainStore, not direct SQL consumers. Goals without child rows return an empty JSON array. The v21 migration imports old JSON when present, and v35 refuses valid leftover JSON without normalized rows before dropping the old column.

During a normal turn, the clean user input is projected before provider execution, assistant and tool-result messages are mirrored as they finish, and settlement reconciles final ordering and usage. Pending rows are recovery data. An unanswered tool call is never treated as safely replayable merely because the process stopped.

The live PI session, active plugin registry generation, process handles, and in-flight provider state are memory objects. They are disposable. Rehydration reads the durable session and transcript and composes current runtime state again.

The GET /users admin directory projects the nullable users.last_active_at timestamp without adding it to /auth/me. UserStore.touchWebActivity conditionally updates it in SQLite at most once per 60 seconds; old rows remain NULL. RouteContext.webPresence holds one ephemeral registry per daemon generation. A successful authenticated, full-login human /events connection registers only after both the initial SSE write and first timestamp write succeed; its existing 30-second ping refreshes the timestamp and an 80-second expiry timer. /events?presence=0 opts out of registration and timestamp writes while retaining the complete event stream, so hidden tabs continue to receive plugin events. The last connection closing or expiring removes the online marker. With no open stream or after restart, nobody is online. Each transition or timestamp change publishes a content-free user-presence event only to currently authorized admin subscribers; it never enters the event feed. The Users directory consumes that invalidation. This is web availability, not interaction or turn activity: /activity/presence still reports working on a turn and is unchanged. Other clients that do not open a full-login stream incur no presence writes.

Usage projection shape 3 retains journal-derived tokens, nullable row cost, calls, duration, attribution and accounting kinds, plus reduced session totals with row_count. It no longer copies measured/effective output timing or costed_rows. Live effective timing remains session-owned and persisted on the journal messages. isUsageRollupReady requires ready = 1 and projection_version >= 3; there is no separate effective-pair readiness version. Core boot calls await prepareBrainUsageRollup(db) before serving cumulative conversation readers. Older projections, including empty ones, are replaced inside the existing immediate transaction; provider attribution and usage are refilled in bounded rowid batches, yielding between batches. Failure restores the previous schema, triggers, totals and readiness. A newly created empty projection is ready immediately. The offline rebuildBrainUsageRollup(db) uses the same shape reset, requires the daemon stopped and a verified backup through the existing CLI, and commits its refill atomically. The rebuild reads the authoritative journal once per pass and holds the write transaction until complete; it does not rewrite journal timing or an already-fed provider counter. BrainUsageStore.compactionUsage is one consumer of the reduced session totals and refuses an unready projection.

usageSql owns usageCounterNumber(value) and usageCounterCost(value) for live counter writes and settledTurnUsage. They accept finite numbers only: absent or nonnumeric tokens contribute zero, while an unavailable price remains null. settledTurnUsage validates the cost object before passing its total to the shared reader. USAGE_COUNTER_COST_SUM is the shared SUM(cost) fragment used by the common origin/provider sums and UsageProviderStore reports, account spend and backfill. A query uses SELECT followed by this fragment and its caller-owned alias. Empty or entirely unpriced groups return null, known zero remains zero, and mixed groups retain the sum of known prices without claiming complete price coverage. These helpers perform no provider lookup, pricing inference or network access. UsageProviderStore.byProvider is a consumer; the same cost rule also reaches global model/day reports.

src/store/debugCursor.ts owns encodeDebugCursor, decodeDebugCursor(cursor, schema), and boundedDebugLimit. Readers supply their own typed Zod cursor schema, reuse the codec for nextCursor, and supply their existing default and maximum limits. Decoding validates JSON at the read boundary; only undefined denotes an absent cursor. ProviderRequestDebugReads and BrainTranscriptReads.debugLegacyTranscript use this shared path. It adds bounded per-page parsing and no persistence or network work. With no cursor, each reader starts its first page. Provider request capture retains configured provider, response timestamps and canonicalization identities in storage; the public diagnostic projection does not expose unused copies or a fictitious assistant-entry link.

ProviderRequestStore.finish(input) persists terminal status, response and usage without an assistantMessageId input. attachResponse(requestId, response) stores a compaction response through the same segment owner; ProviderRequestRecorder is its consumer. Missing responses do not create response segments, and neither method fabricates a journal-entry link. Session summaries retain request and usage totals, capture_started_at, last_request_at and stored_bytes, but no error_count or first_request_at copies. pruneDiagnostics(olderThan, maxStoredBytes), called by the diagnostic retention sweep, removes at most eight eligible sessions per call. Each session is selected and deleted under one IMMEDIATE transaction, with the writer lock released between sessions. Pending requests remain excluded, absent eligible rows end the sweep, and failed deletion rolls back only its transaction.

Schema migration 58 removes only brain_provider_requests.assistant_message_id and the diagnostic summary's error_count and first_request_at copies. Configured provider, response timestamps, canonicalization identities, payload segments, status and usage totals remain. ProviderRequestRecorder continues finishing or interrupting attempts and attaching actual responses through ProviderRequestStore. Absent responses create no response segment, and no journal-entry identity is fabricated. The diagnostic UI consumes the reduced public projection; request capture, provider filtering and existing retention limits are unchanged.

brain_session_origins remains a durable operator ingress ledger, written by UsageOriginStore.recordRequest(sessionId, userId, origin, atMs). One row tracks each session/origin pair; it has no runtime restart or API drill-down reader and does not reconstruct attribution pins. Turn pins remain separate in-memory token identities, with parked_origin carrying restart provenance. usage_by_origin remains the only origin-attributed spend source used by the origin statistics API. An absent pin follows the existing payer resolution; the ledger supplies no attribution fallback. Existing retention, trust downgrades and account cleanup remain.

src/store/brainBranchSql.ts owns ACTIVE_BRANCH, ACTIVE_CONTEXT, ON_BRANCH and ACTIVE_BRANCH_MEMBERSHIP. The membership query binds { session, before } and returns one row only when the history cursor belongs to that session's active branch. BrainTranscriptReads.transcriptPageRows uses it before reading an older page; a missing or abandoned cursor raises StaleHistoryCursorError. Unlike retained-context reads, history membership crosses compaction boundaries. It walks indexed journal-state headers from the active leaf, stops at the matching cursor, reads no message bodies and does not materialize the whole branch. Without an active leaf it returns no row; an initial page with no before cursor skips this probe. Work is proportional to the distance from the leaf to the cursor, or to the branch length for a miss, with no network or persistence writes.

BrainAttachmentReads privately shares ownerAttachmentCandidates(userId, file) between chatImageBelongsTo and chatFileForUser, and sessionAttachmentCandidates(sessionId, file) between sharedImageInSession and chatFileForSession. Each query's LIKE predicate selects candidates only; the existing separate image and file parsers remain the authorization proof and preserve download metadata. Account queries include off-branch and discarded references owned by that account. Session queries read exactly the requested session and exclude discarded entries. No parsed match returns false or undefined, never access inferred from prose. These reads perform one scoped SQL query and parse its matching candidates; they add no writes or public API. The brain attachment download routes and ShareImage/ShareFile session reads consume them.

BrainTranscriptReads.debugLegacyTranscript uses src/store/debugCursor.ts with a local Zod schema for { seq: nonnegative integer }. Readers decode supplied cursors at the boundary, use encodeDebugCursor for continuation, and apply boundedDebugLimit with their existing defaults and maxima. Only an absent cursor starts page zero. This legacy diagnostic reader fetches bounded session-indexed headers first and loads only accepted journal bodies within the UTF-8 byte budget; it performs no writes. The administrator's ConversationDiagnosticsModal is its consumer through the legacy-transcript route.