Billing & Usage

Usage metering & budget policies

The durable, tenant-scoped usage plane — an append-only event stream over a closed metric catalog, bounded rollups, and per-tenant budget policies that warn, shadow-deny, or hard-deny to cap spend and volume.

Usage metering is the measured side of the settled stage — it prices what work consumed, in exact units, for every Worker and surface. It answers two operator questions for every tenant: how much did they consume and should the next unit be allowed. It records tenant-scoped, append-only usage events over a closed metric catalog — routing decisions, connector actions, workflow steps, MCP tool calls, and AI tokens in and out are all metered today — aggregates them into bounded rollups, and enforces per-tenant budget policies that can warn, shadow-deny, or hard-deny. This is the plane you use to cap spend and volume per tenant.

Every read and write is tenant-scoped and gated by the usage authz resource type. In production, authenticate as described in Authentication; the local dev examples below run with auth disabled and pass the tenant explicitly via X-Tenant-ID.

The three planes

record warn shadow_deny deny Surfaces Usage events Rollups Budget policies Event status Cost reporting
  • Events — an immutable, append-only record of one metered occurrence.
  • Rollups — bounded aggregations of events over a window, carrying summed value, event count, and cost.
  • Budget policies — per-tenant caps that match events and stamp an outcome onto each recorded event.

This page owns the durable, calendar-windowed budgets

Several surfaces feed this plane, and one of them (the AI inference proxy) has a separate in-process cost guard that is easy to confuse with a budget policy. The durable, tenant-scoped, calendar-windowed caps are the UsageBudgetPolicy records described here. See In-process vs. durable budgets.

The metric catalog (closed set)

Metering accepts only keys from a fixed catalog. An event or policy referencing any other metric_key is rejected at validation. List the live catalog from GET /api/usage/metrics; each descriptor carries a canonical surface and unit.

Metric keySurfaceUnitRecords
routing.decisionsroutingdecisionCommitted routing decisions.
routing.strategy_evaluationsstrategyevaluationStrategy selection evaluations.
connector.actionsconnector_actioncallConnector action executions.
connector.proxy_requestsconnector_proxyrequestConnector proxy requests.
connector.sync_recordsconnector_syncrecordConnector sync records processed.
connector.sync_run_leasesconnector_syncdecisionConnector sync run lease decisions.
connector.sync_retention_runsconnector_syncrunSync record retention prune/sweep runs.
connector.function_invocationsconnector_functioninvocationConnector function runtime invocations.
connector.execution_locksconnector_functiondecisionFunction execution lock decisions.
connector.execution_lifecyclesconnector_functiontransitionExecution lifecycle transitions/cleanup.
connector.artifact.upload_bytesconnector_artifactbyteArtifact bytes uploaded or captured.
connector.artifact.download_bytesconnector_artifactbyteArtifact bytes downloaded.
connector.artifact.storage_bytesconnector_artifactbyteArtifact bytes for storage budgeting.
workflow.runsworkflowrunWorkflow runs started or completed.
workflow.stepsworkflowstepWorkflow step executions.
workflow.schedule_ticksworkflow_scheduletickWorkflow schedule ticks.
mcp.tool_callsmcpcallMCP tool calls.
ai.requestsai_inferencerequestAI inference requests.
ai.tokens.inputai_inferencetokenAI inference input tokens.
ai.tokens.outputai_inferencetokenAI inference output tokens.
ai.cost.usdai_inferenceusdEstimated AI inference cost in USD.
ai.latency.msai_inferencemsEnd-to-end latency for each provider attempt.
ai.errorsai_inferenceerrorFailed provider attempts.
policy.evaluationspolicyevaluationPolicy evaluation executions.

The usage event

A UsageEvent is one metered occurrence. It is validated on record and again during preview.

Prop

Type

correlation carries decision_id, routable_id, pool_id, recipient_id, workflow_id, workflow_run_id, workflow_step_id, agent_definition_id, agent_session_id, experiment_id, experiment_assignment_id, connector_execution_id, sync_run_id, function_run_id, promotion_candidate_id, mcp_request_id, and trace_id — every one optional, so an event can be traced back to what produced it.

Dimension safety: allow-list, no secrets, no payloads

