# Policies

Policies restrict **what** a call may do, independent of cost. A policy block
returns **HTTP 403**.

## Policy types

| `type` | Effect | `value` |
|---|---|---|
| `model_block` | Refuse calls to a model | not used (null) |
| `model_allow` | One entry in an allow-list: only listed models may be called | not used (null) |
| `max_input_tokens` | Cap input tokens per call | positive number, required |
| `max_output_tokens` | Cap output tokens per call | positive number, required |

Every enabled `model_allow` row that shares one exact `scope` is one list. A
call passes when, for every list that applies to it, the model matches some
entry. Lists narrow each other, they never widen: a scoped list can only remove
models from what the account list permits.

Both `model_block` and `model_allow` judge the model the caller **asked for**
and the model that will be **served** after any reroute, and refuse if either
fails. A reroute cannot make forbidden traffic legal in either direction.

## Matching

| `match_mode` | Matches |
|---|---|
| `exact` (default) | the model name exactly |
| `prefix` | model names starting with `model` |
| `contains` | model names containing `model` |

Model names are lowercased when stored, so matching is case-insensitive.

## Enforcement

| `enforcement` | Behaviour |
|---|---|
| `block` | Refuse the call with HTTP 403 |
| `warn` | Allow, but record the violation |

`model_block` and `model_allow` policies are always `block`: `enforcement` is
ignored for them.

## Scope

A policy is account-wide unless it carries a `scope`, the same object a
reroute rule uses:

```json
{ "scope": { "agent_id": "summarizer" } }
{ "scope": { "customer_id": "acme" } }
{ "scope": { "agent_id": "summarizer", "customer_id": "acme" } }
```

Fields: `agent_id`, `customer_id`, `agent_name`. They match the call's
`x-agent-id`, `x-customer-id` and `x-agent-name` headers (or the same fields in
request metadata). A key outside those three is a 400, never silently dropped.

A scoped policy applies to:

- calls attributed to the agent / customer it names, and
- calls that carry **no attribution** for a dimension it names.

It does **not** apply to a call attributed to a different agent or customer.

The second bullet is deliberate. Attribution is a header the caller sets, so if
an unattributed call fell through to account-wide rules only, dropping
`x-agent-id` would switch off every block scoped to that agent. An unattributed
call is therefore held to every scoped policy on the account, and the 403 says
so: `unattributed_dimensions` lists what was missing and the message names the
header to send. An agent that attributes its calls is judged only by the
policies that name it. Reroutes keep the opposite default (a scoped reroute
skips an unattributed call), because a reroute is a preference and a policy is
a refusal.

## Create

```http
POST /api/policies
x-spendline-key: <owner or admin credential>
Content-Type: application/json

{
  "name": "No frontier models in the free tier",
  "type": "model_block",
  "model": "gpt-5.6",
  "match_mode": "exact",
  "enabled": true
}
```

Scoped block, this agent only:

```json
{
  "name": "Summarizer may not use frontier models",
  "type": "model_block",
  "model": "gpt-5",
  "match_mode": "prefix",
  "scope": { "agent_id": "summarizer" },
  "enabled": true
}
```

Allow-list for one customer (two entries, one list):

```json
{ "name": "acme: mini", "type": "model_allow", "model": "gpt-4o-mini", "scope": { "customer_id": "acme" } }
{ "name": "acme: haiku", "type": "model_allow", "model": "claude-haiku-", "match_mode": "prefix", "scope": { "customer_id": "acme" } }
```

Token cap:

```json
{
  "name": "Cap summarizer output",
  "type": "max_output_tokens",
  "model": "claude-",
  "match_mode": "prefix",
  "value": 2048,
  "enforcement": "block",
  "enabled": true
}
```

Creating a policy requires **owner or admin** authority. A scoped child key
cannot do it, propose it during
[onboarding](https://www.spendline.ai/agents/onboarding.md) or ask the human.

## Read

```bash
curl https://www.spendline.ai/api/policies \
  -H "x-spendline-key: $SPENDLINE_API_KEY"
```

```bash
# what has actually been blocked
curl https://www.spendline.ai/api/policies/blocked-events \
  -H "x-spendline-key: $SPENDLINE_API_KEY"
```

## Allow-once

A blocked call can be granted a single-use exception:

```
POST /api/policies/blocked-events/:id/allow-once
```

The exception is time-bound and consumed by the next matching call. Use it for a
genuine one-off, not as a way to defeat a policy, repeated allow-once on the
same policy means the policy is wrong and should be changed with an audit trail.

## "Restrict which models this workflow can use"

1. Give the workflow its own `x-agent-id` on every call.
2. Either block the models it must not use with `model_block` policies scoped to
   `{ "agent_id": "<that id>" }`, or list the models it may use with
   `model_allow` policies carrying the same scope.
3. Optionally pair it with an agent-scoped budget for the spend side.

Calls from that workflow that name (or would be served) a disallowed model get
HTTP 403 before they reach the provider. Calls from other, attributed workflows
are untouched. Calls with no `x-agent-id` are held to the workflow's policies
too, so make sure every call from the workflow is attributed.

Error codes on the 403: `model_blocked` (a block policy) and `model_not_allowed`
(an allow-list). The latter includes `allowed_models`, the list the call failed.
