Skip to content

Data

1. Overview

Persistent and in-memory state: the SQLite virtual-key store, the disk-backed tamper-evident audit log, and the in-memory two-phase governance stores (limiter, budget). Legacy governance stores remain instance-local: providerstore remains SQLite-only; keystore supports SQLite and Postgres. The legacy limiter/budget packages are memory-only (not persistent stores at all), and no Redis/Valkey dependency exists anywhere in the tree. ADR-046 adds a shared gateway profile with PostgreSQL identity and atomic resource admission. The default SQLite/in-memory profile remains single-replica. ADR-045 adds opt-in Postgres monetary authority and a private local SQLite reservation journal for node-local fleets. Postgres also backs bodystore, policystore, telemetry, and analytics Mode B.

2. Components

Component Path Purpose
Global monetary authority internal/authority/pgstore/, internal/authority/local/ Postgres commits finite grants and owns UTC budget windows; private node journals reserve each billable attempt before dispatch. No inference-time database call; no refund on expiry/restart; uncertain outcomes retain their bound. See ADR-045.
Shared admission internal/authority/pgstore/shared*.go Atomic global RPM/TPM, calendar token quotas, team/key money and ADR-045 policy money; captured-window replay-safe settlement and global usage.
Shared key store internal/keystore/postgres*.go, import.go Full hashed-key/team backend, per-request snapshots, conditional revoke, immutable seed fingerprints and transactional SQLite import.
Key store internal/keystore/sqlite.go SHA-256-hashed virtual keys, Postgres-portable schema; also owns the teams table (D3, ADR-016): name (PK), allowed_models, rpm, tpm, tokens_per_day, quota_on_exceeded, budget_usd_micros, budget_on_exceeded, budget_usd_micros_per_day (daily budget Phase 1 — per-team calendar-day µUSD cap, 0 = unlimited), guardrail_id, guardrail_version (D6, ADR-019 — per-team Bedrock Guardrail override), allowed_regions (D7, ADR-020 — per-team region lock), created_at, updated_at. guardrail_id/guardrail_version/allowed_regions/budget_usd_micros_per_day are teams' ALTER-TABLE migrations (it shipped as a brand-new table under D3, so no pre-existing rows needed catching up until D6; budget_usd_micros_per_day is the table's first budget-related ALTER — the original money columns rode the CREATE TABLE); ensureSchema shares a small existingColumns/applyMigrations helper pair between keys and teams instead of duplicating the PRAGMA-scan loop.
Store interface internal/keystore/keystore.go Store, Principal, Allows() (RBAC); TeamStore (UpsertTeam/GetTeam/ListTeams/DeleteTeam, D3) is a separate interface so the existing Store fakes in internal/server's tests are unaffected; KeyEnsurer (EnsureKey, ADR-023) is the same pattern — EnsureKey upserts a caller-supplied plaintext (ON CONFLICT(key_hash) DO UPDATE, deliberately excluding revoked/created_at) so a config-declared virtual key (config.VirtualKeys, bootstrapped in newGateway right after keystore.OpenSQLite) survives a wiped-and-recreated key store across restarts, unlike Create/CreateWithOptions which always generate a fresh random plaintext
Provider store internal/providerstore/sqlite.go opt-in DB topology (ADR-008): providers (refs only — no secret column; guardrail_id/guardrail_version TEXT columns, D6/ADR-019 — a DB-registered Bedrock provider's own default Guardrail, ALTER-TABLE migrations mirroring auth_header), model_targets (ordered routes), model_aliases (model, alias PK — ADR-021 follow-up: a model's alias→canonical names, group-level so a multi-target fallback chain can't duplicate them; a brand-new CREATE TABLE IF NOT EXISTS, no ALTER-TABLE needed), meta (durable seeded marker); portable TEXT/INTEGER DDL
Audit writer internal/audit/writer.go single-writer hash chain, retained-WAL validation and latched admission pressure
Audit WAL internal/audit/wal.go disk buffer for buffer_then_block durability
Audit verify internal/audit/verify.go per-instance segmented chain verification
Audit anchoring internal/audit/s3anchor/ opt-in WORM (S3 Object Lock) chain-head anchoring → tamper-resistant (ADR-012); refs/PII-free anchor objects
Limiter store internal/limiter/limiter.go in-memory token bucket (TPM/RPM), two-phase
Budget store internal/budget/budget.go in-memory microUSD budget, two-phase; calendar-day and calendar-month windows are separate counters keyed by the window tag (budget:day:team:acme vs budget:month:team:acme — the tag-first key is what keeps them out of one bucket); both the day window's midnight and the month window's first-of-month anchor honor the budget_timezone config key (Governor.SetBudgetTimezone, one anchor per deployment) — default UTC when unset
Body store internal/bodystore/ opt-in captured-body store (D4, ADR-018), OUTSIDE the audit chain: bodies table (ref PK, record_id, team, created_ts, expires_ts, size, wrapped_key_nonce/wrapped_key_ct, req_nonce/req_ct, resp_nonce/resp_ct — BLOB/BYTEA ciphertext; resp_* nullable = streaming request-only). Envelope AEAD (per-record data key wrapped by a config-ref master key). Two backends (sqlite.go/postgres.go), TTL + size-cap Purge, hard-deletable per-row (GDPR erasure). Key rotation: mayu bodies rewrap-key (ADR-018 deferred item) rewraps wrapped_key_* only, via Store.ListWrappedKeys/UpdateWrappedKey (CAS) — never reads or rewrites req_*/resp_*
Analytics index internal/analytics/ derived usage read-model; events table gained ts + body_ref columns (D4, ADR-018) via ALTER-if-missing (SQLite) / ADD COLUMN IF NOT EXISTS (Postgres); backs GET /admin/logs
ULID pkg/ulid/ulid.go monotonic record IDs (Crockford base32)
Usage telemetry store internal/telemetry/ ADR-036: UsageBatch/UsageEntry wire types (integer µUSD, 5m/1h cache tiers separate, resolved model names; Validate rejects poison classes — in-batch dup keys, NUL bytes, >1e15 counts, out-of-range windows); MemoryAggregator (always-on, wall-clock 24h retention, snapshot-under-lock Rows); PostgresAggregator (usage_windows table — PK (dataplane, window_start, team, "user", model), window_start secondary index, portable types: pg_dump/RDS snapshots are the backup story; lazy connect, advisory-lock migration 847003, batch-replace tx, DSN never in errors); DurableAggregator (PG-commit→memory→ack write-through; queries prefer PG, memory fallback marked degraded)

3. Key Decisions

  • SQLite (modernc.org/sqlite, cgo-free) default → static binary, 5-minute boot.
  • Per-instance audit hash chain so restarts segment cleanly instead of reading as tampering.
  • audit.failure_mode supports only default buffer_then_block: 1024 retained WAL records or any WAL append/truncate failure close new inference admission. Restart counts the retained backlog without automatic sink replay. In-flight requests can still record settlement; see durability and recovery.
  • Admin-plane events (admin_key_created / admin_key_revoked / admin_denied, ADR-004) carry principal.user (opaque OIDC sub — never email) and principal.auth_method (oidc | break_glass); auth_method is appended at the END of PrincipalRef so pre-change chains still verify byte-exactly (mixed-version fixture test).
  • Two-phase stores (check then debit) so a denied request never charges the team.
  • Prompt/response bodies are NEVER in the audit chain (ADR-003 content-free invariant preserved): opt-in audit.log_bodies (D4, ADR-018) captures them into a separate, encrypted, deletable body store; the chain carries only an opaque body_ref. Record.body_ref/record_ref are appended at the END of the struct (omitempty pointers), so mixed-version chains verify byte-exactly.
  • ADR-041 budget-tier substitution: RequestRef.model_substituted_from (appended at the end of that struct, omitempty, same byte-stability rule) carries the client's ORIGINAL requested model when a tier substituted it; model_requested by then already holds the model actually served. The internal/analytics read-model (GET /admin/logs) does not yet carry this column — the audit chain is authoritative for it today.
  • Team governance policy has two sources — the config file and a teams DB record — with the DB record winning when both name the same team (D3, ADR-016); internal/governance.Governor resolves this via a per-request keystore lookup (SetTeamLookup), not a cache, so a console edit enforces on the very next request with no restart.
  • Per-team region lock (D7, ADR-020): TeamRecord.AllowedRegions restricts a team to providers labeled with one of these regions; an UNLABELED provider is always dropped for a restricted team (fail-closed). A config-declared team with no DB record still gets its config allowed_regions enforced (the one case TeamRecord is synthesized from config rather than read from a row) — but a DB record, once it exists, wins wholesale over that config policy, same ADR-016 precedence as every other team field.

Local accounting validates each token class and the total before changing quota, TPM or money counters. Negative debits cannot return capacity, and accumulated quota/spend saturates instead of wrapping. PreCheck returns an admission handle; the caller defers Close, and Settle reconciles its original TPM reservations once. Pending buckets survive cleanup; missing or invalid usage retains the estimate. These are instance-local limits; shared Postgres and durable monetary authority retain their separate per-attempt reservations. Invalid usage is omitted from settled audit usage and span token attributes; completion and cost evidence remain, with pricing_missing: true when settlement rejects the usage. Valid partial TTL counts remain recorded. Uncertain usage debits only validated observed tokens to daily quota, while TPM retains the admission estimate as a minimum; no estimated daily-quota debit is fabricated. Negative admission estimates use the distinct invalid_token_estimate audit deny code before consuming any limits. See ADR-039. The admission estimate is internal/tokenest's content-aware request estimate, the same value as the context gate and the local count answer (a Bedrock count estimates its decoded InvokeModel body; a count that does not fit the in-flight byte budget is answered at four bytes per token).

4. Code Pointers

  • internal/keystore/sqlite.go — schema + SHA-256 lookup
  • internal/audit/writer.go — single-writer goroutine, pending-based WAL truncate
  • internal/audit/verify.go — audit verify chain check

Pricing rate table (ADR-030)

Cost is integer µUSD, never float. The rate key is (config provider name, UPSTREAM model id) — the id in models.<name>.targets[].model, not the canonical ingress name. Getting this wrong is the single most common way to end up billing 0.

Optional short_context_limit plus long_context selects a whole-response rate using uncached input + cache reads + 5m writes + 1h writes. Equality uses the short rate; strictly greater uses long for every token class, including output. It is per attempt, not a session total or marginal overage. Both fields must be present together; negative, nonfinite, overflowing, nested or unpriced long rates refuse. Authority reservations use each class's maximum across both tiers, including when a long rate decreases. cost.context_tier is append-only and omitted for legacy flat records. See the verified GPT price snapshot and pricing guide.

Published Standard tier prices do not cover client-selected Priority/Flex/ Ultrafast: context-priced generation requests refuse those explicit service tiers before billing. cache_write_unsupported: true prevents deriving a published-looking write rate where a model card has no write price; unexpected write usage stays unpriced, never silently free. pricing sync refuses to flatten an already-declared context price. Region-prefix fallback is a legacy convenience, not price parity; use exact profile keys for regional premiums.

Rule Detail
Key (provider, upstream); exact match wins
Bedrock cross-region ONE leading global. / us. / eu. / apac. / us-gov. is stripped on a miss. This legacy convenience does not imply price parity; regional premiums require exact profile keys.
Model versions Never collapsed. Each version needs its own rate even when prices coincide today.
Provider Never collapsed. Configure and verify rates per provider/account; do not assume either price parity or a markup.
Cache rates Legacy defaults derive read 0.1x, 5m write 1.25x, 1h write 2.0x. Declare published model-specific cache rates where defaults do not apply. Explicit values win; 0 means derive unless writes are explicitly unsupported.
on_missing block → refuse to boot on an unpriced route AND deny at runtime (402 pricing_missing). allow (default) → log loudly, bill 0. An unrecognized value is a load error.
version Free-form label landing in every audit record's cost.pricing_version. Set it so a disputed invoice can be pinned to its rates.
free A 0/0 override needs an explicit "free": true — the ONLY way to declare a genuinely zero-cost model. That opt-in is what makes cost 0 + missing=false a deliberate statement instead of an accident.
0/0 input_per_mtok: 0 + output_per_mtok: 0 without free is a LOAD ERROR — 0 means unpriced, not free. A single-sided zero is allowed. Enforced twice: at load, and again at table build, because BuildState is also reached by the UI-write overlay and by hot reload, which never see the file loader.
pricing sync mayu pricing sync --config <p> [--out <f>] generates pricing.overrides for the config's bedrock routes from the AWS Price List Query API. Offline, operator-run, never on the request path. Needs IAM pricing:GetProducts. Exits 1 naming any route it could not resolve — it never emits a placeholder. bedrock_responses routes are reported unresolved; openai_compatible routes are skipped by design. Richer cache/context/free overrides are protected. See sync limits.

Adding a newly released model requires its published input/output/cache figures and any request-context tiers, not just two token rates. With on_missing: "block" a forgotten rate fails the boot and names the route, so it cannot reach production silently. For a Bedrock route, mayu pricing sync fetches those two figures from the AWS Price List Query API — except Claude-on-Bedrock, which has no current Price List SKUs (verified against the live API, 2026-08-26: the us-east-1 Bedrock price list carries only five legacy Anthropic rows — Claude 2.0, 2.1, Instant, 3 Haiku, 3 Sonnet — each input-only with no output row), so those rates stay manual.

5. Cross-references

  • Related modules: internal/governance, internal/server (auth)
  • Related ADRs: docs/decisions/ (SQLite-vs-Postgres decision — to be recorded)
  • Related runbooks: docs/runbooks/ (audit verification, backup)

Policy-routing metadata and generations (ADR-043)

Providerstore adds providers.data_boundary (TEXT, omitted/unknown conveys no trust) and model_metadata keyed by model with context_window (INTEGER) and capabilities (JSON text). Existing DBs migrate to unknown boundary/zero context/no capabilities. Seed, overlay, admin read/write/export, and live snapshots preserve metadata and copy slices. This adds no shared-state enforcement backend.

policy.Store.MatchingRoutingPolicies returns caller-owned matching rules from one atomic snapshot. Rejected distributed sensitive documents retain a fail-closed subject gate until valid replacement; failed local reloads retain the last valid snapshot. Apply-time targets use current live.Holder.RoutedAndPriced; requests still validate candidates after topology edits. Policy and topology publication are separate. Upgrade all participants before activating the new rule shapes.

Audit request.routing is appended with omitempty and mixed-version exact-byte verification. It records requested/selected/proposed model, bounded decision fields, policy names/generations, and planned/actual provider and boundary. Actual attempt fields distinguish same-model retries across providers. No detected text, prompt, key ID, or client session value is added to request.routing; the ADR-057 client.session_ref digest is a separate field. Existing body logging stays independent. No-rule traffic omits the field unless an ADR-029 model fallback applied: then routing carries reason: model_fallback, the client's requested model in requested_model and model_fallback_from (at most 256 bytes), and model_fallback_source (model_fallbacks or family). A policy decision keeps its own reason and gains the same two fields. See decision semantics.

Audit console evidence (ADR-057)

The console reads four append-only, omitempty additions to terminal audit records (a request_completed record, or a request_started record that carries an outcome, which is how every gateway denial is written), plus a console-only projection in the analytics index. Older records and the exact-byte chain verification are unchanged.

attempts[] has at most eight entries: the first seven and the last.

Field Type Values and bounds
provider string Configured provider name, never a URL.
model string Gateway model name the attempt served (the configured model, after alias and fallback resolution); not the upstream model ID sent to the provider.
status string ok, upstream_error, model_not_found, stream_failed, timeout, canceled.
http integer The upstream's own status; omitted when the upstream never answered.
start_ms, dur_ms integer Offset from request start; duration.

An attempt ends when the provider exchange ends, before settlement and body capture, so the timeline's settle row is gateway work after the provider. model_not_found is the ingress's own verdict at a cross-model boundary. A completed stream or an upstream verdict is not overwritten by a later cancel. A request denied after an earlier attempt keeps that attempt.

Field Type Values and bounds
client.request_class string main, subagent, workflow, compaction, auxiliary, side_reply, from the x-claude-code-request-class header only; any other value is dropped. Claude Code 2.1.312 does not send it, so the field is absent on real traffic.
client.session_ref string s1: plus 32 hex digits (see security).
auto_mode.verdict string block, allow, unknown. An estimate. Only request_completed records carry it.
auto_mode.stage string s1 (block call), s2 (auxiliary-class text rule), server (Bedrock safeguard); rules in security.
auto_mode.signature string claude-code-2.1, the client response format the rules follow.

latency{total_ms, ttft_ms} is now written on every terminal record on every ingress; ttft_ms is omitted when zero. When a request makes more than one attempt, outcome.fallback_used and outcome.fallback_chain (the attempts' provider names) are written from attempts; they were declared before but never written.

Governance denial source

A gateway denial record also carries governance when the refusing component names its limit. It is written on request_started records with an outcome only.

Field Type Values and bounds
governance.scope string team, key, user, gateway (a setting of the whole gateway, such as pricing).
governance.limit string rpm, tpm, tokens_per_day, tokens_per_month, budget_month, budget_day, budget (a lease gate, which reports no window), pricing.
governance.source string config (config file teams or pricing.on_missing), team_record (/admin/teams, or the shared team snapshot), policy, lease (control-plane budget lease), key (the key's own option), budget_store (the budget store or the local liability ledger was full; no limit refused). Omitted when the governor was built without sources.
governance.policy object {name, generation, rule} of the binding GovernancePolicy rule when source is policy. generation is omitted when the authority does not report one (ADR-045 grants).

The source is the layer whose value binds after composition: a policy rule narrower than the config or team record, the config or team record when it is stricter or equal, a lease allowance that clamps a budget, or the key's option. Local PreCheck, the pricing guard, the ADR-054 local liability reservation, the ADR-045 authority and ADR-046 shared admission all name their limit; a refusal by an unavailable store names none. Reservation refusals carry outcome.error budget_authority_unavailable on every ingress. The field holds names only: never the limit value, a counter, key material or a user identifier. Policy and rule names are operator text and enter the chain verbatim. A denial written before this field has no governance.

Console tables

Both stores gain audit_events (one row per terminal record) and audit_event_categories (id, category), created with the existing index. The events table and Usage numbers are unchanged. The exact audit line is kept in record_json in both modes (Mode B keeps the bytes the aggregator read from the segment, not a re-encoding). Projection is first-write-wins on id. A table created before the governance columns gains gov_scope, gov_limit and gov_source when the index opens (SQLite adds each missing column; Postgres adds them once, under migration marker 2). Existing rows keep empty values, so the console shows a limit denial among them as not recorded (older record), and boot replay does not rewrite them. The same one-time step clears a stale routing policy_ref that an earlier release wrote on governance- and RBAC-coded denial rows, because that rule did not refuse the request; routing denials keep theirs.

Free text taken from the record (model_requested, model_actual, provider, policy_ref, the boundaries, request_class, session_ref, trace_id, auto_signature) drops NUL bytes, replaces invalid UTF-8 and is cut to 256 bytes, the filter cap; a client's model string reaches denial records unvalidated. In Mode A the projection is best-effort per record: boot replay gives each row its own savepoint in the replay transaction, and the live sink writes each row with its categories in one transaction; a row the store refuses is skipped and logged by record ID and store code only, never blocks the Usage rows, and is offered again by the next boot replay. Mode B writes a tick's console rows in the transaction that also holds its Usage rows and checkpoint, in one savepoint. Only when that fails with a row error (SQLSTATE class 22 or 23, or class 54 for a program limit such as an oversized id or key_id index entry) does it retry the rows one by one, each in its own savepoint; a row refused that way is skipped and logged the same way, and the tick commits. Any other console error aborts the whole tick: its Usage rows and checkpoint do not commit either, so nothing is lost and the next tick retries the same records. A transient error (a timeout, a deadlock, a lost connection) clears by itself. A persistent one, for example 42501 after a grant change, 42P01 or 42703 after a schema change, or P0001 from an operator trigger, holds back Usage and console ingestion alike on every tick until it is fixed; it shows as a repeated analytics mode_b tick failed log line and a growing lag_seconds in GET /admin/analytics/health. Fix the grant, schema or trigger and the next tick resumes from the unchanged checkpoint; if the derived tables were edited by hand, POST /admin/analytics/rebuild re-derives them from the retained segments. A console error is logged by its SQLSTATE only, never the Postgres message; an error without a SQLSTATE (a client-side failure such as a lost connection) is logged as the pgx client error.

Column group Columns
Identity and time id (primary key), ts, ts_ms, event, kind, team, key_id, ingress, stream, status
Decision deny_code, routing_reason, privacy, masked, would_restrict, pii, policy_ref, planned_boundary, actual_boundary, gov_scope, gov_limit, gov_source
Client and trace request_class, session_ref, trace_id
Routing model_requested, model_actual, provider, fallback, attempt_count
Auto mode auto_verdict, auto_stage, auto_signature
Usage input_tokens, output_tokens, cost_micros, total_ms, ttft_ms, has_body
Source record_json

policy_ref names the governance rule of a governance denial (name#generation·rule, or name·rule without a generation). Otherwise it is the first routing policy, except on a denial with a governance or RBAC deny code: a routing rule the request also matched did not refuse it.

kind is derived in the index and never written to the audit chain. First match wins: auto_mode_blocked (verdict block), privacy_blocked, policy_denied, upstream_error, privacy_masked, privacy_internal, ok. For a status of 400 or more, a completed record whose last attempt failed is upstream_error before any code matching, because the code is the upstream's; gateway decisions and legacy completed records without attempts are matched by their deny code. Below 400, a partial stream is upstream_error, and so is a completed stream whose last attempt is stream_failed (an upstream error event or a missing end of stream after the 200 was sent). deny_code (status 400 or more) is outcome.error; only a gateway decision without one falls back to routing.reason, so a completed upstream error never shows a routing outcome such as unchanged as a denial.

Coverage: Mode A is built from the configured file sinks, which a restart replays, and has no rebuild endpoint. Mode B POST /admin/analytics/rebuild truncates the audit tables with the other derived tables, and the existing ingest path repopulates them. A record no longer in any source is not in the console; coverage_start_ms in the summary is the earliest time the index holds.

Configuration

Key Values Effect
audit.client_evidence.session_ref key_scoped (default), off off stops writing client.session_ref; existing records keep their links. request_class is unaffected.
audit.client_evidence.auto_mode on (default), off off stops auto mode estimation.
otel.trace_link_template http or https URL containing {trace_id}, without user info (user:password@ is rejected) Console link only; {trace_id} is replaced by the record's 32-hex trace ID. Sent to the console through /admin/capabilities.

Admin change evidence (observability console)

request.change is appended at the end of the audit request object with omitempty, so records without it keep their exact bytes. The observability console sets it on admin_observability_changed records: target is observability_limits or observability_rules, before and after are sha256: revisions of the persisted state file bytes, and names lists the changed exporters or console rules. A break-glass or unconfigured-identity administrator is recorded as principal.user plus principal.auth_method; an OIDC administrator under verified identity (key_store.identity) as principal.identity (a digest), never the raw subject. The state itself never enters the chain. Admin records are not projected into the audit console tables above.

observability.console.state_file holds the runtime limits as {"version": 1, "exporters": {...}} (per exporter paused, signals, max_content_bytes, max_sample_percent), written by mayu with mode 0600 through a temp file, fsync and rename. The limits revision is the SHA-256 of the canonical bytes mayu writes, so for a file mayu wrote it equals the digest in the audit record. A file that does not parse refuses boot.

observability.console.policy_file holds the console-owned GovernancePolicy documents: names start with console-, each has one FailOpen observability rule named export, and documents are sorted by name and separated by ---. The local policy file channel loads it like any other policy file. Its revision is the SHA-256 of the file bytes.