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

# Tenant Provisioning

> Deploy a new tenant on the control plane: request placement, poll to completion, and read what was built.

<Warning>
  **Superseded — `quint-provisiond` never deployed.** Tenant provisioning, approval,
  deprovisioning, observation and token minting now live on the deploy gateway. Write new clients
  against [Tenant Lifecycle](/api/tenant-lifecycle) and
  [Request a Tenant](/api/endpoint/post-tenant-lifecycle).

  What changed that matters: routes moved from `/internal/provision/*` to `/v1/tenants/*`; the
  single shared operator token became a per-operator WorkOS JWT; and creating or destroying a
  tenant now requires a **second** operator to approve. This page is kept for the design record
  and for the two-stage placement/database distinction, which still holds.
</Warning>

<Note>
  This was the **internal operator** control-plane API, not a customer surface — served by
  `quint-provisiond` on the private control-plane address under `/internal/`, authenticated by a
  single shared operator token.
</Note>

## What this API does

Provisioning a tenant is **two stages**. This API is stage 1 only.

<Frame caption="Tenant provisioning — the two stages, and what each actually builds. Blue is stage 1 (this API); violet is the operator CLI; the amber state is where a correct stage-1 run parks.">
  <img src="https://mintcdn.com/quintsecurity/3jYNkmj-z6bGZtfz/images/tenant-provisioning-flow.png?fit=max&auto=format&n=3jYNkmj-z6bGZtfz&q=85&s=7a3a956c37420246022112056fcad54e" alt="Tenant provisioning flow: an operator calls GET /internal/provision/cells then POST /internal/provision/tenants; invalid input or a taken slug returns 422 or 409 with nothing built; a valid request records intake, opens the flow row synchronously, returns 202 with a poll_url, and provisions the org, registry row, cell assignment and database_name in the background; the client polls GET /internal/provision/tenants/{slug} until terminal, waiting poll_after_ms between polls, and parks at state provisioned with schema_version NULL; an operator then runs quint-control db-provision out of band to create the database, 72 tables, tsvc_ role, secret and two KMS ARNs, after which the same poll endpoint reports a populated schema_version and it is safe to route traffic; both stages append to tenant_registry_events, which has no HTTP route." width="5824" height="1234" data-path="images/tenant-provisioning-flow.png" />
</Frame>

| Stage            | Trigger                                                        | Creates                                                                                                                                                      | Observable marker                                                       |
| ---------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| **1. Placement** | `POST /internal/provision/tenants` — this API                  | Organization, registry row, cell assignment, `database_name` *as a name*, commercial intake record, trial window                                             | `state` reaches `provisioned`; `placement.schema_version` is **`null`** |
| **2. Database**  | `quint-control db-provision` — operator CLI, **no HTTP route** | The physical database, \~72 tables at the head migration, the `tsvc_<slug>` login role, the per-tenant Secrets Manager secret, two KMS ARNs (envelope + MAC) | The same poll endpoint starts reporting `placement.schema_version`      |

**`state: "provisioned"` means *placed*, not *usable*.** A tenant can reach `provisioned` with no
database behind it. The one reliable signal that stage 2 has run is a non-null
`placement.schema_version`.

<Warning>
  **Anything that routes traffic must refuse a null `schema_version`, never default it.** A UI that
  treats `provisioned` as "ready" will show a working workspace for a tenant with no database.
</Warning>

## Base URL and authentication

```
http://<control-plane-host>:8090
```

Every route except `/healthz` requires the operator token:

```
Authorization: Bearer <operator token>
```

The comparison is `subtle.ConstantTimeCompare`, so a **wrong** token is byte-identical to a
**missing** one — both return the same 401 body. There is no per-user identity, no scopes, and no
audit of *who* called: the token is the whole authorization model.

```json 401 theme={null}
{
  "error": "unauthorized",
  "detail": "provide the operator token as `Authorization: Bearer <token>`"
}
```

## The integration contract

Three rules. Follow them and the client is correct across every state.

<Steps>
  <Step title="POST returns 202, never a finished tenant">
    The response carries `poll_url`. It is always accepted-and-pending; there is no synchronous
    success shape to handle.
  </Step>

  <Step title="Obey `terminal` — do not re-derive it">
    Stop polling when the server says `terminal: true`. The stop rule lives with the state machine
    so it is not reimplemented differently in each client.

    Note the trap: **`failed` with a `next_retry_at` is *not* terminal.** The background sweep will
    resume it. Only `active`, or `failed` with no scheduled retry, are terminal.
  </Step>

  <Step title="Obey `poll_after_ms` for cadence">
    The server widens the interval as the flow goes quiet (1500 ms while moving, 3000 ms when
    waiting on something external, 5000 ms between retries). Do not hardcode an interval.
  </Step>
