omnigate

Upgrading

For someone who runs an earlier commit and wants to run the current one. Versions before the first tag (the pre-release 0.1.0-rc.1) are named by commit. Upgrades from two of them, 10bc009 and 6d5ed6e, to 0.1.0-rc.1 have been run as a test (Tested upgrades). This page covers the two security commits of 11 November 2026 in detail, and the close-out commit that followed them (The close-out commit):

Commit Process What it is
e4e30e4 data plane Limits before the body is read, the gateway's own response framing, exact billing of abandoned streams, the healthcheck subcommand
d79406b control plane, Compose Capped outbound responses, stores that survive bad input, duplicate-detection ids in per-day files, media pricing settings, memory limits in Compose

What they fixed, and who was affected, is in SECURITY.md. If you come from before commit 10bc009, read Coming from older commits at the end first: those steps still apply and are not repeated here.

Contents:

Before you start

  1. Back up the control plane's data directory, all of it, with the control plane stopped or at least idle: the ledger's active segment, sealed segments and both snapshots, jobs.jsonl, keys.json, the audit log and the media directory. The first start of the new control plane rewrites the ledger snapshot in a format the old code does not read (Going back).
  2. Check your config file against the stricter rules in Configuration that used to load and no longer does. A file the old control plane loaded can now stop the new one at startup.
  3. If you price media routes per second, decide on default_seconds for their targets: without it, a job that names no duration is now refused (What changes for clients).
  4. If your data planes can be cut off from the control plane for more than three days with usage in their spool, set "retention": {"ledger_ids_days": 14} (the old default) or more before you start the new control plane (Defaults that changed).
  5. Compose users: look at memory. Both containers now have a 1 GiB limit. With the compose.yaml of d79406b the data plane's budgets were the binary's defaults, which admit more than that; the current one sets them to fit (The close-out commit). See Deployment and set OMNIGATE_CONTROL_MEMORY, OMNIGATE_DATAPLANE_MEMORY or the data plane's OMNIGATE_REQUEST_MEMORY_MB and OMNIGATE_PARSE_MEMORY_MB (in .env) accordingly.
  6. If a proxy or load balancer in front of the data plane sends request bodies with chunked encoding, make it send a Content-Length: chunked request bodies now answer 411.

The order to upgrade the two processes

Upgrade both. The two commits belong together as one security update, and each fixes a different process.

Nothing in the interface between the two processes changed in these two commits: the config snapshot has the same shape, usage events and refusal counts are sent the same way, and the forwarded jobs API is the same. A new process therefore works next to the other's previous commit (10bc009 or 76bba24) for the length of an upgrade. Both mixed pairs have since been run as a test, with the 0.1.0-rc.1 build on one side and 10bc009 or 6d5ed6e on the other (Tested upgrades): text requests, usage, media jobs and downloads worked in both. A few minutes of a test are not a soak, so do not leave it that way for longer than the upgrade takes.

