Skip to main content

Vendale engineering

API contract

The typed service surface the desktop depends on, and the HTTP contract behind it.

Maintained with the code · Download .md

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

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

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

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

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

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)

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)

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)

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

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)

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)

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)

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):

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):

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)

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)

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)

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.

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.

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)

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)

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)

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)

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:

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)

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)

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)

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)

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)

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)

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)

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)

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)

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.

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)

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).

Practical guides live in the help center; release history in the changelog.