> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# V3 API Endpoints

> REST API reference for QTG v3 — movements, templates, registry, admin, and agent surfaces

# V3 API Endpoints Reference

> Source files: `src/qtg/interfaces/api/routes/`, `src/qtg/interfaces/api/schemas/`

This document covers the QTG v3 HTTP API. All v3 endpoints are mounted under `settings.api_prefix` (default `/v3`). Only `healthz` is mounted at the root, and the `/dashboard/*` BFF is mounted at the root outside the HMAC scope.

The detailed sections below document the core movement / template / registry / route-catalog / capital-transfer lane in full. Additional endpoint families that are registered but documented in less detail here are listed under [Additional endpoint families](#additional-endpoint-families) — refer to their route source files for request/response schemas.

***

## Inbound auth overview

When `MG_AUTH_ENABLED=true`, all `/v3/**` requests except `/healthz` require inbound HMAC authentication.

Required headers:

* `X-QTG-Key-Id`
* `X-QTG-Timestamp`
* `X-QTG-Nonce`
* `X-QTG-Signature`

### Role axis (admin / operator / agent)

Authorization is a **3-role axis**, not the legacy 6-purpose model. Each `api_client_keys` row carries a `role` constrained by a DB CHECK to one of `admin`, `operator`, `agent`. Keys are seeded with `seed_auth_client --role <role>` (there is no `--purpose` flag, and there is no `read` role). Per-route allowed roles live in `ROLE_ALLOWED_ROUTES` in `src/qtg/interfaces/api/middleware/auth/hmac.py` (Pro routes extend it at boot via `register_route_role()`).

The three role sets used by the route map:

* `admin` — admin only.
* `operator` — admin + operator (the `_OPS` set).
* `agent` — agent only.
* All roles — admin + operator + agent (the `_ALL` set).

Representative route -> role mapping:

| Route(s)                                                                                                                                                                                                                                  | Allowed roles               |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `POST /v3/movements`, `GET /v3/movements*`, `GET /v3/whoami`, `GET /v3/balances`, `GET /v3/route-catalog`, `POST /v3/routes/recommend`                                                                                                    | all roles                   |
| `approve` / `reject` / `actions/{resume,retry,cancel}`, `GET /v3/executors`, `GET /v3/signers`, capital-transfers, `GET /v3/audit/events`, `GET /v3/registry/audit`                                                                       | operator (admin + operator) |
| `POST /v3/plan-templates`, `PATCH /v3/plan-templates/{template_key}`, `PATCH /v3/executors/{key}`, `PATCH /v3/signers/{key}`, `POST /v3/admin/cutover-mode`, `POST /v3/registry/verify-drift`, `POST /v3/executors/ccip/refresh-registry` | admin                       |
| `POST /v3/agent-authorities/{key}/template-proposals`, `POST /v3/agent-authorities/{key}/bridge-attempt`                                                                                                                                  | agent                       |

The `agent` role is bound 1:1 to a network-scoped `AgentAuthority`. `AgentNamespaceMiddleware` enforces that the request path namespace matches the authority's namespace, returning `403 agent_namespace_mismatch` on a mismatch.

> **Note:** Older sections of this doc still reference a `Purpose:` scope per endpoint (e.g. `read`, `capital_transfer`). Those are legacy labels — the live authorization control is the role axis above. Treat a legacy `read` as "all roles", `approval`/`operate`/`capital_transfer` as "operator", `admin`/`write` as documented by `ROLE_ALLOWED_ROUTES`.

See the document below for canonical string construction, signing examples, and the key seed procedure:

* `docs/reference/infrastructure/auth-rollout.md` — HMAC authentication enablement procedure and validation record

Auth failures generally respond as follows.

* `401` — missing header / invalid key / signature mismatch / replay / timestamp out of range
* `403` — key is valid but role not allowed for the route (or agent namespace mismatch)
* `503` — route role mapping missing (fail-closed)

### Rate Limiting

When `MG_RATE_LIMIT_ENABLED=true`, a per-key in-memory fixed-window rate check is performed after HMAC authentication. Buckets are keyed by role class:

* read-class (`agent` role): 300 RPM (default `MG_RATE_LIMIT_READ_RPM`)
* write-class (`admin` / `operator` roles): 60 RPM (default `MG_RATE_LIMIT_WRITE_RPM`)
* When the limit is exceeded: `429 {"detail": "rate limit exceeded"}` + `Retry-After` header
* `/healthz` and `/dashboard/*` are not subject to rate limiting
* Configuration: `MG_RATE_LIMIT_ENABLED`, `MG_RATE_LIMIT_READ_RPM`, `MG_RATE_LIMIT_WRITE_RPM`

***

## Endpoint Summary

| Method | Path                                                  | Description                                            | Tag               |
| ------ | ----------------------------------------------------- | ------------------------------------------------------ | ----------------- |
| GET    | `/healthz`                                            | Health check                                           | health            |
| POST   | `/v3/plan-templates`                                  | Register template                                      | plan-templates    |
| GET    | `/v3/plan-templates/{template_id}`                    | Template detail lookup                                 | plan-templates    |
| POST   | `/v3/movements`                                       | Movement request creation                              | movements         |
| GET    | `/v3/movements`                                       | Movement list lookup (`state`, `strategy_id`, `limit`) | movements         |
| GET    | `/v3/movements/{movement_id}`                         | Movement detail lookup                                 | movements         |
| GET    | `/v3/movements/{movement_id}/timeline`                | Event timeline lookup                                  | movements         |
| POST   | `/v3/movements/{movement_id}/approve`                 | Approve movement                                       | movements         |
| POST   | `/v3/movements/{movement_id}/reject`                  | Reject movement                                        | movements         |
| POST   | `/v3/movements/{movement_id}/actions/resume`          | Resume movement                                        | movements         |
| POST   | `/v3/movements/{movement_id}/actions/retry`           | Retry movement                                         | movements         |
| POST   | `/v3/movements/{movement_id}/actions/cancel`          | Cancel movement                                        | movements         |
| GET    | `/v3/executors`                                       | Executor registry lookup                               | registry          |
| PATCH  | `/v3/executors/{executor_key}`                        | Executor state update                                  | registry          |
| GET    | `/v3/signers`                                         | Signer registry lookup                                 | registry          |
| PATCH  | `/v3/signers/{signer_key}`                            | Signer state update                                    | registry          |
| PUT    | `/v3/signers/{signer_key}/expected-identity`          | Pin an EVM signer's expected identity                  | registry          |
| POST   | `/v3/auto-approve-policies`                           | Create auto-approval policy                            | auto-approve      |
| GET    | `/v3/auto-approve-policies`                           | List auto-approval policies                            | auto-approve      |
| GET    | `/v3/auto-approve-policies/{policy_id}`               | Auto-approval policy detail                            | auto-approve      |
| PATCH  | `/v3/auto-approve-policies/{policy_id}`               | Update auto-approval policy                            | auto-approve      |
| GET    | `/v3/route-catalog`                                   | Available route catalog lookup                         | route-catalog     |
| POST   | `/v3/routes/recommend`                                | Advisory route recommendation lookup                   | route-recommend   |
| GET    | `/v3/budgets/{strategy_id}`                           | Per-strategy remaining budget lookup                   | budgets           |
| GET    | `/v3/balances`                                        | Per-venue balance snapshot lookup                      | balances          |
| POST   | `/v3/capital-transfers`                               | Provider-internal capital transfer creation            | capital-transfers |
| GET    | `/v3/capital-transfers`                               | Capital transfer list lookup                           | capital-transfers |
| GET    | `/v3/capital-transfers/{transfer_id}`                 | Capital transfer detail lookup                         | capital-transfers |
| POST   | `/v3/capital-transfers/{transfer_id}/actions/resolve` | Capital transfer manual resolve                        | capital-transfers |

***

## Capital transfer API family

`capital-transfers` is a treasury/capital-ops subsystem separated from the existing movement/template/route-catalog lane.

v1 contract:

* provider-neutral public surface
* admin/operator-only
* first create returns `201 Created`
* same `idempotency_key` + semantically same request returns `200 OK` with the existing resource
* same `idempotency_key` + materially different request returns `409 Conflict`
* pending provider outcomes remain in `PENDING_PROVIDER_CONFIRMATION` with source-side hold retained and destination capacity unchanged

## 1. GET /healthz

Checks service state. It is mounted at the root without a prefix.

### Response

**200 OK**

```json theme={null}
{
  "ok": true,
  "service": "movement-guard",
  "api_prefix": "/v3"
}
```

***

## 2. POST /v3/plan-templates

Registers a transfer plan template. Multiple versions can be registered for the same `template_key`.

### Request Body — `PlanTemplateCreateRequest`

| Field               | Type                   | Required | Description                                                                            |
| ------------------- | ---------------------- | -------- | -------------------------------------------------------------------------------------- |
| `template_key`      | string (1-256)         | Y        | Unique template key                                                                    |
| `name`              | string (min 1)         | Y        | Template display name                                                                  |
| `version`           | integer (>= 1)         | Y        | Version number                                                                         |
| `owner`             | string (max 128)       | N        | Owner identifier                                                                       |
| `nodes`             | array\[PlanNodeCreate] | Y        | List of node definitions                                                               |
| `edges`             | array\[PlanEdgeCreate] | N        | List of edge definitions (default empty array)                                         |
| `approval_policy`   | object                 | N        | Approval policy (default `{}`)                                                         |
| `risk_controls`     | object                 | N        | Risk controls (default `{}`). Free-form — only two keys are read at runtime, see below |
| `completion_policy` | CompletionPolicyView   | Y        | Completion policy                                                                      |

**What `risk_controls` actually enforces**:

`risk_controls` is a free-form object. The API accepts any keys, and every key is
carried into the compiled plan snapshot and into `compiled_plan_hash`, so whatever you
put here is approval-pinned and auditable. But only two keys change runtime behaviour:

| Key                         | Where it is read                                                              | Effect                                                                                            |
| --------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `max_amount`                | `assert_dispatch_amount_within_risk_cap` (`application/services/dispatch.py`) | Fail-closed at dispatch: a node whose resolved amount exceeds the cap does not execute            |
| `max_amount` / `min_amount` | `route_recommend` (`application/services/route_recommend.py`)                 | Filters route recommendations by amount range                                                     |
| `max_amount`                | `materialize_ephemeral_template` (`application/services/agent_proposals.py`)  | Decides whether an agent proposal reuses an existing ephemeral template version or cuts a new one |
| `max_amount` / `min_amount` | `route_catalog` (`application/services/route_catalog.py`)                     | The only keys projected into the route-catalog response; the rest are dropped                     |

`max_amount` is denominated in the **intent asset, not USD** — it is compared against the
node's resolved `amount`. On an XRP template, `"max_amount": "1000"` is 1,000 XRP.

Every other key is descriptive metadata. `asset_allowlist` (seeded templates, and the
quickstart examples) and `network_allowlist` (seeded templates only) are **read by
nothing** — a template carrying `"asset_allowlist": ["XRP"]` does not reject a movement
whose `intent.asset` is `USDT`. Amount-shaped keys other than `max_amount` and
`min_amount` (`max_amount_usd`, `hardcap_usd`) are likewise inert; use `max_amount` if
you want a cap that binds.

Fund-safety controls that *do* bind live elsewhere, and none of them is configured
through a plan-template field: the address allowlist (a DB plane, checked at on-chain
dispatch), the approval-policy plane (the `approval_policy_targets` table), and the
outflow / balance-reservation caps.

In particular the template's own `approval_policy` object — listed just above
`risk_controls` in the table — is **not** that plane. Nothing reads its keys; approval is
required by default and is waived only by the policy plane. It is covered by the
plan-graph hash, so it is auditable as a declaration of intent, but setting
`{"required_approvals": 2}` there does not require two approvals.

**PlanNodeCreate**:

| Field               | Type           | Required | Description                                                                   |
| ------------------- | -------------- | -------- | ----------------------------------------------------------------------------- |
| `node_key`          | string (1-128) | Y        | Node identifier key                                                           |
| `node_kind`         | string (1-32)  | Y        | Node kind (`action`, `observe`, `manual_gate`, `compensation`, `post_action`) |
| `action_type`       | string (1-128) | Y        | Execution action type                                                         |
| `executor_selector` | object         | N        | Executor selection criteria                                                   |
| `signer_selector`   | object         | N        | Signer selection criteria                                                     |
| `input_schema`      | object         | N        | Input schema                                                                  |
| `config`            | object         | N        | Node settings                                                                 |
| `has_side_effect`   | boolean        | N        | Whether there are side effects (default `false`)                              |
| `timeout_policy`    | object         | N        | Timeout policy                                                                |
| `retry_policy`      | object         | N        | Retry policy                                                                  |
| `risk_patch`        | object         | N        | Risk override                                                                 |

**PlanEdgeCreate**:

| Field            | Type           | Required | Description                                                                           |
| ---------------- | -------------- | -------- | ------------------------------------------------------------------------------------- |
| `from`           | string (1-128) | Y        | Source node key (JSON key is `from`, alias)                                           |
| `to`             | string (1-128) | Y        | Destination node key (JSON key is `to`, alias)                                        |
| `edge_type`      | string (1-32)  | Y        | Edge kind (`on_success`, `on_failure`, `on_timeout`, `on_cancel`, `on_manual_resume`) |
| `condition_expr` | string         | N        | Condition expression                                                                  |
| `priority`       | integer        | N        | Priority (default 0)                                                                  |
| `join_key`       | string         | N        | Join key                                                                              |

**CompletionPolicyView**:

| Field                  | Type         | Required | Description                |
| ---------------------- | ------------ | -------- | -------------------------- |
| `completion_assurance` | string(enum) | Y        | Completion assurance level |

`completion_assurance` allowed values: `provider_completed`, `destination_observed`, `destination_credited`, `destination_finalized`, `protocol_finalized`

### Request Example

`executor_selector` is an abstract selector. `action_type` is required and `provider` / `chain_family` are optional. On-chain nodes use `signer_selector` in the form `{"chain_family": "evm", "profile": ...}` when needed.

```json theme={null}
{
  "template_key": "upbit-to-binance-xrp",
  "name": "Upbit -> Binance XRP Transfer",
  "version": 1,
  "owner": "ops-team",
  "nodes": [
    {
      "node_key": "withdraw_xrp",
      "node_kind": "action",
      "action_type": "cex_withdrawal",
      "executor_selector": {"action_type": "cex_withdrawal", "provider": "upbit"},
      "config": {"exchange": "upbit", "asset": "XRP", "network": "XRP"},
      "has_side_effect": true
    },
    {
      "node_key": "observe_withdrawal",
      "node_kind": "observe",
      "action_type": "cex_withdrawal_status",
      "executor_selector": {"action_type": "cex_withdrawal_status", "provider": "upbit"},
      "config": {"exchange": "upbit"}
    },
    {
      "node_key": "observe_deposit",
      "node_kind": "observe",
      "action_type": "cex_deposit_status",
      "executor_selector": {"action_type": "cex_deposit_status", "provider": "binance"},
      "config": {"exchange": "binance", "asset": "XRP"}
    }
  ],
  "edges": [
    {"from": "withdraw_xrp", "to": "observe_withdrawal", "edge_type": "on_success"},
    {"from": "observe_withdrawal", "to": "observe_deposit", "edge_type": "on_success"}
  ],
  "approval_policy": {"required_approvals": 1},
  "risk_controls": {"max_amount": "1000"},
  "completion_policy": {"completion_assurance": "destination_credited"}
}
```

### Response — `PlanTemplateCreateResponse`

**201 Created**

| Field                    | Type                 | Description                                                       |
| ------------------------ | -------------------- | ----------------------------------------------------------------- |
| `template_id`            | UUID                 | ID of the created template                                        |
| `template_key`           | string               | Template key                                                      |
| `version`                | integer              | Version number                                                    |
| `graph_shape`            | string               | Graph shape (`linear`, `branching`, `merging`, `split`, `hybrid`) |
| `requires_graph_runtime` | boolean              | Whether branch runtime is required                                |
| `executable_in_v3_0`     | boolean              | Whether the v3.0 engine can execute                               |
| `non_executable_reasons` | array\[string]       | Reasons for non-executability                                     |
| `completion_policy`      | CompletionPolicyView | Registered completion policy                                      |

```json theme={null}
{
  "template_id": "550e8400-e29b-41d4-a716-446655440000",
  "template_key": "upbit-to-binance-xrp",
  "version": 1,
  "graph_shape": "linear",
  "requires_graph_runtime": false,
  "executable_in_v3_0": true,
  "non_executable_reasons": [],
  "completion_policy": {"completion_assurance": "destination_credited"}
}
```

### Error Responses

| Status                   | Condition                                                                                |
| ------------------------ | ---------------------------------------------------------------------------------------- |
| 409 Conflict             | Same template\_key + version already exists                                              |
| 422 Unprocessable Entity | Graph validity check failure (cycles, disconnection, etc.) or completion\_policy missing |

***

## 3. GET /v3/plan-templates/{template_id}

Retrieves detailed information about a registered template.

### Path Parameters

| Parameter     | Type | Description |
| ------------- | ---- | ----------- |
| `template_id` | UUID | Template ID |

### Response — `PlanTemplateDetailResponse`

**200 OK**

All `PlanTemplateCreateResponse` fields plus the additional fields below:

| Additional field  | Type                 | Description                 |
| ----------------- | -------------------- | --------------------------- |
| `name`            | string               | Template display name       |
| `approval_policy` | object               | Approval policy             |
| `risk_controls`   | object               | Risk controls               |
| `nodes`           | array\[PlanNodeView] | Node details (including id) |
| `edges`           | array\[PlanEdgeView] | Edge details (including id) |

**PlanNodeView**: all PlanNodeCreate fields plus `id` (UUID)

**PlanEdgeView**:

| Field            | Type           | Description                                     |
| ---------------- | -------------- | ----------------------------------------------- |
| `id`             | UUID           | Edge ID                                         |
| `from`           | string         | Source node key (converted from node\_key)      |
| `to`             | string         | Destination node key (converted from node\_key) |
| `edge_type`      | string         | Edge kind                                       |
| `condition_expr` | string \| null | Condition expression                            |
| `priority`       | integer        | Priority                                        |
| `join_key`       | string \| null | Join key                                        |

### Error Responses

| Status        | Condition               |
| ------------- | ----------------------- |
| 404 Not Found | Template does not exist |

***

## 4. POST /v3/movements

Creates a movement request. Immediately after creation it enters the `PENDING_APPROVAL` state.

### Request Body — `MovementCreateRequest`

| Field              | Type             | Required | Description                                           |
| ------------------ | ---------------- | -------- | ----------------------------------------------------- |
| `template_key`     | string (1-256)   | Y        | Template key to use                                   |
| `template_version` | integer (>= 1)   | Y        | Version number to use                                 |
| `strategy_id`      | string (max 128) | N        | Strategy identifier (used in auto-approve evaluation) |
| `intent`           | object           | N        | Movement intent (default `{}`)                        |
| `input_params`     | object           | N        | Execution parameters (default `{}`)                   |
| `callback`         | object           | N        | Callback settings (default `{}`)                      |

If `input_params.node_config` is an object, it supplements each node's template config at execution time. For the same key, the template config takes precedence. Example: Hyperliquid `hl.topup` passes the Arbitrum RPC endpoint via `input_params.node_config.arbitrum_rpc_endpoint`.

### Request Example

```json theme={null}
{
  "template_key": "upbit-to-binance-xrp",
  "template_version": 1,
  "intent": {
    "asset": "XRP",
    "amount": "25.0",
    "source": {"venue": "upbit", "venue_type": "exchange"},
    "destination": {"venue": "binance", "venue_type": "exchange"}
  },
  "input_params": {
    "amount": "25.0",
    "asset": "XRP",
    "network": "XRP",
    "source_exchange": "upbit",
    "destination_exchange": "binance"
  },
  "callback": {
    "url": "https://dashboard.example.com/v1/callbacks/movement"
  }
}
```

### Response — `MovementCreateResponse`

**201 Created**

| Field                    | Type           | Description                                                       |
| ------------------------ | -------------- | ----------------------------------------------------------------- |
| `movement_id`            | UUID           | ID of the created request                                         |
| `request_state`          | string         | Current state (`PENDING_APPROVAL` or `APPROVED`)                  |
| `compiled_plan_hash`     | string         | Hash of the compiled execution plan                               |
| `approval_required`      | boolean        | Whether manual approval is required (`false` means auto-approved) |
| `auto_approve_result`    | object \| null | Auto-approve evaluation result (null if missing)                  |
| `strategy_id`            | string \| null | Strategy identifier (value passed in the request)                 |
| `graph_runtime_required` | boolean        | Whether branch runtime is required                                |
| `executable_in_v3_0`     | boolean        | Whether the v3.0 engine can execute                               |
| `non_executable_reasons` | array\[string] | Reasons for non-executability                                     |
| `current_frontier`       | array\[string] | List of initial frontier node keys                                |

```json theme={null}
{
  "movement_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "request_state": "PENDING_APPROVAL",
  "compiled_plan_hash": "sha256:abc123...",
  "approval_required": true,
  "graph_runtime_required": false,
  "executable_in_v3_0": true,
  "non_executable_reasons": [],
  "current_frontier": ["withdraw_xrp"]
}
```

### Internal processing flow

1. Look up the plan version by `template_key` + `template_version`
2. If `executable_in_v3_0 == false`, return 409 Conflict
3. Load nodes/edges, then finalize executor/signer bindings via `resolve_bindings()`
4. Determine DAG root nodes via `current_frontier_node_keys()`
5. Generate the hash via `build_compiled_snapshot()` + `compute_compiled_plan_hash()`
6. Create MovementRequest (state: `PENDING_APPROVAL`, expires after `approval_ttl_seconds`)
7. Create a MovementRequestNode for each plan node (state: `BLOCKED`)

### Error Responses

| Status        | Condition                                                        |
| ------------- | ---------------------------------------------------------------- |
| 404 Not Found | The template\_key + template\_version combination does not exist |
| 409 Conflict  | Template cannot be executed in v3.0                              |

***

## 5. GET /v3/movements

Retrieves the list of movement requests. Sorted by creation time descending; supports `state`, `strategy_id`, and `limit` filters.

### Query Parameters

| Parameter     | Type           | Default | Description                                  |
| ------------- | -------------- | ------- | -------------------------------------------- |
| `state`       | string \| null | `null`  | Filter by state using a `RequestState` value |
| `strategy_id` | string \| null | `null`  | Filter by strategy identifier                |
| `limit`       | integer        | `20`    | Number of items to return (`1..100`)         |

### Response — `MovementListResponse`

**200 OK**

| Field         | Type                    | Description               |
| ------------- | ----------------------- | ------------------------- |
| `movements`   | array\[MovementSummary] | List of request summaries |
| `total`       | integer                 | Total count after filters |
| `next_cursor` | string \| null          | Currently always `null`   |

**MovementSummary**:

| Field           | Type           | Description             |
| --------------- | -------------- | ----------------------- |
| `movement_id`   | UUID           | Request ID              |
| `request_state` | string         | Current state           |
| `template_key`  | string \| null | Linked template key     |
| `intent`        | object \| null | Original intent payload |
| `strategy_id`   | string \| null | Strategy identifier     |
| `created_at`    | datetime       | Creation time           |
| `updated_at`    | datetime       | Update time             |

### Example

```bash theme={null}
curl -s "http://localhost:8100/v3/movements?strategy_id=strategy-arb&state=PENDING_APPROVAL&limit=20"
```

***

## 6. GET /v3/movements/{movement_id}

Retrieves detailed information about a movement request.

### Path Parameters

| Parameter     | Type | Description |
| ------------- | ---- | ----------- |
| `movement_id` | UUID | Request ID  |

### Response — `MovementDetailResponse`

**200 OK**

| Field                         | Type                         | Description                    |
| ----------------------------- | ---------------------------- | ------------------------------ |
| `movement_id`                 | UUID                         | Request ID                     |
| `request_state`               | string                       | Current state                  |
| `compiled_plan_hash`          | string                       | Compiled plan hash             |
| `template`                    | TemplateRef                  | Template reference info        |
| `effective_completion_policy` | CompletionPolicyView         | Active completion policy       |
| `current_frontier`            | array\[string]               | Current frontier node keys     |
| `reservation_status`          | string                       | Resource reservation state     |
| `manual_reason`               | string \| null               | Reason for manual intervention |
| `strategy_id`                 | string \| null               | Strategy identifier            |
| `auto_approve_result`         | object \| null               | Auto-approve evaluation result |
| `nodes`                       | array\[MovementNodeView]     | Node state list                |
| `artifacts`                   | array\[MovementArtifactView] | Artifact list                  |

**TemplateRef**:

| Field          | Type    | Description    |
| -------------- | ------- | -------------- |
| `template_id`  | UUID    | Template ID    |
| `template_key` | string  | Template key   |
| `version`      | integer | Version number |

**MovementNodeView**:

| Field             | Type           | Description                   |
| ----------------- | -------------- | ----------------------------- |
| `node_key`        | string         | Node identifier key           |
| `node_state`      | string         | Current node state            |
| `provider_ref_id` | string \| null | Primary provider reference ID |
| `provider_state`  | string \| null | Provider state                |

**MovementArtifactView**:

| Field             | Type   | Description   |
| ----------------- | ------ | ------------- |
| `artifact_type`   | string | Artifact kind |
| `artifact_digest` | string | Content hash  |

```json theme={null}
{
  "movement_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "request_state": "EXECUTING",
  "compiled_plan_hash": "sha256:abc123...",
  "template": {
    "template_id": "550e8400-e29b-41d4-a716-446655440000",
    "template_key": "upbit-to-binance-xrp",
    "version": 1
  },
  "effective_completion_policy": {"completion_assurance": "destination_credited"},
  "current_frontier": ["observe_withdrawal"],
  "reservation_status": "held",
  "manual_reason": null,
  "nodes": [
    {"node_key": "withdraw_xrp", "node_state": "COMPLETED", "provider_ref_id": "abc123", "provider_state": "DONE"},
    {"node_key": "observe_withdrawal", "node_state": "OBSERVING", "provider_ref_id": "abc123", "provider_state": "PROCESSING"},
    {"node_key": "observe_deposit", "node_state": "BLOCKED", "provider_ref_id": null, "provider_state": null}
  ],
  "artifacts": [
    {"artifact_type": "prepared_action", "artifact_digest": "sha256:def456..."}
  ]
}
```

### Error Responses

| Status        | Condition                   |
| ------------- | --------------------------- |
| 404 Not Found | movement\_id does not exist |

***

## 7. GET /v3/movements/{movement_id}/timeline

Retrieves the movement request event timeline (audit trail). Ordered chronologically.

### Path Parameters

| Parameter     | Type | Description |
| ------------- | ---- | ----------- |
| `movement_id` | UUID | Request ID  |

### Response — `list[MovementTimelineEventView]`

**200 OK**

| Field        | Type           | Description                                                      |
| ------------ | -------------- | ---------------------------------------------------------------- |
| `id`         | integer        | Event sequence number                                            |
| `event_type` | string         | Event kind (`node_state_transition`, `request_state_transition`) |
| `actor_type` | string         | Actor kind (`system`, `operator`)                                |
| `actor_id`   | string \| null | Actor identifier                                                 |
| `old_state`  | string \| null | Previous state                                                   |
| `new_state`  | string \| null | New state                                                        |
| `detail`     | object         | Detailed information                                             |
| `created_at` | datetime       | Event occurrence time                                            |

```json theme={null}
[
  {
    "id": 1,
    "event_type": "request_state_transition",
    "actor_type": "operator",
    "actor_id": "admin-user",
    "old_state": "PENDING_APPROVAL",
    "new_state": "APPROVED",
    "detail": {"comment": "Approved for execution"},
    "created_at": "2026-03-19T10:00:00Z"
  },
  {
    "id": 2,
    "event_type": "node_state_transition",
    "actor_type": "operator",
    "actor_id": "admin-user",
    "old_state": "BLOCKED",
    "new_state": "READY",
    "detail": {"reason": "approved"},
    "created_at": "2026-03-19T10:00:00Z"
  },
  {
    "id": 3,
    "event_type": "node_state_transition",
    "actor_type": "system",
    "actor_id": "dispatcher",
    "old_state": "READY",
    "new_state": "PREPARING",
    "detail": {},
    "created_at": "2026-03-19T10:00:02Z"
  }
]
```

***

## 8. POST /v3/movements/{movement_id}/approve

Approves a movement request. On approval, frontier nodes transition to `READY` state and execution begins.

### Path Parameters

| Parameter     | Type | Description |
| ------------- | ---- | ----------- |
| `movement_id` | UUID | Request ID  |

### Request Body — `MovementApproveRequest`

| Field                | Type           | Required | Description                                                            |
| -------------------- | -------------- | -------- | ---------------------------------------------------------------------- |
| `approver_id`        | string (1-128) | Y        | Approver identifier                                                    |
| `compiled_plan_hash` | string (min 1) | Y        | Compiled plan hash (must match the value returned at request creation) |
| `comment`            | string         | N        | Approval comment                                                       |

```json theme={null}
{
  "approver_id": "ops-admin",
  "compiled_plan_hash": "sha256:abc123...",
  "comment": "Approved for execution"
}
```

### Response — `MovementActionResponse`

**200 OK**

| Field                | Type           | Description                |
| -------------------- | -------------- | -------------------------- |
| `movement_id`        | UUID           | Request ID                 |
| `request_state`      | string         | New state (`APPROVED`)     |
| `current_frontier`   | array\[string] | Current frontier           |
| `reservation_status` | string         | Reservation state (`held`) |

### Internal processing flow

1. Check that request state is `PENDING_APPROVAL`
2. Verify `compiled_plan_hash` match
3. Verify availability of executor/signer bindings for frontier nodes
4. `approval_status` -> `approved`, `reservation_status` -> `held`
5. Transition frontier nodes `BLOCKED` -> `READY`
6. Transition request state to `APPROVED`
7. Enqueue `request_approved` callback

### Error Responses

| Status        | Condition                                                               |
| ------------- | ----------------------------------------------------------------------- |
| 404 Not Found | movement\_id does not exist                                             |
| 409 Conflict  | Not in `PENDING_APPROVAL` state, hash mismatch, or bindings unavailable |

***

## 9. POST /v3/movements/{movement_id}/reject

Rejects a movement request.

### Request Body — `MovementRejectRequest`

| Field         | Type           | Required | Description         |
| ------------- | -------------- | -------- | ------------------- |
| `approver_id` | string (1-128) | Y        | Rejecter identifier |
| `comment`     | string         | N        | Rejection reason    |

```json theme={null}
{
  "approver_id": "ops-admin",
  "comment": "Amount exceeds daily limit"
}
```

### Response — `MovementActionResponse`

**200 OK** (request\_state: `REJECTED`)

### Internal processing

1. Check that request state is `PENDING_APPROVAL`
2. `approval_status` -> `rejected`
3. Transition request state to `REJECTED`
4. Enqueue `request_rejected` callback

### Error Responses

| Status        | Condition                       |
| ------------- | ------------------------------- |
| 404 Not Found | movement\_id does not exist     |
| 409 Conflict  | Not in `PENDING_APPROVAL` state |

***

## 10. POST /v3/movements/{movement_id}/actions/resume

Resumes a request in `WAITING_MANUAL_ACTION` or `MANUAL_INTERVENTION` state.

### Request Body

None (empty POST).

### Response — `MovementActionResponse`

**200 OK** (request\_state: `EXECUTING`)

### Internal processing

1. Check that request state is `WAITING_MANUAL_ACTION` or `MANUAL_INTERVENTION`
2. Transition request state to `EXECUTING`
3. Enqueue `request_resumed` callback

### Error Responses

| Status        | Condition                   |
| ------------- | --------------------------- |
| 404 Not Found | movement\_id does not exist |
| 409 Conflict  | Not in a resumable state    |

***

## 11. POST /v3/movements/{movement_id}/actions/retry

Finds and retries nodes in `FAILED` or `UNKNOWN` state.

### Request Body

None (empty POST).

### Response — `MovementActionResponse`

**200 OK** (request\_state: `EXECUTING`)

### Internal processing

1. Search `FAILED` or `UNKNOWN` nodes ordered by `updated_at` ascending
2. Transition the node to `READY`
3. Set `current_frontier` to that node's `node_key`
4. Transition request state to `EXECUTING`
5. Enqueue `request_retried` callback

### Error Responses

| Status        | Condition                   |
| ------------- | --------------------------- |
| 404 Not Found | movement\_id does not exist |
| 409 Conflict  | No retryable nodes          |

***

## 12. POST /v3/movements/{movement_id}/actions/cancel

Cancels a movement request. Cancellation is possible only for templates that define a cancel path (`on_cancel` edge).

### Request Body

None (empty POST).

### Response — `MovementActionResponse`

**200 OK** (request\_state: `CANCELLED`)

### Internal processing

1. Check that the plan version has an `on_cancel` edge
2. Transition request state to `CANCELLED`
3. If there are completed nodes with side effects, keep `reservation_status` as `held`
4. If no side effects, set `reservation_status` to `released`
5. Enqueue `request_cancelled` callback

### Error Responses

| Status        | Condition                                      |
| ------------- | ---------------------------------------------- |
| 404 Not Found | movement\_id does not exist                    |
| 409 Conflict  | No `on_cancel` edge, so cancel is not possible |

***

## 13. GET /v3/executors

Retrieves the registered executor registry. Merges DB-persisted entries with in-memory registrations.

### Response — `list[ExecutorRegistryView]`

**200 OK**

| Field          | Type           | Description                           |
| -------------- | -------------- | ------------------------------------- |
| `executor_key` | string         | Unique executor key                   |
| `mode`         | string         | Execution mode (`local` / `remote`)   |
| `status`       | string         | Executor state (`active`, `disabled`) |
| `health`       | object \| null | Health state info                     |

```json theme={null}
[
  {"executor_key": "exec.cex.withdrawal_action", "mode": "local", "status": "active", "health": {"state": "up"}},
  {"executor_key": "exec.cex.withdrawal_observe", "mode": "local", "status": "active", "health": {"state": "up"}},
  {"executor_key": "exec.cex.deposit_observe", "mode": "local", "status": "active", "health": {"state": "up"}},
  {"executor_key": "exec.observe.destination_chain_receive", "mode": "local", "status": "active", "health": null},
  {"executor_key": "exec.cctp.burn", "mode": "local", "status": "disabled", "health": {"state": "up"}}
]
```

***

## 14. PATCH /v3/executors/{executor_key}

Changes the executor registry state. This is an operational control that blocks/allows new movement approvals; it does not perform in-process hot reload.

Even if no DB row exists, when an in-memory binding exists for the same key, the row is auto-materialized before the state is reflected.

### Request Body — `UpdateRegistryStatusRequest`

| Field    | Type   | Required | Description            |
| -------- | ------ | -------- | ---------------------- |
| `status` | string | Y        | `active` or `disabled` |

### Response

**200 OK**

```json theme={null}
{
  "executor_key": "exec.cex.withdrawal_action",
  "status": "disabled"
}
```

### Error Responses

| Status                    | Condition                                                     |
| ------------------------- | ------------------------------------------------------------- |
| 404 Not Found             | Executor key with neither DB row nor in-memory registry entry |
| 422 Unprocessable Content | Disallowed status value                                       |

***

## 15. GET /v3/signers

Retrieves the registered signer registry. Returns a merge of the persistent registry and the in-memory registry.

### Response — `list[SignerRegistryView]`

**200 OK**

| Field                     | Type           | Description                                                                                                |
| ------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------- |
| `signer_key`              | string         | Unique signer key                                                                                          |
| `kind`                    | string         | Signer kind (`persistent` for DB rows, class name for in-memory)                                           |
| `status`                  | string         | Signer registry state                                                                                      |
| `expected_signer_address` | string \| null | Operator-enrolled, canonical lowercase EVM address; null for non-EVM or not enrolled                       |
| `observed_signer_address` | string \| null | Canonical lowercase EVM address most recently written by health refresh; null when unavailable or orphaned |
| `identity_state`          | string \| null | `match`, `mismatch`, `missing_expected`, or `missing_observed`; null when identity is not applicable       |
| `health`                  | object \| null | Health state info                                                                                          |

```json theme={null}
[
  {
    "signer_key": "signer.evm.local",
    "kind": "persistent",
    "status": "active",
    "expected_signer_address": "0x1234567890abcdef1234567890abcdef12345678",
    "observed_signer_address": "0x1234567890abcdef1234567890abcdef12345678",
    "identity_state": "match",
    "health": {"state": "up"}
  }
]
```

***

## 16. PATCH /v3/signers/{signer_key}

Changes the signer registry state. This is an operational control that blocks/allows new movement approvals; it does not perform in-process hot reload.

Even if no DB row exists, when an in-memory binding exists for the same key, the row is auto-materialized before the state is reflected.

`PATCH` allows only `active` and `disabled` as status.

### Request Body — `UpdateRegistryStatusRequest`

| Field    | Type   | Required | Description            |
| -------- | ------ | -------- | ---------------------- |
| `status` | string | Y        | `active` or `disabled` |

### Response

**200 OK**

```json theme={null}
{
  "signer_key": "signer.evm.local",
  "status": "disabled"
}
```

### Error Responses

| Status                    | Condition                                                   |
| ------------------------- | ----------------------------------------------------------- |
| 404 Not Found             | Signer key with neither DB row nor in-memory registry entry |
| 422 Unprocessable Content | Disallowed status value                                     |

***

## 17. PUT /v3/signers/{signer_key}/expected-identity

Pins the expected identity for an enrolled EVM signer. This is an **admin-only** Free registry operation; it neither rotates a signer nor changes movement bindings.

### Request Body — `SetExpectedSignerIdentityRequest`

| Field                       | Type           | Required | Description                                                        |
| --------------------------- | -------------- | -------- | ------------------------------------------------------------------ |
| `expected_address`          | string         | Y        | Nonzero, `0x`-prefixed 40-hex EVM address; normalized to lowercase |
| `previous_expected_address` | string \| null | N        | Compare-and-set value; use `null` for first enrollment             |
| `reason`                    | string         | Y        | Nonblank operator reason recorded in the audit event               |

### Request Example

```json theme={null}
{
  "expected_address": "0x1234567890abcdef1234567890abcdef12345678",
  "previous_expected_address": null,
  "reason": "initial KMS identity enrollment"
}
```

### CAS and runtime semantics

The route validates the target is a persisted EVM signer, resolves the live identity through the optional signer identity protocol, and locks the registry row. A change succeeds only when the supplied `previous_expected_address` equals the stored value and the submitted expected address equals the freshly resolved runtime identity. No-op precedence is explicit: when stored expected identity, submitted identity, and live identity are all `A`, the response is `noop` even if `previous_expected_address` is stale `C`; the stale compare-and-set value matters only when a change is requested.

### Response — `SetExpectedSignerIdentityResponse`

**200 OK**

| Field                     | Type           | Description                       |
| ------------------------- | -------------- | --------------------------------- |
| `signer_key`              | string         | Enrolled signer key               |
| `outcome`                 | string         | `changed` or `noop`               |
| `expected_signer_address` | string         | Stored canonical expected address |
| `observed_signer_address` | string \| null | Last health-refresh observation   |
| `identity_state`          | string         | Current EVM identity posture      |

```json theme={null}
{
  "signer_key": "signer.cctp.mainnet",
  "outcome": "changed",
  "expected_signer_address": "0x1234567890abcdef1234567890abcdef12345678",
  "observed_signer_address": "0x1234567890abcdef1234567890abcdef12345678",
  "identity_state": "match"
}
```

### Stable error codes

| Status | `detail.code`                     | Condition                                            |
| ------ | --------------------------------- | ---------------------------------------------------- |
| 404    | `signer_not_found`                | No persisted signer row                              |
| 422    | `signer_identity_not_applicable`  | Signer is not EVM                                    |
| 422    | `invalid_expected_signer_address` | Expected or previous address is invalid or zero      |
| 422    | `reason_required`                 | Reason is blank                                      |
| 409    | `signer_runtime_unavailable`      | Runtime signer cannot provide a valid fresh identity |
| 409    | `expected_identity_conflict`      | CAS value is stale                                   |
| 409    | `signer_identity_mismatch`        | Submitted identity differs from the runtime identity |

This surface establishes only the expected-versus-observed registry contract. It does **not** enforce movement-time or rotation-time address drift; that enforcement is a separate follow-up.

***

## 18. POST /v3/auto-approve-policies

Creates an auto-approval policy. The `client_id + strategy_id` combination must be unique.

### Request Body — `CreatePolicyRequest`

| Field                    | Type                         | Required | Description                              |
| ------------------------ | ---------------------------- | -------- | ---------------------------------------- |
| `client_id`              | string (UUID)                | Y        | API client ID that will own the policy   |
| `strategy_id`            | string (1-128)               | Y        | Strategy identifier                      |
| `asset`                  | string (1-20)                | Y        | Target asset (e.g. `XRP`, `USDC`)        |
| `allowed_templates`      | array\[AllowedTemplateEntry] | Y        | Allowed template list (at least 1 entry) |
| `max_amount_per_request` | string (decimal, positive)   | Y        | Maximum amount per request               |

**AllowedTemplateEntry**:

| Field              | Type    | Required | Description      |
| ------------------ | ------- | -------- | ---------------- |
| `template_key`     | string  | Y        | Template key     |
| `template_version` | integer | Y        | Template version |

### Response — `PolicyResponse`

**201 Created**

| Field                    | Type           | Description                |
| ------------------------ | -------------- | -------------------------- |
| `id`                     | string (UUID)  | Policy ID                  |
| `client_id`              | string         | Owning client ID           |
| `strategy_id`            | string         | Strategy identifier        |
| `asset`                  | string         | Target asset               |
| `allowed_templates`      | array\[object] | Allowed template list      |
| `max_amount_per_request` | string         | Maximum amount per request |
| `is_active`              | boolean        | Whether active             |
| `created_at`             | string         | Creation time              |
| `updated_at`             | string         | Update time                |

### Error Responses

| Status                   | Condition                                                       |
| ------------------------ | --------------------------------------------------------------- |
| 409 Conflict             | `client_id + strategy_id` combination already exists            |
| 422 Unprocessable Entity | Validation failure (negative amount, empty template list, etc.) |

***

## 19. GET /v3/auto-approve-policies

Retrieves the list of registered auto-approval policies.

### Response — `list[PolicyResponse]`

**200 OK**

***

## 20. GET /v3/auto-approve-policies/{policy_id}

Retrieves auto-approval policy details.

### Path Parameters

| Parameter   | Type | Description |
| ----------- | ---- | ----------- |
| `policy_id` | UUID | Policy ID   |

### Response — `PolicyResponse`

**200 OK**

### Error Responses

| Status        | Condition             |
| ------------- | --------------------- |
| 404 Not Found | Policy does not exist |

***

## 21. PATCH /v3/auto-approve-policies/{policy_id}

Updates an auto-approval policy. Updates only the submitted fields.

### Path Parameters

| Parameter   | Type | Description |
| ----------- | ---- | ----------- |
| `policy_id` | UUID | Policy ID   |

### Request Body — `UpdatePolicyRequest`

| Field                    | Type                         | Required | Description                       |
| ------------------------ | ---------------------------- | -------- | --------------------------------- |
| `is_active`              | boolean                      | N        | Toggle active state               |
| `allowed_templates`      | array\[AllowedTemplateEntry] | N        | Replace the allowed template list |
| `max_amount_per_request` | string (decimal, positive)   | N        | Change maximum amount per request |
| `asset`                  | string                       | N        | Change target asset               |

### Response — `PolicyResponse`

**200 OK**

### Error Responses

| Status                   | Condition             |
| ------------------------ | --------------------- |
| 404 Not Found            | Policy does not exist |
| 422 Unprocessable Entity | Validation failure    |

***

## Auto-approve behavior flow

When `MG_AUTO_APPROVE_ENABLED=true` and a `POST /v3/movements` request contains `strategy_id`:

1. Extract `client_id` from HMAC auth
2. Look up the matching policy set by `client_id + strategy_id` in `auto_approve_policies`
3. Five-stage evaluation: `is_active` -> `asset` -> `template` -> `amount` -> `binding_available`
4. If all checks pass: `PENDING_APPROVAL` -> `APPROVED` auto transition, frontier nodes `READY`
5. On failure: stay in `PENDING_APPROVAL`, record the reason in `auto_approve_result`

Important: policies are **bound to client\_id**. Client A's policy does not apply to client B's request.

***

## 22. GET /v3/route-catalog

Retrieves the catalog of available routes. Returns a combination of template metadata, execution statistics, and executor health.

### Query Parameters

| Parameter           | Type               | Default  | Description                                                                           |
| ------------------- | ------------------ | -------- | ------------------------------------------------------------------------------------- |
| `source_venue`      | string             | `null`   | Filter by source venue                                                                |
| `destination_venue` | string             | `null`   | Filter by destination venue                                                           |
| `asset`             | string             | `null`   | Filter by asset                                                                       |
| `transport_family`  | string             | `null`   | Filter by transport family                                                            |
| `status`            | PlanTemplateStatus | `active` | Filter by template state (`active`, `inactive`, `deprecated`). If `null`, returns all |
| `executable`        | boolean            | `true`   | Include only `executable_in_v3_0=true`                                                |
| `include_health`    | boolean            | `false`  | Whether to include executor health info                                               |

### Response — `RouteCatalogResponse`

**200 OK**

```json theme={null}
{
  "routes": [
    {
      "template_key": "upbit-binance-xrp",
      "name": "Upbit -> Binance XRP",
      "version": 2,
      "graph_shape": "linear",
      "node_count": 3,
      "executable": true,
      "metadata": {
        "transport_family": "cex_withdraw",
        "source_venue": "upbit",
        "destination_venue": "binance",
        "asset": "XRP"
      },
      "stats": {
        "total_completed": 42,
        "total_failed": 3,
        "success_rate": 0.933,
        "avg_duration_seconds": 185.5,
        "max_duration_seconds": 612.0
      },
      "fee_policy": {
        "expected_fee": "0.25",
        "max_fee_abs": "1.0"
      },
      "risk_controls": {
        "max_amount": "10000",
        "min_amount": "100"
      },
      "health": {
        "status": "healthy",
        "executors": [
          {"executor_key": "exec.cex.upbit.withdrawal", "health_state": "up"},
          {"executor_key": "exec.cex.upbit.withdrawal_observe", "health_state": "up"},
          {"executor_key": "exec.cex.binance.deposit_observe", "health_state": "up"}
        ]
      }
    }
  ],
  "generated_at": "2026-04-05T12:34:56.789012+00:00"
}
```

### Response fields

| Field                             | Description                                                                                                     |
| --------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `stats`                           | Execution statistics (current + previous month bucket). `null` if no data                                       |
| `fee_policy`                      | Fee-related fields extracted from `risk_controls` (`expected_fee`, `max_fee_abs`, `max_fee_pct`, `max_fee_usd`) |
| `risk_controls`                   | Amount-limit fields extracted from `risk_controls` (`max_amount`, `min_amount`)                                 |
| `health`                          | Included when `include_health=true`. When `false`, the field itself is omitted (no JSON key)                    |
| `health.status`                   | `healthy` (all up), `degraded` (mixed), `unavailable` (all down/missing/ambiguous)                              |
| `health.executors[].health_state` | Executor health: `up`, `down`, `unknown`, `missing` (not registered), `ambiguous` (2+ matches)                  |

### Error Responses

| Status                   | Condition                                   |
| ------------------------ | ------------------------------------------- |
| 422 Unprocessable Entity | `status` parameter is an invalid enum value |

### Auth

Purpose: `read`

***

## 23. POST /v3/routes/recommend

Retrieves an optimal route recommendation (advisory). Based on route catalog candidates, it computes feasibility and priority scores.

### Request Body

| Field               | Type                   | Default | Description                                                                      |
| ------------------- | ---------------------- | ------- | -------------------------------------------------------------------------------- |
| `asset`             | string                 | —       | Asset filter                                                                     |
| `source_venue`      | string \| null         | `null`  | Source venue filter                                                              |
| `destination_venue` | string \| null         | `null`  | Destination venue filter                                                         |
| `amount`            | decimal string \| null | `null`  | Used for amount-based feasibility judgment. If `null`, amount checks are skipped |
| `priority`          | `speed` \| `cost`      | `speed` | Ranking criterion                                                                |

### Response — `RouteRecommendResponse`

**200 OK**

```json theme={null}
{
  "recommendations": [
    {
      "rank": 1,
      "score": 1.0,
      "feasibility": "feasible",
      "infeasible_reasons": [],
      "route": {
        "template_key": "upbit-binance-xrp",
        "name": "Upbit -> Binance XRP",
        "version": 2,
        "graph_shape": "linear",
        "node_count": 3,
        "executable": true,
        "metadata": {
          "transport_family": "cex_withdraw",
          "source_venue": "upbit",
          "destination_venue": "binance",
          "asset": "XRP"
        },
        "stats": null,
        "fee_policy": {},
        "risk_controls": {},
        "health": {
          "status": "healthy",
          "executors": [
            {"executor_key": "exec.a", "health_state": "up"}
          ]
        }
      }
    }
  ],
  "request": {
    "asset": "XRP",
    "source_venue": "upbit",
    "destination_venue": "binance",
    "amount": "5000",
    "priority": "speed"
  },
  "generated_at": "2026-04-05T12:34:56.789012+00:00"
}
```

### Feasibility rules

* `not_executable`
* `executor_unavailable`
* `amount_below_min`
* `amount_above_max`

Feasible routes always rank ahead of infeasible ones.

### Error Responses

| Status                   | Condition                                             |
| ------------------------ | ----------------------------------------------------- |
| 422 Unprocessable Entity | `asset` missing, `amount <= 0`, or invalid `priority` |

### Auth

Purpose: `read`

***

## MovementActionResponse common response

approve, reject, resume, retry, and cancel all use the same response schema.

| Field                | Type           | Description                |
| -------------------- | -------------- | -------------------------- |
| `movement_id`        | UUID           | Request ID                 |
| `request_state`      | string         | Updated request state      |
| `current_frontier`   | array\[string] | Current frontier node keys |
| `reservation_status` | string         | Resource reservation state |

***

## Authentication model

When `MG_AUTH_ENABLED=true`, the HMAC authentication middleware applies to all `/v3/**` requests except `/healthz`. The middleware validates the `X-QTG-Key-Id` / `X-QTG-Timestamp` / `X-QTG-Nonce` / `X-QTG-Signature` headers and controls route access based on the 3-role axis (`admin` / `operator` / `agent`) described in [Inbound auth overview](#inbound-auth-overview).

When `MG_RATE_LIMIT_ENABLED=true`, a per-key fixed-window rate limit check is performed additionally after authentication passes.

In the approval API (`approve`), validating `compiled_plan_hash` equality serves as a plan-integrity check; the `approver_id` is the operator label carried in the request body and is stored as such.

For the authentication enablement procedure and key seed CLI usage, refer to `docs/reference/infrastructure/auth-rollout.md`.

***

## Callback event kinds

Callback events enqueued to the outbox on state changes:

| event\_type         | When emitted                  |
| ------------------- | ----------------------------- |
| `request_approved`  | Approval completed            |
| `request_rejected`  | Rejected                      |
| `request_expired`   | Approval expired              |
| `request_resumed`   | Manual resume                 |
| `request_retried`   | Retried                       |
| `request_cancelled` | Cancelled                     |
| `request_completed` | Fully completed               |
| `request_failed`    | Failed (reservation released) |
| `request_attention` | Manual intervention required  |
| `frontier_advanced` | DAG frontier advanced         |

***

## 24. GET /v3/budgets/{strategy_id}

Retrieves the per-strategy remaining budget. Returns active policies with a window cap configured.

### Path Parameters

| Parameter     | Type   | Description         |
| ------------- | ------ | ------------------- |
| `strategy_id` | string | Strategy identifier |

### Query Parameters

| Parameter   | Type | Required | Description                         |
| ----------- | ---- | -------- | ----------------------------------- |
| `client_id` | UUID | N        | Restrict to a specific client scope |

### Response

**200 OK**

```json theme={null}
{
  "strategy_id": "arb.eth_usdc",
  "policies": [
    {
      "policy_id": "...",
      "window_hours": 24,
      "max_amount_per_window": "10000.00",
      "max_count_per_window": 50,
      "reserved_amount": "3000.00",
      "consumed_amount": "500.00",
      "available_amount": "6500.00",
      "count_in_window": 5
    }
  ]
}
```

| Field              | Type           | Description                                                 |
| ------------------ | -------------- | ----------------------------------------------------------- |
| `reserved_amount`  | string         | Amount reserved but not yet consumed/released               |
| `consumed_amount`  | string         | Amount consumed within the window                           |
| `available_amount` | string \| null | `max_amount - (reserved + consumed)`. null if no amount cap |
| `count_in_window`  | int            | Count of reservations within the window not yet released    |

### Auth

* Purpose: `read`

***

## 25. GET /v3/balances

Retrieves the latest per-venue balance snapshots. The lookup unit is the latest row by `(venue_key, asset, account_type)`, and only the latest row per combination is returned.

### Query Parameters

| Parameter      | Type    | Default | Description                                                                                               |
| -------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------- |
| `venue_key`    | string  | `null`  | Look up a specific venue                                                                                  |
| `asset`        | string  | `null`  | Look up only a specific asset                                                                             |
| `account_type` | string  | `null`  | Look up a specific account type. When provided, trimmed + uppercased and then filtered (`spot` -> `SPOT`) |
| `fresh`        | boolean | `false` | Attempt on-demand fetch when `venue_key` is specified                                                     |

### Response — `BalanceResponse`

**200 OK**

```json theme={null}
{
  "balances": [
    {
      "venue_key": "binance-master",
      "asset": "USDT",
      "account_type": "SPOT",
      "available": "15000.00",
      "withdrawable": "15000.00",
      "total": "15000.00",
      "details": {"wallet_type": 0},
      "fetched_at": "2026-04-17T03:12:45.123456+00:00",
      "stale": false,
      "qtg_pending_outflow": "0",
      "qtg_effective_withdrawable": "15000.00",
      "qtg_effective_reason": null
    },
    {
      "venue_key": "binance-sub-alpha",
      "asset": "USDT",
      "account_type": "USDT_FUTURE",
      "available": "3200.00",
      "withdrawable": "3200.00",
      "total": "3200.00",
      "details": {"wallet_type": "UMFUTURE"},
      "fetched_at": "2026-04-17T03:12:45.123456+00:00",
      "stale": false,
      "qtg_pending_outflow": "250.00",
      "qtg_effective_withdrawable": "2950.00",
      "qtg_effective_reason": "capital_transfer_reservation"
    },
    {
      "venue_key": "okx-funding",
      "asset": "USDT",
      "account_type": "FUNDING",
      "available": "1000.00",
      "withdrawable": "1000.00",
      "total": "1000.00",
      "details": {"account_type": "funding"},
      "fetched_at": "2026-04-17T03:12:45.123456+00:00",
      "stale": false,
      "qtg_pending_outflow": "0",
      "qtg_effective_withdrawable": "1000.00",
      "qtg_effective_reason": null
    },
    {
      "venue_key": "bybit-fund",
      "asset": "USDT",
      "account_type": "FUND",
      "available": "500.00",
      "withdrawable": "500.00",
      "total": "500.00",
      "details": {"account_type": "FUND"},
      "fetched_at": "2026-04-17T03:12:45.123456+00:00",
      "stale": false,
      "qtg_pending_outflow": "0",
      "qtg_effective_withdrawable": "500.00",
      "qtg_effective_reason": null
    }
  ]
}
```

### Response meaning

| Field                        | Type           | Description                                                                                                   |
| ---------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------- |
| `venue_key`                  | string         | QTG venue binding key                                                                                         |
| `asset`                      | string         | Normalized asset symbol                                                                                       |
| `account_type`               | string         | Venue account bucket. Carries capital-transfer safety and latest snapshot identity                            |
| `available`                  | string \| null | Available amount reported by the venue                                                                        |
| `withdrawable`               | string \| null | Amount actually movable out of the venue. Primary basis for route selection/feasibility                       |
| `total`                      | string \| null | Total balance provided by the venue                                                                           |
| `details`                    | object \| null | Normalized venue-specific details. May include Gateway `domains[]` breakdown or CEX wallet/account type hints |
| `fetched_at`                 | string \| null | Latest snapshot UTC time                                                                                      |
| `stale`                      | boolean        | Whether stale per `MG_BALANCE_STALE_THRESHOLD_SECONDS`                                                        |
| `qtg_pending_outflow`        | string         | Pending outflow held by QTG-internal reservations that has not yet been deducted/cleared                      |
| `qtg_effective_withdrawable` | string \| null | QTG-side available amount based on `withdrawable - qtg_pending_outflow`                                       |
| `qtg_effective_reason`       | string \| null | Reason the effective value differs from the raw venue value                                                   |

### Behavior notes

* When `fresh=true` and a fetcher exists for the `venue_key`, on-demand fetch is attempted first, then the latest snapshot is retrieved.
* When the `account_type` query param is provided, it is trim + uppercased. Example: `?account_type=usdt_future` retrieves the `USDT_FUTURE` snapshot.
* Latest-row identity is `(venue_key, asset, account_type)`. Spot treasury and USDT-M Futures inventory are distinct snapshot/reservation pools even for the same asset.
* If on-demand fetch fails, falls back to the last stored snapshot.
* If no snapshot exists, returns `200 OK` with an empty `balances` array.
* `SPOT` / `USDT_FUTURE` are the primary account types linked to Binance capital-transfer reservations. Balance-only account types such as `FUNDING` (e.g. OKX) / `FUND` (e.g. Bybit) may also be returned as snapshot/filter values.

### Auth

* Purpose: `read`

***

## 26. POST /v3/capital-transfers

Creates a provider-internal treasury/capital transfer. The current Binance v1 implementation is master-signed `universalTransfer`-based, and the source/destination account type is a first-class QTG contract field.

### Request Body — `CapitalTransferCreateRequest`

| Field                      | Type            | Required | Description                                                                                                                                                 |
| -------------------------- | --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `source_venue`             | string (1-128)  | Y        | Source venue binding key                                                                                                                                    |
| `destination_venue`        | string (1-128)  | Y        | Destination venue binding key                                                                                                                               |
| `source_account_type`      | string (max 32) | N        | Source account type. If omitted/empty, `SPOT`; when provided, normalized to uppercase. Supported values in the current contract: `SPOT`, `USDT_FUTURE`      |
| `destination_account_type` | string (max 32) | N        | Destination account type. If omitted/empty, `SPOT`; when provided, normalized to uppercase. Supported values in the current contract: `SPOT`, `USDT_FUTURE` |
| `asset`                    | string (1-32)   | Y        | Asset symbol. Normalized to uppercase                                                                                                                       |
| `amount`                   | decimal         | Y        | Positive amount                                                                                                                                             |
| `reason`                   | string (min 1)  | Y        | Operator/audit reason                                                                                                                                       |
| `idempotency_key`          | string (1-128)  | Y        | Semantic idempotency key                                                                                                                                    |
| `strategy_id`              | string (1-128)  | N        | Strategy attribution                                                                                                                                        |

### Request Example — master Spot -> sub USDT-M Futures

```json theme={null}
{
  "source_venue": "binance-master",
  "destination_venue": "binance-sub-alpha",
  "source_account_type": "SPOT",
  "destination_account_type": "USDT_FUTURE",
  "asset": "USDT",
  "amount": "1000.00",
  "reason": "fund sub futures hedge inventory",
  "idempotency_key": "cap-20260417-master-sub-alpha-001",
  "strategy_id": "stage3a"
}
```

### Request Example — sub USDT-M Futures -> master Spot

```json theme={null}
{
  "source_venue": "binance-sub-alpha",
  "destination_venue": "binance-master",
  "source_account_type": "USDT_FUTURE",
  "destination_account_type": "SPOT",
  "asset": "USDT",
  "amount": "750.00",
  "reason": "sweep futures margin back to spot treasury",
  "idempotency_key": "cap-20260417-sub-alpha-master-001"
}
```

### Response — `CapitalTransferView`

**201 Created** for a new semantic request. **200 OK** when the same `idempotency_key` is replayed with a semantically identical request.

```json theme={null}
{
  "transfer_id": "018f7347-7a44-7a80-9b0c-0f3a62c2e710",
  "request_state": "COMPLETED",
  "source_venue": "binance-sub-alpha",
  "destination_venue": "binance-master",
  "source_account_type": "USDT_FUTURE",
  "destination_account_type": "SPOT",
  "asset": "USDT",
  "amount": "750.00",
  "reason": "sweep futures margin back to spot treasury",
  "strategy_id": null,
  "idempotency_key": "cap-20260417-sub-alpha-master-001",
  "provider": "binance",
  "provider_transfer_id": "123456789",
  "created_at": "2026-04-17T03:15:00.000000Z",
  "updated_at": "2026-04-17T03:15:01.000000Z"
}
```

### Response meaning

| Field                                              | Type           | Description                                                                                       |
| -------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------- |
| `transfer_id`                                      | UUID           | QTG capital transfer ID                                                                           |
| `request_state`                                    | string         | `SUBMITTING`, `PENDING_PROVIDER_CONFIRMATION`, `COMPLETED`, `FAILED`, `MANUAL_INTERVENTION`, etc. |
| `source_venue` / `destination_venue`               | string         | Source/destination venue binding keys                                                             |
| `source_account_type` / `destination_account_type` | string         | Normalized source/destination account types. Reservations are also split along this dimension     |
| `asset`                                            | string         | Normalized asset symbol                                                                           |
| `amount`                                           | string         | Requested amount                                                                                  |
| `reason`                                           | string         | Request reason                                                                                    |
| `strategy_id`                                      | string \| null | Strategy attribution                                                                              |
| `idempotency_key`                                  | string         | Semantic idempotency key                                                                          |
| `provider`                                         | string \| null | Executor provider                                                                                 |
| `provider_transfer_id`                             | string \| null | Transfer id returned by the provider                                                              |
| `created_at` / `updated_at`                        | datetime       | Creation/update times                                                                             |

### Binance v1 topology notes

* Binance master binding is Spot treasury only: if either side is master, that side's account type must be `SPOT`.
* Sub-account bindings can move between `SPOT` and `USDT_FUTURE` under supported topologies:
  * master `SPOT` -> sub `SPOT` or `USDT_FUTURE`
  * sub `SPOT` or `USDT_FUTURE` -> master `SPOT`
  * same sub `SPOT` ↔ `USDT_FUTURE`
* Cross-sub transfers are intentionally rejected in v1.
* Same-account no-op transfers are rejected: for the same sub-account, `source_account_type` and `destination_account_type` must differ.
* Master-to-master topology is not a supported capital-transfer operation.

### Error Responses

| Status                   | Condition                                                                                               |
| ------------------------ | ------------------------------------------------------------------------------------------------------- |
| 409 Conflict             | Same `idempotency_key` with semantic mismatch, or provider/topology compatibility conflict              |
| 422 Unprocessable Entity | Required field missing, amount/asset/account type normalization failure, or executor validation failure |

### Auth

* Purpose: `capital_transfer`

***

## 27. GET /v3/capital-transfers

Retrieves capital transfers, ordered newest first.

### Query Parameters

| Parameter     | Type            | Default | Description                             |
| ------------- | --------------- | ------- | --------------------------------------- |
| `state`       | string          | `null`  | Look up a specific request state        |
| `strategy_id` | string          | `null`  | Look up a specific strategy attribution |
| `limit`       | integer (1-100) | `20`    | Number of items to return               |

### Response — `CapitalTransferListResponse`

```json theme={null}
{
  "capital_transfers": [
    {
      "transfer_id": "018f7347-7a44-7a80-9b0c-0f3a62c2e710",
      "request_state": "COMPLETED",
      "source_venue": "binance-sub-alpha",
      "destination_venue": "binance-master",
      "source_account_type": "USDT_FUTURE",
      "destination_account_type": "SPOT",
      "asset": "USDT",
      "amount": "750.00",
      "reason": "sweep futures margin back to spot treasury",
      "strategy_id": null,
      "idempotency_key": "cap-20260417-sub-alpha-master-001",
      "provider": "binance",
      "provider_transfer_id": "123456789",
      "created_at": "2026-04-17T03:15:00.000000Z",
      "updated_at": "2026-04-17T03:15:01.000000Z"
    }
  ],
  "total": 1
}
```

### Auth

* Purpose: `capital_transfer`

***

## 28. GET /v3/capital-transfers/{transfer_id}

Retrieves a single capital transfer. The response schema is identical to `CapitalTransferView`.

### Path Parameters

| Parameter     | Type | Description             |
| ------------- | ---- | ----------------------- |
| `transfer_id` | UUID | QTG capital transfer ID |

### Auth

* Purpose: `capital_transfer`

***

## 29. POST /v3/capital-transfers/{transfer_id}/actions/resolve

Manually resolves a capital transfer in `PENDING_PROVIDER_CONFIRMATION` or `MANUAL_INTERVENTION` state to a terminal/holding state.

### Request Body — `CapitalTransferResolveRequest`

| Field          | Type             | Required | Description                                                  |
| -------------- | ---------------- | -------- | ------------------------------------------------------------ |
| `target_state` | string           | Y        | Allowed values: `COMPLETED`, `FAILED`, `MANUAL_INTERVENTION` |
| `note`         | string (max 512) | N        | Operator note                                                |

### Auth

* Purpose: `capital_transfer`

***

## Additional endpoint families

Beyond the movement / template / registry / route-catalog / capital-transfer lane documented in detail above, the following families are registered in `src/qtg/interfaces/api/routes/`. They are summarized here; consult the cited source files for full request/response schemas.

### Identity and audit

| Method | Path                 | Roles                                              | Source        |
| ------ | -------------------- | -------------------------------------------------- | ------------- |
| GET    | `/v3/whoami`         | all                                                | `whoami.py`   |
| GET    | `/v3/audit/events`   | admin + operator (the read is itself meta-audited) | `audit.py`    |
| GET    | `/v3/registry/audit` | admin + operator (legacy registry audit)           | `registry.py` |

`GET /v3/audit/events` supports filters: `time_range_start`, `time_range_end`, `actor_client_id`, `actor_key_id`, `entity_type`, `entity_key`, `action`, `request_id`, `outcome`, `namespace`, `cursor`, `limit` (max 500).

### Agent authorities and template proposals

| Method | Path                                                       | Roles                                         | Source                 |
| ------ | ---------------------------------------------------------- | --------------------------------------------- | ---------------------- |
| GET    | `/v3/agent-authorities/{authority_key}`                    | all                                           | `agent_authorities.py` |
| POST   | `/v3/agent-authorities/{authority_key}/template-proposals` | agent                                         | `agent_authorities.py` |
| POST   | `/v3/agent-authorities/{authority_key}/bridge-attempt`     | agent                                         | `agent_authorities.py` |
| GET    | `/v3/template-proposals/{proposal_id}`                     | all                                           | `agent_authorities.py` |
| POST   | `/v3/template-proposals/{proposal_id}/promote`             | admin                                         | `agent_authorities.py` |
| POST   | `/v3/template-proposals/{proposal_id}/execute`             | operator (D10: propose-only, no auto-execute) | `agent_authorities.py` |

### Agent wallet top-ups / funding envelopes

| Method | Path                                                  | Roles    | Source                   |
| ------ | ----------------------------------------------------- | -------- | ------------------------ |
| POST   | `/v3/agent-wallet-funding/envelopes`                  | admin    | `agent_wallet_topups.py` |
| PATCH  | `/v3/agent-wallet-funding/envelopes/{envelope_id}`    | admin    | `agent_wallet_topups.py` |
| GET    | `/v3/agent-wallet-funding/envelopes`                  | operator | `agent_wallet_topups.py` |
| GET    | `/v3/agent-wallet-funding/envelopes/{envelope_id}`    | all      | `agent_wallet_topups.py` |
| POST   | `/v3/agent-authorities/{authority_key}/wallet-topups` | all      | `agent_wallet_topups.py` |

The agent-wallet top-up lane is dedicated and must not be driven through generic movement creation — see the Agent Wallet Top-Up rules in `CLAUDE.md`.

### CCIP registry, verify-drift, and Hyperliquid

| Method | Path                                                              | Roles    | Source                     |
| ------ | ----------------------------------------------------------------- | -------- | -------------------------- |
| POST   | `/v3/executors/ccip/refresh-registry`                             | admin    | `ccip_registry.py`         |
| POST   | `/v3/registry/verify-drift`                                       | admin    | `registry_verify_drift.py` |
| POST   | `/v3/hyperliquid/master-account`                                  | admin    | `hyperliquid.py`           |
| POST   | `/v3/hyperliquid/strategy-bindings`                               | admin    | `hyperliquid.py`           |
| GET    | `/v3/hyperliquid/strategy-bindings/{strategy_id}`                 | operator | `hyperliquid.py`           |
| POST   | `/v3/hyperliquid/strategy-bindings/{strategy_id}/agent-wallets`   | admin    | `hyperliquid.py`           |
| GET    | `/v3/hyperliquid/strategy-bindings/{strategy_id}/agent-wallets`   | operator | `hyperliquid.py`           |
| POST   | `/v3/hyperliquid/strategy-bindings/{strategy_id}/pause`           | admin    | `hyperliquid.py`           |
| GET    | `/v3/hyperliquid/strategy-bindings/{strategy_id}/emergency-state` | operator | `hyperliquid.py`           |

### Dashboard BFF (`/dashboard/*`)

The dashboard backend-for-frontend is mounted at the root **outside** the `/v3` HMAC scope (`build_dashboard_router()` in `routes/dashboard/`, prefix `/dashboard`). Dashboard writes use **per-operator HMAC v3** (nonce + timestamp): the browser holds a non-extractable `CryptoKey` and signs each write. The old bearer-token-in-bundle model is gone — `MG_DASHBOARD_WRITE_TOKEN` / `VITE_DASHBOARD_WRITE_TOKEN` have been **removed**, and `main.py` hard-fails startup if `MG_DASHBOARD_WRITE_TOKEN` is set (even to empty). Localhost binding is now defense-in-depth, not the primary control. The expected writer-key count defaults to `dashboard_writer_expected_key_count = 8`. See [`runbooks/dashboard-auth-v1-to-hmac.md`](/runbooks/dashboard-auth-v1-to-hmac).

***

## Related documents

* [Data Model Reference](/reference/infrastructure/data-model) — table details
* [Bootstrap & Configuration](/reference/infrastructure/bootstrap) — settings and initialization
* [Runtime Workers](/reference/workers/runtime-workers) — per-worker behavior details