The recommended order is control plane first, then the data planes:

  1. Control plane. Stop it, start the new one, and wait for GET /readyz to answer 200.
    • It is the process that can refuse to start (a config value the stricter rules reject), and the one that changes files on disk. Find that out while the old data planes are still serving: a data plane keeps serving text requests from its last config while the control plane is away, and spools usage until it is back. Only /v1/jobs* and /v1/files/* answer 503 in the meantime.
    • Its first start reads the old ledger snapshot, moves the duplicate-detection ids into per-day files and writes a new snapshot. Nothing needs to be run by hand.
    • A media job that a provider had accepted resumes polling after the restart, as before.
  2. Data planes, one at a time if you run several. Each stop is a graceful one: requests in flight get OMNIGATE_SHUTDOWN_GRACE_MS (10 seconds) to finish, queued usage is delivered or stays in the spool, and the new binary reads the old spool file and the old config cache as they are.

With Compose, docker compose up -d --build rebuilds and replaces both; Compose starts the control plane first and does not wait for it, which is fine, since the new data plane starts from its cached config.

Each process forgets what it keeps in memory when it restarts, as with any restart: rate-limit buckets, cooldowns, the response cache, learned model quirks and refusal counts not yet reported on the data plane; the control plane's rate-limit buckets, lockouts and refusal counts.

What changes on disk

Control plane (its data directory)

File Change Old code reading it afterwards
ledger.snapshot, ledger.snapshot.prev Written as version 2 from the first start on. A version 2 snapshot holds the totals and, per day, how long that day's id file was; it no longer holds the ids themselves, so its size follows the number of keys, not the number of requests. A version 1 snapshot is still read, once, at the first start. Ignores a version 2 snapshot (ledger: ignoring ledger.snapshot (unexpected contents)) and rebuilds from the segments that are still on disk. See Going back.
ledger.ids.YYYY-MM-DD (new) One file per UTC day on which usage was recorded, next to the ledger, 16 bytes per event id, appended and never rewritten. Created at the first start from the ids in the old snapshot. Whole days are deleted by retention.ledger_ids_days. Not looked at.
ledger.jsonl, ledger.NNNNNN.jsonl Unchanged. Reads them.
jobs.jsonl A job whose input is over 8 KiB as JSON is written with "input": null and "input_file": true. Jobs created before the upgrade keep their input inline. Reads the file, but sees such a job without its input.
jobs.jsonl.inputs/ (new directory; <OMNIGATE_JOBS>.inputs/) One file <job id>.json per large input, written before the job that points at it, deleted with the job. At a start, an input file without a job is removed. Not looked at.
keys.json A key may have max_jobs_per_day and, since the close-out commit, max_concurrent. An rpm above 1,000,000 that an older commit wrote is read as 1,000,000. Ignores the new fields.
audit.jsonl Failed and throttled sign-ins are no longer one line each: the first from an address in a minute is a line, the rest are counted and written as one summary line with a repeats member. Every text field is made valid UTF-8 before it is written. Since the close-out commit a line can have a peer member, and address is the address the caller was counted under. Still JSON lines.

Back up the new files with the old ones. ledger.ids.* belongs to the snapshot that names it, and jobs.jsonl.inputs/ to jobs.jsonl:

The rule is the one that already applied: back up the whole data directory, not single files (Deployment).

Data plane (its working directory, /data in the image)

File Change
omnigate-usage.spool Same format (one JSON event per line). The new binary rewrites it to shed delivered events while it runs, so its size follows what is undelivered. A file left by the old binary is read as it is; a file of any size no longer stops a start, and if it holds more than can be queued (100,000 events or 128 MiB) the oldest are kept and the rest discarded with an error in the log.
omnigate-config.cache.json Unchanged.

Deployment files

File Change
compose.yaml mem_limit on both omnigate services (1 GiB each unless .env says otherwise). Since the close-out commit also the data plane's three memory budgets, in its environment.
.env Two new optional variables, OMNIGATE_CONTROL_MEMORY and OMNIGATE_DATAPLANE_MEMORY. An existing .env without them gets the 1 GiB defaults. Since the close-out commit also OMNIGATE_REQUEST_MEMORY_MB, OMNIGATE_PARSE_MEMORY_MB and OMNIGATE_CACHE_MAX_MB (256, 256 and 64 when absent).
deploy/Caddyfile The proxy sets X-Forwarded-For itself, from the address of the connection, for both hosts. If you run your own proxy configuration with OMNIGATE_TRUST_FORWARDED=1, make yours do the same.
dataplane/Dockerfile A HEALTHCHECK that runs /omnigate-dp healthcheck. docker ps now shows a health status for the data-plane container; Compose itself does nothing with it.
config/omnigate.example.json Shows the new settings. Your own config/omnigate.json is not touched and needs none of them.

Defaults that changed

Setting Before Now Since
OMNIGATE_ABORT_DRAIN_MS (how long an abandoned stream's upstream is read on) 5000 120000 e4e30e4
How much more of an abandoned answer is read 8 MiB, fixed OMNIGATE_ABORT_DRAIN_MAX_MB, 64 e4e30e4
A client that stays connected but reads nothing cut off after OMNIGATE_STREAM_IDLE_TIMEOUT_MS, 300000 cut off after the new OMNIGATE_CLIENT_STALL_TIMEOUT_MS, 30000 e4e30e4
OMNIGATE_STREAM_IDLE_TIMEOUT_MS 300000, both directions 300000, a silent upstream only e4e30e4
Requests one key may have in flight no limit OMNIGATE_MAX_CONCURRENT_PER_KEY, 512 e4e30e4
Request bodies held at once no limit OMNIGATE_REQUEST_MEMORY_MB, 1024 (a quarter per key) e4e30e4
Memory for parsing JSON into trees no limit OMNIGATE_PARSE_MEMORY_MB, 1024 (half per key; at most 96 MiB per tree) e4e30e4
Abandoned streams read on at once each on its own connection, up to the connection cap 256 e4e30e4
Usage queue 100,000 events 100,000 events and 128 MiB e4e30e4
retention.ledger_ids_days 14 3 d79406b
media.max_jobs_per_day (and a key's max_jobs_per_day) no limit 10000 per key per UTC day d79406b
media.webhooks.max_concurrent no limit 16 d79406b
media.webhooks.max_concurrent_per_key no limit 4 d79406b
GET /v1/jobs per key no limit the key's rpm, or 600 per minute when it has none d79406b
A webhook receiver's answer read whole at most 16 KiB read d79406b
A provider's or the bucket's answer to an API call read whole at most 4 MiB; an answer that carries media inline up to four thirds of media.max_output_bytes plus 64 KiB, at most 256 MiB d79406b
The control plane's count of job creations fixed one-minute windows a token bucket like the data plane's (burst of rpm, refilled at rpm per minute) d79406b
Memory limit of each omnigate container under Compose none 1g (OMNIGATE_CONTROL_MEMORY, OMNIGATE_DATAPLANE_MEMORY) d79406b
The data plane's memory budgets under Compose the binary's 1024, 1024 and 64 256, 256 and 64, set by compose.yaml from .env close-out
Retry-After on 429 too_many_jobs_today 10 seconds until midnight UTC, at most 3600 close-out
The deadline on the gateway's own answers OMNIGATE_STREAM_IDLE_TIMEOUT_MS OMNIGATE_CLIENT_STALL_TIMEOUT_MS close-out

What each means in practice:

Configuration that used to load and no longer does

The control plane validates more of the config file. At startup a rejected value stops it, with a log line that names the setting; on a reload the previous config stays in force.

Setting Was Now
keys[].rpm, upstreams[].timeout_ms Any number; a fraction was cut to a whole number A whole number: rpm 0 to 1,000,000, timeout_ms 0 to 3,600,000. 600.0 is rejected.
routes[].cache_ttl_s, targets[].weight A whole number of 0 or more Also at most 31,536,000 and 1,000,000
keys[].budget_usd Any number, negative included 0 to 1e12
Text and media prices 0 or more Also at most 1e9
keys[].max_open_jobs, media.max_open_jobs A whole number of 0 or more Also at most 1,000,000,000
media.poll_interval_ms Any number A whole number, 1 to 3,600,000
media.max_output_bytes Any number A whole number, 1 to 1 TiB
media.max_input_bytes Any number A whole number, 1 to 8,000,000
media.routes[].timeout_s Any number A whole number, 1 to 604,800
media.storage.url_ttl_s Any number A whole number, 1 to 31,536,000
media.webhooks.max_attempts Any number A whole number, 1 to 100
media.webhooks.timeout_ms Any number A whole number, 1 to 600,000
media.webhooks.retry_schedule_ms Any list At most 50 whole numbers, each 0 to 2,592,000,000
A media target with per_second_usd on an upstream of kind openai_images, elevenlabs, gemini or gemini_interactions Loaded Needs default_seconds (close-out commit)

In each "any number" row, a fraction used to be cut to a whole number and is now rejected, as is zero where the range starts at 1. Through the admin API, PATCH /admin/keys/{name} refuses an rpm above 1,000,000, a budget_usd above 1e12, a max_open_jobs above 1,000,000,000 and a models list with more than 1,000 names or a name over 256 bytes (400 invalid_request).

New settings, all optional: a key's max_jobs_per_day and (close-out commit) max_concurrent, media.max_jobs_per_day, a media route's allowed_input and price_multipliers, a media target's default_seconds, min_seconds and max_seconds, media.webhooks.max_concurrent and max_concurrent_per_key. See Configuration.

The data plane has five new environment variables, all optional: OMNIGATE_MAX_CONCURRENT_PER_KEY, OMNIGATE_REQUEST_MEMORY_MB, OMNIGATE_PARSE_MEMORY_MB, OMNIGATE_CLIENT_STALL_TIMEOUT_MS and OMNIGATE_ABORT_DRAIN_MAX_MB; OMNIGATE_ABORT_DRAIN_MS has a new default. Like every numeric variable they take digits only.

What changes for clients

Error bodies keep their shape: the OpenAI or Anthropic envelope with the gateway's reason in error.code. What is new is listed here.

New answers

Status error.code From When
400 request_too_complex data plane A request that must be translated (or have its stream_options rewritten) has an object with more than 1,024 members, or needs a tree of more than 128 times its size plus 1 MiB (at most 96 MiB).
400 invalid_request data plane An object with more than 65,536 members, a key over 8 KiB, or model, stream or stream_options twice, spelled with escapes or (from the third-review commit) in another letter case; a stream or include_usage that is not a boolean.
411 invalid_request data plane A chunked request body. Transfer-Encoding: chunked on a POST was accepted before; now every request with a body needs a Content-Length.
429 concurrency_limit_exceeded data plane The key has 512 requests in flight. retry-after: 1.
429 request_memory_exceeded data plane The key's share of the request-memory or tree budget is in use. retry-after: 1.
503 overloaded data plane The whole budget is in use. Unlike the overloaded at the connection cap, this one is in the client's protocol and has a request id. retry-after: 1.
429 too_many_jobs_today control plane POST /v1/jobs beyond the key's jobs per UTC day.
429 rate_limit_exceeded control plane GET /v1/jobs more often than the key's rpm, or 600 a minute.
503 storage_unavailable control plane POST /v1/jobs when the job could not be written to disk.
400 invalid_request control plane POST /v1/jobs on a route priced per second without a duration (and without a configured default), above the route's max_seconds, with an input field outside the route's allowed_input, or with a value outside its price_multipliers.

The official OpenAI and Anthropic SDKs retry 429 and 503 answers that carry retry-after by themselves.

Changed behaviour

On the control plane's own port

What changes for operators

The close-out commit

The commit that follows 55a294d closes loose ends the two security commits left. Nothing changes on disk except two optional members (a key's max_concurrent in keys.json, peer on audit lines), both ignored by older code, and either process can be upgraded first. What an installation can notice:

Before you start

What clients can notice

Change Before Now
A provider's error that is not in its protocol's shape (an array, error as a string, plain text) Passed on as it came Put into the protocol's error envelope with the provider's message and code; a well-formed one is still passed on byte for byte (API)
File downloads over 64 KiB transfer-encoding: chunked, no length content-length, as before e4e30e4
429 too_many_jobs_today Retry-After: 10 Retry-After: the seconds until midnight UTC, at most 3,600
A non-streaming answer whose provider sends the head and then nothing The request ended there (after the stream idle timeout), and the connection's later deadlines could be lost A failed attempt, StreamIdleTimeout: the next target is tried, or 502 all_upstreams_failed
A client that takes none of one of the gateway's own answers (an error, the model list, a cache hit) Cut off after OMNIGATE_STREAM_IDLE_TIMEOUT_MS (5 minutes) Cut off after OMNIGATE_CLIENT_STALL_TIMEOUT_MS (30 seconds)
GET /login with a bearer token from an address locked out for wrong tokens The sign-in page, 200 429 too_many_attempts, as on /

What operators can notice

Going back from this commit to 55a294d needs nothing: remove max_concurrent from file keys only if you want to (older control planes ignore it).

The third-review commit

The commit that follows 1cc721f fixes what the third security review found (advisories). Either process can be upgraded first. On disk: a managed key in keys.json can gain carried_totals, and a job in the job log provider_pending; older code ignores both. What an installation can notice:

What clients can notice

Change Before Now
"Model", "STREAM", "Stream_Options" or another case of model, stream, stream_options at the top level of a text request; another case of include_usage inside stream_options Ignored by the gateway, passed on to the provider 400 invalid_request
"stream" or "stream_options.include_usage" that is not true, false or null ("true", 1) Read as false, passed on 400 invalid_request
A stream whose provider never reports usage (it ignores stream_options) Recorded with zero tokens, free Billed an estimate, "estimated": true (Operations)
POST /v1/jobs with metadata over 16 KiB as JSON Accepted up to media.max_input_bytes together with the input 413 payload_too_large
POST /v1/jobs with a webhook_url over 2,048 bytes Accepted 400 invalid_webhook_url
GET /v1/jobs: a job whose metadata is over 1 KiB In the list "metadata": null, "metadata_omitted": true; GET /v1/jobs/{id} shows it
Cancelling a job whose provider call had started cost_usd 0, always; webhook at once cost_usd is the route's price if the provider completed the call anyway; the job counts as open, and its webhook waits, until that is known (at most the route's timeout) (API)
A job whose provider returns more than 16 outputs, or more than 2 GiB of them All downloaded That attempt fails (too_many_outputs, output_fetch_failed); raise media.max_outputs_per_job or media.max_job_output_bytes if a model of yours needs more

What operators can notice

Going back to 1cc721f needs nothing. A job cancelled under the new code whose account is still open ("provider_pending": true) is not settled by the old code: let such jobs finish (at most a route's timeout) before going back, or accept that they stay out of the ledger.

The privacy mode commit

The commit that follows 8421df7 adds privacy mode. Nothing changes for a key that does not use it, with the exceptions in the last table.

Upgrade both processes before you give a key the setting, and check GET /admin/keys afterwards: a control plane from before this commit ignores a privacy it is sent (in the config file and on PATCH /admin/keys alike), without an error, and the key is then simply not private.

Mixed versions

Control plane Data plane What happens
new old The snapshot carries each key's privacy; an old data plane ignores it. Redaction, unlinking, "store": false and the cache exemption do not happen until the data plane is upgraded, and no response says x-omnigate-privacy. Media jobs are private and encrypted as configured (that is the control plane's work). An x-omnigate-privacy request header is ignored by the old data plane and not passed on.
old new No key has the setting. The request header works for text requests (redact, unlink, no-store); on POST /v1/jobs the new data plane passes x-omnigate-privacy: no-store on and the old control plane ignores it, so the job is not private. Redaction counts are reported with the refusal counters and an old control plane shows them there, as omnigate_rejected_requests_total{reason="redacted_email"} and so on.

Upgrade both before telling anyone a key is private.

On disk

New settings, and one new default

Setting Default Note
key privacy off Privacy mode.
upstream privacy_store_flag by host (api.openai.com) Configuration.
retention.private_outputs_hours 24 Only concerns private jobs, which exist only once the setting or the header is used.

What clients and operators can notice without using it

Change Before Now
A request with an x-omnigate-privacy header Ignored Acted on; a value that is not understood answers 400 invalid_request
The config snapshot (GET /internal/config) No privacy on keys Every key has privacy (null when off), every upstream privacy_store_flag; the snapshot's version changes once with the upgrade
GET /admin/keys, GET /admin/overview Keys gain privacy and privacy_summary; the overview gains redactions
/metrics omnigate_redactions_total, omnigate_key_private
The retention sweep's log line Ends with the ledger segments Ends with "and the outputs of N private jobs"
A job's outputs[] entries url, content_type, bytes, sha256 The same for jobs that are not encrypted; four more fields for those that are
tools/e2e.py Standard library only Uses pyrage when it is installed, to decrypt what the gateway encrypted; without it that one check is skipped and the run says so (OMNIGATE_REQUIRE_PYRAGE=1 turns the skip into a failure, as CI does)

0.1.0-rc.1

The first tagged version. Nothing changes on disk and nothing in the interface between the two processes; either can be upgraded first. What an installation can notice:

Going back to 99dd04c needs nothing.

The attestation commit

The commit after 0.1.0-rc.1 adds attested upstreams, the attested part of privacy mode, the privacy dry run and the Private chat page. Nothing changes for an installation that uses none of them, with the exceptions in the last table. Nothing changes on disk.

Mixed versions. The data plane enforces an attestation verdict; the control plane produces it.

Control plane Data plane What happens
new old The snapshot carries each target's verdict and each key's attested; an old data plane ignores both. Attested targets are used whether or not they are verified, and a key's attested restricts nothing. An x-omnigate-privacy: attested request header is refused by it with 400. No response says x-omnigate-attestation, and x-omnigate-dry-run is ignored: the request is sent to a provider.
old new An attestation setting on an upstream is ignored by the old control plane, without an error, and nothing is verified; a key with "privacy": {"attested": true} makes the old control plane refuse the config (it does not know the field). The new data plane sees no verdict on any target and refuses every attested request.

Upgrade both, the data plane included, before you rely on any of it, and check a response's x-omnigate-attestation header and GET /admin/attestations afterwards.

What an installation can notice without using it

Change Before Now
x-omnigate-privacy: attested 400 invalid_request Accepted; on a gateway without attested upstreams the request is refused with 503 attestation_unverified
A request with an x-omnigate-dry-run header Ignored (the request was sent) privacy answers with a dry run; any other value is 400 invalid_request
A key's privacy in GET /admin/keys and in the snapshot Four fields (three in the snapshot) One more, attested
/metrics omnigate_attestation_checks_total (three series, all 0 without attested upstreams)
The dashboard's top bar A link to the Private chat page. Using the page creates a managed key named private-chat; a config-file key of that name stops the page from working
The control plane's outbound calls With an attested upstream only: the upstream's host, Intel's and NVIDIA's attestation services (list)

The build-around commit

The commit after the attestation one adds what outside tools build on (Building on omnigate): top-ups, the usage feed and the request tag. Nothing changes for an installation that uses none of them, with the exceptions below.

On disk (control plane)

File Change Old code reading it afterwards
keys.json A top-level "topups" list: the applied top-ups, newest first, at most 10,000. Written (empty) at the first key change after the upgrade. Reads the keys and ignores the list. Its next key change writes the file without it, so the references are forgotten: a payment replayed after going back would be credited again.
ledger.jsonl and its segments An event may have a "tag". Ignores it.
jobs.jsonl A job may have a "tag". Ignores it.
audit.jsonl key.topup entries, with a topup object. Shows them like any entry.

Mixed versions

Control plane Data plane What happens
new old An x-omnigate-tag header is ignored without an error: the request is served and its event has no tag. Top-ups and the usage feed work (they are the control plane's alone).
old new A valid tag is accepted by the data plane and sent with the usage event; the old ledger stores the field as it is and shows it nowhere. POST /admin/keys/{name}/topup and GET /admin/events answer 404.

What an installation can notice without using it

Change Before Now
A request with an x-omnigate-tag header Ignored Recorded if it is 1 to 64 letters, digits, ., _, :, -; otherwise, or sent twice, 400 invalid_request
POST /admin/keys/{name}/topup, GET /admin/events 404 The new endpoints
The audit log key.topup entries

The receipts commit

Adds a receipt for each answer from an attested upstream, and more ElevenLabs products for media jobs. Nothing changes for an installation without an attested upstream, with the exceptions in the table.

Between the two processes. A usage event for an answer from an attested target now carries "receipt": {"request_sha256", "response_sha256", "completion_id"} (null on every other event), and the data plane sends such requests with x-no-aliasing: true.

Control plane Data plane What happens
new old No event carries hashes: no receipt is made. GET /v1/attestations/<id> is answered 404 not_found by the old data plane. Attested routing works as before.
old new The old control plane ignores the new field (and records the usage as before); GET /v1/attestations/<id> gets its 404.

On disk (control plane). A new file, data/receipts.json (OMNIGATE_RECEIPTS), written only when there is an attested upstream. An older control plane does not read it; deleting it loses the receipts and nothing else. Include it in backups if receipts matter to you.

Before After
The control plane's environment The provider's API key was needed by the data plane only For receipts the control plane needs the attested upstream's api_key_env too (it asks the provider for each signature). Without it every receipt is not_verified (api_key_missing); nothing else is affected.
Requests to an attested upstream Carry x-no-aliasing: true: a provider that would have served the request with another model under an alias now refuses it.
retention One more setting, receipts_days (30). It appears in GET /admin/overview's config and on the dashboard.
Retention.sweep result, /metrics receipts in the sweep's counts; omnigate_receipts{verdict} (three series, all 0 without attested upstreams).
A private media job (no-store) Could be seen as succeeded for a moment before input_erased appeared Its input is erased in the same write that makes it terminal.
ElevenLabs media targets Text to speech only A target may name "api": "sound", "music", "image" or "video"; without it nothing changes.

The credits and payments commit

Adds prepaid credit as a rule of its own (a burn multiplier, bonus-eligible credit, a weekly bonus) and, opt-in, payments through NEAR Intents. With neither credits nor payments in the config nothing changes in what the gateway does; the differences are the additive ones in the tables.

Between the two processes. The data plane now passes /v1/payments* on to the control plane (GET and POST), also for a key that is blocked.

Control plane Data plane What happens
new old /v1/payments* is answered 404 not_found by the old data plane: payments cannot be used until it is upgraded. Credit (the multiplier, the bonus) is the control plane's alone and works.
old new The new data plane passes /v1/payments* on and the old control plane answers 404.

On disk (control plane).

File Change Old code reading it afterwards
keys.json A top-up record gains "source" and "eligible". A prepaid managed key gains "credits" (its two balances and the weekly measure) at its first top-up after the upgrade, and "burn_multiplier" when it is given one. A key that is never topped up and has no multiplier is written exactly as before. Ignores the new fields, and drops them at its next key change: the bonus measure of the current week and which credit was eligible are then lost (the budget itself is not). Bonuses already credited stay, as top-ups.
ledger.jsonl, ledger.NNNNNN.jsonl A usage event of a prepaid key whose burn multiplier is not 1 carries "credits_usd" next to "cost_usd". Credit added to a key (a top-up, a bonus) is recorded as an event of "kind": "credit" with an amount_usd, at no cost. At a multiplier of 1 and without top-ups the files are what they were. Reads cost_usd only: a key's credit burned shrinks back to its cost, so a prepaid key that was blocked at a multiplier above 1 may be unblocked. It counts each credit event as one request of that key, at no cost, with no tokens.
ledger.snapshot Holds one more kind of row (what each key burned beyond cost). Loads it; the rows are not looked at.
payments.json (new; OMNIGATE_PAYMENTS) Written only when payments is configured: one record per payment, with its deposit address and state. Does not read it. An open payment is then never credited by the gateway: credit it by hand with a top-up under its reference.

Back payments.json up with the rest of the data directory.

Before After
A key with a total budget Blocked when its cost reached the budget Blocked when its credit burned (cost times its burn multiplier) reaches the budget. The same at the default multiplier of 1.
GET /admin/keys, a key Three more fields: credits_burned_usd, credits (an object for a prepaid key, null otherwise), and period_spend_usd of a total budget is now the credit burned. The dashboard's "spent" for a total budget is that figure too.
GET /admin/events Text requests and media jobs Every event has credits_usd; top-ups and bonuses appear as events of "kind": "credit". A consumer that assumed every event is usage should skip that kind (examples/usage_export.py writes them as rows with amount_usd, source and reference).
POST /admin/keys/{name}/topup amount_usd, reference Also takes source and bonus_eligible; the answer's topup has source and eligible. A reference that starts with bonus: can collide with the gateway's own (bonus:<key>:<ISO week>); pick other names.
Audit log New actions written by the gateway itself, with "auth": "system" and "address": null: key.bonus, payment.create, payment.settle, payment.refunded, payment.failed. A reader that assumes every line has an address should not.
/metrics omnigate_key_credits_burned_usd_total, omnigate_payments{status}, omnigate_payments_credited_usd.
The key store file Rewritten when a key changes With a bonus rate set, also rewritten every credits.sample_interval_s (15 minutes) while any key holds bonus-eligible credit.
Config credits and payments were unknown top-level sections, silently ignored Both are read and checked: a config that happened to carry one with fields the gateway does not know no longer loads.
Admin API GET /admin/topups, GET /admin/payments.
Gateway API /v1/payments*: 404 unless payments are configured.

Tested upgrades

tools/upgrade_test.py builds an older commit, lets it write state, stops or kills it, and starts the current build on the same files (Contributing). On 11 November 2026 it was run from two commits to the tree that became 0.1.0-rc.1, on Windows, against the mock providers:

From On-disk format it starts from
10bc009 Ledger snapshot version 1, no ledger.ids.* files, no jobs.jsonl.inputs/
6d5ed6e Ledger snapshot version 2 and id files; before carried_totals, provider_pending, privacy mode and price variants

Each was run twice: with the old processes asked to stop, and with both killed while a media job was running at the provider. In all four runs the current build then showed:

The two mixed pairs (old data plane with the current control plane on the upgraded files; current data plane with the old control plane on fresh files) served text in both protocols, recorded usage once, ran a media job and logged no error on either side. The privacy rows of the table above were checked the same way: a key made private is not private through an old data plane, and is as soon as the data plane is current; the request header redacts on a current data plane whatever the control plane.

What this does not show: an upgrade under load, one with a large ledger (the test writes a few hundred events), bucket storage, Linux, or the container images. One thing it found is fixed in 0.1.0-rc.1 (the stop described above); the old data plane in these runs showed it, and had to be killed in the "asked to stop" variant.

Going back

Data plane. The old binary reads the new binary's spool and config cache. Going back loses nothing but the fixes.

Control plane. Going back is possible but not free:

Coming from older commits

The steps for earlier changes still apply, in this order:

If you run a commit before Do
97cf396, fde530c, 3a0b147 (the first security fixes) Read SECURITY.md: besides updating, you may have to set a new admin token and rotate gateway keys.
3a0b147 Split the single .env into .env.control and .env.dataplane, and make sure the admin token has at least 16 characters; a release build needs OMNIGATE_CONFIG. Steps: Deployment.
fde530c Clients: paths must be in canonical form (no trailing slash, no percent-encoding), query strings plain, JSON nested at most 64 levels; a POST without a length answers 411. Abandoned streams are billed. See API.
10bc009 Clients of the jobs API and scripts against the admin API: the reason of an error from the control plane moved from error.type to error.code. Numeric environment variables are read strictly in both processes.
17e0fe6, b3c5b39 Clients: the data plane's errors became provider-shaped, with the gateway's reason in error.code instead of error.type. Reports: input_tokens excludes prompt-cache reads, which are counted separately. See the "Changed" section of the changelog.

The ledger needs no manual migration from any commit: a plain ledger.jsonl from before segments and snapshots existed is read as it is, and a version 1 snapshot is converted at the first start. The job log is read by newer code whatever wrote it; the reverse does not hold (older code cannot read the patch lines and input files that newer code writes).

This page on GitHub