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

# Control Plane

> Multi-tenancy, brand configuration, the transactional outbox and DLQ, and progressive migration.

# Control Plane

The control plane manages multi-tenant brand configuration, event ingestion, and reliable
dispatch into Temporal.

```
  API layer                Database                    Dispatcher
 ───────────      ─────────────────────────      ────────────────────
  Brand /          brands                         poll loop
  connector        connector_bindings             every 1s, batch 25
  CRUD        ──▶  brand_workflow_bindings
                        (→ AutomationConfig)      exponential backoff
  Event                                           on errors, cap 30s
  ingestion   ──▶  workflow_dispatch_outbox  ◀──── claim pending
  POST /v1/                    │                        │
  events/{slug}                └──▶ workflow_dispatch_dlq└──▶ Temporal
```

## Multi-tenancy

| Concept                | Meaning                                                                    |
| ---------------------- | -------------------------------------------------------------------------- |
| **Tenant**             | Top-level organization                                                     |
| **Brand**              | Tenant-scoped entity with its own connectors, rules, and workflow bindings |
| **Row-Level Security** | PostgreSQL RLS enforces tenant isolation at the database layer             |
| **API authentication** | SHA-256 hashed API keys with tenant scoping                                |

Auth runs in one of three modes via `CONTROL_PLANE_AUTH_MODE`: `required` (the default),
`optional`, or `disabled`.

<Warning>
  `disabled` removes tenant scoping from API requests. It exists for local development — never
  set it on anything reachable from outside your machine.
</Warning>

## Outbox and DLQ

Events flow through a transactional outbox so delivery survives crashes and Temporal outages:

1. Event ingested, validated, inserted into `workflow_dispatch_outbox`.
2. Dispatcher claims a batch of pending rows using `SELECT FOR UPDATE SKIP LOCKED`.
3. For each, it generates a **deterministic workflow ID** (SHA-256) and dispatches to Temporal.
4. On success, the row is marked `dispatched`.
5. On failure, `attempt_count` increments and the row retries with backoff.
6. After `max_attempts` (default **20**), the row moves to `workflow_dispatch_dlq`.
7. Operators can list, retry, or resolve DLQ items via the API.

<Note>
  The deterministic workflow ID is what makes redelivery safe: a duplicate event resolves to the
  same Temporal workflow ID, so it dedupes rather than starting a second run.
</Note>

`SELECT FOR UPDATE SKIP LOCKED` lets multiple dispatcher instances coexist without
double-claiming — though the deployment runs a single dispatcher with a `maxUnavailable: 0`
disruption budget.

## Progressive migration

Brands move from legacy systems to the engine through routing modes rather than a cutover:

```
legacy ──▶ shadow ──▶ canary N% ──▶ live
100% old   both run,   gradual        100% new
           compared    rollout
```

| Mode        | Behavior                                                                              |
| ----------- | ------------------------------------------------------------------------------------- |
| **legacy**  | 100% old system                                                                       |
| **shadow**  | Both systems process; outcomes compared on the parity dashboard (`parity_results`)    |
| **canary**  | A configurable percentage routes to the new system, with stable hashing by `brand_id` |
| **live**    | 100% new system                                                                       |
| **dry-run** | Events marked as test data; no real dispatch                                          |

Stable hashing by `brand_id` means a given brand stays on the same side of a canary split
between runs, rather than flapping.

## Brand configuration API

Brand config is versioned, with draft/evaluate/apply rather than direct mutation:

| Endpoint                                        | Purpose                       |
| ----------------------------------------------- | ----------------------------- |
| `GET /v1/brands/{id}/config`                    | Current config                |
| `POST /v1/brands/{id}/config/draft`             | Draft a change                |
| `POST /v1/brands/{id}/config/assist`            | Assisted authoring            |
| `POST /v1/brands/{id}/config/evaluate`          | Evaluate a draft              |
| `POST /v1/brands/{id}/config/evaluate-golden`   | Evaluate against golden cases |
| `POST /v1/brands/{id}/config/apply`             | Apply the draft               |
| `GET /v1/brands/{id}/config/versions`           | Version history               |
| `GET /v1/brands/{id}/config/versions/{version}` | A specific version            |

<Tip>
  `evaluate-golden` runs a draft against a known-good case set before you apply it. Use it as the
  gate on config changes — it's the difference between finding a regression in evaluation and
  finding it in production traffic.
</Tip>

## Brand lifecycle

| Endpoint                                        | Purpose                           |
| ----------------------------------------------- | --------------------------------- |
| `GET`/`POST` `/v1/brands`                       | List and create brands            |
| `GET`/`PATCH` `/v1/brands/{id}`                 | Read and update                   |
| `POST /v1/brands/{id}/activate`                 | Activate                          |
| `GET`/`POST` `/v1/brands/{id}/api-keys`         | Manage brand API keys             |
| `POST /v1/brands/{id}/api-keys/{key_id}/revoke` | Revoke a key                      |
| `GET /v1/brands/{id}/connectors`                | Connector bindings                |
| `GET /v1/bootstrap/templates`                   | Available templates               |
| `POST /v1/bootstrap/brands`                     | Bootstrap a brand from a template |

Billing endpoints cover contracts, periods, rated outcomes, reconciliation, and sync under
`/v1/brands/{id}/billing`.

## Task queue and workflow scoping

Two allow-list pairs constrain what a brand binding may target:

| Variable                                             | Constrains                     |
| ---------------------------------------------------- | ------------------------------ |
| `CONTROL_PLANE_ALLOWED_TASK_QUEUES` / `_PREFIXES`    | Permitted Temporal task queues |
| `CONTROL_PLANE_ALLOWED_WORKFLOW_NAMES` / `_PREFIXES` | Permitted workflow names       |

Without an explicit allow-list, the workflow name must match the binding's `template_key`.

## Database schema

PostgreSQL, with migrations under `migrations/control-plane/`. The initial migration creates
`tenants`, `brands`, `workflow_templates`, `policy_sets`, `connector_bindings`,
`brand_workflow_bindings`, `brand_policy_bindings`, `brand_config_versions`,
`idempotency_keys`, `event_ingestion_log`, and `brand_migration_ledger`; later migrations add
`workflow_dispatch_outbox` and the DLQ.

Run migrations with:

```bash theme={null}
cargo run -p engine-control-plane --bin migrate
```

## Related

* [Configuration](/next-temporal/configuration)
* [Operations](/next-temporal/operations)
