somm explained · Part 3 of 6

The Data Substrate

Every layer of somm — routing, evaluation, recommendations, agent tools — reads and writes one project-local SQLite file. This page walks the tables that file contains, the append-only discipline that governs them, and the privacy and cost guarantees built into the schema itself.

The previous page followed a single call from generate() to a recorded row. This page is about where that row lands, and why the shape of the storage — not a policy document — is what makes somm's privacy and cost promises hold.

One project-local database: .somm/calls.sqlite

Every call an application makes through somm is recorded in a local .somm/calls.sqlite database, with provider, model, token, cost, latency, and outcome data (README.md:67). There is no hosted control plane behind it. Configuration resolves in layers — defaults, the project's pyproject.toml, environment variables, then explicit arguments — before selecting a project-local or registered database (packages/somm-core/src/somm_core/config.py:84). The precedence is pinned by tests: an existing project-root .somm beats registry reuse, an explicit SOMM_DB_DIR beats everything tested, and reusing a registered database is announced on stderr so telemetry is never silently shared (tests/test_config_load.py:78, tests/test_config_load.py:102, tests/test_config_load.py:135).

Constructing a Repository creates the database with owner-only file permissions, applies migrations automatically, and configures WAL, foreign keys, and per-thread, fork-aware connections (packages/somm-core/src/somm_core/repository.py:177). The write path never requires a running service: a per-process WriterQueue batches short writes into the database, and repeated lock failures spill to permission-restricted JSONL files that are later replayed atomically (notes/PLAN.md:1179, packages/somm/src/somm/telemetry.py:270). Even provider cooldown state lives in SQLite, with separate model-level and provider-wide entries, so it survives restarts (docs/BLUEPRINT.md:187).

flowchart LR
  subgraph immutable["Append-only records"]
    calls[("calls")]
    updates[("call_updates")]
    pv[("prompt_versions")]
    rev[("workload_revisions")]
    receipts[("eval receipts")]
  end
  subgraph pointers["Mutable pointers"]
    wl[("workloads — live row")]
    labels[("prompt labels")]
  end
  subgraph optin["Opt-in bodies"]
    samples[("samples")]
    ds[("datasets")]
  end
  calls -- "late metadata" --> updates
  wl -- "every mutation appends a snapshot" --> rev
  labels -- "point at" --> pv
  calls -- "deterministic sampling" --> samples
  samples -- "promoted idempotently" --> ds
  ds -- "graded" --> receipts
  decisions[("decisions")]
  decisions -. "best-effort mirror" .-> global[("~/.somm/global.sqlite")]
    

Append-only telemetry: immutable calls, mutable pointers via call_updates

A Call is an immutable telemetry event. The record carries route, token, latency, cost, outcome, tracing, cache, and citation fields (packages/somm-core/src/somm_core/models.py:225); on the client side each normalized record includes provider/model attribution, hashes, errors, correlation data, and cache usage (packages/somm/src/somm/client.py:1265). Once written, a call row is never edited. When an outcome changes after the fact — an application marks a result bad hours later — that change is appended to call_updates instead of overwriting the original (packages/somm-core/src/somm_core/repository.py:1403).

Telemetry is append-only: late metadata goes into call_updates, preserving deterministic audit history.— docs/BLUEPRINT.md:56

Everything downstream respects this contract. Evaluation and recommendation services consume calls to produce their own records without rewriting the original telemetry (docs/BLUEPRINT.md:171). Failures are recorded the same way — deliberately visible and bounded in telemetry rather than silently swallowed (README.md:162) — which is why somm calls --status error is the diagnostic route bug reports ask for (.github/ISSUE_TEMPLATE/bug_report.md:28).

Prompt versions, workload revisions, and audit history

19schema versions, applied atomically on startup

The schema is at version 19, which evolved the original workload/prompt/call ledger into decisions, workload revisions, datasets, evaluation receipts, campaigns, and canonical model aliases (packages/somm-core/src/somm_core/version.py:7). ensure_schema() applies the packaged SQL migrations automatically (packages/somm-core/src/somm_core/schema.py:80), and each migration commits its DDL and version stamp in the same transaction, so a database can never end up partially upgraded (packages/somm-core/src/somm_core/schema.py:91).

The append-only discipline extends beyond calls, using a consistent pattern: immutable snapshots underneath, mutable pointers on top (notes/GAMEPLAN-2026-07.md:95).

Privacy classes: PRIVATE workloads fail closed rather than egress

A workload is registered once with its privacy class; every subsequent call inherits the policy. A workload registered with PrivacyClass.PRIVATE is restricted to local providers — and if no local provider is available, the call fails instead of sending data upstream (examples/private_workload.py:19, examples/private_workload.py:27). Attempting to enable shadow evaluation on a private workload is rejected with SommPrivacyViolation (examples/private_workload.py:36), and the example pairs the class with a zero-dollar daily budget as an additional safeguard (examples/private_workload.py:24).