</Steps>

## States

| State                    | Terminal                              | `poll_after_ms` | Meaning                                                     |
| ------------------------ | ------------------------------------- | --------------- | ----------------------------------------------------------- |
| `requested`              | no                                    | 1500            | Flow row opened; work not started                           |
| `provisioning`           | no                                    | 1500            | `quint-control provision` is running                        |
| `provisioned`            | no                                    | 3000            | Placed. **Database may not exist** — check `schema_version` |
| `routed`                 | no                                    | 3000            | DNS/routing done; waiting on an agent                       |
| `awaiting-first-checkin` | no                                    | 3000            | Waiting on the first agent check-in                         |
| `active`                 | **yes**                               | 0               | Fully live                                                  |
| `failed`                 | **only if `next_retry_at` is absent** | 5000            | See `operator_reason`                                       |

Every state also carries a `customer_message` — fixed, enumerated, neutral text, safe to show a
customer verbatim. It is generated from state at read time so the same words are never written
twice. Use it rather than composing your own copy.

***

## `GET /internal/provision/cells`

Placement targets. Call this before a POST to pick a region that can actually accept a tenant.

Optional `?region=` filters.

<ResponseField name="id" type="string">Cell id, e.g. `aws-use1-cell-1`.</ResponseField>
<ResponseField name="region" type="string">The value to pass as `region` on the POST.</ResponseField>
<ResponseField name="cloud" type="string">Derived from the cell id prefix: `aws`, `gcp`, `local`, or `unknown`. A **convention**, not a schema column — `cells` has no provider column.</ResponseField>
<ResponseField name="capacity" type="integer">Configured maximum.</ResponseField>
<ResponseField name="tenant_count" type="integer">Current occupancy.</ResponseField>
<ResponseField name="free" type="integer">`capacity - tenant_count`, floored at 0. Capacity can be lowered below occupancy to close a cell to new placements, so a would-be negative is normal and reported as 0.</ResponseField>
<ResponseField name="open" type="boolean">Whether this cell accepts new placements. **Filter on this**, not on `free`.</ResponseField>

```bash cURL theme={null}
curl "$BASE/internal/provision/cells?region=us-east-1" \
  -H "Authorization: Bearer $OPERATOR_TOKEN"
```

```json Response theme={null}
[
  {
    "id": "aws-use1-cell-1",
    "region": "us-east-1",
    "cloud": "aws",
    "capacity": 200,
    "tenant_count": 12,
    "free": 188,
    "open": true,
    "created_at": "2026-08-01T10:22:41.183Z"
  }
]
```

***

## `POST /internal/provision/tenants`

Records the commercial intake, opens the flow row, and starts provisioning in the background.
Answers **202**.

Unknown fields are **rejected** (`400 malformed_json`) — a misspelled field is a mistake, not a
default. Body limit 64 KB.

### Required

<ParamField body="slug" type="string" required>
  DNS label: lowercase alphanumeric and hyphens, no leading or trailing hyphen, **max 56
  characters**. Lowercased and trimmed server-side. This is the tenant's permanent identifier and
  the key for every other call.
</ParamField>

<ParamField body="name" type="string" required>
  Organization display name.
</ParamField>

<ParamField body="owner_email" type="string" required>
  Owner's email. Lowercased and trimmed server-side.
</ParamField>

### Optional

<ParamField body="region" type="string" default="local">
  Resolves against `cells.region` and so decides the cell — and the cloud. Take this from
  `GET /cells`.
</ParamField>

<ParamField body="plan_tier" type="string" default="core">
  One of `core`, `team`, `enterprise`. **Recorded, never enforced** — it does not affect
  provisioning or gate any feature. (`growth` is not a value and returns 422.)
</ParamField>

<ParamField body="isolation" type="string" default="shared">
  Only `shared` can be provisioned. `dedicated` and `self-hosted` are **refused with 422**, not
  quietly downgraded — a dedicated stack is not reachable by `terraform apply` and self-hosted runs
  in the customer's own account. Recording either would put a claim in a durable field that
  disagrees with what was built. Track upgrade intent with the deal instead.
