# API Contract

No backend API is implemented in Phase 1 or Phase 2 Stage 2C.

Stage 2B and Stage 2C use typed frontend mock services only:

```text
authService.loginWithOtp
authService.loginWithQr
authService.logout
notificationService.list
searchService.search
dashboardService.getDashboard
conversationService.list
conversationService.getById
conversationService.assign
conversationService.setStatus
conversationService.markRead
messageService.list
messageService.sendText
messageService.updateStatus
messageService.retry
customerService.list
customerService.getById
customerService.validate
customerService.create
customerService.update
customerService.archive
customerService.restore
customerService.addNote
customerService.setTags
customerService.assignAgent
orderService.list
orderService.getById
orderService.validate
orderService.create
orderService.update
orderService.changeOrderStatus
orderService.changePaymentStatus
orderService.changeFulfillmentStatus
orderService.addNote
orderService.cancel
productService.list
productService.getById
productService.validate
productService.create
productService.update
productService.updateStock
productService.enable
productService.disable
productService.delete
catalogueService.list
catalogueService.getById
catalogueService.create
catalogueService.update
catalogueService.archive
catalogueService.restore
catalogueService.addProduct
catalogueService.removeProduct
catalogueService.reorderProducts
broadcastService.list
broadcastService.getById
broadcastService.create
broadcastService.update
broadcastService.startMockSend
broadcastService.cancelBroadcast
templateService.list
templateService.getById
templateService.create
templateService.update
templateService.duplicate
templateService.archive
templateService.restore
segmentService.list
segmentService.getById
segmentService.create
segmentService.update
segmentService.delete
ticketService.list
ticketService.getById
ticketService.create
ticketService.update
ticketService.assign
ticketService.changeStatus
ticketService.resolve
ticketService.addNote
analyticsService.get
teamService.listMembers
teamService.getMember
teamService.updateMember
teamService.suspendMember
teamService.reactivateMember
teamService.deactivateMember
teamService.transferAssignments
invitationService.list
invitationService.create
invitationService.cancel
invitationService.resend
invitationService.acceptInvitation
roleService.list
roleService.getById
roleService.create
roleService.update
roleService.duplicate
roleService.delete
roleService.setDefault
organizationSettingsService.get
organizationSettingsService.update
userPreferencesService.get
userPreferencesService.update
auditService.list
auditService.getById
auditService.appendAuditEvent
```

These are proposed client-side service seams, not HTTP endpoints.

Before any backend feature is built, define each endpoint here with:

- Method
- Route
- Authentication
- Request body
- Response body
- Errors
- Authorization rules
- Organization isolation
- Idempotency requirements
- Pagination
- Filtering

## Phase 3B Implemented Endpoints

```text
GET    /api/v1/customers
POST   /api/v1/customers
GET    /api/v1/customers/{id}
PATCH  /api/v1/customers/{id}
POST   /api/v1/customers/{id}/archive
POST   /api/v1/customers/{id}/restore
POST   /api/v1/customers/{id}/notes
GET    /api/v1/customers/{id}/notes
GET    /api/v1/customers/{id}/activities
GET    /api/v1/customers/{id}/identities
POST   /api/v1/customers/{id}/tags

GET    /api/v1/tags
POST   /api/v1/tags

GET    /api/v1/conversations
POST   /api/v1/conversations
GET    /api/v1/conversations/{id}
PATCH  /api/v1/conversations/{id}
POST   /api/v1/conversations/{id}/assign
POST   /api/v1/conversations/{id}/status
POST   /api/v1/conversations/{id}/read
POST   /api/v1/conversations/{id}/unread

GET    /api/v1/conversations/{id}/messages
POST   /api/v1/conversations/{id}/messages
PATCH  /api/v1/conversations/{id}/messages/{mid}/status
POST   /api/v1/conversations/{id}/messages/{mid}/retry

GET    /api/v1/search?q=...

POST   /api/v1/dev/conversations/{id}/inbound   (development only)
```

## Phase 3C Implemented Endpoints

```text
GET    /api/v1/orders
POST   /api/v1/orders
GET    /api/v1/orders/{id}
PATCH  /api/v1/orders/{id}
POST   /api/v1/orders/{id}/status
POST   /api/v1/orders/{id}/payment-status
POST   /api/v1/orders/{id}/fulfillment-status

GET    /api/v1/products
POST   /api/v1/products
GET    /api/v1/products/{id}
PATCH  /api/v1/products/{id}
POST   /api/v1/products/{id}/stock
POST   /api/v1/products/{id}/availability
POST   /api/v1/products/{id}/archive
POST   /api/v1/products/{id}/restore
DELETE /api/v1/products/{id}
POST   /api/v1/products/{id}/variants/bulk-update

GET    /api/v1/catalogues
POST   /api/v1/catalogues
GET    /api/v1/catalogues/{id}
PATCH  /api/v1/catalogues/{id}
POST   /api/v1/catalogues/{id}/archive
POST   /api/v1/catalogues/{id}/restore
POST   /api/v1/catalogues/{id}/products
DELETE /api/v1/catalogues/{id}/products/{product_id}
PUT    /api/v1/catalogues/{id}/products/reorder
```

## Phase 3D Implemented Endpoints

```text
GET    /api/v1/tickets
POST   /api/v1/tickets
GET    /api/v1/tickets/{id}
PATCH  /api/v1/tickets/{id}
POST   /api/v1/tickets/{id}/status
POST   /api/v1/tickets/{id}/assign
POST   /api/v1/tickets/{id}/notes

GET    /api/v1/templates
POST   /api/v1/templates
GET    /api/v1/templates/{id}
PATCH  /api/v1/templates/{id}
POST   /api/v1/templates/{id}/archive
POST   /api/v1/templates/{id}/restore
POST   /api/v1/templates/{id}/duplicate

GET    /api/v1/segments
POST   /api/v1/segments
GET    /api/v1/segments/{id}
PATCH  /api/v1/segments/{id}
DELETE /api/v1/segments/{id}

GET    /api/v1/broadcasts
POST   /api/v1/broadcasts
GET    /api/v1/broadcasts/{id}
PATCH  /api/v1/broadcasts/{id}
DELETE /api/v1/broadcasts/{id}
POST   /api/v1/broadcasts/{id}/schedule
POST   /api/v1/broadcasts/{id}/send
POST   /api/v1/broadcasts/{id}/complete-send
POST   /api/v1/broadcasts/{id}/pause
POST   /api/v1/broadcasts/{id}/resume
POST   /api/v1/broadcasts/{id}/cancel

GET    /api/v1/analytics/overview?days=N

GET    /api/v1/search?q=...  (extended: tickets, broadcasts, templates, segments)
```

Phase D5 UI note: `/app/templates` uses these internal template endpoints only.
Provider-synced WhatsApp templates remain under WhatsApp connection template
sync endpoints and must not be conflated with internal broadcast templates.

## Native Auth Closure & Readiness Endpoints (Implemented)

```text
GET    /health                                   liveness (always 200)
GET    /ready                                    readiness (bounded SELECT 1; 200 ready / 503 not_ready)

POST   /api/v1/auth/register                     self-service org creation → org + user + rt cookie (201)
POST   /api/v1/auth/login                        email + password + org slug + remember_me → LoginResponse: either
                                                  access token (body) + rt cookie, OR (V3.3c-2) mfa_required=true with
                                                  mfa_session_token and no tokens yet
GET    /api/v1/auth/me                           user/org/role/permissions from JWT; mfa_enabled (V3.3c-2)
POST   /api/v1/auth/refresh                      restore/rotate refresh token from cookie alone → new access token
POST   /api/v1/auth/logout                       revoke refresh session, clear cookie
POST   /api/v1/auth/logout-all                   revoke every active session for this user (all devices)
GET    /api/v1/auth/sessions                     list active sessions (id, created_at, device_hint, ip_address,
                                                  user_agent, last_seen_at, is_current)
DELETE /api/v1/auth/sessions/{session_id}        revoke a single session
POST   /api/v1/auth/change-password              validate current password, re-hash
```

`POST /auth/login`'s `remember_me` field (default `true`) controls refresh-cookie persistence:
`true` issues a 30-day persistent cookie; `false` issues a session cookie cleared when the
browser/WebView process fully exits. `POST /auth/logout-all` and `GET /auth/sessions` are
desktop-wired as of 2026-07-21 (Settings → Security → "Sign out all other sessions"); the
session-list UI itself remains backend-only (not surfaced in the desktop UI). As of V3.3c-1,
`ip_address`, `user_agent`, and `last_seen_at` are real fields backed by `refresh_tokens`
columns of the same name (captured at login/register, carried forward across rotation);
`device_hint` alone is still not populated by any code path.

### MFA Endpoints (V3.3c-2)

```text
POST /api/v1/auth/mfa/enroll                     Bearer-authenticated; returns {secret, provisioning_uri} for a new
                                                  authenticator enrollment (does not enable MFA until verified)
POST /api/v1/auth/mfa/verify                     Bearer-authenticated; {code} completes enrollment, sets
                                                  mfa_enabled=true, returns recovery_codes (shown once)
POST /api/v1/auth/mfa/disable                    Bearer-authenticated; {password} required; clears the secret and
                                                  every recovery code
POST [redacted]  Bearer-authenticated; {password, code} required; invalidates the
                                                  old recovery codes and returns a fresh set (shown once)

POST /api/v1/auth/mfa/login-enroll               mfa_session_token only (no Bearer token exists yet); returns
                                                  {secret, provisioning_uri} for a pending mandatory enrollment
POST /api/v1/auth/mfa/login-verify               mfa_session_token + code; completes either a mandatory enrollment
                                                  or a normal challenge and returns a LoginResponse with real tokens
```