Crucially, prompt and response bodies are not stored by default at all. Bodies enter the database only through opt-in shadow sampling, which is deterministically sampled, size-capped, and forbidden for private workloads (packages/somm/src/somm/client.py:850, packages/somm-core/src/somm_core/repository.py:642). The same policy is enforced independently at several layers:

LayerWhat it enforcesWhere
RoutingPRIVATE workloads route only to local providers; none available → the call failsexamples/private_workload.py:27
WorkersShadow evaluation is workload-opt-in and excludes private workloadspackages/somm-service/src/somm_service/workers/shadow_eval.py:102
StorageDatabases and registries are created with owner-only permissionspackages/somm-core/src/somm_core/repository.py:185
HooksHook events omit prompt and response bodies entirelydocs/plugins.md:79
AgentsMCP replay refuses private workloads; recorded bodies are truncated to 4,000 characters and wrapped as untrusted datapackages/somm-mcp/src/somm_mcp/server.py:663, packages/somm-mcp/src/somm_mcp/server.py:49

somm doctor keeps the storage layer honest over time, checking for permission drift alongside processes, cooldowns, and migrations (docs/errors/SOMM_PORT_BUSY.md:11).

What fails closed Hard per-workload daily budgets refuse the call before provider dispatch, so fallback can never route around a cap (docs/errors/SOMM_BUDGET_EXCEEDED.md:23, packages/somm/src/somm/client.py:571). In the service proxy, a budget-rejected call creates neither spend nor telemetry records (packages/somm-service/src/somm_service/proxy.py:322). And PRIVATE workloads with no local provider fail rather than egress.
What fails open Advisory intelligence never blocks live inference: a missing price records zero cost (packages/somm-core/src/somm_core/pricing.py:240), an unreadable fleet database contributes zero usage (packages/somm-core/src/somm_core/plans.py:376), and hooks, mirroring, and learned overrides are designed so their failures cannot break the call path (packages/somm/src/somm/hooks.py:246).

Pricing without the network: the bundled cost snapshot

Every call row carries a USD cost, and computing it requires no network access. Pricing is seeded and synchronized from an offline snapshot bundled into the wheel, cached in-process for ten minutes, and applied to token counts to produce per-call cost (packages/somm-core/src/somm_core/pricing.py:108, packages/somm-core/src/somm_core/pricing.py:233). Tests assert the packaged snapshot covers every paid routed provider and can populate model intelligence entirely offline (tests/test_pricing_bundle.py:38, tests/test_pricing_bundle.py:47).

Synchronization is careful about ownership: a bundle refresh replaces stale seeded data but preserves manually entered prices, which stay authoritative over bundled data, and a fingerprint check skips repeat work (tests/test_pricing_bundle.py:66, tests/test_pricing_bundle.py:84). When a price is genuinely missing, the call still succeeds: cost records as zero, and paid providers emit a once-per-provider/model warning while free providers stay silent (tests/test_pricing_safety.py:87, tests/test_pricing_safety.py:101, tests/test_pricing_safety.py:115).

10 minin-process pricing cache
$0.00cost recorded when a price is missing — visible, never blocking
warning per paid provider/model pair

Plans and quotas: pacing spend across every project on the machine

Cost data becomes governance at machine scope, not just project scope. A local registry tracks every project database on the machine (packages/somm-core/src/somm_core/registry.py:122, packages/somm-core/src/somm_core/registry.py:157), and the plans layer aggregates usage across all of them to calculate pay-as-you-go burn rates or metered-window pacing — it can even infer quota ceilings from observed 429 events (packages/somm-core/src/somm_core/plans.py:370, packages/somm-core/src/somm_core/plans.py:521, packages/somm-core/src/somm_core/plans.py:627).

Provider plans load from TOML with modes, quotas, units, windows, and soft utilization targets (tests/test_plans.py:37). Calendar-window limits flag overuse only when usage both exceeds the soft target and runs ahead of elapsed time; rolling windows use direct utilization because they lack a fixed calendar progression (tests/test_plans.py:139, tests/test_plans.py:149). The resulting status feeds routing directly:

Pacing stateMeaningRouter response
okUsage is within the plan's paceProvider stays in normal preference order
over_paceUsage is ahead of the soft target for the windowProvider is deferred behind normal providers
exhaustedThe quota window is used upProvider is removed from the chain

All of this remains advisory in the fail-open sense established above: a broken or absent plan governor falls back to the original provider chain, preserving availability (tests/test_plans.py:272, tests/test_plans.py:281). The hard line stays where it was — the per-workload daily budget, checked before dispatch, which no amount of fallback can route around.

The substrate in one sentence One owner-only SQLite file per project holds immutable calls and prompt versions, revisioned workloads, opt-in samples, datasets, receipts, and decisions — and the privacy and budget rules are enforced in the same code that reads and writes it.

The Life of a Call

How a single generate() becomes the immutable row described here — routing, fallback, and the telemetry writer.

The Intelligence Loop

How workers consume calls, samples, and receipts to grade quality and produce evidence-backed recommendations.

Engineering Reference

Migrations, the writer queue, spill-and-replay, and the rest of the machinery underneath the schema.