</ParamField>

<ParamField body="dev_endpoints" type="integer" default="0">
  0–100000. Recorded, never enforced.
</ParamField>

<ParamField body="server_endpoints" type="integer" default="0">
  0–100000. Recorded, never enforced.
</ParamField>

<ParamField body="security_profile_target" type="string" default="monitor">
  `monitor` or `enforce`. Recorded, never enforced.
</ParamField>

<ParamField body="notes" type="string">
  Free text for the operator's terms.
</ParamField>

<ParamField body="recorded_by" type="string">
  Who took the order. Free text — not authenticated.
</ParamField>

<ParamField body="gateways" type="string[]">
  **Accepted by the parser and then refused with 422.** A name-constraint grant needs an AWS KMS
  Ed25519 key, unavailable locally and uncreatable by the pinned Terraform provider. Provision
  here, then issue the grant with `quint-control provision --gateway ...` against a stack that has
  the key. Send an empty array or omit the field.
</ParamField>

### Response — 202

<ResponseField name="slug" type="string" />

<ResponseField name="state" type="string">Always `requested` at this point.</ResponseField>
<ResponseField name="region" type="string">The resolved region, after defaulting.</ResponseField>
<ResponseField name="poll_url" type="string">Path to poll. Always valid immediately — the flow row is opened synchronously, so there is no window where this 404s.</ResponseField>

<ResponseField name="accepted" type="boolean" />

```bash cURL theme={null}
curl -X POST "$BASE/internal/provision/tenants" \
  -H "Authorization: Bearer $OPERATOR_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
    "slug": "acme",
    "name": "Acme Corp",
    "owner_email": "ops@acme.example",
    "region": "us-east-1",
    "plan_tier": "team",
    "isolation": "shared",
    "dev_endpoints": 25,
    "security_profile_target": "monitor",
    "recorded_by": "hamza"
  }'
```

```json 202 theme={null}
{
  "slug": "acme",
  "state": "requested",
  "region": "us-east-1",
  "poll_url": "/internal/provision/tenants/acme",
  "accepted": true
}
```

### Errors

| Code | `error`          | Cause                                                                                                                                                                                                                      |
| ---- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `malformed_json` | Unparseable body, or an **unknown field**                                                                                                                                                                                  |
| 422  | `validation`     | Missing `slug`/`name`/`owner_email`; slug not a DNS label or over 56 chars; bad enum; endpoint count out of range; `gateways` present; `isolation` not `shared`                                                            |
| 409  | `state`          | A provision for this slug is **already running in this process** — poll it, do not start a second. Or the slug already belongs to a provisioned tenant, in which case **nothing was recorded and nothing was provisioned** |
| 503  | `database`       | Intake could not be recorded. Nothing was provisioned                                                                                                                                                                      |

Intake is recorded **before** provisioning and its failure is fatal to the request: a provision
that later fails still leaves the operator's terms on disk, which is when they matter most.

***

## `GET /internal/provision/tenants/{slug}`

The poll endpoint, and the one read a monitoring UI should be built on. Returns **404
`not_found`** when no flow row exists for the slug.

<ResponseField name="slug" type="string" />

<ResponseField name="org_id" type="string | null">Null until the organization row exists.</ResponseField>
<ResponseField name="state" type="string">See the state table above.</ResponseField>
<ResponseField name="terminal" type="boolean">**Stop polling when true.** Do not re-derive this.</ResponseField>
<ResponseField name="poll_after_ms" type="integer">Server-advised interval until the next poll.</ResponseField>
<ResponseField name="attempts" type="integer">Provision attempts so far.</ResponseField>
<ResponseField name="failed_code" type="integer | null">`quint-control` exit code. Operator-only.</ResponseField>
<ResponseField name="next_retry_at" type="datetime | null">When present on a `failed` state, the sweep will retry and the flow is **not** terminal.</ResponseField>

<ResponseField name="updated_at" type="datetime" />

<ResponseField name="operator_reason" type="string">Names the failing subsystem, e.g. `quint-control exit 4 (database)`. **Operator-only — never proxy to a customer surface.**</ResponseField>
<ResponseField name="customer_message" type="string">Neutral text safe to show a customer. Included so an operator can see exactly what the customer is being told.</ResponseField>
<ResponseField name="activation_duration_ms" type="integer">Present once the flow has both endpoints. First `requested` → first `active`. Derived from the journal, never stored.</ResponseField>

