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

# Provisioning Portal

> The staff-only internal portal for onboarding a new client end-to-end — client profile, data, training, evaluation, hosting, and go-live — plus its RBAC model.

<Info>
  This page documents the **internal, staff-only** portal (`/v1/provisioning/*`, excluding the
  platform-key M2M endpoint covered in [Provisioning API reference](/api-reference/provisioning)).
  It is a different surface from [Client Provisioning](/deploy-security/client-provisioning)'s
  `onboard_client()` tenant setup — this portal onboards a client's **ML model**: uploading their
  historical issue data, training a workspace model on it, evaluating it against quality gates,
  and hosting it for inference. Every claim below is verified against the code on this branch.
</Info>

## Turned off by default

The entire portal is gated behind one flag:

```bash theme={null}
PROVISIONING_PORTAL_ENABLED=false   # default
```

When off, **every** `/v1/provisioning/client-profiles/*`, `/v1/provisioning/training-runs/*`,
`/v1/provisioning/models/*`, and `/v1/provisioning/services/*` route returns `404 Not Found` —
never `403`. This is deliberate: an unauthenticated probe should not even be able to learn the
portal exists. The check runs as the outermost FastAPI dependency on the router, ahead of auth:

```python theme={null}
async def _require_portal_enabled() -> None:
    if not settings.PROVISIONING_PORTAL_ENABLED:
        raise ApiError(404, "not_found", "Not found")
```

(`app/routers/provisioning_portal.py`)

<Note>
  `POST /v1/provisioning/clients` — the platform-key M2M tenant-creation endpoint documented in
  [Provisioning API reference](/api-reference/provisioning) — is a **separate router with a
  separate guard** (`require_provisioning_key`) and is **not** affected by
  `PROVISIONING_PORTAL_ENABLED`. Turning the portal off does not turn off tenant provisioning.
</Note>

## The seven stages

The portal's UI/API contract is organized into seven numbered stages. This numbering comes
from the build plan's portal-stage↔API-contract table and is corroborated directly in the
router's own comments (`app/routers/provisioning_portal.py`: *"config form (portal step 03)"*,
*"portal step 03->04"*, *"portal stage 04's live console"*, *"go-live, step 06->07"*).

| Stage | Name            | What happens                                                                        | Endpoints                                                                                             |
| ----- | --------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 01    | Client profile  | Create the client record (display name, tenant slug, industry, primary data format) | `POST/GET/PATCH /provisioning/client-profiles[/{id}]`                                                 |
| 02    | Historical data | Register a data source (upload / S3 / database) and upload the file                 | `POST .../data-sources`, `POST .../data-sources/{id}/upload`, `POST .../data-sources/{id}/validate`   |
| 03    | Training config | Choose an embedding model + cluster preset for the training run                     | `GET /provisioning/model-registry`                                                                    |
| 04    | Train model     | Submit the training run; watch its live console                                     | `POST .../training-runs`, `GET .../training-runs/{id}`, `GET .../training-runs/{id}/events` (SSE)     |
| 05    | Evaluate        | Run the evaluation gate suite; approve, override, or reject                         | `POST .../evaluate`, `GET .../evaluation`, `POST .../approve`, `POST .../override`, `POST .../reject` |
| 06    | Host service    | Attach an inference service to the approved artifact                                | `POST .../services`, `GET /provisioning/services/{id}`                                                |
| 07    | Go live         | Activate the service; view the go-live summary                                      | `POST /provisioning/services/{id}/activate`, `GET .../summary`                                        |

<Warning>
  This 7-stage UI numbering is **not** the same enum as `client_profiles.status`. The status
  column only has six *reachable* values plus one declared-but-unused one — see
  [Model Lifecycle](/deploy-security/model-lifecycle) for the exact state machine. "Evaluate"
  (stage 05) has no dedicated client status at all: a client profile sits at `status=trained` for
  the entire train→evaluate→approve window. The evaluation's own state lives on the separate
  `training_runs`/`eval_runs` tables. Stage numbering and `client_profiles.status` are two
  different axes — don't conflate them.
</Warning>

## RBAC model

### Staff, not tenant roles

Staff access is a **global, cross-organization** concept, entirely separate from a workspace's
tenant `memberships.role` (owner/admin/analyst/viewer). A user becomes staff by having a live
row in `staff_profiles`, granted via:

```http theme={null}
POST /v1/provisioning/staff
Authorization: Bearer <staff:admin JWT>
{"user_id": "usr_...", "staff_role": "ml_eng"}
```

(`staff_role: null` revokes.) This endpoint itself requires `staff:admin` — granting staff
access is admin-only, one level above the router's own "any staff" floor. Four staff roles
exist, ranked lowest to highest:

