# Usage metering & budget policies (/docs/billing/usage-metering)



Usage metering is the measured side of the **settled** stage — it prices what
work consumed, in exact units, for every <Term name="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](/docs/auth); the local dev examples below run with auth
disabled and pass the tenant explicitly via `X-Tenant-ID`.

## The three planes [#the-three-planes]

```mermaid
flowchart LR
  A[Surfaces] -->|record| B[Usage events]
  B --> C[Rollups]
  B --> D[Budget policies]
  D -->|warn shadow_deny deny| E[Event status]
  C --> F[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.

<Callout type="info" title="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](#in-process-vs-durable-budgets).
</Callout>

## The metric catalog (closed set) [#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 key                          | Surface              | Unit         | Records                                       |
| ----------------------------------- | -------------------- | ------------ | --------------------------------------------- |
| `routing.decisions`                 | `routing`            | `decision`   | Committed routing decisions.                  |
| `routing.strategy_evaluations`      | `strategy`           | `evaluation` | Strategy selection evaluations.               |
| `connector.actions`                 | `connector_action`   | `call`       | Connector action executions.                  |
| `connector.proxy_requests`          | `connector_proxy`    | `request`    | Connector proxy requests.                     |
| `connector.sync_records`            | `connector_sync`     | `record`     | Connector sync records processed.             |
| `connector.sync_run_leases`         | `connector_sync`     | `decision`   | Connector sync run lease decisions.           |
| `connector.sync_retention_runs`     | `connector_sync`     | `run`        | Sync record retention prune/sweep runs.       |
| `connector.function_invocations`    | `connector_function` | `invocation` | Connector function runtime invocations.       |
| `connector.execution_locks`         | `connector_function` | `decision`   | Function execution lock decisions.            |
| `connector.execution_lifecycles`    | `connector_function` | `transition` | Execution lifecycle transitions/cleanup.      |
| `connector.artifact.upload_bytes`   | `connector_artifact` | `byte`       | Artifact bytes uploaded or captured.          |
| `connector.artifact.download_bytes` | `connector_artifact` | `byte`       | Artifact bytes downloaded.                    |
| `connector.artifact.storage_bytes`  | `connector_artifact` | `byte`       | Artifact bytes for storage budgeting.         |
| `workflow.runs`                     | `workflow`           | `run`        | Workflow runs started or completed.           |
| `workflow.steps`                    | `workflow`           | `step`       | Workflow step executions.                     |
| `workflow.schedule_ticks`           | `workflow_schedule`  | `tick`       | Workflow schedule ticks.                      |
| `mcp.tool_calls`                    | `mcp`                | `call`       | MCP tool calls.                               |
| `ai.requests`                       | `ai_inference`       | `request`    | AI inference requests.                        |
| `ai.tokens.input`                   | `ai_inference`       | `token`      | AI inference input tokens.                    |
| `ai.tokens.output`                  | `ai_inference`       | `token`      | AI inference output tokens.                   |
| `ai.cost.usd`                       | `ai_inference`       | `usd`        | Estimated AI inference cost in USD.           |
| `ai.latency.ms`                     | `ai_inference`       | `ms`         | End-to-end latency for each provider attempt. |
| `ai.errors`                         | `ai_inference`       | `error`      | Failed provider attempts.                     |
| `policy.evaluations`                | `policy`             | `evaluation` | Policy evaluation executions.                 |

## The usage event [#the-usage-event]

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

<TypeTable
  type="{
  metric_key: { type: &#x22;string&#x22;, description: &#x22;A catalog key. Rejected if not in the closed set.&#x22; },
  surface: { type: &#x22;string&#x22;, description: &#x22;The originating surface (e.g. routing, ai_inference).&#x22; },
  value: { type: &#x22;number&#x22;, description: &#x22;The metered amount. Must be finite and non-negative.&#x22; },
  unit: { type: &#x22;string&#x22;, description: &#x22;Canonical unit (e.g. decision, token, usd). Max 32 chars.&#x22; },
  idempotency_key: { type: &#x22;string&#x22;, description: &#x22;Caller-supplied de-duplication key.&#x22; },
  dimensions: { type: &#x22;map<string,string>&#x22;, description: &#x22;Allow-listed attribution keys only. See Dimension safety.&#x22; },
  correlation: { type: &#x22;object&#x22;, description: &#x22;Decision / pool / recipient / workflow / trace ids linking the event to its origin.&#x22; },
  cost_estimate: { type: &#x22;object&#x22;, description: &#x22;amount_micros, currency (ISO-4217), pricing_version, source.&#x22; },
  status: { type: &#x22;string&#x22;, description: &#x22;recorded | warned | shadow_denied | denied — stamped by budget evaluation.&#x22; },
}"
/>

`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.

