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_modesupports only defaultbuffer_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) carryprincipal.user(opaque OIDCsub— never email) andprincipal.auth_method(oidc|break_glass);auth_methodis appended at the END ofPrincipalRefso 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 opaquebody_ref.Record.body_ref/record_refare 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_requestedby then already holds the model actually served. Theinternal/analyticsread-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
teamsDB record — with the DB record winning when both name the same team (D3, ADR-016);internal/governance.Governorresolves 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.AllowedRegionsrestricts 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 configallowed_regionsenforced (the one caseTeamRecordis 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 lookupinternal/audit/writer.go— single-writer goroutine, pending-based WAL truncateinternal/audit/verify.go—audit verifychain 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.