<ResponseField name="placement" type="object | null">
  Null while `requested`/`provisioning` (no registry row yet), and null after a full deprovision.
  Not an error.

  <Expandable title="placement">
    <ResponseField name="cell_id" type="string" />

    <ResponseField name="cloud" type="string">`aws`, `gcp`, `local`, or `unknown`.</ResponseField>
    <ResponseField name="database_name" type="string">`tenant_<slug>`. **A name, not proof the database exists.**</ResponseField>
    <ResponseField name="schema_version" type="string | null">**The stage-2 marker.** Null means the physical database has not been built. Non-null is the head migration it was built at, e.g. `000159_sessions_agent_backfill`.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="intake" type="object | null">
  Null for tenants provisioned by the CLI or before the intake table existed. Normal, not an error.

  <Expandable title="intake">
    <ResponseField name="plan_tier" type="string" />

    <ResponseField name="isolation" type="string" />

    <ResponseField name="requested_region" type="string">What the operator **asked for**, which can differ from where the tenant landed.</ResponseField>

    <ResponseField name="dev_endpoints" type="integer" />

    <ResponseField name="server_endpoints" type="integer" />

    <ResponseField name="security_profile_target" type="string" />

    <ResponseField name="notes" type="string" />

    <ResponseField name="recorded_by" type="string" />

    <ResponseField name="placement_matches_request" type="boolean | null">
      Whether the cell it landed in serves the region requested. **`false` is the single most
      useful anomaly this API surfaces** — it means placement fell back or the row was moved, which
      is invisible from either side alone. `null` means *unknown* (no placement yet, or the
      verification read failed) and must be rendered differently from `false`.
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="journal" type="object[]">Per-transition timestamps, oldest first. Each entry: `from_state` (null on the first), `to_state`, `failed_code`, `at`, `is_note`.</ResponseField>

```json 200 theme={null}
{
  "slug": "acme",
  "org_id": "8f14e45f-ea0f-4a3b-9c21-7d0b1e6a4c33",
  "state": "provisioned",
  "terminal": false,
  "poll_after_ms": 3000,
  "attempts": 1,
  "updated_at": "2026-08-28T20:42:30.521Z",
  "customer_message": "Your workspace is ready and is being connected.",
  "placement": {
    "cell_id": "aws-use1-cell-1",
    "cloud": "aws",
    "database_name": "tenant_acme",
    "schema_version": null
  },
  "intake": {
    "plan_tier": "team",
    "isolation": "shared",
    "requested_region": "us-east-1",
    "dev_endpoints": 25,
    "server_endpoints": 0,
    "security_profile_target": "monitor",
    "recorded_by": "hamza",
    "placement_matches_request": true
  },
  "journal": [
    { "from_state": null,             "to_state": "requested",    "at": "2026-08-28T20:42:30.175Z", "is_note": false },
    { "from_state": "requested",      "to_state": "provisioning", "at": "2026-08-28T20:42:30.176Z", "is_note": false },
    { "from_state": "provisioning",   "to_state": "provisioned",  "at": "2026-08-28T20:42:30.521Z", "is_note": false }
  ]
}
```

<Note>
  **`200` means "a request was once made", not "a tenant exists".** Deprovisioning drops the
  registry row but leaves the flow row, so a fully deprovisioned tenant still answers `200` with
  `state: "provisioned"` and a `customer_message` saying the workspace is ready. The tell is
  **`placement: null`**. A monitoring UI must treat that combination as *gone*, not *ready*.
</Note>

***

## `GET /internal/provision/tenants/{slug}/events`

The flow journal on its own, for a timeline view. Same 404 behaviour.

<ResponseField name="slug" type="string" />

<ResponseField name="journal" type="object[]">Same entries as the `journal` field above, oldest first.</ResponseField>

This is the **flow state-machine** journal. It is not the resource ledger — see below.

***

## Reading history and resource telemetry

Two ledgers exist, and they answer different questions. Only the first has an HTTP route.

| Ledger                   | Records                                                                                                                                                                                                                                                         | Reachable over HTTP    |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| `onboarding_flow_events` | Flow state transitions (`requested` → `provisioning` → `provisioned`)                                                                                                                                                                                           | Yes — `GET .../events` |
| `tenant_registry_events` | **Resource lifecycle**: `provisioned`, `db_provision_started`, `db_provisioned` (with role, database, schema version), `tenant_kms_keys_created` (with both ARNs), `workos_org_created`, `grant_issued`, `db_deprovisioned`, `offboarded`, and fan-out outcomes | **No**                 |

