Accounting protocol v2¶
The ownership and trade-offs behind this runtime are recorded in the accounting protocol v2 architecture decision.
This integration has shared accounting adapters for HTTP provider calls, charged cache hits, Realtime, batch, and selector calls. Legacy mode remains available. Production activation still requires the qualification and rollout checks below. Startup and Helm reject incompatible role combinations. Code changes after an accepted image require a new unchanged-image qualification before a release claim.
Accounting v2 removes the per-request sequence of budget reads, operation writes, dispatch writes, spend writes, and audit writes from the gateway hot path. PostgreSQL remains the durable authority. The compatibility execution mode uses two required acknowledgements. Native execution uses the local permits and terminal journal described in the architecture decision.
- One short atomic admission transaction reuses or allocates a process-scoped, short-lived PostgreSQL grant, consumes it for the microbatch, records immutable reservation events, and grants provider dispatch. Allocation escrows money from every applicable hard-budget window and leases bounded partition slots once for several operations.
- A separate microbatched finalization records the exact charge or an explicit provisional debit. Grant reconciliation applies all settled operations to their budget windows in one set-based update and releases unused escrow.
Finalization does not mutate the active grant row. The worker derives grant completion from operation state, so reservation and finalization queues do not serialize on the same process grant.
The queues are bounded and independent. Admission pressure cannot consume the finalization queue. A reservation replay never returns a dispatch token, so retrying a database call cannot repeat provider work. All identities, pricing inputs, request fingerprints, attribution, spend payloads, and redacted audit envelopes are frozen in the durable event stream.
Failure semantics¶
- No reservation ACK means no provider dispatch.
- Budget exhaustion returns HTTP 429 before dispatch.
- Capacity, timeout, stale generation, or database uncertainty returns HTTP 503 before dispatch.
- Confirmed success commits the exact charge before a non-stream response or terminal stream frame is released.
- Provider failure or lost acknowledgement after dispatch keeps the remaining allowance as a provisional debit. It is never silently released.
- Process crashes are recovered by converting expired dispatched reservations to provisional debits without resending requests.
- A failover deployment may run only if the first reservation covers its worst-case customer charge. Earlier attempts stay provisional until evidence resolves them.
Budget edits and native balances¶
Adding a hard-budget scope while matching permits remain live returns admin HTTP 409
with code budget_policy_requires_drain. The requested budget does not commit.
Pause inference for the affected scope, let accepted work settle,
and let owners return unused permits. Retry when matching grants and direct
reservations have drained. Other scopes can continue. Older live grants without
scope data require a one-time drain of the active generation. Do not clear receipts,
delete grants, or force counters to zero. This rule also applies to direct database
policy writes. An edit to an
existing budget window still checks all reserved and provisional money.
Adding or restoring a cap retains charges from the current budget period, even
before reporting completes. A cap below existing spend and holds returns 409
with code budget_policy_below_debits. Resolve uncertain operations and unreported
grant capacity before a new hard-budget window is created. Do not use a cap change
as a spend reset. Policy edits can read retained financial history on the control
pool. If an edit times out, it rolls back; drain the affected scope and retry.
Apply migration 20261007160000_accounting_budget_policy_history before rollout.
In native mode, organization, team, key, and user admin GET responses use native
balances. Current finite budgets use committed window balances. Unlimited scopes
use projected facts and retained legacy history for the current budget period.
These values can lag until settlement or projection completes. A missing finite
authority is an explicit 503, not a zero-spend result. Mutation receipts remain
independent from reporting; read the entity again for its current balance.
When budget_notifications_enabled is true, the existing notification worker
checks accounting thresholds in pages of 32 organizations. Enable the existing
global notification policy and the required email or Slack configuration too.
Discovery is asynchronous and uses the control-plane database pool. The worker
keeps durable deduplication and fenced delivery. It does not add database or
network calls to inference admission.
If a known page fails, the scan continues to later pages and retries the failed
page on the next full pass. Delivery of accepted intents continues. An unknown
balance is never treated as zero.
Native selector billing uses shared accounting health. It does not require a legacy spend worker on the same API process. Legacy selector billing still does.
Compatibility projection¶
deltallm_accounting_events is the source of truth. The dedicated accounting worker
uses fenced per-partition checkpoints to project completed events to the existing
spend ledger and required audit outbox. Projection is idempotent. API pods do not run
this worker when the Helm accountingWorker role is enabled.
The worker also rolls renewable budget windows. If a renewable window expires before its successor is ready, admission fails closed with 503; it never treats a missing window as unlimited. Hour, day, and calendar-month resets preserve the configured monthly anchor day.
In native execution, the existing recovery owner performs window renewal after
grant settlement. It uses the same batch limit and cycle deadline. Migration
20261007170000_accounting_budget_period_sync and its corrective migration
20261007171000_accounting_monthly_reset_metadata must be applied before the new
worker image. The reset trigger also repairs an unchanged expired date when an operator
edits a budget. Cap removal cancels pending renewal. Old-period spend is not copied
into a restored cap, and current-period spend is kept exactly once. Explicit
invalid reset dates still fail. No extra inference database call is added.
Projection claims up to accounting_projection_max_concurrent_partitions partitions
at once. Spend rows and required audit envelopes are written in sink batches, while
expiry recovery, grant closure, window rollover, and backlog measurement run on the
separate maintenance interval.
Journal and native reporting lanes use the configured projection poll interval after the first empty claim. Each later empty successful claim doubles the idle wait, up to the larger of that interval and one second. Completed work resets the wait and starts the next claim immediately. A local wake also resets it. Cross-process work can wait for that idle interval plus bounded database time before discovery. Error backoff and required health checks do not change.
Database preparation¶
Request IDs and blocked reporting records¶
HTTP ingress uses one request ID for accounting and response headers. A single caller ID is retained when it has 1 to 256 visible ASCII characters without spaces. Missing, empty, repeated, or invalid headers receive a generated ID. Caller correlation IDs do not replace the server's economic operation identity. Non-HTTP finalization uses the operation ID when no valid correlation ID exists.
Apply migration 20261010130000_accounting_reporting_request_ids before the
corrected image. It changes only the reporting function. For an accepted event
with a missing, null, or empty request ID, reporting uses the existing operation
ID. The accepted event and its source hash remain unchanged. Charge, attribution,
audit, fencing, and replay checks remain active. No second charge is created.
Restart the corrected reporting workers and allow the blocked page to retry. Confirm that reporting drains and all API and accounting readiness checks pass before resuming normal traffic. Do not delete the event, reset a checkpoint, or clear budget holds to make recovery pass. Other invalid records still fail closed and require investigation. Keep applied migrations unchanged.
The native qualification must include requests without x-request-id. A run
that always supplies this header does not prove that normal clients are safe.
Grant execution policy¶
Apply migration 20261010083000_accounting_grant_execution_policy before the
upgraded image. It disables JIT compilation only inside the short grant admission,
allocation, reservation and finalization functions. The caller's JIT setting is
restored after success or error. Request deadlines, budget rules, durable records
and retry identities do not change. This migration changes no table data.
Upgrade terminal publication ordering¶
Migration 20261007173000_accounting_event_publication_order orders terminal
publication within each accounting partition. It also keeps grant-backed expiry
out of the direct-reservation recovery owner. Existing migrations stay unchanged.
Apply 20261008001000_accounting_event_publication_parent_locks in the same
stopped-writer upgrade. It orders the implicit parent locks for journal records
that retain reservations. Do not deploy the publication change without this
correction.
Stop all accounting event writers and reporting workers before applying this migration. This includes API and batch writers, journal processing, expiry recovery, and operator reconciliation. First let accepted work finish under its current owner. Do not overlap old and new writer functions during this upgrade.
If this database already used native reporting, reset only its native reporting
checkpoint while the workers are stopped. This example uses generation 1;
use the generation that you have checked for your deployment:
UPDATE deltallm_accounting_projection_checkpoints
SET last_sequence=0, lease_owner=NULL, lease_token=NULL,
lease_expires_at=NULL, last_error_code=NULL, updated_at=NOW()
WHERE protocol_name='primary' AND generation=1
AND projection_name='accounting-read-model-v2';
This update changes at most 64 fixed checkpoint cells. It does not delete events, balances, usage facts, audit records, or rollups. The native projector can replay existing events without adding their effects twice. Do not reset the compatibility projector with this command.
Restart the upgraded accounting workers first. Wait until native reporting has finished the replay and worker health is ready before restarting inference. Replay time depends on retained history. A conflicting fact stops replay; do not delete that fact to force progress. A new database needs no checkpoint reset.
For a deployment that still uses the supported compatibility reporting owner, the new image also accepts expiry and reconciliation event keys. Existing audit UUIDs and UUID-source fallback identities remain unchanged. Recovery keys use the native stable audit identity. Restart the upgraded compatibility worker; its blocked checkpoint retries normally. Do not reset a checkpoint just to clear this ID error, and do not run native and compatibility reporting for the same generation. The native replay command above is not a compatibility replay procedure. Keep existing compatibility spend and audit deduplication rows intact.
Apply the Prisma migrations first. Stop all legacy API and worker writers. Let accepted billing work settle. Then prepare a generation from exact legacy balances:
uv run python scripts/prepare_accounting_v2.py \
--database-url "$DATABASE_URL" \
--generation 1 \
--partitions 16 \
--max-outstanding-per-partition 4096 \
--activate
Preparation is transactional and idempotent while the generation is prepared. It
also recognizes an already active generation after a lost commit acknowledgement. It
refuses to continue when legacy reservations are outstanding, an exact committed
balance exceeds its limit, a reset policy is invalid or overdue, or the resulting
window count does not match finite legacy budgets. A deployment with no hard budgets
is valid and produces zero windows.
Preparation and activation also reject unresolved realtime intents, spend outbox rows, selector operations, batch jobs, and batch completion receipts. Review blocked work; do not delete it to make activation pass. These checks assume stopped writers. They do not support an online rollout with both legacy and v2 billing writers.
Partition capacity should cover maximum admitted provider calls, with headroom for the 14-minute crash-recovery horizon. More partitions reduce lock contention; they do not weaken budget scopes because window rows are locked independently in stable order.
Runtime configuration¶
All accounting settings are startup-only. A typical Helm overlay is:
accountingWorker:
enabled: true
replicaCount: 1
autoscaling:
enabled: true
minReplicas: 1
maxReplicas: 4
oldestEventAge:
enabled: true
prometheus:
customMetrics:
enabled: true
config:
general_settings:
accounting_protocol_enabled: true
accounting_protocol_generation: 1
accounting_microbatch_max_size: 8
accounting_microbatch_dwell_ms: 2
accounting_reservation_max_pending: 4096
accounting_finalization_max_pending: 8192
accounting_reservation_max_pending_bytes: 8388608
accounting_finalization_max_pending_bytes: 8388608
accounting_statement_timeout_ms: 250
accounting_finalization_ack_timeout_ms: 1000
accounting_max_provider_attempts: 3
accounting_grants_enabled: true
accounting_grant_target_operations: 32
accounting_grant_ttl_seconds: 30
accounting_projection_batch_size: 64
accounting_projection_max_concurrent_partitions: 4
accounting_projection_maintenance_interval_ms: 1000
spend_ingestion_mode: outbox
audit_ingestion_mode: outbox
audit_enabled: true
Helm disables spend, audit, and projection workers on API pods and enables them on the accounting worker. Its PostgreSQL and Redis pools are included in dependency-capacity validation. API pods do not open the telemetry-worker database pool when they own no telemetry worker. The dedicated worker defaults to smaller control and foreground pools because it is not selected by the gateway Service; override those pools only with an updated capacity report.
The direct accounting pool sets statement_timeout, lock_timeout, and jit=off
on every connection. Its statements are key-bounded and must finish within
accounting_statement_timeout_ms. Compiling one plan with JIT can take longer than
that budget when stale table statistics overstate a statement's cost.
Each API process has separate reservation and finalization byte budgets. Both default to 8 MiB and are startup-only. Each budget covers immutable queued payloads, selected work waiting for a database acknowledgement, and a fixed charge for queue metadata. It is not an RSS limit. The item limits still apply. A full queue fails closed; it does not evict another operation or borrow the other queue's budget.
Collection stops at 1 MiB of serialized JSON or the configured item count, whichever
comes first. Large valid terminal records can use several batches. This keeps the
per-record payload limits and avoids an oversized batch after provider completion.
Caller cancellation before collection releases its byte charge. After selection,
the charge remains until persistence finishes or the worker fails. The metric
deltallm_accounting_queue_retained_bytes{queue="reservation|finalization"} records
each queue's conservative charge. With P API processes, the two default queue
budgets total P * 16 MiB, in addition to clients, request bodies, and local leases.
The accounting-worker HPA always supports CPU and memory targets. Its oldest-event-age target requires the Prometheus custom-metrics adapter and is mandatory when accounting worker autoscaling is enabled in the production profile. Scale-down stabilization must cover the pod termination grace period so claimed work can drain.
Grant size is a throughput/fairness trade-off. A larger target reduces hot-window mutation frequency but temporarily escrows more of a shared budget per API process. Start with an 8-item persistence batch, a 32-operation grant, and a 30-second TTL. Tune the batch and grant independently from measured statement latency, grant utilization, budget-exhaustion decisions, and tail latency. PostgreSQL remains authoritative; Redis does not hold grant balances.
Use the isolated hot-budget profile before adding another service to the durable path:
uv run python -m tests.performance.accounting_grant_profile \
--database-url "$DISPOSABLE_DATABASE_URL" \
--mode both --rate 1000 --duration 10 --processes 8 --batch-size 8 \
--output .load-results/accounting-grants
The command refuses a database with an active accounting generation. It reports microbatch sizes, reservation transaction-sequence and caller latency, finalization database-call latency, reservation decisions, errors, closed grants, and final economic invariants. This isolates the database protocol; it does not replace the end-to-end HTTP/provider/Redis qualification.
The clean-main integration also supports --mode all. It adds the inactive
pre-issued permit bank to this comparison. This mode uses the same two-connection
accounting pool and terminal owner. It does not enable permits in the gateway.
Use a new output directory for each run. Check the commit, file hashes, and
working_tree_dirty field before comparing results. A dirty-tree result is a
development probe, not release evidence.
For the bundled local dependencies, layer the accounting evaluation profile after the normal evaluation profile. It sizes PostgreSQL for the complete rolling topology:
helm upgrade --install deltallm deploy/kubernetes/helm \
-f deploy/kubernetes/helm/values-eval.yaml \
-f deploy/kubernetes/helm/values-accounting-eval.yaml \
--set secret.values.masterKey="$MASTER_KEY" \
--set secret.values.saltKey="$SALT_KEY"
After activation, writes to legacy key, user, team, organization, and team-model hard budget policies update the authoritative active window in the same control-plane transaction. Removing a hard budget expires its active window. Lowering a limit below committed, reserved, or provisional debit is rejected instead of creating overspend.
Provisional reconciliation¶
After reviewing provider evidence, release an unbilled provisional amount:
uv run python scripts/reconcile_accounting_v2.py \
--database-url "$DATABASE_URL" \
--generation 1 \
--operation-id 00000000-0000-0000-0000-000000000000 \
--additional-charge 0 \
--evidence-reason "provider confirmed no charge" \
--provider-evidence-reviewed
For a confirmed additional charge, pass its exact amount and a JSON spend payload with
the same cost_exact through --spend-payload-file. The function atomically moves the
confirmed amount to committed spend, releases the remainder from every scope, and
appends an operator-audited reconciliation event. It never calls a provider.
Qualification gates¶
Before production activation, run:
- migrations from an empty database and the supported upgrade fixture;
- hermetic accounting contract and request-path tests;
- real PostgreSQL tests for hot-window concurrency, replay, recovery, reconciliation, and reset rollover;
- Helm schema, lint, and capacity tests;
- shared realtime, batch, and selector adapters with recovery tests;
- the 50/100/200/500 RPS workload with zero accounting 500/503 errors, no overspend, bounded queues, recovered projection lag, and p95/p99 within the release SLO.
PR 10 load qualification remains separate from this architectural implementation.
Rollback¶
Set accounting_grants_enabled: false and roll API pods to return new reservations to
the direct-window v2 implementation. This is a safe performance rollback, not a move to
eventual accounting. Continue running the accounting worker until old grants are closed
and projection backlog is zero. To disable accounting v2 entirely, follow the drain and
generation-fencing sequence in the architecture decision instead of removing the
worker or schema first.