```python theme={null}
STAFF_ROLE_ORDER: Dict[str, int] = {"support": 0, "fde": 1, "ml_eng": 2, "admin": 3}
```

(`app/security.py`)

The `staff` JWT claim is set **only** from a live `staff_profiles` row at token-issue time
(`resolve_staff_role`) — never derived from an identity provider's attributes, request headers,
or any other caller input. A token minted before a grant (or after a revoke) keeps the old
claim until it expires or the user re-authenticates.

### require\_staff + require\_feature — two independent checks

Every route under the portal router carries the router-level dependency `require_staff()` (any
staff claim required — 403 otherwise). Many routes add a **second**, independent check on top:

* `require_staff("admin")` / `require_staff("ml_eng")` — a **minimum staff role**, checked
  against `STAFF_ROLE_ORDER`.
* `require_feature("internal.<key>")` — the caller's **effective feature set** (from
  `role_features`, keyed off `staff:<role>`) must contain the named key.

Both checks are enforced server-side, in addition to each other — "defense in depth: the
feature matrix is the UI's contract (what `/v1/me`'s `features[]` renders controls off of), the
staff role check is the hard boundary" (`app/store/rbac_seed.py`).

### The six internal.\* feature keys

| Key                       | Guards                                                                  | Who has it                                 |
| ------------------------- | ----------------------------------------------------------------------- | ------------------------------------------ |
| `internal.provisioning`   | Access to the portal generally                                          | `staff:fde`, `staff:ml_eng`, `staff:admin` |
| `internal.eval_approve`   | D3 approve, D5 reject                                                   | `staff:ml_eng`, `staff:admin`              |
| `internal.eval_override`  | D4 override                                                             | `staff:admin` only                         |
| `internal.host_manage`    | E1 create/attach, E5 activate/retire (the **service lifecycle**)        | `staff:ml_eng`, `staff:admin`              |
| `internal.host_promote`   | E3 shadow/canary/promote/rollback (the **artifact promotion pipeline**) | `staff:ml_eng`, `staff:admin`              |
| `internal.retrain_manage` | F1 launch a manual/backfill retrain                                     | `staff:ml_eng`, `staff:admin`              |

`internal.host_manage` and `internal.host_promote` are a deliberate **split**, not a rename of
one boolean: attach/retire changes which service exists at all; promote/rollback changes which
model version live traffic is routed to — different blast radius, so they're gated separately
(`app/store/rbac_seed.py`).

### The four staff roles' effective feature sets

| Staff role      | Feature keys (in addition to the read-only product views every staff role gets)                                                                         |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `staff:support` | `internal.cross_org_read` only — read-only, no portal access                                                                                            |
| `staff:fde`     | `internal.provisioning`                                                                                                                                 |
| `staff:ml_eng`  | `internal.provisioning`, `internal.model_registry`, `internal.eval_approve`, `internal.host_manage`, `internal.host_promote`, `internal.retrain_manage` |
| `staff:admin`   | Everything `staff:ml_eng` has, **plus** `internal.eval_override` and `internal.cross_org_read`                                                          |

Source of truth: `ROLE_FEATURES` in `app/store/rbac_seed.py`. Only `staff:admin` can override a
failed evaluation — see [Evaluation Gates](/deploy-security/evaluation-gates) for what that
means and why it exists.

### Per-request overrides beyond the role floor

A few mutations check something the router-level and endpoint-level dependencies can't see —
the request **body**:

```python theme={null}
if body.plan == "pro" and auth.staff_role != "admin":
    raise ApiError(403, "forbidden", "plan='pro' requires staff:admin")
```

(E1 create-service, `app/routers/provisioning_portal.py`) — hosting a client on the `pro` Render
plan additionally requires `staff:admin`, even though attach itself only needs `staff:ml_eng` +
`internal.host_manage`.

## Audit trail

Every mutation writes an `audit_log` row via `repo.write_audit(...)`, including
`before`/`after` snapshots. Staff-global actions (like granting staff access itself) are
audited against the **acting** admin's own workspace\_id — `audit_log.workspace_id` is `NOT
NULL` and there is no workspace-agnostic anchor for a staff-global action. This is a
documented, deliberate choice, not an oversight.

## Related pages

* [Model Lifecycle](/deploy-security/model-lifecycle) — the state machines stages 04-07 drive
* [Evaluation Gates](/deploy-security/evaluation-gates) — what stage 05 actually checks
* [Inference & Queue](/deploy-security/inference-and-queue) — what "hosting" (stage 06-07) means on FREE vs PROD
* [Onboard a client](/guides/onboard-a-client) — the operator walkthrough
* [Provisioning API reference](/api-reference/provisioning) — full endpoint/request/response detail for both provisioning surfaces