Dimension keys must be in a fixed allow-list (for example provider_key, connection_id, action_key, workflow_id, model, ai_provider, agent_definition_id, agent_session_id, experiment_id, experiment_assignment_id, mcp_tool, pool_id, recipient_id, route_outcome, strategy, environment, outcome, source). Arbitrary keys are opt-in only under the custom. prefix. Validation rejects keys or values that look like secrets or PII (password, token, secret, authorization, ssn, credit_card, prompt, completion, …) and rejects raw JSON payloads (values wrapped in {} or []) or bearer/basic/api_key= material. Limits: at most 32 dimensions, keys ≤ 96 chars, values ≤ 256 chars. Keep usage attribution low-cardinality and non-sensitive.

Budget policies

A UsageBudgetPolicy is a per-tenant cap. Its match key is the tuple metric_key + surface + unit + dimensions; a policy matches an event when those coordinates agree and every policy dimension is present with the same value on the event (the event may carry extra dimensions).

Prop

Type

Windows are UTC calendar-aligned: hour truncates to the hour, day to the UTC day, week to the Monday-start week, month to the first of the month. The window that a given event falls into is derived from its observed_at.

The three actions

ActionAt/over the limitBlocks the caller?
warnRecords the event, logs the breach, stamps warned.No — observe only.
shadow_denyRecords the event, stamps shadow_denied.No — simulates a denial so you can size a cap before enforcing it.
denyStamps denied; Allowed is false.Yes — a hard cap.

shadow_deny is the safe way to roll out a cap: you see exactly which events would have been blocked without actually blocking anything, then promote the policy to deny once the limit is calibrated.

Enforcement

On every recorded event, the service loads matching policies, sums prior usage over each policy's window, and computes a projection.

below threshold at or over limit fraction at or over limit Record event Load matching policies Sum used over window projected equals used plus value projected vs limit within_budget budget_threshold_reached warn apply policy action Most restrictive wins Stamp event status

For each matching policy: projected = used + value, where used is the sum of prior in-window usage.

ConditionReasonResulting status
projected < limit * threshold_pctwithin_budgetrecorded
projected >= limit * threshold_pct (but < limit)budget_threshold_reachedwarned
projected >= limit, action denybudget_exhausteddenied
projected >= limit, action shadow_denybudget_shadow_exhaustedshadow_denied
projected >= limit, action warnbudget_exhausted_warnwarned

When several policies match one event, the most restrictive outcome wins and is stamped onto the event, in precedence order denied > shadow_denied > warned > recorded.

threshold_pct of 0 means no early warning

The warn threshold is limit * threshold_pct. If threshold_pct is unset (0), it is treated as the full limit — so you get no pre-exhaustion warning, only the at-limit outcome. Set threshold_pct to, say, 0.8 to be warned at 80% of the cap.

Preview: a read-only admission check

POST /api/usage:preview runs the exact same budget evaluation as recording does, but persists nothing. It returns the projected outcome — allowed, a status, and the per-policy decisions — so a caller can decide whether to proceed before doing metered work.

PreviewUsageImpact(event) evaluate budgets (no write) allowed, status, decisions RecordUsage(event) if allowed Caller Usage

allowed is false only when the projected status is denied; warned and shadow_denied previews are still allowed.

HTTP API

All endpoints are tenant-scoped and gated by the usage resource type (UsageService). Reads use read, writes use write. Rollup and status pages default to 100 rows and cap at 500.

Method & pathRPCPurpose
GET /api/usage/metricsListUsageMetricsThe closed metric catalog / descriptors.
GET /api/usageQueryUsageBounded rollups; default 24h window; include_events adds raw events.
POST /api/usage:previewPreviewUsageImpactDry-run admission check; warn/shadow/deny outcome without recording.
POST /api/usage/budget-policiesUpsertUsageBudgetPolicyCreate or update a policy.
GET /api/usage/budget-policies/{policy_id}GetUsageBudgetPolicyFetch one policy.
GET /api/usage/budget-policiesListUsageBudgetPoliciesList policies for the tenant.
DELETE /api/usage/budget-policies/{policy_id}DeleteUsageBudgetPolicyDelete one policy.
GET /api/usage/budget-statusGetUsageBudgetStatusRecent per-window budget states.

QueryUsage bounds itself: if to is omitted it defaults to now, and if from is omitted it defaults to 24 hours before to. Both QueryUsage and GetUsageBudgetStatus rollups carry cost_micros, making them the per-tenant usage/cost reporting endpoints.

curl "https://your-host/api/usage/metrics" \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID"

The same operations are exposed on the MCP operational surface as usage_metrics_list, usage_query, usage_budget_preview, usage_budget_policy_upsert, usage_budget_policy_delete, usage_budget_policies_list, and usage_budget_status.

Setting a cap

Confirm the metric

GET /api/usage/metrics and pick the metric_key, surface, and unit you want to cap — the policy must match those coordinates exactly.