Every `code` field accepts either a live 6-digit TOTP code or one of the
user's unused recovery codes; the backend records which kind matched
(`auth.mfa.challenge.succeeded` vs `auth.mfa.recovery_code.used`) but the
request shape is identical either way. `mfa_session_token` is a DB-backed,
sha256-hash-only opaque token (mirroring `RefreshToken`'s storage pattern)
issued by `POST /auth/login` when it resolves `mfa_required: true`; it is
never a JWT and carries no elevated claims of its own.

`/ready` returns `{"status":"ready","database":"ok"}` (200) or
`{"status":"not_ready","database":"unavailable"}` (503) and never exposes
credentials, connection strings, or exception details. `/health` returns
`{"status":"ok","service":"vendale-backend"}`. See `docs/SECURITY.md` for
the liveness-vs-readiness contract and token policies.

## Phase 4A WhatsApp Provider Endpoints (Implemented)

```text
GET    /api/v1/whatsapp/connections                      list connections (org-scoped)
POST   /api/v1/whatsapp/connections                      create connection
GET    /api/v1/whatsapp/connections/{id}                 get connection (no access token returned)
PATCH  /api/v1/whatsapp/connections/{id}                 update display_name/waba_id/phone fields
DELETE /api/v1/whatsapp/connections/{id}                 delete connection
POST   /api/v1/whatsapp/connections/{id}/token           set encrypted access token (stored AES-256-GCM; plaintext never returned)
POST   /api/v1/whatsapp/connections/{id}/status          status transitions (pending→active, active→disabled, etc.)
POST   /api/v1/whatsapp/connections/{id}/verify          verify WABA/phone via GET /{version}/{waba_id}/phone_numbers; advances to verified
POST   /api/v1/whatsapp/connections/{id}/subscribe       subscribe WABA via POST /{version}/{waba_id}/subscribed_apps; advances to active

GET    /webhooks/meta/whatsapp                           Meta webhook verification challenge (hub.verify_token check; echoes hub.challenge)
POST   /webhooks/meta/whatsapp                           Meta webhook event receipt (X-Hub-Signature-256 constant-time HMAC-SHA256; 403 on invalid)

POST   [redacted]          development only; reset runtime fake provider state
POST   [redacted]       development only; select deterministic fake provider scenario
GET    [redacted]          development only; inspect redacted fake provider diagnostics
POST   [redacted]   development only; create WhatsApp Cloud conversation fixture
POST   /api/v1/dev/acceptance/viewer-user                development only; create low-permission acceptance user
```

Security constraints (enforced):
- `encrypted_access_token` is AES-256-GCM encrypted with master key from env; never returned in responses.
- `META_APP_SECRET` and `META_WEBHOOK_VERIFY_TOKEN` are backend-only env vars; never exposed to the desktop or API consumers.
- Live Meta sends require both `META_LIVE_MODE=true` and `META_ALLOW_LIVE_SEND=true`.
- `WHATSAPP_PROVIDER_TRANSPORT=fake` is development-only; production startup rejects it.
- All webhook POST requests are rejected (403) if `X-Hub-Signature-256` is missing or invalid.

## Planned Later Endpoints

```text
POST /api/auth/login
POST /api/auth/refresh
POST /api/auth/logout

GET  /api/contacts
GET  /api/contacts/{id}

GET  /api/conversations
GET  /api/conversations/{id}
GET  /api/conversations/{id}/messages

GET  /api/orders
GET  /api/orders/{id}
POST /api/orders
PATCH /api/orders/{id}
POST /api/orders/{id}/notes
POST /api/orders/{id}/cancel

GET  /api/products
GET  /api/products/{id}
POST /api/products
PATCH /api/products/{id}
PATCH /api/products/{id}/stock
DELETE /api/products/{id}

GET  /api/catalogues
GET  /api/catalogues/{id}
POST /api/catalogues
PATCH /api/catalogues/{id}
POST /api/catalogues/{id}/products
DELETE /api/catalogues/{id}/products/{product_id}

POST /api/messages
POST /api/messages/templates
POST /api/messages/media

GET  /webhooks/whatsapp
POST /webhooks/whatsapp

WS   /ws
```

Meta credentials must never be exposed to the desktop client.

## Phase 4B WhatsApp Template Broadcast Endpoints (Implemented)

```text
POST /api/v1/whatsapp/connections/{id}/templates/sync     sync provider templates for an active/verified connection
GET  /api/v1/whatsapp/connections/{id}/templates           list synced provider templates; supports supported_only=true

GET  /api/v1/whatsapp/consents                            list customer channel consent records
PUT  /api/v1/whatsapp/consents                            upsert customer opt-in/opt-out for channel + purpose

POST /api/v1/broadcasts                                   create mock or whatsapp_cloud_template broadcast
POST /api/v1/broadcasts/{id}/prepare                      freeze recipients and run eligibility checks
GET  /api/v1/broadcasts/{id}/recipients                   list frozen recipients and delivery state
POST /api/v1/broadcasts/{id}/send                         queue durable dispatch jobs for eligible recipients
POST /api/v1/broadcasts/{id}/pause                        pause queued durable jobs
POST /api/v1/broadcasts/{id}/resume                       requeue eligible/retryable recipients
POST /api/v1/broadcasts/{id}/cancel                       cancel queued/retryable durable jobs
```

Broadcast `delivery_mode` values:

- `whatsapp_mock`: existing simulated broadcast path.
- `whatsapp_cloud_template`: approved text-template path with server-side eligibility, consent enforcement, recipient freezing, and durable worker dispatch.

Provider-template responses are read-only; the product does not create, edit, or delete Meta templates in Phase 4B.

## Phase 4C Media And Interactive Endpoints (Accepted 2026-06-20)

```text
POST /api/v1/conversations/{id}/messages/media            upload, validate, store, send provider media message
GET  /api/v1/media?media_type=image&ready_only=true       list clean outbound media assets for broadcast creation
GET  /api/v1/media/{asset_id}/download                    authorized clean-media download
POST /api/v1/conversations/{id}/read                      provider mark-read attempt in provider-enabled conversations
```

Phase 4C also extends existing Phase 4B endpoints:

- `POST /api/v1/whatsapp/connections/{id}/templates/sync` now classifies approved text, media-header, quick-reply, and URL-button templates.
- `GET /api/v1/whatsapp/connections/{id}/templates?supported_only=true` returns supported text, media, and interactive templates.
- `POST /api/v1/broadcasts` accepts `media_asset_id` for `whatsapp_cloud_template` broadcasts; backend validation requires outbound, clean, non-quarantined, non-expired media that matches the template header type.
- `POST /api/v1/broadcasts/{id}/prepare` validates and freezes clean, non-quarantined, non-expired media assets for media-header templates.
- The broadcast dispatch worker uploads frozen media when needed and sends typed Meta template components through the provider boundary.
- Inbound interactive replies persist reply type/id/title/description, context provider message ID, source message ID, source provider attempt ID, BroadcastRecipient ID where resolvable, and diagnostic correlation status/details. Reply receipt does not trigger automated business actions.

Full native fake-provider acceptance passed (2026-06-20), covering image/document/audio/video outbound and inbound, quarantine/recovery, interactive reply BroadcastRecipient correlation, broadcast pause/resume/cancel lifecycle, mark-as-read provider call, restart/logout persistence, and browser-storage security.

Remaining gap (non-blocking): full media-asset preview in the broadcast monitoring page is a UI polish item. Live Meta activation requires separate production credentials, infrastructure, and operator authorization.

## Production Dashboard And Operational Module Endpoints (2026-06-22)

```text
GET    /api/v1/dashboard/overview

GET    /api/v1/tasks
POST   /api/v1/tasks
GET    /api/v1/tasks/{task_id}
PATCH  /api/v1/tasks/{task_id}
POST   /api/v1/tasks/{task_id}/complete
POST   /api/v1/tasks/{task_id}/reopen
DELETE /api/v1/tasks/{task_id}

GET    /api/v1/automations
POST   /api/v1/automations
GET    /api/v1/automations/{automation_id}
PATCH  /api/v1/automations/{automation_id}
POST   /api/v1/automations/{automation_id}/validate
POST   /api/v1/automations/{automation_id}/status
POST   /api/v1/automations/{automation_id}/dry-run
GET    /api/v1/automations/{automation_id}/executions

GET    /api/v1/copilot/readiness
```

All endpoints require authenticated API-mode access and backend RBAC. The
dashboard endpoint accepts `range`, `from`, `to`, and `timezone` query
parameters and returns explicit unavailable states for metrics that cannot yet
be calculated safely.

## SaaS Commercialization Endpoints (2026-07-31)

Billing (all require an authenticated principal; `billing.view` to read,
`billing.manage` to move money or cancel):

```text
GET  /api/v1/billing/subscription              current standing; can_write resolved server-side
GET  /api/v1/billing/invoices                  paginated billing history
POST /api/v1/billing/setup-fee/initialize      returns a Paystack hosted checkout URL
POST /api/v1/billing/subscription/initialize   starts the recurring plan (setup fee must be paid)
POST /api/v1/billing/subscription/cancel       disables the provider subscription

POST /webhooks/paystack                        outside /api/v1 and the JWT chain
```

`POST /webhooks/paystack` verifies `x-paystack-signature` as HMAC-SHA512 over the
raw request body before parsing, dedupes on a unique SHA-256 `payload_hash`, and
always returns 200 so a bug of ours cannot trigger a Paystack retry storm.
Handled events: `charge.success`, `subscription.create`, `subscription.enable`,
`invoice.payment_failed`, `charge.failed`, `subscription.disable`,
`subscription.not_renew`. Card details never reach Vendale — checkout is
hosted by Paystack and only opaque references are stored.

Subscription lifecycle: `pending_setup → active → past_due → read_only`, plus
`cancelled`. `active`, `past_due`, and `grace` permit writes; `read_only` blocks
mutations with **402** while reads always succeed. Enforcement is applied by a
router-level dependency (not inside `get_current_principal`, which stays
stateless) and is disabled unless `BILLING_ENFORCEMENT_ENABLED` is true.

AI Copilot (`copilot.use` for everyday actions, `copilot.manage` to bind a
credential):

```text
GET   /api/v1/copilot/readiness                      status; includes automatic_send_enabled (always false)
GET   /api/v1/copilot/usage                          tenant quota/usage from the gateway
POST  /api/v1/copilot/connect                        provisions this tenant's own gateway organization
PATCH /api/v1/copilot/config                         enable/disable, model, retention, or supply a key
POST  /api/v1/copilot/disconnect                     clears the stored credential
POST  /api/v1/copilot/chat                           operator-facing chat
POST  /api/v1/copilot/conversations/{id}/draft       drafts a reply; sends nothing (201)
GET   /api/v1/copilot/drafts                         list drafts by conversation/status
POST  /api/v1/copilot/drafts/{id}/resolve            accepted | discarded; sends nothing
```

There is deliberately no send endpoint here. A draft can only reach a customer
through the existing conversation message endpoint, invoked explicitly by an
agent, so `LIVE_SEND_ENABLED` and `provider_send_enabled` still govern delivery.
Drafting requires both `copilot.use` and `inbox.reply`, so a viewer cannot
produce customer-facing text.

AI traffic is proxied to the Axon gateway's `POST /v1/messages` (Anthropic-
compatible — the gateway's native chat endpoint strips system prompts). Gateway
failures map to stable statuses: 503 not configured / provider unavailable, 502
invalid key, 403 suspended, 402 quota exhausted, 429 rate limited. Quota and
rate-limit messages are passed through verbatim because they carry reset hints.

## WhatsApp Flows Endpoints (Phase F, 2026-07-31)

```text
GET    /api/v1/whatsapp/connections/{id}/flows                    list Flow definitions
POST   /api/v1/whatsapp/connections/{id}/flows                    create a draft Flow
GET    /api/v1/whatsapp/connections/{id}/flows/{flow_id}          get a Flow definition
PATCH  /api/v1/whatsapp/connections/{id}/flows/{flow_id}          update a draft Flow (422 once published)
POST   /api/v1/whatsapp/connections/{id}/flows/{flow_id}/publish  create-on-Meta (if needed) + upload JSON + publish
POST   /api/v1/whatsapp/connections/{id}/flows/{flow_id}/deprecate deprecate a published Flow

POST   /api/v1/whatsapp/connections/{id}/flows/keypair             generate/rotate this connection's RSA keypair
GET    /api/v1/whatsapp/connections/{id}/flows/keypair             get the current public key (never returns the private key)

POST   /api/v1/conversations/{id}/messages/flow                    send a published Flow to this conversation's customer

POST   /webhooks/meta/whatsapp/flows/{connection_id}/data-exchange  encrypted Flow data-exchange (public, outside JWT)
```

All `/flows/*` routes under `/whatsapp/connections/{id}` reuse the existing
`whatsapp_connection.view` (read) / `.manage` (write) permissions — no new
permission keys were added for this feature. `POST /flows/keypair` and
`GET /flows/keypair` are declared before the `{flow_id}` routes in source so
`"keypair"` is never matched as a `flow_id` path parameter (Starlette
resolves routes in declaration order — this was a real bug caught by a
dedicated regression test, not a defensive assumption).

`POST /conversations/{id}/messages/flow` requires the target Flow to already
be `status="published"` (i.e. it has a `provider_flow_id`) — publishing is a
separate, connection-level operation this send-path endpoint does not
perform implicitly. Like every other outbound send, it is gated by
`provider_send_enabled` (live Meta or the development fake transport).

The data-exchange endpoint's response is raw base64 **text**, not a JSON
envelope, on success — this matches Meta's own reference server exactly. Its
error status codes (421 decryption failed, 427 flow_token invalid, 432
signature mismatch) are Meta's own documented Flow endpoint codes, not this
codebase's usual 400/403/404 conventions. See `docs/ARCHITECTURE.md` and
`docs/RESEARCH_LOG.md` (2026-07-31 entries) for the full protocol and
endpoint-shape rationale.

## Instagram DM Endpoints (Phase I1-I2, 2026-07-31)

```text
GET    /api/v1/instagram/connections                    list Instagram connections
POST   /api/v1/instagram/connections                    create connection
GET    /api/v1/instagram/connections/{id}                get connection (no access token returned)
PATCH  /api/v1/instagram/connections/{id}                update display_name/ig_user_id/ig_username
DELETE /api/v1/instagram/connections/{id}                delete connection
POST   /api/v1/instagram/connections/{id}/token          set encrypted access token
POST   /api/v1/instagram/connections/{id}/status         status transitions
POST   /api/v1/instagram/connections/{id}/verify         verify via GET {IG_ID}?fields=id,username,name
POST   /api/v1/instagram/connections/{id}/activate       verified -> active
```

These mirror the WhatsApp connection routes 1:1, with one deliberate naming
difference: there is no `/subscribe` route. Instagram Login app webhook
subscription is configured once at the app level in the Meta developer
console, not per-connection, so `/activate` simply advances the connection's
own status — it makes no outbound Meta API call (unlike WhatsApp's
`/subscribe`, which really does `POST .../subscribed_apps`).

All routes reuse `instagram_connection.view` (read) / `.manage` (write)
permissions — new keys added to the catalogue, flowing through owner/admin/
manager exactly like `whatsapp_connection.*`. `encrypted_access_token` is
never returned in any response.

No live Instagram Business account or live credentials have been used;
`verify`/`activate` are exercised only against `FakeHttpTransport` in tests.
Live activation requires `META_INSTAGRAM_LIVE_MODE=true` AND
`META_INSTAGRAM_ALLOW_LIVE_SEND=true` (both false in all committed
configuration, mirroring WhatsApp's gate but kept entirely separate — see
`docs/ARCHITECTURE.md`).

## Instagram Webhook and Send Endpoints (Phase I3-I5, 2026-07-31)

```text
GET  /webhooks/meta/instagram    webhook verification (hub.challenge) — public, outside JWT
POST /webhooks/meta/instagram    inbound event delivery — public, outside JWT
```

Both reuse `verify_webhook_signature` and the `hub.challenge` handshake with
zero modification from the WhatsApp routes — same `META_WEBHOOK_VERIFY_TOKEN`
and `META_APP_SECRET`, since these are configured per-app in the Meta
developer console, not per-product. Dispatch to the Instagram-specific
processing path happens inside `WebhookProcessingService.handle_delivery()`
based on `payload["object"] == "instagram"`.

Outbound Instagram sends use the **existing** conversation message endpoint
(`POST /conversations/{id}/messages` — no new Instagram-specific send route
was added). `MessageService.send_message()` internally resolves
`is_instagram_dm` from the conversation's `channel`/`instagram_connection_id`
and routes to the Instagram provider automatically when
`instagram_provider_send_enabled` is true — the same dispatch shape already
used for WhatsApp's `is_whatsapp_cloud`.

I6-I7 complete: AI drafting (`POST /copilot/conversations/{id}/draft` and the
`generate_ai_draft` automation action) is confirmed to work unmodified for
Instagram conversations — verified with dedicated tests, not just assumed.
Desktop UI (Settings `InstagramSettings` section, `instagramApiService.ts`,
Inbox channel badge) is complete. This closes Phase I (Instagram DM Channel)
entirely. No live Instagram Business account or live credentials were used.

## Phase D5 Desktop API Usage

D5 native API-mode smoke verified the desktop uses existing backend contracts
for dashboard, products, catalogues, broadcasts, tickets, templates, segments,
team, roles, and audit. No backend endpoint or schema change was introduced for
D5 closure; frontend API loaders now hydrate backing collections for direct
detail route behavior.

## Request Correlation ID (cross-cutting)

`RequestIDMiddleware` (`backend/app/middleware/request_id.py`) runs on
every request across the whole backend, not just `/platform-admin`.
It resolves one correlation ID per request: a caller-supplied
`X-Request-ID` header is honoured when it matches a conservative allowlist
(`^[A-Za-z0-9_.-]{1,100}$`); otherwise, or when the header is missing,
malformed, or oversized, a fresh UUID4 is generated instead. The resolved
ID is always echoed back as the `X-Request-ID` response header, so a
caller can find the ID that was actually used even when it supplied none
itself or supplied one that got replaced.

This closes the gap the platform-audit foundation (V3 Part 2) and
`/platform-admin/search` both documented from their own introduction:
`PlatformAuditEvent.request_id` existed as a column with no middleware to
populate it. Every platform-admin mutation endpoint now threads the
resolved ID from `request.state.request_id` through its
`PlatformAdminService` call into `AuditService.append_platform(request_id=...)`,
so `GET /platform-admin/audit` returns a real, non-null `request_id` for
every event recorded from this point forward. Rows written before this
middleware existed still have `request_id = null` -- a true historical
fact, never backfilled or fabricated.

Deliberately out of scope for this slice (tracked as natural follow-up,
not silently included): request-scoped structured logging correlation
(stamping `request_id` onto every log line via a logging filter/contextvar)
and indexing `PlatformAuditEvent.request_id` as a `/platform-admin/search`
category. Both are straightforward extensions of the same resolved ID,
not blocked on anything this slice left unfinished.

## Platform Admin Endpoints

All routes below live under `/platform-admin` and are gated at the router
level by `require_platform_superuser` (`User.is_active` and
`User.is_superuser`, re-read from PostgreSQL on every request: this is
entirely orthogonal to tenant RBAC, not a permission-catalogue key). Since
V3.3a, a subset of routes (marked below) additionally require one
`platform.*` permission, resolved from the caller's `PlatformRole` and also
re-read from PostgreSQL on every request. See the "V3.3a: Platform RBAC"
section further down for the full model.

```text
GET   /platform-admin/overview                       cross-tenant counts (workspaces, users, memberships, subscriptions by status)
GET   /platform-admin/workspaces                      paginated/searchable workspace list
PATCH /platform-admin/workspaces/{workspace_id}       activate/suspend, set subscription_status; requires reason (10-500 chars); audited [platform.workspaces.manage]
POST  /platform-admin/workspaces/{workspace_id}/suspend            block login/API access; idempotent; self-session guarded [platform.workspaces.manage]
POST  /platform-admin/workspaces/{workspace_id}/reactivate         restore login/API access; idempotent; does not change read-only status [platform.workspaces.manage]
POST  /platform-admin/workspaces/{workspace_id}/read-only/enable   block writes only; idempotent [platform.workspaces.manage]
POST  /platform-admin/workspaces/{workspace_id}/read-only/disable  restore writes; no-op unless currently read-only (never overrides a real billing lapse) [platform.workspaces.manage]
POST  /platform-admin/workspaces/{workspace_id}/revoke-sessions    revoke every active session for every user in the workspace [platform.sessions.revoke]
POST  /platform-admin/workspaces/{workspace_id}/automations/pause  disable every currently-enabled automation rule; never deletes rules [platform.automations.manage]
POST  /platform-admin/workspaces/{workspace_id}/automations/resume re-enable exactly the rules the last pause disabled [platform.automations.manage]
GET   /platform-admin/users                           paginated/searchable global user list
PATCH /platform-admin/users/{user_id}                 activate/deactivate, grant/revoke is_superuser; requires reason; audited; self-lockout guarded [platform.users.manage]
POST  /platform-admin/users/{user_id}/revoke-sessions              revoke every active session for one user [platform.sessions.revoke]
GET   /platform-admin/users/{user_id}/sessions                     list one user's active sessions (id, created_at, ip_address,
                                                       user_agent, last_seen_at; no is_current concept) [platform.security.read]
DELETE /platform-admin/users/{user_id}/sessions/{session_id}       revoke a single session for one user [platform.sessions.revoke]
GET   /platform-admin/roles                           the fixed 6-role platform catalogue with each role's permission_keys
PATCH /platform-admin/users/{user_id}/platform-role   assign/change/remove a user's platform role; requires reason and a live
                                                       step_up_token (V3.3c-2); audited; last-platform_owner guarded;
                                                       downgrading revokes the target's existing sessions [platform.roles.manage]
POST  /platform-admin/step-up                         re-verify the caller's own password + TOTP/recovery code for a
                                                       short-lived (~10 min) step_up_token, required by the role/MFA-reset/
                                                       revoke-all-sessions mutations above and below (V3.3c-2)
POST  /platform-admin/users/{user_id}/mfa/reset       destroy a user's MFA secret and recovery codes, forcing fresh
                                                       enrollment; requires reason + step_up_token; caller never sees the
                                                       target's secret/codes (V3.3c-2) [platform.security.manage]
POST  [redacted]           destructive: revoke every active session for every
                                                       platform administrator, including the caller's own; requires reason +
                                                       step_up_token (V3.3c-2) [platform.sessions.revoke]
GET   /platform-admin/audit                           immutable platform_audit_events, filterable by workspace_id,
                                                       actor_id, action, target_type, date_from, date_to, and search
GET   /platform-admin/runtime                         secret-safe API/DB/channel/webhook health snapshot

GET   /platform-admin/subscriptions                   paginated/searchable/status-filterable OrganizationSubscription list
GET   /platform-admin/invoices                        [redacted] Invoice list
GET   /platform-admin/billing/overview                subscription status counts, invoice counts, revenue collected (30d/all-time),
                                                       recent failed payments; deliberately returns no mrr/arr field (no stored price)

GET   /platform-admin/jobs/summary                    per-queue counts (automation/broadcast/media), normalized into
                                                       queued/active/completed/failed/other buckets
GET   /platform-admin/jobs?queue=<automation|broadcast|media>   paginated/searchable/status-filterable job list; queue is required;
                                                       status values are each queue's own real vocabulary, not the normalized buckets
POST  /platform-admin/jobs/{job_id}/retry?queue=<automation|media>   requeue a job stuck in a genuinely terminal failed
                                                       state; 409 on any other status; broadcast queue rejected with 422 [platform.jobs.retry]
POST  /platform-admin/jobs/{job_id}/cancel?queue=<automation|media>  cancel a queued/pending/failed job; 409 if actively
                                                       leased (cannot be interrupted safely); broadcast queue rejected with 422 [platform.jobs.cancel]

GET   /platform-admin/attention                       Command Center alert items (channel errors, failed jobs, subscriptions
                                                       needing attention, webhook failures); empty list when nothing needs review

GET   /platform-admin/security/overview               administrator/owner/legacy-admin counts, per-role user counts, 24h
                                                       sensitive-action count, attention items, the last 10 platform audit
                                                       events, auth telemetry (V3.3c-1), and MFA/privilege-change counts
                                                       (mfa_protected_administrators, administrators_without_mandatory_mfa,
                                                       privilege_changes_24h; V3.3c-2) [platform.security.read]
GET   /platform-admin/security/administrators         paginated/searchable list of every is_superuser user with
                                                       is_legacy_admin, tenant_roles, last_session_at, role_assigned_at, and
                                                       (V3.3c-2) mfa_enabled, mfa_enrolled_at, mfa_last_verified_at,
                                                       active_session_count [platform.security.read]
```

`PATCH /platform-admin/workspaces/{id}` is the only mutation surface for
subscription status: the desktop's Subscriptions & Billing tab calls this
same endpoint rather than introducing a parallel one. Bracketed
`[platform.*.key]` annotations mark routes that require the named permission
in addition to the router-level `is_superuser` gate (see "V3.3a: Platform
RBAC" below); routes with no bracket remain gated by `is_superuser` alone, a
deliberate V3.3a scope boundary, not an oversight.

```text
GET /platform-admin/search?q=<query>                         cross-tenant search: workspace, user, customer (phone),
                                                               order, invoice, webhook_event, job, message, error (code)
GET /platform-admin/workspaces/{workspace_id}/diagnostics    composite per-workspace health: subscription, channel
                                                               connections, webhook health, job queues, automation
                                                               health, recent provider errors, derived last activity,
                                                               plus a synthesized checklist and overall verdict (V5.7)
GET /platform-admin/workspaces/{workspace_id}/timeline       [platform.workspace_timeline.read] merged billing +
                                                               platform-admin history for one workspace, paginated
```

`/search` accepts a `q` query param (2-200 chars). A UUID-shaped query does
exact primary-key lookups across the relevant tables; any other query runs
bounded (`LIMIT 5` per category) `ILIKE` matches. There is still no
"request ID" search category: `PlatformAuditEvent.request_id` is now
populated on every platform-admin mutation (see `RequestIDMiddleware`
below), but `/search` was not extended to index by it in this slice --
tracked as a natural, separately-scoped follow-up, not silently claimed
done alongside the middleware itself. The `error` category searches
`MessageProviderAttempt.error_code`, which is real and already populated,
instead.

`/workspaces/{id}/diagnostics` 404s for an unknown workspace id. Channel
connections are listed individually, not aggregated, since a workspace can
have more than one connection per channel. Automation health reports
`trigger_queue_failed` (infrastructure/queue layer) and `execution_failed`
(business rule logic layer) as two separate counts, since they represent
different failure modes.

### Channel Control Center (V4.1)

```text
GET /platform-admin/channels/summary                          per-channel-type totals (total/active/errors/disabled)
                                                                plus a distinct-workspace-connected count [platform.channels.read]
GET /platform-admin/channels                                  paginated/searchable cross-tenant list of every
                                                                WhatsApp/Instagram/Facebook connection; optional channel,
                                                                status, workspace_id filters [platform.channels.read]
GET /platform-admin/channels/{channel}/{connection_id}         single connection detail with its 10 most recent
                                                                provider errors [platform.channels.read]
```

`GET /channels` never collapses a workspace's multiple connections of the
same channel into one row -- each connection is its own list item, always
paired with its workspace. When `channel` is omitted, all three connection
tables are queried and merged (sorted by `created_at desc`, paginated in
memory); this is an admin-console-scale operation, not built to scale past
the volume of connections a real deployment has today, matching this
codebase's existing precedent for admin-only cross-table merges.

Message traffic (`message_count_inbound`/`message_count_outbound`,
`last_message_at`) is computed per connection via the same parallel FK
columns the Phase I architecture already established
(`Message.provider_connection_id` / `.instagram_connection_id` /
`.facebook_page_connection_id`). `failed_attempt_count` uses the equivalent
`MessageProviderAttempt` FK columns for WhatsApp and Instagram, but
**`MessageProviderAttempt` has no per-connection FK for Facebook Page
sends** (only `organization_id` + `provider="facebook_page"`, a pre-existing
gap from Phase 4E, not introduced by this endpoint) -- a workspace with more
than one Facebook Page connection sees the same org-wide failure count on
every one of its Facebook rows rather than a precise per-page split. This is
a documented precision limit, not silently presented as exact.

All identifier fields returned (`phone_number`, `phone_number_id`,
`waba_id`, `ig_username`, `ig_user_id`, `facebook_page_id`,
`facebook_page_name`, `page_username`) are the same non-secret fields the
tenant-facing connection endpoints already return -- never an access token.

### WhatsApp Control Center (V4.2)

```text
GET [redacted]        template counts by status/category/quality_rating,
                                                                 plus active/removed and distinct-workspace counts [platform.channels.read]
GET [redacted]                paginated/searchable cross-tenant list of every synced
                                                                 Meta template; optional workspace_id, connection_id,
                                                                 status, category filters [platform.channels.read]
```

Both routes are declared before `/channels/{channel}/{connection_id}` in
source. This is not optional: Starlette matches routes in declaration
order, not by specificity, so if the templates routes were declared after
the parameterized detail route, a request for
`/channels/whatsapp/templates` would match `{channel}/{connection_id}`
first (`channel="whatsapp"`, `connection_id="templates"`, which fails UUID
conversion) -- the same class of bug the Phase F `/flows/keypair` route
ordering fix caught. A dedicated regression test asserts both templates
routes resolve correctly rather than 422ing.

`status` and `quality_rating` are Meta's own values (e.g. `APPROVED` /
`PENDING` / `REJECTED` for status, `GREEN` / `YELLOW` / `RED` for quality),
stored verbatim by `WhatsAppTemplateService` and returned as free text here,
never normalized into an invented enum -- filtering by an unrecognized
future Meta value still works correctly. Template `body_text`/`header_text`/
`footer_text` are included in the response: these are business-authored,
Meta-reviewed marketing/utility content, not private customer data, so the
"never expose customer content globally" principle governing other
Platform Admin surfaces does not apply here.

### Webhook/Event Inspector (V4.3)

```text
GET [redacted]   delivery/event counts, unresolved/failed
                                                  breakdowns, and per-channel/per-event-type
                                                  totals [platform.channels.read]
GET /platform-admin/channels/webhooks           paginated/searchable cross-tenant list of every
                                                  MetaWebhookEvent; optional workspace_id, channel,
                                                  event_type, status filters [platform.channels.read]
```

Both routes are declared before `/channels/{channel}/{connection_id}` for
the same reason V4.2's templates routes are: `/channels/webhooks/summary`
is the same 2-path-segment shape as `{channel}/{connection_id}` and would
otherwise be swallowed by it. Covered by a dedicated regression test, same
as V4.2.

Built around `MetaWebhookEvent` (one row per logical event), not
`MetaWebhookDelivery` (one row per raw POST): neither model has a stored
`channel` column, and `channel` is derived from each event's own
`event_key` prefix (`"message:"`/`"status:"` = whatsapp, `"ig_message:"`
= instagram, `"fb_page_message:"` = facebook) rather than a database
column, since Instagram/Facebook deliveries always leave
`MetaWebhookDelivery.connection_id` null (it only FKs `whatsapp_connections`)
and reuse `phone_number_id` as a generic "receiving account id" slot. A
delivery that never produced any event (e.g. it failed before any entry
was parsed) has no reliable channel to report and is out of the event
list's scope, though it is still counted in the summary's
`unresolved_deliveries`/`failed_deliveries` tallies.

No raw webhook payload is stored anywhere in this schema by design (for
storage/credential-safety), so there is nothing to redact-and-display
beyond the already-safe fields captured at processing time (`wamid`,
`wa_id`, `status_value`, `event_key`). "Retries" from the general Platform
Admin brief doesn't apply to webhook events either: they are processed
synchronously on receipt, never queued or retried the way automation/
broadcast/media jobs are.

`unresolved_deliveries` (an event/delivery whose `organization_id` is
null because connection resolution failed) is a genuinely useful
operational signal, not an error to hide -- surfaced explicitly rather
than silently dropped, matching the same "an unresolvable payload is
still recorded" philosophy `billing_events.organization_id` already
established.

### Broadcast Safety (V4.4)

```text
GET  /platform-admin/broadcasts/summary                          active-broadcast counts by status,
                                                                    large-send and high-failure-rate counts
                                                                    with the real thresholds echoed back [platform.broadcasts.read]
GET  /platform-admin/broadcasts                                  paginated/searchable cross-tenant broadcast list;
                                                                    optional workspace_id, status, channel,
                                                                    active_only filters [platform.broadcasts.read]
POST /platform-admin/broadcasts/{broadcast_id}/pause              [platform.broadcasts.manage]
POST /platform-admin/broadcasts/{broadcast_id}/resume             [platform.broadcasts.manage]
POST /platform-admin/broadcasts/{broadcast_id}/cancel             [platform.broadcasts.manage]
POST /platform-admin/workspaces/{workspace_id}/broadcasts/pause   pause every currently-"sending"
                                                                    broadcast in a workspace [platform.broadcasts.manage]
POST /platform-admin/workspaces/{workspace_id}/broadcasts/resume  resume exactly the broadcasts the
                                                                    most recent pause paused [platform.broadcasts.manage]
```

Unlike V4.1-V4.3 (all read-only), this is the first V4 capability that
mutates tenant data, so it gets its own permission pair
(`platform.broadcasts.read` / `.manage`) rather than reusing
`platform.channels.read` -- `.read` is granted to the standard four
read-holding roles (`platform_owner`, `platform_admin`, `support_admin`,
`read_only_auditor`); `.manage` is granted to the three roles that already
hold comparable mutation power elsewhere (`platform_owner`,
`platform_admin`, `support_admin` -- the same three that hold
`platform.workspaces.manage` and `platform.automations.manage`), not
`security_admin` or `billing_admin`.

Every mutation route delegates to the existing `BroadcastService` -- the
exact code a tenant admin's own pause/resume/cancel buttons call -- and
never touches `BroadcastDispatchJob` rows directly. `BroadcastService`'s
own state-machine validation is preserved unmodified (e.g. pausing an
already-paused broadcast still 422s); this endpoint group adds no new
broadcast lifecycle rules of its own.

`large_send_threshold` (recipient count, default 1000) and
`high_failure_rate_threshold` (failed/sent ratio, default 0.10) are fixed,
named constants on `PlatformAdminService`, echoed back in every summary
response so the desktop UI never hardcodes a duplicate value that could
drift from the real threshold. Both are described everywhere as
thresholds, never as anomaly detection.

`/workspaces/{id}/broadcasts/pause` and `/resume` mirror
`/workspaces/{id}/automations/pause`/`/resume` (V3.2) exactly: pause
records the precise set of broadcast ids it paused in the audit event's
`after_state`, and resume looks up that same audit event to restore only
that set, only if each is still `paused` -- a broadcast a tenant admin has
independently cancelled, completed, or otherwise moved since the platform
pause is never silently overridden.

`before_state`/`after_state` on the two `broadcast.*` per-broadcast audit
actions record `{"status": "..."}` before and after the transition; the
two `workspace.broadcasts.*` bulk actions record the affected broadcast id
list under `after_state`, matching `workspace.automations.pause`/`.resume`'s
existing `paused_rule_ids` shape.

An `outbound_broadcasts` feature-flag kill switch (V3.4 pattern) gates
`BroadcastDispatchService.run_once()`: when a platform administrator
creates this flag and sets it disabled, the broadcast dispatch worker
stops claiming new jobs (queued/retry-scheduled jobs simply wait; nothing
is lost or errored). It fails open like `new_automation_runs` -- inert
until a platform administrator deliberately creates and disables it.

### Operational Freeze (V4.5)

No new route. Freezing or unfreezing automation execution, broadcast
dispatch, or outbound messages for one workspace reuses the existing V3.4
feature-flag target endpoints exactly:

```text
POST   /platform-admin/feature-flags                              create the flag the first time a
                                                                     given category is ever frozen for
                                                                     any workspace [platform.feature_flags.manage]
POST   /platform-admin/feature-flags/{flag_id}/targets             freeze: {target_type: "workspace",
                                                                     target_id: <org id>, enabled: false} [platform.feature_flags.manage]
DELETE /platform-admin/feature-flags/{flag_id}/targets/{target_id} unfreeze [platform.feature_flags.manage]
```

The three operational keys are `new_automation_runs` (already existed as
a global-only kill switch since V3.4), `outbound_broadcasts` (already
existed since V4.4), and `workspace_outbound_messages` (new in V4.5,
gates all three outbound-send channels -- WhatsApp, Instagram, Facebook
Page -- through one shared check). None of the three flags' own global
`enabled` field is touched by a workspace-scoped freeze; only a
`target_type="workspace"` override is added or removed. See
`docs/ARCHITECTURE.md`'s "Platform Admin V4.5" entry for exactly how each
of the three background code paths resolves the per-workspace override.

### AI Provider/Model Registry (V4.6)

```text
GET /platform-admin/ai/summary                    total config rows, per-status breakdown,
                                                     enabled count, error count [platform.ai.read]
GET /platform-admin/ai/configs                    paginated/searchable cross-tenant list;
                                                     optional workspace_id, status filters [platform.ai.read]
GET /platform-admin/ai/configs/{workspace_id}     single config detail; 404 for a workspace
                                                     that has never opened Copilot [platform.ai.read]
```

`AiProviderConfig` has a `UniqueConstraint` on `organization_id` (one row
per workspace, not per-connection like the three-table channel model), so
`list_ai_configs()` needs no per-provider-type dispatch the way
`list_channels()` does. A row's mere existence with `status="not_configured"`
means the workspace has opened Copilot at least once, not that it has
connected a provider -- most workspaces have zero rows, which is the
honest default, not a gap. `provider_type` is always `"axon_gateway"`
today (the only value `AiService.provision()` ever sets), returned as
real data rather than a hardcoded literal.

This is registry/configuration visibility only. Live gateway usage/quota
(`AiService.account_usage()`, which calls the Axon gateway's real
`GET /v1/account` per-org using that org's own decrypted key) is
deliberately not called from either endpoint here -- doing so from a
cross-tenant list would call a live external API once per row. That
remains reserved for a future per-workspace detail view (V4.7).

### AI Usage Accounting (V4.7)

```text
GET /platform-admin/ai/usage/summary               cross-tenant MessageDraft aggregate: total_drafts,
                                                      by_status, by_source, total_input_tokens,
                                                      total_output_tokens, workspaces_with_drafts [platform.ai.read]
GET /platform-admin/ai/configs/{workspace_id}/usage  live Axon gateway account/quota for ONE
                                                      workspace, via AiService.account_usage() [platform.ai.read]
```

Both reuse `platform.ai.read` -- no new permission, no new migration.

`/ai/usage/summary` is a **lower bound**, not total AI usage: it counts
only persisted `MessageDraft` rows (automation-generated and Copilot
"suggest reply" drafts). Ad-hoc Copilot chat (`AiService.chat()`) is
never persisted anywhere in this codebase, so it cannot be counted here.
This is stated in the response schema's docstring and must not be
presented as complete usage anywhere it is surfaced.

`/ai/configs/{workspace_id}/usage` delegates directly to the existing
tenant-facing `AiService.account_usage()` -- the real, complete answer for
one workspace, fetched live using that workspace's own decrypted gateway
key. It returns whatever the gateway's real `GET /v1/account` response
contains as an untyped pass-through object (`account: dict[str, Any]` /
`Record<string, unknown>` on the desktop), never a fixed schema this
codebase invents, since the gateway's response contract is not something
this codebase controls. It inherits `AiService`'s own error behavior
unmodified: 503 if the workspace has never configured or has disabled AI
Copilot, and whatever `AiService._map_gateway_error()` maps a real gateway
failure to (e.g. 502 for an invalid key) -- these are real, honest errors,
never suppressed or converted into a fabricated success response. This
endpoint is deliberately excluded from any cross-tenant list or summary;
see the V4.6 entry above for why.

### AI Cost Control (V4.8)

```text
POST /platform-admin/ai/configs/{workspace_id}/disable   [platform.ai.manage]
POST /platform-admin/ai/configs/{workspace_id}/enable    [platform.ai.manage]
```

The first AI Control Plane capability that mutates tenant data rather
than only displaying it, so it gets its own dedicated permission
(`platform.ai.manage`, migration `4ae40777760f`, `is_high_risk=true`)
rather than reusing the read-only `platform.ai.read` -- granted to
`platform_owner`, `platform_admin`, `support_admin` only, matching the
same three-role grant set `platform.broadcasts.manage` (V4.4) already
uses for comparable mutation power.

Both routes delegate directly to the existing tenant-facing
`AiService.update_config()` -- the exact code path a tenant's own
Settings AI toggle already calls -- rather than writing to
`AiProviderConfig` directly. Both require a `reason` (10-500 chars, the
standard Platform Admin convention) and record a platform audit event
(`ai.disabled` / `ai.enabled`) with explicit `before_state`/`after_state`
of `{"enabled": true/false}`.

Disabling AI for a workspace that has never opened Copilot (no existing
`AiProviderConfig` row) still succeeds rather than 404ing: `update_config()`
calls `_get_or_create_config()` internally, so the row is created in a
disabled state. A genuinely unknown `workspace_id` (no such `Organization`)
still 404s, checked before any `AiService` call.

This was re-scoped from the originally-planned cross-tenant AI *cost
visibility* to an *action* lever after reading the real Axon gateway
source directly (`GET /v1/account`'s response fields: `wallet_balance_cents`,
`wallet_spend_cap_cents`, `wallet_spent_this_month_cents`,
`wallet_remaining_cap_cents`, `wallet_headroom_cents`,
`token_quota_monthly`, `tokens_used_this_month`, `tokens_remaining`,
`max_members`) confirmed that a cross-tenant cost rollup is not honestly
buildable in this slice: wallet/spend data lives entirely gateway-side,
reading it requires one live HTTP call per workspace (the same "never
call in bulk across a workspace list" boundary V4.6/V4.7 already
established), and this program's engineering-integrity rule forbids
fabricating a rollup from data this codebase does not hold. Disable/
enable is a genuine, honest, immediately buildable capability using only
local data instead.

### AI Execution Log (V4.9)

```text
GET /platform-admin/ai/executions   [platform.ai.read]
```

A drill-down over individual `MessageDraft` rows -- the aggregate
`/ai/usage/summary` (V4.7) already covers, this endpoint covers the
per-row list, the same "aggregate then drill-down" progression V4.1
(channel summary) -> V4.3 (webhook event list) already established.
Reuses `platform.ai.read`; no new permission, no new migration.

Query params: `workspace_id`, `status` (`pending`/`accepted`/`discarded`/
`superseded`), `source` (`copilot`/`automation`), `limit`, `offset`.

`PlatformAiExecutionRead` deliberately never includes `MessageDraft.body`
-- the actual drafted reply text, generated from real customer
conversation content. Exposing it here would leak one workspace's
customer data into a cross-tenant admin view, which this program's
standing tenant-isolation rule forbids. Every other metadata field
(status, source, model, input/output token counts, requested/resolved-by
user ids, timestamps) is included.

A failed `generate_draft()` attempt never appears in this list at all:
`AiService.generate_draft()` only creates a `MessageDraft` row after a
*successful* gateway response, so there is no persisted record of failed
generation attempts anywhere in this codebase -- only
`AiProviderConfig.last_error_code`/`last_error_summary`/`last_checked_at`
(already exposed via V4.6's config endpoints) reflects the single most
recent failure. This is a real, honestly documented gap, not a filter
this endpoint silently applies.

### AI Governance (V4.10)

```text
GET  /platform-admin/ai/governance          [platform.ai.read]
POST /platform-admin/ai/governance/disable  [platform.ai.manage]
POST /platform-admin/ai/governance/enable   [platform.ai.manage]
```

A platform-wide (not per-workspace) kill switch for AI generation,
backed by the existing V3.4 feature-flag mechanism (`ai_generation_enabled`
key) rather than a new one. `GET` returns `{enabled, configured,
updated_at}`; `configured=false` means the flag has never been created,
in which case `enabled` is always `true` (V3.4's fail-open resolution
rule -- never a fabricated "on" default invented by this endpoint).

The two `POST` routes delegate to the existing `create_feature_flag()`/
`update_feature_flag()` service methods (V3.4) -- the same code path the
Feature Flags page itself uses -- rather than a new mutation mechanism,
producing the identical `feature_flag.created`/`feature_flag.updated`
platform audit actions every other flag change already produces.

Gated by `platform.ai.manage`, deliberately not
`platform.feature_flags.manage`: `support_admin` holds the former but
only `platform.feature_flags.read`, so gating this AI-specific action
behind the generic flag-management permission would have locked out the
role built for exactly this kind of incident response.

`AiService._require_generation_enabled()` checks this flag at the very
top of both `chat()` and `generate_draft()`, before any per-workspace
`AiProviderConfig` lookup, gateway key decryption, or conversation
lookup -- a disabled flag returns 503 immediately regardless of whether
the target workspace is configured, enabled, or even exists. This is
distinct from V4.8's `POST /ai/configs/{workspace_id}/disable`, which
only stops one workspace and requires enumerating every workspace
individually to achieve a full stop; this flag stops every workspace at
once, including one created after it is disabled.

### Platform Analytics (V5.1)

```text
GET /platform-admin/analytics/overview          [platform.analytics.read]
GET /platform-admin/analytics/trend?days=1-365  [platform.analytics.read]
```

Cross-tenant business-activity visibility, distinct from
`/platform-admin/overview`, which only covers workspace/user/subscription
counts. `analytics/overview` returns active-workspace count (last 7
days), total customers/conversations/inbound and outbound messages/
orders/open and resolved tickets, and `orders_revenue_by_currency` -- a
per-currency breakdown (`dict[str, str]`, Decimal amounts as strings to
preserve exact precision through JSON), deliberately never summed into
one total since `Order.currency` is per-order, not fixed platform-wide.

`analytics/trend` returns a new-customer growth trend, summed across
every workspace and bucketed by UTC calendar day. The bucketing query
explicitly pins truncation to UTC (`date_trunc('day', col, 'UTC')`, the
3-argument form) rather than relying on the 2-argument form's
session-timezone-dependent truncation -- see docs/ARCHITECTURE.md's V5.1
entry for the real bug this fixed.

### Workspace Health (V5.3)

```text
GET [redacted]                [platform.workspace_health.read]
GET /platform-admin/workspace-health?health_status&search    [platform.workspace_health.read]
```

Named "workspace health," not "customer health" (the roadmap's own name
for this unit): describes a workspace's standing as one of Arche Axon's
own paying customers, distinct from the existing `Customer` model, which
means a workspace's own end-customer everywhere else in this codebase.

Every workspace's `subscription_status`, `last_activity_at` (max
`Message.created_at` for that org), `days_since_last_activity`,
`open_ticket_count`, and a derived `health_status`
(`healthy` / `needs_attention` / `at_risk`). `health_status` is computed
from fixed, named thresholds in a documented order (subscription status
checked before activity recency), never a fabricated weighted score --
see docs/ARCHITECTURE.md's V5.3 entry for the exact rule and rationale.

`health_status` and `search` are both applied in-memory after fetching
every workspace's health row (the status is Python-computed, not a
database column), matching the admin-console-scale precedent V4.1's own
cross-channel merge already established -- not built to scale past
today's real workspace volume.

### Support Cases (V5.5)

```text
GET /platform-admin/support-cases/summary                            [platform.support_cases.read]
GET /platform-admin/support-cases?workspace_id&status&priority&category&limit&offset  [platform.support_cases.read]
```

A cross-tenant drill-down over individual `Ticket` rows -- the natural
next step after V5.1's basic open/resolved counts and V5.3's per-workspace
`open_ticket_count`, neither of which lets an operator browse or filter
individual tickets across every workspace.

`PlatformSupportCaseRead` deliberately never includes `Ticket.title` or
`Ticket.description` -- both may carry customer-specific narrative
content authored by a workspace's own support agents. Exposing them on a
cross-tenant admin surface would leak one workspace's support content
into a platform administrator's view of an unrelated workspace, which
this program's standing tenant-isolation rule forbids -- the same
"metadata yes, content no" boundary `PlatformAiExecutionRead` (V4.9)
already established for `MessageDraft.body`. Every other field (status,
priority, category, source, `age_days`, `is_overdue`, timestamps, and the
owning workspace's name/slug) is included.

Unlike V5.3's Python-computed `health_status`, `status`/`priority`/
`category`/`organization_id` are real, indexed `Ticket` columns, so
`workspace_id`/`status`/`priority`/`category` filtering and `limit`/
`offset` pagination happen in genuine SQL, not an in-memory filter over a
fully-fetched candidate set.

`is_overdue` is `Ticket.due_at < now()` AND the ticket's `status` is one
of a fixed `_SUPPORT_CASE_OPEN_STATUSES` set (`open`/`in_progress`/
`waiting_on_customer`/`reopened`) -- a resolved or closed ticket past its
original due date is never counted as overdue. Both the status list and
the comparison are fixed, named thresholds, never a fabricated risk
score, matching V5.3's `health_status` and V4.4's broadcast-safety
thresholds.

### Workspace Timeline (V5.4)

```text
GET /platform-admin/workspaces/{workspace_id}/timeline?limit&offset   [platform.workspace_timeline.read]
```

A single-workspace detail endpoint -- like V3 Part 1's existing
`/workspaces/{workspace_id}/diagnostics`, not a cross-tenant list like
V5.1/V5.3/V5.5. Renamed from the roadmap's "Company Timeline" since
nothing in this codebase is ever called a "Company"; see
`docs/ARCHITECTURE.md`'s V5.4 entry for the same naming discipline V5.3
already established.

Merges three sources into one chronological feed sorted descending by
`occurred_at`: a synthetic "Workspace created" milestone from
`Organization.created_at`, every `BillingEvent` row for the workspace
(previously never exposed via Platform Admin at all), and every
`PlatformAuditEvent` row targeting the workspace (already a safe
cross-tenant surface since V3 Part 2). The tenant `AuditEvent` table is
deliberately never read: its `summary`/`entity_label` fields are free
text a workspace's own agents write about their own customers/orders/
tickets, the same customer-content risk `MessageDraft.body` (V4.9) and
`Ticket.title`/`.description` (V5.5) were already excluded for.

All rows for the one requested workspace are fetched in full (the
candidate set is bounded by definition to a single organization's own
history), merged and sorted in Python, then paginated in memory --
admin-console scale, matching V4.1's channel merge and V5.3's health
computation, applied here to a per-workspace view.

### One-click Diagnostics (V5.7)

No new route. Extends the existing V3 Part 1
`GET /platform-admin/workspaces/{workspace_id}/diagnostics` response with
two fields: `overall_status` (`healthy` / `needs_attention` / `at_risk`,
the same vocabulary V5.3's Workspace Health already uses) and `checks`
(a list of `{key, label, status: "pass"|"warn"|"fail", detail}` items).

Both are synthesized purely from data the endpoint already gathers for
its existing panels -- no new query, no new external call, no fabricated
score. Seven checks: workspace access (suspended or not), subscription
standing, channel connections (any in an `error` state), webhook delivery
(failure rate against V4.4's `_HIGH_FAILURE_RATE_THRESHOLD`), job queues
(any failed job across automation/broadcast/media), automation health
(trigger-queue or execution failures), and recent activity (reusing
V5.3's own inactivity-day thresholds). `overall_status` is the worst of
V5.3's own `_derive_health_status()` verdict (subscription + activity
only, so this endpoint's overall status never disagrees with the same
workspace's Workspace Health row for the same two facts) and the worst
individual checklist item (which additionally covers connections,
webhooks, queues, and automation -- signals V5.3 does not consider).

Deliberately `pass`, not `warn`: a workspace with no subscription record
or no channel connections configured yet. Both are normal onboarding
states, not diagnostic problems -- flagging them would make every
not-yet-onboarded workspace read as `needs_attention` by default,
drowning out genuinely concerning signals.

### Platform audit foundation (V3 Part 2)

`PlatformAuditEventRead` (`GET /platform-admin/audit`) now includes
`actor_id`, `actor_email`, `actor_role` (the actor's own organization
role_type at the time of the action; there is no separate platform-admin
role tier beyond `is_superuser`), `changes` (per-field before/after diffs),
`before_state`/`after_state` (full snapshot dicts, only populated where the
call site provides them), `ip_address`, `user_agent`, and `request_id`.
`request_id` is populated on every platform-admin mutation as of the
request correlation-ID middleware below (`RequestIDMiddleware`); a
`PlatformAuditEvent` written before that middleware existed still has
`request_id = null`, which is a real historical fact, not something to
backfill.

`before_state`/`after_state` values accept `str | int | float | bool |
list[str] | None` (widened from a scalar-only union in V4.4, when
`workspace.broadcasts.pause`/`.resume`'s `after_state.{paused,resumed}_broadcast_ids`
list values -- the exact same shape `workspace.automations.pause`/`.resume`'s
`after_state.paused_rule_ids` had already been writing to the database
unvalidated for several sessions -- were fetched back through `GET /audit`
for the first time and failed Pydantic validation. Not a new capability;
a latent response-model gap that a broader browse of the audit endpoint
happened to be the first thing to actually exercise.

`AuditService.append_platform()` redacts any `changes`/`before_state`/
`after_state` key or field name matching a secret-shaped pattern (`token`,
`secret`, `password`, `credential`, `authorization`, `cookie`, `jwt`, `key`,
`payload`, `stack`, `trace`, case-insensitive) to the literal string
`"REDACTED"` before the row is ever written, so no secret value can reach
`platform_audit_events` even if a future call site passes one by mistake.
The desktop audit detail dialog additionally masks the same pattern at
render time, matching the existing tenant-level `AuditDetailPage` convention,
as defense in depth rather than the primary safeguard.

`GET /platform-admin/audit` accepts `actor_id` (exact UUID match), `action`
(exact string match against the fixed, currently two-value action
namespace), `target_type` (exact match), `date_from`/`date_to` (inclusive
calendar-date bounds on `created_at`, in the server's timezone), alongside
the existing `workspace_id` and free-text `search`. There is no route to
update or delete an audit event: `platform_audit_events` has no
`updated_at` column and the router exposes no `PATCH`/`DELETE` for
`/platform-admin/audit/{id}`, matching the existing tenant `AuditEvent`
model's immutability guarantee. Platform superusers acting on themselves or
other platform superusers are audited identically to any other actor; there
is no exemption.

### V3.2: Controlled Administrative Actions

Every new mutation below follows the same shape: authorize (router-level
`PlatformSuperuser`) -> validate current state -> require a reason (10-500
chars) -> mutate transactionally -> record a platform audit event with
explicit `before_state`/`after_state` -> return the resulting state. All are
idempotent where idempotency is meaningful (state-toggle actions no-op with
no new audit event when the workspace is already in the target state;
revocation/pause/resume/job actions always audit the call itself, since the
act of calling them is meaningful even when nothing was affected).

**Workspace lifecycle.** `PlatformWorkspaceRead.lifecycle_status` is a
derived field (`active | read_only | suspended`) combining the existing
`Organization.is_active` and `OrganizationSubscription.status` columns, not
a new stored field. `workspace.suspend` sets `is_active=false`; it blocks
interactive login and every authenticated API call (existing behavior,
already enforced by principal resolution), but deliberately does **not**
stop background workers (broadcast dispatch, automation runtime, media
ingestion) or inbound WhatsApp/Instagram/Facebook webhook ingestion, since
those run outside the JWT/API layer entirely and this matches the same
"never lose customer data" principle billing enforcement already uses.
Making SUSPENDED fully halt background workers is real, valuable follow-up
work (touching three independent worker loops), tracked as a deliberate gap
rather than silently bundled into this action. `workspace.reactivate` only
restores login/API access; it never changes read-only status.
`workspace.read_only.enable`/`.disable` only ever move `subscription_status`
between `active` and `read_only`: disable is a no-op (not an error) unless
the workspace is currently `read_only`, so it can never be used to paper
over a genuine billing lapse (`past_due`/`grace`/`cancelled`).

**Session revocation.** Both revoke endpoints reuse the existing
`RefreshTokenRepository.revoke_all_for_user`/new `revoke_all_for_organization`
(keyed off `refresh_tokens.organization_id`, added in the 2026-07-21 session-
restore fix). The response contains only a count; no token value, hash, or
identifier is ever returned.

**Automation pause/resume.** `workspace.automations.pause` disables every
currently-`enabled` `AutomationRule` for the workspace and records the exact
set of rule ids it touched in the audit event's `after_state.paused_rule_ids`
(never deletes rule configuration/conditions/actions). `resume` reads the
most recent `workspace.automations.pause` audit event for that workspace and
re-enables only the rules in that recorded set that are *still* `disabled`
-- a rule a tenant admin has independently re-enabled since the pause is
left untouched, so a platform pause/resume cycle can never silently override
a tenant's own later decision.

**Queue actions.** Retry/cancel operate on individual `automation_trigger_events`
or `media_ingestion_jobs` rows only, gated by the exact same terminal-state
guarantees the workers themselves already rely on: retry only from `failed`
(a status the claim query never re-picks up on its own once a job has fully
exhausted its terminal failure path), cancel only from `queued`/`pending`/
`failed` (never `leased`, since an actively-claimed job cannot be
interrupted without real worker-level cancellation support, which does not
exist yet). Cancelling a media job also moves the linked `MediaAsset.download_state`
to `failed` so the asset does not appear permanently stuck with no active
job. Broadcast dispatch jobs are deliberately **not** exposed here: they
already have safe, tested `pause`/`resume`/`cancel` semantics at the parent
`Broadcast` level (with recipient-status and count-aggregate sync a raw
per-job mutation would bypass), so `queue=broadcast` returns 422 pointing
the caller at that existing surface instead of a new, less consistent one.

**Known gap, tracked for V3/V4:** `actor_role` on every platform audit event
(including these new actions) still records the actor's own organization
`role_type`, not a distinct platform-admin role. Vendale has only one
platform-role tier today (`User.is_superuser`). A real `platform_owner` /
`platform_admin` / `support_admin` / `security_admin` role system is
future work; until it exists, platform authorization remains intentionally
coupled to the `is_superuser` boolean alone, and `actor_role` is honestly
labeled as the actor's tenant role, not fabricated as something it is not.

### V3.3a: Platform RBAC

Introduces the `platform_owner` / `platform_admin` / `security_admin` /
`support_admin` / `billing_admin` / `read_only_auditor` role tier flagged
above as tracked V3/V4 work. `actor_role` on new/upgraded platform audit
events still records the actor's tenant `role_type` (unchanged, since that
field predates this feature and other call sites depend on it); the actor's
platform role is implicit in which permission gate they passed, not a
separate audit field in this slice.

**Authorization model.** Platform permissions are resolved from the database
on every request, exactly like the existing `is_superuser` check, and
deliberately NOT embedded in the JWT the way tenant `role_type`/`permissions`
are. Tenant permissions are trusted for the access token's 15-minute
lifetime; platform permissions cannot be, since revoking a compromised
platform admin's access must take effect immediately, not after their token
happens to expire or refresh. The router-level `PlatformSuperuser` dependency
(`is_superuser` check) remains the coarse "has platform access at all" gate,
unchanged; `require_platform_permission(key)` is a second, route-level
dependency that adds a finer-grained check on top for specific mutating
endpoints only.

**Legacy compatibility.** `User.platform_role_id` is nullable. A user with
`is_superuser=True` and `platform_role_id=NULL` (every pre-V3.3a platform
admin, until explicitly migrated) is treated as having full access on every
permission-gated endpoint, identical to their behavior before this feature
existed. The `b3f6d1a9c2e4` migration backfills every existing
`is_superuser=True` user to `platform_owner` explicitly at migration time
rather than relying on this fallback indefinitely, but the fallback itself
remains load-bearing for any future superuser created before a role is
assigned (e.g. via the legacy `PATCH /users/{id}` `is_superuser=true` toggle,
which still exists unchanged and does not itself assign a platform role).

**Role catalogue.** Fixed, system-defined, not user-creatable in this slice
(no `POST /platform-admin/roles`). `platform_owner` holds all 14 permission
keys and is the only role with `platform.roles.manage`; `platform_admin`
holds everything else; `security_admin`/`support_admin`/`billing_admin` are
curated specialist subsets (see `[redacted].py`
for the exact per-role key lists and rationale); `read_only_auditor` holds
every `.read` key and nothing else. `platform.billing.read` and
`platform.jobs.read` were added beyond the reviewer's original key list for
completeness/symmetry (a `billing_admin` role needs something to actually
grant, and `workspaces`/`users` both already had paired `.read`/`.manage`
keys, so `jobs` got the same treatment).

**`PATCH /platform-admin/users/{user_id}/platform-role`** keeps
`User.is_superuser` in sync automatically: assigning any role sets it `True`
(holding any platform role implies at least read access to the whole
console, since no read endpoint is permission-gated more granularly in this
slice); setting `role_type: null` removes platform access entirely and sets
it `False`. Idempotent: re-assigning the role a user already holds returns
200 with no new audit event. Guards against ever reaching zero active
`platform_owner` users, mirroring the existing is_superuser self-lockout
check's shape (excluding the target user from the "remaining owners" count
correctly blocks both a platform_owner demoting themselves and a platform_owner
demoting the last other owner).

**Scope boundary, not an oversight.** Only the specific mutating endpoints
listed in the bracket-annotated table above were converted to permission
checks. Broad read/list endpoints (`overview`, `list workspaces`, `list
users`, `runtime`, `audit`, `search`, `diagnostics`, `subscriptions`,
`invoices`, `billing overview`, `jobs summary/list`, `GET /roles` itself)
remain gated by `is_superuser` alone. Converting every remaining endpoint to
its own `.read` permission is real, valuable follow-up work, deferred to keep
this slice reviewable and its blast radius limited to genuinely privileged
mutations.

**Deliberately not done in V3.3a** (per the locked V3.3 sequencing): no
Security Center UI, no MFA (none exists in this codebase; see the honest gap
recorded in `docs/ROADMAP.md`'s 2026-08-19 V3.3a entry), no re-authentication
step-up for sensitive actions (role changes, platform-owner promotion, mass
session revocation), no failed-login tracking. These are V3.3b/V3.3c/V4
scope.

### V3.3b: Security Center

Strictly visibility-first, per the user's explicit constraint when
greenlighting this phase: no MFA, no fabricated security signals, only data
Vendale can already prove. Both routes compose over tables that already
existed (`users`, `platform_roles`, `refresh_tokens`,
`platform_audit_events`) -- no new migration.

`GET /security/overview` reports administrators/active/platform-owner/
legacy-admin/inactive-with-access counts, a user count per platform role
(all 6 roles, `0` where unused), a 24-hour count of `platform_audit_events`
rows, an attention list, and the last 10 platform audit events.

`GET /security/administrators` lists every `is_superuser=True` user.
`is_legacy_admin` is derived (`is_superuser=True and platform_role_id is
None`), not stored. `tenant_roles` is the distinct set of `Role.role_type`
values across the user's organization memberships. `last_session_at` is
`MAX(refresh_tokens.created_at)` for that user -- deliberately named and
documented as exactly what it is (the last time any session was created or
rotated), not a dedicated "last login" field, since no such column exists
anywhere in this codebase. `role_assigned_at` is populated only when a real
`platform.role.assigned` `PlatformAuditEvent` exists whose `after_state`
matches the user's *current* role; a legacy admin whose access predates
platform roles (the `b3f6d1a9c2e4` migration's backfill, not an audited
assignment) correctly reports `null` rather than a fabricated timestamp.

**Attention items**, every one derived from a real count, never a fabricated
risk score: legacy unrestricted administrators; inactive users retaining
platform access; "only one active administrator holds full owner-level
access" (fires whenever the count of active `platform_owner` role-holders
plus active legacy admins is `<= 1`, since legacy admins have owner-
equivalent access); and two named-threshold volume items -- more than 20
platform-audit events organization-wide in the last 24 hours, and more than
10 by a single actor in the same window. Both thresholds are fixed
constants in the service (`_HIGH_VOLUME_ACTIONS_24H`,
`_HIGH_VOLUME_ACTOR_ACTIONS_24H`), described in both the API response and
the desktop UI as a threshold being exceeded, never presented as anomaly
detection or a learned baseline.

**Deliberately excluded** (per the locked V3.3 sequencing, confirmed by
reading the code rather than assumed): MFA status, failed-login counts,
suspicious-IP detection, device risk, impossible-travel alerts, per-session
IP/user-agent risk scoring, step-up re-authentication. None of the
underlying telemetry exists in this codebase yet -- these remain V3.3c
scope once that foundation is built.

### V3.3c-1: Auth Security Telemetry

Builds the auth/session telemetry foundation the V3.3b entry above named as
the remaining gap, split from V3.3c-2 (MFA + privilege protections) per the
user's explicit instruction not to mix telemetry, authentication behavior,
and privilege hardening into one change.

`GET /security/overview` gained five fields, all scoped to platform
administrators (`AuthSecurityEvent.target_is_admin`), matching this
console's existing scope: `failed_logins_24h`, `successful_logins_24h`,
`active_administrator_sessions`, `revoked_administrator_sessions_24h`,
`accounts_with_repeated_failures`. The underlying `auth_security_events`
ledger itself records every user's login/session activity system-wide, not
just administrators, since the login endpoint is the same code path for
everyone; only Security Center's own rollups filter to the administrator
subset.

A new attention item fires per account meeting the deterministic
"repeated failed authentication" rule: `>= AUTH_REPEATED_FAILURE_THRESHOLD`
(default 5) failures against the same `attempted_email` within
`AUTH_REPEATED_FAILURE_WINDOW_MINUTES` (default 15), admin-scoped. This is a
fixed, named threshold over real counted rows, described in both the API
response and the UI as a threshold, never as anomaly detection.

No new routes beyond the V3.3b two; `GET /auth/sessions` (documented above)
gained real `ip_address`/`user_agent`/`last_seen_at` fields as a direct
consequence of this slice's `refresh_tokens` schema change.

**Deliberately excluded**, per the locked V3.3c-1/V3.3c-2 split: MFA
enroll/verify/recovery-codes behavior (only a `mfa_enabled` placeholder
column exists), step-up re-authentication, session-revocation-on-role-
downgrade, and any desktop UI for per-session revoke controls beyond what
already existed. These are V3.3c-2 scope.

### V3.3c-2: MFA + Privilege Protections

Builds the MFA and privilege-hardening scope the V3.3c-1 entry above named
as excluded. `User.mfa_enabled` stops being a placeholder.

`POST /auth/login` now returns `LoginResponse` (a superset of the previous
`TokenResponse`): `mfa_required: false` (default) behaves exactly as before;
`mfa_required: true` returns `mfa_session_token` and
`mfa_enrollment_required` instead of any token. No caller that ignored the
new fields breaks, since a non-MFA account's response shape is unchanged.
MFA is mandatory (enrollment forced, not silently skipped) for platform
roles `platform_owner`, `platform_admin`, and `security_admin` -- resolved
from the caller's *platform* role, not their tenant role.

`POST /platform-admin/step-up` re-verifies the caller's own password + a
live TOTP/recovery code and returns a short-lived (~10 minute)
`step_up_token`, required by three mutations:
`PATCH .../users/{id}/platform-role`, `POST .../users/{id}/mfa/reset`, and
`POST .../administrators/revoke-all-sessions`. The token is never embedded
in the normal access JWT and is checked against a DB-backed session row on
each use, so revoking it (or letting it expire) takes effect immediately
regardless of the caller's JWT lifetime.

Role-change hardening: a role downgrade (rank decrease, see
`docs/ARCHITECTURE.md`'s V3.3c-2 entry for the exact ordinal and the
`role_type=None` legacy-admin edge case) immediately revokes every existing
session for the target user after the role change commits; a promotion does
not force a re-login. `admin_reset_mfa()` destroys the target's MFA secret
and recovery codes without ever exposing them to the calling administrator,
gated by the new `platform.security.manage` permission plus step-up.
`GET/DELETE /users/{id}/sessions[/{session_id}]` expose per-session detail
for any single administrator, reusing the same `ip_address`/`user_agent`/
`last_seen_at` columns V3.3c-1 added to `refresh_tokens`.

`GET /security/overview` gained `mfa_protected_administrators`,
`administrators_without_mandatory_mfa`, `privilege_changes_24h`;
`GET /security/administrators` gained per-administrator `mfa_enabled`,
`mfa_enrolled_at`, `mfa_last_verified_at`, `active_session_count`. New
attention items follow the same fixed-threshold convention as V3.3c-1:
CRITICAL for a `platform_owner` without MFA, WARNING for 5+ failed attempts
against an administrator within 15 minutes (existing rule, now
administrator-scoped) and for each remaining legacy unrestricted
administrator, INFO for an administrator holding an unusually high count of
concurrent active sessions.

**Deliberately excluded**, per the locked V3.3 sequencing: no re-
authentication requirement finer-grained than the existing step-up gate
(e.g. no separate "confirm again" step for the panic-button revoke-all
action beyond the step-up it already requires), no admin-configurable MFA
policy beyond the fixed mandatory-role set, no hardware security key
(WebAuthn/FIDO2) support -- TOTP and recovery codes only. These remain
open, undecided future scope, not a documented commitment.

### V3.4: Feature Flags

Closes V3. All routes require the router-level `PlatformSuperuser` gate
plus one of the two new permissions below.

```text
GET    /platform-admin/feature-flags                              list all flags [platform.feature_flags.read]
POST   /platform-admin/feature-flags                               create a draft flag [platform.feature_flags.manage]
GET    /platform-admin/feature-flags/{flag_id}                     get one flag + its targets [platform.feature_flags.read]
PATCH  /platform-admin/feature-flags/{flag_id}                      update [redacted] [platform.feature_flags.manage]
POST   /platform-admin/feature-flags/{flag_id}/targets              add a workspace/user/platform_role override [platform.feature_flags.manage]
DELETE /platform-admin/feature-flags/{flag_id}/targets/{target_id}  remove an override [platform.feature_flags.manage]
POST   /platform-admin/feature-flags/{flag_id}/evaluate             read-only preview: resolve this flag for an optional user_id/organization_id [platform.feature_flags.read]
```

`platform.feature_flags.read` (migration `d3f7a2c5e819`) is new; the
`platform.feature_flags.manage` permission already existed in the platform
RBAC catalogue as a reserved key with no routes behind it until now.
Granted to `platform_owner`/`platform_admin`/`support_admin`/
`read_only_auditor`, matching every other read/manage pair in the
catalogue. There is no hard-delete route: retiring a flag sets
`status="archived"` (via `PATCH .../feature-flags/{id}`), which always
resolves `enabled=False` regardless of any other field -- avoids ever
losing the historical audit trail of a flag's targets/rollout history.

`POST .../evaluate` never mutates or audits anything; it exists purely so
the Platform Admin UI can show "what would this resolve to for workspace
X / user Y" before making a change. It resolves the *caller's own* platform
role for the `platform_role` step when neither an explicit `user_id` nor
`organization_id` is supplied, so an admin previewing a flag from their own
session sees a value consistent with what they would actually experience.

Every mutation requires a `reason` (10-500 chars, same convention as every
other platform-admin action) and writes a platform audit event using one of
a fixed vocabulary: `feature_flag.created`, `feature_flag.updated`,
`feature_flag.target.added`, `feature_flag.target.removed`,
`feature_flag.rollout.changed`, `feature_flag.archived`. `update_flag()`
selects the most specific action name (archival, then a rollout-percentage
change, then a plain update) rather than always writing the generic
`updated` event.

Resolution order, caching, the deterministic rollout hash, and the one real
flag (`new_automation_runs`) wired into `AutomationRuntime.run_once()` are
documented in full in `docs/ARCHITECTURE.md`'s "Platform Admin V3.4:
Feature Flags" section -- that resolution order is a permanent contract and
must not be silently reordered by future changes to this endpoint group.

Tenant-side endpoints are unaffected except `GET /auth/me`, which now
additionally returns `feature_flags: Record<string, boolean>` -- the full
`resolve_all_for_subject()` result for the calling user/organization,
resolved once at session bootstrap. This is UX-only: it lets the desktop
render or hide interface, but it is never itself an authorization boundary.
Any backend capability gated by a flag (today, only
`AutomationRuntime.run_once()`'s `new_automation_runs` check) independently
re-evaluates the same flag server-side before acting.

### Incident Management (V5)

```text
GET /platform-admin/incidents/summary      [platform.incidents.read]
GET /platform-admin/incidents?workspace_id&kind&severity&search&limit&offset
                                           [platform.incidents.read]
```

A derived, read-only incident view over the failure data the platform
already records -- deliberately NOT a new mutable incident-tracking
subsystem, and it adds no mutation endpoints: every remediation action
stays in its existing surface (Jobs retry/cancel, Broadcasts
pause/resume/cancel, Channels connection detail).

An "incident" is a group of concrete failure rows (signals) sharing the
same kind, failure signature, and workspace within a fixed 24-hour
lookback window. Signals come from exactly three existing surfaces, each
using that surface's own established failure predicate and timestamp
column: failed queue jobs (`status == "failed"` on
`automation_trigger_events` / `broadcast_dispatch_jobs` /
`media_ingestion_jobs`, windowed by `created_at`), failed webhook events
(`meta_webhook_events.processing_error IS NOT NULL`, windowed by
`created_at`), and failed provider send attempts
(`message_provider_attempts.succeeded IS FALSE`, windowed by
`attempted_at`). Webhook events with a NULL `organization_id` (deliveries
that never resolved to a tenant) are excluded -- they remain visible in
V4.3's cross-tenant webhook inspector instead of being attributed to a
fabricated workspace incident.

Clustering key: `kind:workspace_id:error_code` (error summaries are
free-form detail and never part of the key). Severity is a fixed band:
`critical` at >= 10 signals, `warning` at >= 3, otherwise `elevated`.
Every signal in a response is open by construction: failed job rows leave
the set when the existing Jobs actions change their status, and log-type
signals age out of the window; there is deliberately no `closed_count`
because closed signals either change status in place (jobs keep no
history) or have no status at all (logs).

List filters: `kind` (`queue_job` | `webhook_event` | `provider_attempt`),
`severity` (`critical` | `warning` | `elevated`), `search` (workspace
name/slug, title, error code). Signals never include customer content --
no message bodies, recipient identifiers, or conversation text.

Permission: new `platform.incidents.read` (migration `b7d9f3a1c5e8`,
standard four-role `.read` grant set: platform_owner, platform_admin,
support_admin, read_only_auditor). security_admin deliberately holds no
analytics-surface `.read` keys and is denied.

## Platform Admin V6.1: Workspace Data Inventory (Data Administration)

`GET /api/v1/platform-admin/workspaces/{workspace_id}/data-inventory`
returns a read-only count of every tenant-scoped data family a workspace
holds: customers, conversations, messages, orders, tickets, products,
catalogues, media_assets, broadcasts, automation_rules, and
message_drafts. Every count is a property of existing rows -- nothing is
stored or fabricated.

There are deliberately no export or delete endpoints in this module:
destructive tenant-data capabilities have no existing surface precedent
and would need their own product decision first (the same rule V5
Incident Management applied to remediation actions). Workspace
operations remain in their existing surfaces (Workspaces suspend/read-
only/sessions, Jobs, Broadcasts, Channels).

Permission: new `platform.data_admin.read` (migration `e5a9c7f3b2d6`,
standard four-role `.read` grant set: platform_owner, platform_admin,
support_admin, read_only_auditor).

## Platform Admin V6: Global Configuration (Read-Only)

`GET [redacted]` returns a read-only
view of the deployment's real global toggles: `meta_live_mode`,
`meta_allow_live_send`, the three per-channel live-send gates
(`meta_whatsapp_live_send_enabled`, `meta_instagram_live_send_enabled`,
`meta_facebook_live_send_enabled` -- each the config's own composite
property the send paths actually consult), and
`mcp_live_actions_enabled`.

Deliberately read-only: these are deployment environment variables, not
database rows. Changing them is an operator action (edit env + restart);
a UI mutation would silently disagree with the running process until
restart. No secret values are ever returned (no app secrets, no JWT
secret, no credentials).

Gate: `PlatformSuperuser` (the same coarse is_superuser gate as
/runtime; V3.3a's documented scope for coarse-gated reads).