`tenant_registry_events` is append-only, indexed on `(org_id, at DESC)`, carries a `jsonb` detail
and an `actor`, and is the audit trail for where a tenant's data has lived. It is the right source
for resource-level telemetry — but there is currently **no route, and no CLI subcommand, that reads
it**. Reaching it means SQL against the control database.

<Warning>
  **Known gap for a monitoring UI.** Deprovisioning deletes the `tenant_registry` row rather than
  marking it (`lifecycle` allows only `trial`, `active`, `provisioned` — there is no
  `deprovisioned` value). Every fleet read inner-joins that table, so **churned tenants are
  invisible** to `quint-control fleet` and to anything built on it, even though their history rows
  survive. Do not build a "tenant history" view on the fleet read; it can only ever show live
  tenants.
</Warning>

Also worth knowing when designing a console:

* **`plan_tier` never reaches the registry.** It lives only in the intake record; `organizations.plan` is unrelated and will disagree. Read the tier from `intake`, not from the org.
* **The per-tenant secret id is not in any event**, but it is derivable: `quint/tenant/<slug>/db`.
* **Provisioning writes nothing to `admin_audit_log`.** Only offboarding does. If that is the compliance evidence surface, tenant lifecycle is not currently in it.

***

## Stage 2 and teardown (CLI only)

Neither has an HTTP route. A UI can display these as the operator's next action but cannot invoke
them.

```bash theme={null}
# Stage 2 — build the physical database
quint-control db-provision --slug <slug> \
  --admin-dsn "$ADMIN_DSN" \
  --migrations-dir infra/migrations \
  --apply-script scripts/apply-migrations.sh

# Teardown — exports before it drops
quint-control db-deprovision --slug <slug> --confirm-slug <slug> \
  --admin-dsn "$ADMIN_DSN" --export-dir <dir>
```

`db-deprovision` exports the database, drops it, retires the role, schedules the secret and both
KMS keys for deletion, and frees the cell slot — offboarding strictly last, and idempotent. It does
**not** delete the flow row, which is why the poll endpoint keeps answering 200.

<Warning>
  **`db-provision` reaches for real AWS and has no local-mode guard for Secrets Manager.** KMS is
  guarded by `QUINT_TENANT_KMS=fake`; Secrets Manager is not, so the endpoint override is the only
  thing between a local run and a write to whatever account the ambient credentials resolve to.
  `unset AWS_PROFILE` is load-bearing — a profile outranks dummy keys.
</Warning>

***

## Postman collection

Committed alongside this page at `api/collections/quint-provisioning.postman_collection.json`
(source of truth: `docs/api/quint-provisioning.postman_collection.json` in the platform repo — the
two are byte-identical, so re-copy rather than edit the copy). It ships a runnable
folder, **End-to-end — provision a tenant and watch it progress**, which picks a real open cell,
mints a unique slug per run, self-chains the poll loop on `poll_after_ms`, asserts the flow parks
at `provisioned` with a null `schema_version`, and then asserts the flip after stage 2. Verified
green: 20 requests, 43 assertions, 0 failures.

To get a daemon to point at:

```bash theme={null}
scripts/provisioning-api-local.sh up     # scratch Postgres + migrations + daemon, ~20s
scripts/provisioning-api-local.sh test   # the same, then drives both stages and asserts each
scripts/provisioning-api-local.sh down
```

`up` prints the exact `newman` invocation with the port and token it chose.

Only the **`remote`** environment is committed here, and every secret-typed value in it ships
empty — fill `base_url` and `operator_token` for the daemon you are pointing at. The platform repo
also carries a `local` environment preset for `127.0.0.1:8090`; it is deliberately not published,
because a committed environment is the easiest place for a real token to end up by accident.

### Two traps that cost real time

* **Postman environments must hold config only.** Environment scope resolves *before* collection
  variables, so an environment that also declares `slug` shadows every
  `pm.collectionVariables.set` the scripts make — the POST then interpolates a stale value and
  answers 422 for a reason absent from the request you are reading. The collection now fails loudly
  on this.
* **`pm.info.iteration` is not a poll counter.** It is the Runner's *iteration* index, so in a
  single-iteration run it is `0` forever and an `iteration < N` budget never fires. Count in a
  variable. And never `setNextRequest(null)` to leave a poll loop — it ends the entire run and
  silently skips every later request.