Observe first with shadow_deny

Upsert the policy with action: "shadow_deny" and your candidate limit. Watch GET /api/usage/budget-status and events stamped shadow_denied to see what would have been blocked.

Promote to deny

Once the limit is calibrated, upsert the same policy with action: "deny". Matching events at/over the limit are now stamped denied and callers that preview see allowed: false.

Monitor

GET /api/usage/budget-status returns recent per-window states — used, limit, remaining — so you can alert before a tenant hits the wall.

Wiring & availability

domain/usage/types.go
application/usage/service.go
transport/adapters/usage.go
infrastructure/usage/store
transport/mcp/operational_backend_usage.go

No Postgres = silently no-op budgets

The usage module is always registered, but the store is Postgres-gated: it is built only when a database pool is present. With no pool, the store is nil, which makes the service nil. The result: the HTTP/MCP API returns service unavailable, and every internal metering hook (routing recording, the AI inference bridge, connector admission) silently does nothing — budget policies never fire. If enforcement seems to have no effect, verify the database is configured first.

How the plane is fed

Metering is not called by hand from most places — surfaces emit into it.

Routing

The routing finalizer records routing.decisions for every committed decision via its usage recorder, attaching pool_id, recipient_id, route_outcome, and strategy dimensions plus decision/trace correlation. Dry-run decisions are skipped. See the Routing pipeline.

Connector execution

Connector execution admission calls PreviewUsageImpact as a fact check before running an action, sync, or proxy request. If the projected outcome is a denial, admission is blocked by budget (connector execution blocked by budget). The metered amount per execution is the request's cost_units (default 1). This is where usage budgets meet connector cost accounting — see the Sync engine.

AI inference

The AI inference proxy bridges measured usage into this durable plane. Completed responses emit request, input-token, output-token, and cost events. Every provider attempt emits latency; failed attempts also emit a request and an error event. This preserves retry and failover visibility instead of collapsing a multi-provider request into one opaque total.

MetricWhen it is recorded
ai.requestsOnce for a completed response, or once for each failed provider attempt.
ai.tokens.inputWhen the provider reports prompt/input tokens.
ai.tokens.outputWhen the provider reports completion/output tokens.
ai.cost.usdWhen Ductor can calculate a positive estimate from reported usage.
ai.latency.msFor every provider attempt, including failed attempts.
ai.errorsFor every provider attempt whose outcome is error.

AI events carry ai_provider, model, and outcome dimensions. Callers can also supply bounded attribution for agent_definition_id, agent_session_id, workflow_run_id, workflow_step_id, experiment_id, and experiment_assignment_id. Ductor copies those values into both safe dimensions and the typed correlation object, so one cost or latency spike can be traced to the agent, workflow step, and experiment exposure that produced it. Each value is limited to 256 bytes and cannot contain control delimiters.

Cost-bearing events include amount_micros, currency: "USD", the source ductor_static_model_cost_table, and a pricing-table version. Treat this as a versioned Ductor estimate for attribution and budgets, not as a provider invoice. The provenance fields let you recompute or explain historical totals after pricing data changes.

Untenanted inference is not durably metered

The AI bridge emits durable usage only when a tenant id is present in the request context. Inference without a tenant is cost-tracked in-process for the guards below, but never written to the durable usage plane.

In-process vs. durable budgets

The AI inference proxy also carries its own, separate cost guards — do not confuse them with budget policies.

Budget policies (this page)In-process AI guards
ScopePer tenant, in the databasePer process, single instance
WindowUTC calendar (hour/day/week/month)Process lifetime — cumulative, not a calendar rollup
ConfigUsageBudgetPolicy records via the APIai.routing.max_cost_per_decision_usd, ai.routing.monthly_budget_usd
DefaultOff until you create a policyOff (0) unless set; the enterprise security profile sets nonzero defaults
Enforcementwarn / shadow_deny / deny per policyRejects a decision over the per-decision or cumulative cap

The "monthly" in-process guard is process-lifetime cumulative spend, not a calendar month. For durable, tenant-scoped, calendar-windowed month caps, use a UsageBudgetPolicy with window: "month". For the in-process guards, see the Inference proxy.

Cost reporting to invoicing

QueryUsage and GetUsageBudgetStatus rollups carry cost_micros, so they are the per-tenant usage and cost reporting endpoints. Accrued billable usage — usd-unit cost rows — can be swept into invoices by the usage-invoicing worker, which is experimental and opt-in (gated off by default). For that pipeline, see the Invoicing integration.