<Callout type="warn" title="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.
</Callout>

## Budget policies [#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).

<TypeTable
  type="{
  metric_key: { type: &#x22;string&#x22;, description: &#x22;Catalog key this policy caps.&#x22; },
  surface: { type: &#x22;string&#x22;, description: &#x22;Surface the policy matches.&#x22; },
  unit: { type: &#x22;string&#x22;, description: &#x22;Unit the policy matches.&#x22; },
  dimensions: { type: &#x22;map<string,string>&#x22;, description: &#x22;Optional narrowing predicate; each key must match the event.&#x22; },
  window: { type: &#x22;hour | day | week | month&#x22;, default: &#x22;day&#x22;, description: &#x22;Calendar window the limit resets over (UTC-aligned).&#x22; },
  limit: { type: &#x22;number&#x22;, description: &#x22;Cap for the window. Must be finite and greater than 0.&#x22; },
  action: { type: &#x22;warn | shadow_deny | deny&#x22;, default: &#x22;warn&#x22;, description: &#x22;What happens at/over the limit.&#x22; },
  threshold_pct: { type: &#x22;number&#x22;, description: &#x22;Warn threshold in [0,1]. Fraction of limit that trips an early warning.&#x22; },
}"
/>

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 [#the-three-actions]

| Action        | At/over the limit                                    | Blocks the caller?                                                 |
| ------------- | ---------------------------------------------------- | ------------------------------------------------------------------ |
| `warn`        | Records the event, logs the breach, stamps `warned`. | No — observe only.                                                 |
| `shadow_deny` | Records the event, stamps `shadow_denied`.           | No — simulates a denial so you can size a cap before enforcing it. |
| `deny`        | Stamps `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 [#enforcement]

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

```mermaid
flowchart TD
  A[Record event] --> B[Load matching policies]
  B --> C[Sum used over window]
  C --> D[projected equals used plus value]
  D --> E{projected vs limit}
  E -->|below threshold| F[within_budget]
  E -->|at or over limit fraction| G[budget_threshold_reached warn]
  E -->|at or over limit| H[apply policy action]
  F --> I[Most restrictive wins]
  G --> I
  H --> I
  I --> J[Stamp event status]
```

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

| Condition                                            | Reason                     | Resulting status |
| ---------------------------------------------------- | -------------------------- | ---------------- |
| `projected < limit * threshold_pct`                  | `within_budget`            | `recorded`       |
| `projected >= limit * threshold_pct` (but `< limit`) | `budget_threshold_reached` | `warned`         |
| `projected >= limit`, action `deny`                  | `budget_exhausted`         | `denied`         |
| `projected >= limit`, action `shadow_deny`           | `budget_shadow_exhausted`  | `shadow_denied`  |
| `projected >= limit`, action `warn`                  | `budget_exhausted_warn`    | `warned`         |

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`.

<Callout type="info" title="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.
</Callout>

## Preview: a read-only admission check [#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.

```mermaid
sequenceDiagram
  participant Caller
  participant Usage
  Caller->>Usage: PreviewUsageImpact(event)
  Usage->>Usage: evaluate budgets (no write)
  Usage-->>Caller: allowed, status, decisions
  Caller->>Usage: RecordUsage(event) if allowed
```

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

## HTTP API [#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 & path                                   | RPC                       | Purpose                                                                |
| ----------------------------------------------- | ------------------------- | ---------------------------------------------------------------------- |
| `GET /api/usage/metrics`                        | `ListUsageMetrics`        | The closed metric catalog / descriptors.                               |
| `GET /api/usage`                                | `QueryUsage`              | Bounded rollups; default 24h window; `include_events` adds raw events. |
| `POST /api/usage:preview`                       | `PreviewUsageImpact`      | Dry-run admission check; warn/shadow/deny outcome without recording.   |
| `POST /api/usage/budget-policies`               | `UpsertUsageBudgetPolicy` | Create or update a policy.                                             |
| `GET /api/usage/budget-policies/{policy_id}`    | `GetUsageBudgetPolicy`    | Fetch one policy.                                                      |
| `GET /api/usage/budget-policies`                | `ListUsageBudgetPolicies` | List policies for the tenant.                                          |
| `DELETE /api/usage/budget-policies/{policy_id}` | `DeleteUsageBudgetPolicy` | Delete one policy.                                                     |
| `GET /api/usage/budget-status`                  | `GetUsageBudgetStatus`    | Recent 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.

<Tabs items="[&#x22;List metrics&#x22;, &#x22;Query rollups&#x22;, &#x22;Preview&#x22;, &#x22;Upsert policy&#x22;, &#x22;Budget status&#x22;]">
  <Tab value="List metrics">
    ```bash
    curl "https://your-host/api/usage/metrics" \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID"
    ```
  </Tab>

  <Tab value="Query rollups">
    ```bash
    # Last 24h of AI cost, with raw events included
    curl "https://your-host/api/usage?metric_key=ai.cost.usd&include_events=true" \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID"
    ```
  </Tab>

  <Tab value="Preview">
    ```bash
    curl "https://your-host/api/usage:preview" \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
      -H "Content-Type: application/json" \
      -d '{
        "metric_key": "connector.actions",
        "surface": "connector_action",
        "value": 1,
        "unit": "call"
      }'
    ```
  </Tab>

  <Tab value="Upsert policy">
    ```bash
    # Hard-cap connector actions at 10,000/day, warn at 80%
    curl "https://your-host/api/usage/budget-policies" \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
      -H "Content-Type: application/json" \
      -d '{
        "metric_key": "connector.actions",
        "surface": "connector_action",
        "unit": "call",
        "window": "day",
        "limit": 10000,
        "action": "deny",
        "threshold_pct": 0.8
      }'
    ```
  </Tab>

  <Tab value="Budget status">
    ```bash
    curl "https://your-host/api/usage/budget-status?metric_key=connector.actions" \
      -H "X-Tenant-ID: $DUCTOR_TENANT_ID"
    ```
  </Tab>
</Tabs>

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 [#setting-a-cap]

<Steps>
  <Step>
    ### Confirm the metric [#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.
  </Step>

  <Step>
    ### Observe first with shadow\_deny [#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.
  </Step>

  <Step>
    ### Promote to deny [#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`.
  </Step>

  <Step>
    ### Monitor [#monitor]

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

## Wiring & availability [#wiring--availability]

<Files>
  <File name="domain/usage/types.go" />

  <File name="application/usage/service.go" />

  <File name="transport/adapters/usage.go" />

  <File name="infrastructure/usage/store" />

  <File name="transport/mcp/operational_backend_usage.go" />
</Files>

<Callout type="error" title="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.
</Callout>

## How the plane is fed [#how-the-plane-is-fed]

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

### Routing [#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](/docs/concepts/routing-pipeline).

### Connector execution [#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](/docs/connectors/sync-engine).

### AI inference [#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.

| Metric             | When it is recorded                                                      |
| ------------------ | ------------------------------------------------------------------------ |
| `ai.requests`      | Once for a completed response, or once for each failed provider attempt. |
| `ai.tokens.input`  | When the provider reports prompt/input tokens.                           |
| `ai.tokens.output` | When the provider reports completion/output tokens.                      |
| `ai.cost.usd`      | When Ductor can calculate a positive estimate from reported usage.       |
| `ai.latency.ms`    | For every provider attempt, including failed attempts.                   |
| `ai.errors`        | For 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.

<Callout type="warn" title="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.
</Callout>

## In-process vs. durable budgets [#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                                                      |
| ----------- | ------------------------------------------ | ------------------------------------------------------------------------- |
| Scope       | Per tenant, in the database                | Per process, single instance                                              |
| Window      | UTC calendar (`hour`/`day`/`week`/`month`) | Process lifetime — cumulative, not a calendar rollup                      |
| Config      | `UsageBudgetPolicy` records via the API    | `ai.routing.max_cost_per_decision_usd`, `ai.routing.monthly_budget_usd`   |
| Default     | Off until you create a policy              | Off (0) unless set; the enterprise security profile sets nonzero defaults |
| Enforcement | warn / shadow\_deny / deny per policy      | Rejects 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](/docs/ai/inference-proxy).

## Cost reporting to invoicing [#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](/docs/billing/stripe).

## Related [#related]

<Cards>
  <Card title="Billing overview" href="/docs/billing" description="How metering, plans, commerce, and invoicing fit together." />

  <Card title="Plans & limits" href="/docs/billing/plans" description="Plan limit items and how they relate to usage caps." />

  <Card title="Invoicing integration" href="/docs/billing/stripe" description="Sweeping accrued usd usage into invoices." />

  <Card title="Inference proxy" href="/docs/ai/inference-proxy" description="The separate in-process AI cost guards." />
</Cards>
