Aller au contenu principal

Control Plane API v1 - contract shared by backend, portal, SDKs, CLI

Base path /v1. JSON only (except SSE/WebSocket where noted). OpenAPI at /openapi.json. All SDKs, the portal and the CLI MUST use only the paths below. The backend MUST implement all of them.

Cross-cutting rules​

  • Auth (one of): Authorization: Bearer <OIDC JWT> (Keycloak), X-API-Key: eai_<prefix>_<secret> (tenant API key), mTLS/SPIFFE (X-SPIFFE-ID set by the mesh sidecar, only when EAI_AUTH_SPIFFE=1), or in dev EAI_AUTH_DEV=1 with X-Dev-Tenant: <tenant slug> and X-Dev-User: <id> (roles via X-Dev-Roles: tenant_admin,developer).
  • Tenancy: the tenant is resolved from the credential, never from the body. Optional X-Project: <slug> and X-Environment: dev|test|staging|prod. PostgreSQL row level security enforces isolation.
  • Roles: platform_admin, tenant_admin, security_officer, finops, developer, auditor, agent, viewer. API keys carry scopes (models:invoke, agents:run, tools:invoke, rag:query, finops:read, admin:*...).
  • Errors: {"error": {"code": "policy_denied", "message": "...", "message_i18n": {"en": "...", "fr": "..."}, "details": {...}, "request_id": "..."}} with HTTP 400/401/403/404/409/422/429/5xx. Language chosen by Accept-Language (en, fr); default en. Machine codes never change.
  • Pagination: ?limit=50&cursor=<opaque> -> {"items": [...], "next_cursor": "..."|null}.
  • Idempotency: Idempotency-Key header on POST that create runs, usage events, approvals.
  • Request surface: docs/api/control-plane-v1.requests.json, generated from the app by python -m eai.api_surface (services/control-plane), lists the query parameters and body fields each route reads (extra: ignore = undeclared keys are dropped, allow = free-form body). The SDK and portal conformance tests check every request they build against it; tests/test_api_request_surface.py fails when it is stale.
  • Published OpenAPI: docs/api/openapi-v1.json (sorted keys, no servers, info.x-contract-version), regenerated by the single command python -m eai.api_openapi (services/control-plane) after any route change; --check and tests/test_api_openapi.py fail when it is stale or when the change is breaking (removed route, removed 2xx response field, new required request field, narrowed request enum) without a major bump of CONTRACT_VERSION.
  • Tracing: W3C traceparent accepted and propagated; X-Request-Id returned.
  • Money: decimal strings with a currency.
  • Audit: every mutation and every policy decision writes a hash-chained audit event.
  • Security: every response carries security headers (nosniff, frame denial, strict CSP on JSON, Cache-Control: no-store on /v1, HSTS behind TLS); request bodies are limited (EAI_MAX_BODY_BYTES, uploads EAI_MAX_UPLOAD_BYTES: 413 payload_too_large); requests are rate limited per principal and per client IP (429 rate_limited with Retry-After); calls to tenant-provided URLs go through the egress guard (403 egress_denied, details.reason); prompts and untrusted content are screened for prompt injection (403 prompt_injection_blocked when the tenant mode is block).
  • No em dash / en dash anywhere in code, UI copy, docs.

Resource map (method path -> purpose)​

System​

  • GET /healthz, GET /readyz, GET /v1/meta (version, features, locales ["en","fr"])
  • GET /metrics (Prometheus text format, scraped by the ServiceMonitor; not part of the OpenAPI schema)
  • GET /v1/me (principal, tenant, roles, scopes)
  • GET /v1/catalog/bricks, GET /v1/catalog/bricks/{id} (the A-H brick catalog). Each brick carries technology_en and technology_fr (localized technology); technology is kept equal to technology_en for v1 backward compatibility.
  • GET /v1/roadmap (12 phases)
  • GET /v1/operations/{operation_id} (long operations, architecture B6.3): every long creation (agent runs, evaluation and red team runs, marketplace installations and upgrades, knowledge base reindex, connector sync, video generation) answers with the headers Operation-Id and Operation-Location; this route returns {operation_id, kind, state, status, http_status, resource {type, id, href}, accepted, result, created_by, created_at}, result being the live resource read through its own route with the caller's credentials. With an Idempotency-Key header a retry by the same principal with the same body replays the first answer (Idempotency-Replayed: true) without running the operation again; another body under the same key gets 409 idempotency_conflict, a retry while the first request still runs gets 409 operation_in_progress. Readable by its creator and by tenant_admin, auditor, security_officer, platform_admin of the same tenant (404 otherwise).

Contracts and manifests​

  • GET /v1/contracts, GET /v1/contracts/{name} (JSON Schema), POST /v1/contracts/{name}/validate body: document -> {valid, errors[]}
  • POST /v1/manifests/apply body: any manifest (kind Agent|Model|Tool|Skill|Plugin|Connector|Channel) -> validate, policy check, upsert in the right registry, returns {kind, name, version, status}
  • GET /v1/manifests/{kind}/{name} export as manifest

Identity and tenancy (brick A)​

  • GET/POST /v1/tenants (platform_admin), GET/PATCH /v1/tenants/{id}
  • GET/POST /v1/projects, GET/PATCH/DELETE /v1/projects/{id}; GET/POST /v1/projects/{id}/environments
  • Tenant hierarchy (A6.1, A6.2, ADR-0156): GET/POST /v1/organizations body {slug, name, description?, metadata?}, PATCH /v1/organizations/{organization_id} body {name?, description?, status? (active | archived), metadata?, expected_version?}; GET/POST /v1/business-units (filters organization_id, parent_id) body {organization_id, parent_id?, slug, name, description?, metadata?}, PATCH /v1/business-units/{business_unit_id} (same body as an organization). tenant_admin writes, any tenant role reads. Project creation and update (/v1/projects and /v1/projects/{id}) accept organization_id, business_unit_id (a unit alone brings its organization, null detaches) and metadata; the project list filters on organization_id and business_unit_id. Every row carries version, created_by, updated_by and trace_id (correlation id) stamped by PostgreSQL; PATCH with expected_version is optimistic: 409 version_conflict {expected_version, current_version} when the row moved. Errors: 409 conflict (duplicate slug), 422 invalid_metadata (metadata must be a flat object of lower case keys with text, number, boolean or null values, 16 KB maximum, checked by eai.valid_metadata in the database), 422 invalid_hierarchy (unit of another organization), 404 not_found (organization or unit of another tenant). Audit events organization.create, organization.update, business_unit.create, business_unit.update
  • GET/POST /v1/members, PATCH/DELETE /v1/members/{id} (role assignment)
  • GET/POST /v1/api-keys (secret shown once), DELETE /v1/api-keys/{id}, POST /v1/api-keys/{id}/rotate
  • GET/POST /v1/service-identities (SPIFFE ids), DELETE /v1/service-identities/{id}
  • POST /v1/service-identities/{identity_id}/assume (B9.3 identity.assume_service) {purpose, audience?, ttl_seconds? (30 to 3600, default 900), scopes? (default: the identity scopes)}: 201 {token, context_id, kid, alg, issued_at, expires_at, audience, digest, context, service_identity: {id, name, spiffe_id}, effective: {roles, scopes, clearance}, assumed_by, on_behalf_of, decision_id}. A signed ExecutionContext whose subject is service:<name>; it is not an API credential: downstream services verify it with POST /v1/context/verify or the JWKS, and the caller revokes it with POST /v1/context/{context_id}/revoke. Needs the OpenFGA relation can_assume on service_identity:<id> written explicitly (POST /v1/authz/tuples; no tenant role implies it, administrators included, and a wildcard subject such as user:* is refused with 422 authz_wildcard_not_allowed), then an allow decision for service_identity.assume. The effective rights never exceed the caller's: the roles held by both (never platform_admin), the requested scopes the identity grants and the caller holds, the lower clearance. The caller stays on_behalf_of in the signed document and is the actor of the audit events service_identity.assume (allow or deny) and context.issue. Errors: 403 relation_required, policy_denied, service_identity_scope_exceeds; 404 not_found; 409 service_identity_disabled; 400 context_wrong_audience; 502 authz_backend_error; 503 context_authority_unavailable
  • GET/POST /v1/identity/quota-profiles body {name, period, enforcement, limit_cost, limit_tokens, currency} (tenant_admin writes; security_officer and auditor read), PATCH /v1/identity/quota-profiles/{id} body {period, enforcement, limit_cost, limit_tokens} (also updates the user quotas already granted from the profile), DELETE /v1/identity/quota-profiles/{id} (409 while a group mapping uses it)
  • GET/POST /v1/identity/group-mappings body {group, project_id | project (slug), role, quota_profile_id | quota_profile (name)}, GET/PATCH/DELETE /v1/identity/group-mappings/{id} (PATCH accepts clear_quota_profile; PATCH and DELETE revoke what the mapping granted). Applied at each OIDC authentication from the groups and entra_groups claims: membership and user quota granted, then revoked when the group leaves the token; memberships granted by hand are kept
  • POST /v1/authz/check body {subject, relation, object, contextual_tuples?} (OpenFGA or builtin fallback; contextual_tuples hold for this check only and are never stored; an unreachable OpenFGA answers 502 authz_backend_error, audited authz.check deny with reason backend_unavailable), GET/POST/DELETE /v1/authz/tuples (GET lists tuples, filters subject, relation, object)
  • GET /v1/identity/whoami (any authenticated role): the identity resolved for the request {id, kind, tenant_id, tenant_slug, roles, scopes, clearance, auth_method, display} (auth_method oidc, password, api_key, spiffe or dev)
  • POST /v1/auth/login (no credential) {username, password}: 200 {token, token_type: Bearer, expires_at, must_change_password, user: {username, tenant_id, tenant_slug, roles}}. Opens a portal session of a local account (eai.local_accounts, scrypt hash); the token eais_<prefix>_<secret> is returned once, sent as Authorization: Bearer, valid EAI_LOCAL_SESSION_TTL_SECONDS. The first account comes from EAI_BOOTSTRAP_ADMIN_USERNAME and EAI_BOOTSTRAP_ADMIN_PASSWORD at API startup (roles platform_admin and tenant_admin on the tenant EAI_BOOTSTRAP_TENANT), created once and never reset; while must_change_password is true every other route answers 403 password_change_required, except GET /v1/me, GET /v1/identity/whoami, POST /v1/auth/password and POST /v1/auth/logout. Errors: 401 invalid_credentials (unknown login and wrong password alike), account_disabled; 429 account_locked (after EAI_LOCAL_AUTH_MAX_FAILURES wrong passwords, for EAI_LOCAL_AUTH_LOCKOUT_SECONDS, Retry-After); 403 local_auth_disabled, tenant_suspended. Audited auth.login (allow or deny)
  • POST /v1/auth/password (portal session of a local account) {current_password, new_password}: 200 {changed, password_changed_at, revoked_sessions, must_change_password: false}; the other sessions of the account are revoked. Errors: 403 invalid_current_password, password_session_required; 422 password_too_weak {reasons: [too_short, too_long, same_as_username, unchanged, initial_or_known, letters_and_digits], min_length} (EAI_LOCAL_PASSWORD_MIN_LENGTH, default 12). Audited auth.password.change
  • POST /v1/auth/logout (portal session of a local account): 204, the token is refused from then on (401 session_revoked). Audited auth.logout
  • Resource ReBAC (require_relation): projects, agents, tools, knowledge bases, MCP servers, skills and plugins check a relation (viewer to read, editor to change, owner to delete, can_invoke to run, invoke, query or proxy) with inheritance tenant, project, resource; a refusal is 403 relation_required, audited authz.deny. Creating a resource writes user:<id> owner and, with X-Project, the project:<id> project link.
  • GET/POST /v1/policies, GET/PUT/DELETE /v1/policies/{id}, POST /v1/policies/evaluate (Policy Contract request -> decision), GET /v1/policies/decisions
  • Policy history (IAM_-016): every create, update and delete appends an immutable revision. GET /v1/policies/{id}/revisions (tenant_admin, security_officer, auditor, developer), GET /v1/policies/{id}/revisions/diff?from_revision=&to_revision= (field and rule level changes, rules keyed by id; defaults: previous against latest; 404 for an unknown policy)
  • POST /v1/policies/simulate body {candidate: <policy>, policy_id?, inputs: [Policy Contract requests], recorded: N} (tenant_admin, security_officer, developer): decisions of the active policies versus the same set with the candidate, per input and for the last N recorded decisions; returns results[{input, current, candidate, changed}] and summary{total, changed, allow_to_deny, deny_to_allow}; nothing is changed, the run is audited policy.simulate; only builtin candidates (422 simulation_engine_unsupported)
  • Signed OPA bundles (IAM_-005): POST /v1/policies/bundles body {revision?, data?: {"eai": {...}}} (tenant_admin, security_officer) packs the engine=opa policies (active and draft) with a .manifest revision, signed RS256 with EAI_POLICY_BUNDLE_SIGNING_KEY_FILE (409 on an existing revision, 409 policy_bundle_empty, 503 policy_bundle_signing_unavailable in prod without key); GET /v1/policies/bundles (plus auditor) lists them; GET /v1/policies/bundles/{revision}/bundle.tar.gz (current is the latest; ETag sha256) is the resource OPA polls with an API key; GET /v1/policies/engines (tenant_admin, security_officer, auditor) reports the revision each OPA bundle really runs (read from OPA /v1/data/system/bundles), expected_revision, in_sync, and the OpenFGA store and model in use
  • GET/POST /v1/approvals, GET /v1/approvals/{id}, POST /v1/approvals/{id}/decide {decision: approve|reject, comment} (separation of duties: requester cannot approve)
  • POST /v1/approvals/{id}/cancel {reason} (B9.3 approval.cancel; the requester, tenant_admin or security_officer): a pending approval ends cancelled (decided_at set), audited approval.cancel (a refusal is audited too); a waiting run ends failed with the reason approval_cancelled, a pending break glass grant or mandate is rejected. Errors: 403 forbidden, 404 approval_not_found, 409 approval_closed (not pending)
  • Promotions: POST /v1/models/{model_id}/status with approved, PATCH /v1/tools/{name} with status approved, POST /v1/agents/{id}/resume, POST /v1/skills/{name}/status {status, reason} with approved, POST /v1/mcp/servers/{id}/status {status: active|disabled, reason} with active answer 202 {approval_id, status: pending_approval, kind, ref, target_status}; on approval the risk gate (high risk needs an approved assessment) and the blocking promotion suites run, the outcome is in payload.promotion {result: applied|blocked, reason, evaluations, failed_suites}. Retirements (restricted, deprecated, blocked, suspend, kill) stay immediate.
  • GET/POST /v1/risk/assessments, GET/POST /v1/risk/controls, GET/POST /v1/risk/exceptions (expiry + approver), GET /v1/risk/evidence
  • GET/POST /v1/incidents, GET/PATCH /v1/incidents/{id}, POST /v1/incidents/{id}/actions (suspend agent/model/provider, rotate key)
  • POST /v1/incidents/alerts: Alertmanager receiver (webhook payload v4). Scope incidents:alerts granted explicitly (or incidents:*; the global wildcard * of an interactive session does not count) or role alert_receiver. Body {status, alerts: [{status: firing|resolved, fingerprint, labels, annotations, startsAt, endsAt, generatorURL}]}. One incident (category alert) per open fingerprint: created, updated, resolved or ignored (resolved, never seen). Response {received, items: [{fingerprint, result, incident_id, tenant_id}]}. Audited (incident.alert.open|update|resolve) and forwarded to the SIEM. Alerts land in the tenant of the key; a tenant label is honoured for platform administrators only. Errors: 401 unauthenticated, 403 forbidden, 403 tenant_required, 400 invalid_request (status other than firing or resolved), 422 validation_error.
  • GET /v1/governance/registry (agents, models, tools, owners, risk)
  • Secrets: POST /v1/secrets {path, value} (write only), GET /v1/secrets (metadata only), DELETE /v1/secrets/{path}

Security (cross-cutting)​

  • GET /v1/security/posture (platform_admin, security_officer, tenant_admin): environment, generated_at, protections (security headers, HSTS, allowed hosts, trusted proxies, body limits, rate limits, authentication methods and JWT algorithms, CORS origins, egress policy, API docs exposure), checks [{id, category, status: pass|warn|fail, severity: critical|high|medium|low, message_en, message_fr}], summary {pass, warn, fail} and tenant {tenant_id, settings} for a caller with a tenant.
  • GET/PUT /v1/security/settings (tenant_admin, security_officer; PUT is a partial update audited as security.settings.update): tenant settings stored in tenants.settings.security: prompt_injection_mode (off|detect|block, default EAI_PROMPT_INJECTION_DEFAULT_MODE), untrusted_content_wrapping (bool, default true), output_links (allow|strip_untrusted, default strip_untrusted), output_link_allowlist (host names), egress_allowed_hosts (host names or *.suffix; when not empty, the tenant tools, connectors, MCP servers and providers may only call these hosts; IP addresses and private names are refused here).
  • Security events are audit events whose action starts with security. (security.auth.failed, security.rate_limited, security.egress.denied, security.prompt_injection.detected, security.prompt_injection.blocked, security.output.link_stripped, security.settings.update), read through GET /v1/audit/events.

LLM management (brick B)​

  • GET/POST /v1/providers, GET/PATCH/DELETE /v1/providers/{id} - Provider Registry (16+ providers: openai, azure_openai, azure_ai_foundry, anthropic, google_ai_studio, vertex_ai, aws_bedrock, mistral, cohere, deepseek, groq, together_ai, fireworks_ai, openrouter, ollama, vllm, plus qwen, zai, minimax, lm_studio, openai_codex, github_copilot, huggingface, custom, custom_anthropic). Credentials are write-only (credentials stored in OpenBao/encrypted, never returned).
  • POST /v1/providers/{id}/test (connectivity check), POST /v1/providers/{id}/disable (kill switch), POST /v1/providers/{id}/enable
  • GET /v1/providers/catalog (preset list: id, label, default base URL, auth type, status configured/not configured/not connected, models hint)
  • GET/POST /v1/models, GET/PATCH/DELETE /v1/models/{model_id}, POST /v1/models/{model_id}/status {status} (approved|restricted|deprecated|blocked), POST /v1/models/{model_id}/disable (kill switch), POST /v1/models/{model_id}/enable
  • Model sheet (fields beyond Model Manifest v1, stored in the spec, left out of the manifest export): category (slug of a category visible to the tenant, 422 category_unknown), display_name, knowledge_cutoff (YYYY-MM or YYYY-MM-DD, 422 sheet_invalid); pricing {input_per_mtok, output_per_mtok, cached_input_per_mtok, cache_write_per_mtok, per_image, per_second, per_character, currency} as exact decimals, never negative (422 pricing_invalid). GET /v1/models?category= filters by category; a model without a category gets one inferred from its modalities and capabilities.
  • POST /v1/providers/{id}/models/discover (roles tenant_admin, platform_admin, ai_platform_engineer; audited provider.discover) - lists the models the configured provider serves from its own API with the stored credentials, through the egress guard and the TLS rule: OpenAI compatible GET {api_base}/models (OpenRouter context and prices used), Anthropic GET /v1/models, Gemini GET /v1beta/models (x-goog-api-key), Ollama GET /api/tags, Cohere GET /v1/models, Azure OpenAI deployments, AWS Bedrock ListFoundationModels. Answer {mode: api|manual_only, items, count, registered_count, reason?}; each item {provider_model_id, model_id (suggested), display_name, category (suggested), context_window, max_output_tokens, modalities, capabilities, pricing, reasoning_modes, registered, registered_model_id, hints_applied}, completed with the platform hints. Vertex AI, Azure AI Foundry, OpenAI Codex and GitHub Copilot answer manual_only (no list is invented). Errors: 409 provider_disabled, 403 egress_denied, 422 tls_required / discovery_no_api_base, 502 discovery_auth_failed / discovery_http_error / discovery_network_error / discovery_timeout / discovery_bad_response (with host and upstream_status, never a credential).
  • POST /v1/models/import {provider_id, models: [{provider_model_id, model_id?, category?, display_name?, context_window?, max_output_tokens?, modalities?, capabilities?, reasoning_modes?, default_effort?, knowledge_cutoff?, pricing?, status?}], scope?} (1 to 200 models, roles as POST /v1/models) - registers the selected discovered models, idempotent: {imported, skipped: [{model_id, provider_model_id, reason: already_registered}], imported_count, skipped_count}; one invalid model refuses the whole import (422 import_invalid with problems). Audited model.create per model and model.import.
  • Model categories: GET/POST /v1/llm/categories, GET/PATCH/DELETE /v1/llm/categories/{slug} {slug, label: {en, fr}, description: {en, fr}, icon, sort_order}. Predefined platform categories (text, vision, multimodal, image, video, embedding, tts, stt, live) are read only (409 category_builtin); a category used by a model cannot be deleted (409 category_in_use); 409 category_exists, 404 category_not_found. Each item carries builtin, scope and model_count. Audited model_category.create|update|delete.
  • POST /v1/models/resolve body: call parameters -> normalized/translated native parameters or explicit rejection (Capability Resolver)
  • Model versions (LLM_-004, roles: write tenant_admin, read tenant_admin, security_officer, finops, developer, auditor, viewer): POST /v1/models/{model_id}/versions {version, licence_spdx | licence_terms_ref, weights: {source, digest: sha256:..., size_bytes}, capabilities} (422 model_version_invalid, 409 model_version_exists), GET /v1/models/{model_id}/versions, POST /v1/models/{model_id}/versions/{version}/approval (202, opens an approval model_version_activation decided by tenant_admin or security_officer through POST /v1/approvals/{id}/decide, separation of duties)
  • Rollout and rollback (LLM_-016, role tenant_admin): POST /v1/models/{model_id}/versions/{version}/rollout {canary_percent: 1..100, reason} (403 model_version_not_approved, 409 model_rollout_no_baseline; 100 publishes, less is a canary share served by the router, reported in eai.model_version), POST /v1/models/{model_id}/rollback {reason} (aborts the canary or restores the previous version, 409 model_rollback_unavailable), GET /v1/models/{model_id}/rollout (active, canary, share and history; read roles)
  • Isolation (LLM_-015): PUT /v1/llm/isolation {mode: connected|local_only|air_gapped, project_id, internal_hosts} (roles tenant_admin, security_officer), GET /v1/llm/isolation?project_id= (read roles). In an isolated mode the gateway refuses every external route with 403 isolation_external_route before any outbound call and checks the outbound host again (403 isolation_egress_denied).
  • GET/POST /v1/routes, PUT/DELETE /v1/routes/{id} (Model Router rules and fallback chains), POST /v1/routes/simulate
  • GET /v1/litellm/config (generated LiteLLM config, no secrets; model_info carries max_input_tokens, max_output_tokens, input_cost_per_token, output_cost_per_token, cache_read_input_token_cost, cache_creation_input_token_cost, the unit costs output_cost_per_image, input_cost_per_second, output_cost_per_second, input_cost_per_character by category, supports_* flags and eai_category), POST /v1/litellm/sync. vLLM, Ollama and LM Studio are LiteLLM providers like the others (endpoint and optional key): there is no deployment API.
  • LLM projects, quotas and LiteLLM keys (eai.domains.llm_management, roles tenant_admin, platform_admin, always inside the tenant of the caller, 404 for another tenant): GET /v1/llm/management/status (configured, admin_url, master_key configured|missing, reachable, error_code, missing settings; never a secret value). GET/POST /v1/llm/projects, GET/PATCH/DELETE /v1/llm/projects/{project_id} {name, description, owner, project_id (tenant project id or slug), allowed_models, allowed_categories, status: active|suspended|archived, quota} (409 llm_project_exists, 404 tenant_project_not_found, DELETE 409 llm_project_has_keys), PUT /v1/llm/projects/{project_id}/quota {budget_amount, budget_currency, budget_period: daily|weekly|monthly|total, token_limit, token_period, rpm_limit, tpm_limit, max_parallel_requests, alert_threshold_pct, hard_stop} (422 invalid_quota), POST /v1/llm/projects/{project_id}/sync (push again). A project is mirrored into LiteLLM as a team (/team/new, /team/update: models, max_budget, budget_duration, tpm_limit, rpm_limit; hard_stop: false sends no max_budget, alert only) and reports sync_status pending|synced|failed with sync_error; its daily or monthly budget and token limit are FinOps quotas (eai.quotas, scope project, value the linked tenant project slug, names llm-project:<id>:budget|tokens) enforced by the gateway. Keys: GET/POST /v1/llm/projects/{project_id}/keys {name, key_alias, models, max_budget, budget_period, rpm_limit, tpm_limit, max_parallel_requests, duration_days} (LiteLLM /key/generate with the project team; 422 llm_key_limit_exceeds_project or llm_key_model_not_allowed when a limit is wider than the project; 201 returns the clear key once with Cache-Control: no-store, only the LiteLLM token hash, alias and prefix are stored; the list refreshes spend from /key/info, usage_source litellm|stored), PATCH/DELETE /v1/llm/projects/{project_id}/keys/{key_id} (/key/update; DELETE revokes through /key/delete and keeps the row as revoked), POST /v1/llm/projects/{project_id}/keys/{key_id}/block, POST /v1/llm/projects/{project_id}/keys/{key_id}/unblock (409 llm_key_revoked). Without LiteLLM: 503 litellm_not_configured (set EAI_LITELLM_ADMIN_URL or EAI_LITELLM_URL, and EAI_LITELLM_MASTER_KEY_REF openbao:<path>#<field> | env://<VARIABLE> or EAI_LITELLM_MASTER_KEY); LiteLLM down: 502 litellm_unreachable; LiteLLM refusal: 502 litellm_rejected. Every mutation is audited (llm_project.*, llm_key.*), never with the key.
  • GET /v1/adapters (specialized adapters for video generation, realtime voice, batch...)
  • POST /v1/context/plan body: window, output, reasoning, safety, segments -> budget and compression plan (Context Manager)
  • Gateway (the only path agents use to call models; OpenAI-compatible bodies plus eai extension):
    • POST /v1/gateway/chat/completions (stream via SSE when stream=true)
    • POST /v1/gateway/embeddings (optional dimensions, one of the sizes the registry declares for the model; 422 capability_not_supported when the model has no embeddings capability, 422 embedding_dimensions_invalid)
    • POST /v1/gateway/rerank (422 capability_not_supported when the model has no rerank capability; a pure embedding or rerank model is refused the same way on chat completions)
    • POST /v1/gateway/images/generations, POST /v1/gateway/audio/transcriptions (multipart file + fields, or JSON content_base64), POST /v1/gateway/audio/speech, POST /v1/gateway/video/generations (+ GET /v1/gateway/video/generations/{id})
    • POST /v1/gateway/estimate (estimated cost before execution)
    • Extension body field eai: {effort, agent_id, session_id, run_id, task_id, data_classification, purpose, region_allow, fallback_policy}; response header X-EAI-Decision-Id, X-EAI-Cost-Estimated, X-EAI-Cost-Measured (listed in CORS Access-Control-Expose-Headers so browsers can read them).

Agents and execution (brick C)​

  • GET/POST /v1/agents, GET/PATCH/DELETE /v1/agents/{id}, GET /v1/agents/{id}/versions, POST /v1/agents/{id}/suspend, POST /v1/agents/{id}/resume, POST /v1/agents/{id}/kill
  • POST /v1/agents/{id}/runs, GET /v1/runs, GET /v1/runs/{id} (Execution Contract), POST /v1/runs/{id}/cancel, POST /v1/runs/{id}/resume, GET /v1/runs/{id}/steps, POST /v1/runs/{id}/steps, GET /v1/runs/{id}/replay, GET /v1/runs/{id}/events (SSE)
  • POST /v1/runs/{id}/checkpoints, GET /v1/runs/{id}/checkpoints, GET /v1/checkpoints/{id}
  • POST /v1/runs/{id}/heartbeat (response shape below: the runtime MUST stop when action is stop), GET /v1/heartbeats (stale runs; ?format=prometheus returns Prometheus text metrics)
  • POST /v1/runs/{id}/subagents (spawn, limited by depth and count), GET /v1/runs/{id}/subagents
  • POST /v1/a2a/messages, GET /v1/a2a/inbox (agent to agent, versioned envelope)
  • GET/POST /v1/sessions, GET/DELETE /v1/sessions/{id}, POST /v1/sessions/{id}/messages, GET /v1/sessions/{id}/messages
  • GET/POST /v1/schedules, PATCH/DELETE /v1/schedules/{id} (cron, Temporal-backed)
  • GET/POST /v1/triggers, DELETE /v1/triggers/{id}, POST /v1/events/publish (NATS subject)
  • POST /v1/sandbox/executions, GET /v1/sandbox/executions/{id} (gVisor/Kata profiles, resource limits)

Tools, MCP, skills, plugins, connectors, channels (brick D)​

  • Tools: GET/POST /v1/tools, GET/PATCH/DELETE /v1/tools/{name}, POST /v1/tools/{name}/invoke (Tool Gateway: scopes, policy, DLP, approval, cost), GET /v1/tools/invocations
  • MCP: GET/POST /v1/mcp/servers, DELETE /v1/mcp/servers/{id}, GET /v1/mcp/servers/{id}/tools, POST /v1/mcp/gateway/{server_id} (JSON-RPC 2.0 proxy: initialize, tools/list, tools/call, resources/list, resources/read, prompts/list). GET ?project=<id or slug> keeps the servers of one project; POST body project (id or slug, default the X-Project header) attaches the server to that project (column project_id, OpenFGA project tuple); auth.type is none, bearer, api_key (header), query_param (param, the secret query parameter is added at call time and never stored in the URL) or connector. Isolation per project: a server attached to a project is listed and reached only from that project (the X-Project header or an API key bound to the project); from another project the gateway answers a JSON-RPC error mcp_server_project_mismatch, and the routes /v1/mcp/servers/{id}/tools, /v1/mcp/servers/{server_id}/discover, /v1/mcp/servers/{id}/status, /v1/mcp/catalog?server= and the deletion of the server answer 403 mcp_server_project_mismatch (audited); a request made in a project sees the servers of its project plus the servers shared by the tenant (no project), ?project= of another project is 403 mcp_project_forbidden; tenant_admin and security_officer without a project context keep the view of every project. MCP servers are internal only, never online: a public host is refused at registration and at every call with 403 mcp_online_forbidden (audited security.egress.denied, also for a declared name that resolves to a public address); the gateway reaches the servers hosted by the Navin Manager, the MCP Runtime instances and the internal hosts of EAI_MCP_INTERNAL_HOSTS
  • MCP servers hosted by the Navin Manager (ADR-0165): POST /v1/mcp/hosted/sync (?project= id or slug, default the request project; tenant_admin) reads the list of the hosted servers of the tenant from the Navin Manager (service mcp-host, Manager route /api/v1/mcp/hosted, permission mcp:publish), keeps each token in the Credential Broker (openbao://mcp/hosted/<id>), creates or updates the server <project>-<name> of the project (bearer authentication, allowed tools, hosted_ref) and disables the servers no longer hosted; answer {created, updated, disabled, skipped: [{id, name, project, reason: other_tenant|project_not_found|not_active|invalid_url|token_missing|project_moved}]}, never a token; the mcp-host address must be named in EAI_EGRESS_PRIVATE_ALLOWLIST
  • MCP catalogue: POST /v1/mcp/servers/{server_id}/discover (tenant_admin, developer, relation editor; protocol discovery initialize + tools/list, new or changed schema = new version with approval mcp_tool_version), GET /v1/mcp/catalog?server= (any tenant member; tools, input and output schemas, versions, scopes, status pending_approval, approved, rejected)
  • MCP Gateway access per user and agent: PUT /v1/mcp/servers/{server_id}/access {principals: [principal ids]} (tenant_admin, developer, application_owner; empty list: every member of the project of the server) sets who may call the server; a caller not listed gets the JSON-RPC error mcp_principal_not_allowed from the gateway and 403 mcp_principal_not_allowed from /v1/mcp/servers/{id}/tools and /v1/mcp/servers/{server_id}/discover (audited), and the listings hide the servers the caller may not use; tenant_admin and security_officer keep the management of every server but never call a server they are not listed on; the server of an MCP Runtime instance follows its instance (its owner and its agents only, 409 mcp_instance_access_managed)
  • MCP Runtime (ADR-0167, ADR-0170, ToolHive Kubernetes Operator, EAI_MCP_RUNTIME_ENABLED): isolated MCP instances per tenant, project and user. GET /v1/projects/{project_id}/mcp/instances?status= (relation viewer; {enabled, policy, catalog, templates, items}: the templates of the MCP catalogue of the company in SeaweedFS (EAI_MCP_CATALOG_STORAGE_PROFILE, catalog: source seaweedfs or builtin, profile, key, version, stale, error; the last valid version keeps serving when SeaweedFS cannot be read, 503 mcp_catalog_unavailable when none was ever read, 503 mcp_catalog_not_configured in production without a profile) with their typed parameters, the own instances of the caller, every instance for tenant_admin and security_officer), POST /v1/projects/{project_id}/mcp/instances {template, name?, params, credential_ref?, agents?, allowed_tools?, idle_timeout_seconds?} (relation editor, developer or tenant_admin; 202 status: provisioning; the project policy decides the templates, the hosts (*.domain patterns), the database roles and the quotas: 403 mcp_template_not_allowed, mcp_host_not_allowed, mcp_database_role_not_allowed, 409 mcp_instance_quota, mcp_instance_exists, 422 mcp_param_missing, mcp_param_invalid, mcp_param_unknown, mcp_credential_required, 409 mcp_runtime_disabled; credentials of the instance only: dynamic OpenBao database lease for postgres, the personal openbao:// or env:// secret of the user for github; written in a Secret of the instance, never returned), GET /v1/projects/{project_id}/mcp/instances/{instance_id} (owner or tenant administrators, else 404 mcp_instance_not_found; state read again from ToolHive: provisioning, ready with url and server_id (gateway entry callable by its owner and its agents only, JWT of audience urn:eai:mcp-instance:<id>), failed (failure_reason, refused by the gateway until POST .../start), sleeping (sleep_reason idle, failure, user, removed, lifetime: recreated by the next call of its owner or one of its agents, new generation), terminating, terminated; events: lifecycle journal created, credentials_issued, provisioned, ready, sleep_requested, slept, wake_requested, start_requested, woken, failure_detected, health_failed, destroy_requested, credentials_revoked, resources_deleted, terminated, each with its generation and the trace_id of the request), DELETE /v1/projects/{project_id}/mcp/instances/{instance_id} (end of the task: lease revoked, MCPServer, MCPToolConfig, CiliumNetworkPolicy and Secret deleted, gateway entry disabled, journal kept; termination_reason user or administrator), POST /v1/projects/{project_id}/mcp/instances/{instance_id}/stop (owner or tenant administrators: the instance sleeps now, lease revoked and resources deleted, gateway entry kept), POST /v1/projects/{project_id}/mcp/instances/{instance_id}/start (202: a sleeping or failed instance starts again now, failure count reset, template and policy checked again; 409 mcp_instance_busy while a cleanup is pending), PUT /v1/projects/{project_id}/mcp/policy {allowed_templates, allowed_hosts, allowed_database_roles, idle_timeout_seconds (default 900), max_lifetime_seconds (default 28800), max_instances_per_user, max_instances_per_project} (tenant_admin), GET /v1/mcp/runtime/jwks (no authentication: public keys read by the ToolHive proxies to check the tokens of the gateway). The lifecycle controller runs in the API pods, the only pods allowed to call the Kubernetes API, one replica at a time (EAI_MCP_RUNTIME_REAPER_SECONDS): instances idle past their idle time or at the end of the lifetime of their generation sleep; an instance that does not work (MCPServer Failed, a *Validated condition False, pods in CrashLoopBackOff or failing to pull their image, never picked up by the operator, provisioning timeout, two failed health probes of the proxy, proxy not answering a call) is destroyed at once and sleeps, failed after EAI_MCP_RUNTIME_FAILURE_LIMIT failures within EAI_MCP_RUNTIME_FAILURE_WINDOW_SECONDS; a call through the gateway recreates a sleeping instance and waits for it (EAI_MCP_RUNTIME_WAKE_TIMEOUT_SECONDS), the JSON-RPC error data codes mcp_instance_failed, mcp_instance_starting, mcp_instance_busy and mcp_instance_unavailable tell why a call is refused; failed revocations and deletions are retried; the journal eai.mcp_runtime_events is append only and outlives the instance
  • Skills: GET/POST /v1/skills, GET /v1/skills/{name}, POST /v1/skills/{name}/verify (signature), GET /v1/skills/{name}/load (Skill Loader: refuses unsigned or blocked)
  • Plugins: GET/POST /v1/plugins, GET /v1/plugins/{name}, POST /v1/plugins/{name}/install, POST /v1/plugins/{name}/uninstall, GET /v1/plugins/{name}/status
  • Connectors: GET/POST /v1/connectors, GET/PATCH/DELETE /v1/connectors/{name}, POST /v1/connectors/{name}/credentials (write only, Credential Broker), POST /v1/connectors/{name}/test, POST /v1/connectors/{name}/sync, GET /v1/connectors/{name}/sync-runs, POST /v1/credentials/broker/lease (short-lived delegated token, agent never sees the long-lived secret; optional on_behalf_of: the broker checks the user right can_invoke on connector:<name> before any outbound call and audits connector.on_behalf_of with user, on_behalf_of, connector and decision)
  • Webhooks: POST /v1/webhooks/{connector} (headers X-EAI-Tenant: <tenant slug>, X-EAI-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, optional X-EAI-Delivery: <id> for dedupe; 5 minute replay window; 401 invalid_signature or webhook_replay, 409 webhook_duplicate), GET /v1/webhooks/deliveries
  • Channels: GET/POST /v1/channels, GET/PATCH/DELETE /v1/channels/{name}, POST /v1/channels/{name}/inbound (message in -> run), GET /v1/channels/{name}/sessions. GET ?project=<id or slug> keeps the channels of one project; POST ?project= attaches the channel to a project (409 channel_project_conflict for a channel of another project). Types: web, mobile, email, slack, teams, whatsapp, sms, voice, api and the Navin channels discord, telegram, matrix, mattermost, signal; spec.connection {provider: navin|custom, adapter, settings, secret_refs} names the channel adapter run by the channel bridge of the SDK
  • Navin library (ADR-0164): GET /v1/navin-library (version of the vendored navin package, counts, rule sources), GET /v1/navin-library/{kind} (kind skills, plugins, commands, agents, mcp-presets or channels; q, category), GET /v1/navin-library/{kind}/{name} (content: SKILL.md and files, plugin.json and README, Markdown, full preset or channel contract); any tenant member. Per project (relation viewer to read, editor and tenant_admin, developer or application_owner to write): GET /v1/projects/{project_id}/navin-library (activations, MCP servers and channels of the project), POST /v1/projects/{project_id}/navin-library/activations {kind: skill|plugin|command|agent, name} (a skill is registered in the Skill Registry with the library signature navin-library:<name>, verified against the library digest; a plugin brings its skills, commands and agents), DELETE /v1/projects/{project_id}/navin-library/activations/{activation_id} (a plugin removes what it brought; a library skill no project uses becomes deprecated), POST /v1/projects/{project_id}/navin-library/mcp-presets/{name}/install {name?, url?, transport?, values, credential_refs, allowed_tools?} (MCP server of the project from a preset; secret fields only as openbao:// or env:// references; 422 mcp_preset_field_missing, mcp_preset_secret_ref_required, 409 mcp_preset_needs_endpoint for a stdio preset without url; the list gives the hosting of each preset, command, local or online, and an online preset is installed only with url set to the internal address where the company hosts it, else 403 mcp_online_forbidden), POST /v1/projects/{project_id}/navin-library/channels/{name}/install {name?, agent_ref?, settings, secret_refs, auth?, limits?, dlp?} (channel of the project from the setup contract of a Navin channel; 422 channel_field_missing, channel_field_invalid). Audit events navin_library.activate, navin_library.deactivate, navin_library.mcp_install, navin_library.channel_install
  • Project rules: GET /v1/projects/{project_id}/rules, GET /v1/projects/{project_id}/rules/prompt ({prompt, always, conditional, digest}: always on rules inlined, conditional rules listed with their globs), PUT /v1/projects/{project_id}/rules/{name} {content, mode: always|conditional, description, globs, source} (201 created, 200 replaced; README is reserved, 64 KB per rule), DELETE /v1/projects/{project_id}/rules/{name}, POST /v1/projects/{project_id}/rules/import {files: [{path, content}]} (reads .navin/rules/*.md, .cursor/rules/*.mdc with alwaysApply, description and globs, .cursorrules, .clinerules, .windsurfrules, .github/copilot-instructions.md; other files are returned in skipped). Audit events project.rule.put, project.rule.delete

Data, RAG, memory (brick E)​

  • POST /v1/privacy/classify {text|media_id} -> classification + findings
  • POST /v1/dlp/inspect, POST /v1/dlp/redact (mode: detect|mask|tokenize|block), GET/POST /v1/dlp/rules
  • POST /v1/tokenization/tokenize, POST /v1/tokenization/detokenize (policy + audit)
  • POST /v1/encryption/encrypt, POST /v1/encryption/decrypt, GET/POST /v1/encryption/keys, POST /v1/encryption/keys/{id}/rotate
  • Knowledge: GET/POST /v1/knowledge-bases (POST body adds embedding_model, a registry model with the embeddings capability, default EAI_RAG_EMBEDDING_MODEL, and dimensions, one of the sizes the model declares; 422 embedding_model_invalid or embedding_dimensions_invalid), GET/DELETE /v1/knowledge-bases/{id} (GET returns vector_generation, the running reindex job and datasets, one per source of the knowledge base, architecture A6.1-12: {id, kb_id, name, source_kind: upload|connector, source_ref, classification (the highest received, never lowered automatically), owner, documents (those the caller may read), last_ingested_at, version, lineage_node: {namespace, name}}), POST /v1/knowledge-bases/{id}/documents (ingest into the dataset of its source, returned as dataset_id and cited by the lineage events, then chunk, embed through the Model Gateway with the document classification: 403 classification_exceeded when the embedding model ceiling is lower, 409 knowledge_base_reindexing during a reindex, 502 embedding_dimension_mismatch, 503 vector_store_unavailable), GET /v1/knowledge-bases/{id}/documents, DELETE /v1/knowledge-bases/{id}/documents/{doc_id}, POST /v1/knowledge-bases/{id}/query (ACL filtered retrieval; body {query, top_k, min_score, mode: vector|hybrid, rerank, rerank_model}, response adds mode, reranked, warnings and embedding_model), PUT /v1/knowledge-bases/{id}/acl, POST /v1/knowledge-bases/{id}/reindex (owner relation; body {embedding_model, dimensions}, 202 with the job {job_id, status, from, to, documents_total, ...}, run by the worker job embeddings_reindex; 409 knowledge_base_reindexing, 422 embedding_model_invalid), GET /v1/knowledge-bases/{id}/reindex-jobs (last 50 jobs). Governed RAG (kit sections 7 and 10): POST documents accepts source_connector (the connector must exist and be active, else 403 source_permission_denied; checked again at query time) and chunk_readers (per chunk readers from the source permissions, by chunk position, filtered in the vector store and again before the context is built); a document flagged by the poisoning screening (prompt injection, hidden text, abnormal instructions, outlier embeddings) is stored with status: quarantined and never retrieved; restricted and secret chunk text is stored as a Tink envelope ciphertext; the query response adds citations (document_id, document_version, source_uri, chunk_position) of authorized sources only and lineage_run_id (OpenLineage events, GET /v1/lineage/graph). GET /v1/knowledge-bases/{id}/quarantine (tenant_admin or security_officer, data steward duty) lists the quarantined documents; POST /v1/knowledge-bases/{id}/documents/{doc_id}/review (tenant_admin or security_officer; body {decision: release|reject, reason}; 409 document_not_quarantined) releases or rejects one, audited knowledge.document.release or knowledge.document.reject. Knowledge extensions (V206, ADR-0175 decision 2): POST /v1/knowledge-bases accepts chunking: words|structure and retrieval (strategy: mode, fusion: rrf|weighted, rrf_k, candidates, rerank, rerank_model, rerank_candidates, dedupe, dedupe_threshold, context_window, token_budget, conflicts), replaced with PUT /v1/knowledge-bases/{id}/retrieval (owner relation, audited knowledge_base.retrieval.update); POST documents accepts collection and language; a document with the uri of an existing one becomes its next version, the history is read with GET /v1/knowledge-bases/{id}/documents/{doc_id}/versions (states current|superseded|deleted, Docling artifacts kept in the object storage with kind, object key, size and sha256); GET /v1/knowledge-bases/{id}/documents/{doc_id}/chunks lists the chunks with their structural metadata (source_page, section_title, element_type, language, char_start, char_end, bbox, document_version, content_hash, embedding_model, token_count, structure); the query body adds mode: vector|dense|lexical|hybrid (PostgreSQL full text per chunk language, reciprocal rank fusion), the strategy options and filters {collections, document_ids, metadata, element_types, languages} applied in the store with the ACL, diagnostics: true; the response adds strategy, score_kind, per result rank, scores, metadata and context, signals.possible_conflicts (a heuristic signal, never a verdict), no_result, context_budget, query_language and diagnostics_id, read back with GET /v1/knowledge-bases/{id}/query-diagnostics. Asynchronous ingestion (V207): POST /v1/knowledge-bases/{kb_id}/documents/uploads (editor relation; body {title, filename, content_base64, mime_type, uri, classification, readers, metadata, collection, language}, header Idempotency-Key replays the same answer, 409 idempotency_conflict with another body; the size, the declared format against the real signature and the file name extension are checked, 413 payload_too_large, 422 unsupported_format, format_mismatch, extension_mismatch or archive_refused, 409 ingestion_storage_required; the bytes go to the quarantine prefix; 202 {job_id, kb_id, document_id, document_key, version, state: uploaded, detected_format, size_bytes, sha256}), run by the worker job knowledge_ingestion (states uploaded, quarantined, scanning, rejected, extracting, normalizing, chunking, embedding, indexing, enriching_memory, ready, partially_ready, failed, reindexing, deleting, deleted; scanning through the file security domain, fail closed), GET /v1/knowledge-bases/{kb_id}/ingestion-jobs/{job_id} (the job with its history), POST /v1/knowledge-bases/{kb_id}/ingestion-jobs/{job_id}/retry (failed jobs only, else 409 ingestion_job_not_failed), GET /v1/knowledge-bases/{kb_id}/documents/{doc_id}/status (version state, document_status, searchable and the last ingestion job)
  • Memory: POST /v1/memory, GET /v1/memory, GET/DELETE /v1/memory/{id}, POST /v1/memory/search
  • Memory engines (Navin Memory Provider, ADR-0174): GET/POST /v1/memory/engines, GET /v1/memory/engines/{engine_id}/capabilities, GET /v1/memory/engines/{engine_id}/health, DELETE /v1/memory/engines/{engine_id}, PUT/DELETE /v1/memory/engines/{engine_id}/credential
  • Agent memory policy: GET/PUT /v1/memory/agents/{agent_id}/policy, GET /v1/memory/agents/{agent_id}/policy/versions, POST /v1/memory/agents/{agent_id}/policy/restore
  • Agent memories: GET/POST /v1/memory/agents/{agent_id}/memories, POST /v1/memory/agents/{agent_id}/recall, POST /v1/memory/agents/{agent_id}/memories/search, POST /v1/memory/agents/{agent_id}/memories/purge, POST /v1/memory/agents/{agent_id}/memories/export, GET/PATCH/DELETE /v1/memory/agents/{agent_id}/memories/{item_id}
  • Agent knowledge graph (Cognee): POST /v1/memory/agents/{agent_id}/knowledge, GET /v1/memory/agents/{agent_id}/knowledge/datasets, POST /v1/memory/agents/{agent_id}/knowledge/graph, POST /v1/memory/agents/{agent_id}/knowledge/search, GET /v1/memory/agents/{agent_id}/knowledge/datasets/{dataset_id}/relations, DELETE /v1/memory/agents/{agent_id}/knowledge/datasets/{dataset_id}
  • Retention: GET/POST /v1/retention/policies, POST /v1/retention/run, POST /v1/retention/erase (subject erasure, Opik traces and ClickHouse rows included under external), POST /v1/retention/legal-hold
  • Lineage: POST /v1/lineage/events (OpenLineage), GET /v1/lineage/graph, GET /v1/lineage/datasets/{id}
  • Prompts: GET/POST /v1/prompts, GET /v1/prompts/{name}/versions, POST /v1/prompts/{name}/render

Multimodal (brick F)​

  • GET/POST /v1/media (GET lists the tenant media with pagination; POST uploads, scans, classifies and stores encrypted), GET /v1/media/{id} (?include_content=true returns the decrypted content to authorized callers), DELETE /v1/media/{id}
  • POST /v1/multimodal/vision, POST /v1/multimodal/images/generate, POST /v1/multimodal/images/edit, POST /v1/multimodal/video/process (frames, audio extraction), POST /v1/multimodal/video/generate, POST /v1/multimodal/stt, POST /v1/multimodal/diarize, POST /v1/multimodal/tts, POST /v1/multimodal/dlp
  • GET /v1/multimodal/jobs, GET /v1/multimodal/jobs/{id}, POST /v1/multimodal/jobs/{id}/complete (GPU workers report the result and the measured units of a queued job)
  • Realtime: POST /v1/realtime/sessions (returns LiveKit token + ws url + session id), WebSocket /v1/realtime/ws/{session_id} (events: session.update, input_audio.append, transcript.delta, response.audio.delta, usage.update, error), GET /v1/realtime/sessions/{id}, POST /v1/realtime/sessions/{id}/end, POST /v1/realtime/sessions/{id}/token (new single use ws_token, expiry ws_token_expires_at, 409 session_ended). The ws_token is short lived (EAI_REALTIME_WS_TOKEN_TTL_SECONDS, 300 s), single use and bound to the tenant, the session and the identity; it is checked before any database read. Limits: message size (EAI_REALTIME_WS_MAX_MESSAGE_BYTES, close 1009), event rate (EAI_REALTIME_WS_MAX_EVENTS_PER_SECOND, EAI_REALTIME_WS_BURST, close 4429), session duration and idle timeout (EAI_REALTIME_WS_MAX_SESSION_SECONDS, EAI_REALTIME_WS_IDLE_TIMEOUT_SECONDS, close 4408); invalid, expired or replayed token: close 4401.
  • Realtime voice governance (consent, identity, turns, transcripts; guide docs/operations/en/voice-runtime.md): POST /v1/realtime/sessions also accepts agent_id and on_behalf_of (an agent principal acts for that user; a user may only name itself, otherwise 403 identity_mismatch) and refuses config.dlp_mode detect (422 parameter_invalid). POST /v1/realtime/sessions/{id}/consents (roles tenant_admin, developer, agent; body participant_identity?, purpose, recording_allowed, transcription_allowed, retention_days 1 to 3650, method verbal, click, written or api; 201, audited realtime.consent.record), GET /v1/realtime/sessions/{id}/consents, POST /v1/realtime/sessions/{id}/consents/{consent_id}/revoke (roles tenant_admin, developer, agent; audited realtime.consent.revoke), GET /v1/realtime/sessions/{id}/turns (participant, user, agent, trace id, status, latency), GET /v1/realtime/sessions/{id}/transcripts (roles tenant_admin, developer, auditor, security_officer; DLP filtered text only). WebSocket events added: client response.create {text, voice?}, response.cancel, input_audio.speech_started (barge-in, also any input_audio.append while a response streams); server response.created, response.audio.delta {turn_id, sample_rate}, response.done {latency_ms}, response.cancelled {reason, chunks_sent}, recording.saved (after input_audio.commit with config.record), transcript.delta {turn_id, trace_id, latency_ms}, errors consent_required {purpose} (transcription or recording without an active consent, audited realtime.turn.refused deny), transcript_blocked, tts_failed, response_in_progress, no_active_response. Retention: POST /v1/retention/run resource type realtime deletes transcripts and recordings past the consent retention.

Quality and observability (brick G)​

  • Evaluations: GET/POST /v1/evaluations/suites, GET /v1/evaluations/suites/{id}, POST /v1/evaluations/runs, GET /v1/evaluations/runs, GET /v1/evaluations/runs/{id}, POST /v1/evaluations/gates/check (blocking gate before promotion)
  • A/B comparisons (LLM_-013): POST /v1/evaluations/comparisons {suite_id, baseline: {type, ref, version}, candidate: {...}, tolerance, outputs: {baseline, candidate}} (roles tenant_admin, developer, security_officer; 201 with both runs, per metric deltas and the verdict candidate_better|equivalent|regression|inconclusive; 422 comparison_invalid), GET /v1/evaluations/comparisons?suite_id=, GET /v1/evaluations/comparisons/{id} (also auditor, viewer, finops; 404 comparison_not_found)
  • Red team: POST /v1/redteam/runs, GET /v1/redteam/runs, GET /v1/redteam/runs/{id}
  • Audit: GET /v1/audit/events (filters actor, action, resource, from, to), GET /v1/audit/events/{id}, GET /v1/audit/verify (hash chain), POST /v1/audit/export (WORM, signed with OpenBao Transit or HMAC in dev, see docs/operations/en/siem-audit.md), GET /v1/audit/correlations (events grouped by trace_id/run_id)
  • Application audit events (B9.3 audit.record): POST /v1/audit/events (tenant_admin, developer, agent, security_officer, application_owner, ai_platform_engineer) {action: app.<name>, resource: {type, id, name?}, outcome? (success | failure | error), details? (at most 16384 bytes of JSON), classification?, trace_id? (32 hex), event_time? (client clock, at most EAI_AUDIT_CLOCK_SKEW_SECONDS ahead)}: 201 {event_id, sequence, hash, prev_hash, occurred_at, event_time, action, stream: sdk, actor, decision}. The server appends the event to the tenant hash chain, sealed and exported (outbox, SIEM, WORM) like the platform events: the actor comes from the authentication, occurred_at and the sequence from the server, the client details stay under evidence.details.attributes next to stream: sdk, credentials are redacted. The action must be in the app. namespace and no other field is accepted (422), so a client never writes a platform event. Decided by policy first (audit.record, 403 policy_denied). Errors: 403 forbidden (read only roles), 422 audit_event_too_large, audit_event_time_invalid
  • Telemetry: POST /v1/telemetry/spans (ingest from SDK, forwarded to Opik, the OTel collector and ClickHouse), GET /v1/telemetry/overview, GET /v1/telemetry/traces/{trace_id}, GET /v1/telemetry/slo?window_minutes=60&tenant_id= (roles platform_admin, tenant_admin, auditor, viewer; a tenant caller sees its tenant only, slo_scope_forbidden otherwise; per service: requests, errors, over_latency_target, availability and latency objectives with budget_remaining and burn_rate; slo_window_invalid 422 outside 1 to EAI_SLO_RETENTION_MINUTES; audited telemetry.slo.read; each service also carries target_source: database | settings), PUT /v1/telemetry/slo/targets/{service} (platform_admin only; body {availability (0.5 to < 1), latency_seconds (> 0 to 300), latency_objective (0.5 to < 1), reason?}; writes the objectives of control_plane_api, model_gateway, tool_gateway or realtime in eai.service_slos, which GET /v1/telemetry/slo reads before the EAI_SLO_* defaults; 200 {service, availability, latency_seconds, latency_objective, reason, version, updated_by, updated_at, source: database}; 403 forbidden, 404 slo_service_unknown, 422 validation; audited telemetry.slo.target.update). The span ingestion keeps in PostgreSQL a bounded summary only (allow listed attributes, strings cut to 128 characters, at most 2048 bytes, input_tokens, output_tokens, attributes_dropped); the full span goes to the OTel collector (ADR-0157)
  • Live observability (A4.4-04, FRON-015; roles of the SLO view: platform_admin, tenant_admin, auditor, viewer, support_engineer; tenant rows only): GET /v1/telemetry/traces?status=ok|error|unset&service=&run_id=&from=&to=&limit= (1 to 500, default window the last 24 h): {items: [{trace_id, name, service, start_time, duration_ms, status, spans, errors, run_id, session_id}], from, to}, one row per root span, newest first; GET /v1/telemetry/logs?level=error|info&service=&trace_id=&q=&from=&to=&limit= (1 to 1000): request journal built from the bounded span summaries {items: [{at, level, service, name, message, duration_ms, trace_id, span_id, run_id, attributes}], from, to} (only the allow listed attributes, never a prompt or an output); GET /v1/telemetry/stream?since=&interval=&timeout=&max_events= (text/event-stream): Server-Sent Events trace (new root span), incident (incident created or updated, change: created|updated) and metrics ({at, window_seconds: 60, requests, errors, error_rate, latency_p95_ms} at every tick); each event has an id: cursor sent back as Last-Event-ID to resume; 400 invalid_request on an unknown status or level, 403 forbidden outside the roles.
  • Audit Gateway and Evidence Service (kit section 11, bricks G-11 and G-14; every read, search, verification, payload decryption and export below and under /v1/audit writes an eai.audit_access_log row and its own audit event audit.read|search|verify|export.read|payload.decrypt|seal.read):
    • GET /v1/evidence/seals (roles auditor, security_officer, tenant_admin), POST /v1/evidence/seals (auditor, security_officer; 201 signed seal {seal_number, seq_from, seq_to, merkle_root, prev_seal_hash, seal_hash, alg: Ed25519, key_id, signature, sealed_at}; 409 audit_nothing_to_seal; 503 audit_seal_key_unavailable), GET /v1/evidence/seals/{id}/verify (readers; {seal, valid, failures})
    • GET /v1/evidence/verify (readers; chain, seals, signatures and timestamps event_time <= ingested_at <= sealed_at), GET /v1/evidence/bundle (auditor, security_officer; offline bundle and Ed25519 signed manifest, check with python -m eai.domains.evidence.verify bundle.json; 503 audit_seal_key_unavailable)
    • GET /v1/evidence/alerts?status=open|acknowledged (readers; integrity alerts chain_broken|sequence_gap|seal_invalid|outbox_missing|clock_skew), GET /v1/evidence/gateway (readers; outbox pending, delivered, targets)
    • POST /v1/evidence/events/{event_id}/payload {reason} (only auditor and security_officer, never platform_admin by inheritance; decrypted sensitive payload, audited; 404, 409 audit_payload_tampered, 410 audit_payload_purged, 503 audit_payload_key_unavailable), GET /v1/evidence/access-log?limit= (auditor, security_officer)
    • POST /v1/evidence/holds {case_id, reason, time_from?, time_to?} (auditor, security_officer, tenant_admin; 201), GET /v1/evidence/holds, POST /v1/evidence/holds/{id}/release {reason} (one approval; released at the second distinct approver; 409 audit_hold_same_approver, audit_hold_not_active)
    • POST /v1/evidence/retention/run {kind: retention|erasure, cutoff?, event_ids?, dry_run?} (security_officer, tenant_admin; held payloads are skipped; returns the purge evidence: counts, sequences, Merkle root of the purged digests), GET /v1/evidence/retention/evidence (readers)
    • POST /v1/evidence/runs/{run_id}/components {step_seq?, components: [{kind: model|prompt|tool|skill|plugin, name, version, digest?, attributes?}]} (tenant_admin, agent_operator, developer; 201), GET /v1/evidence/runs/{run_id}/configuration (readers and the same runtime roles; agent version and manifest digest, components by kind, policy decisions of the run; 404)

FinOps (brick H)​

  • Pricing: GET/POST /v1/finops/price-cards, GET /v1/finops/price-cards/{id}, POST /v1/finops/price-cards/{id}/retire, GET /v1/finops/fx-rates, PUT /v1/finops/fx-rates
  • Usage: POST /v1/finops/usage (ingest Usage Event, computes measured cost), POST /v1/finops/usage/batch, GET /v1/finops/usage (filters), GET /v1/finops/summary?group_by=tenant|project|agent|model|provider|usage_type|day
  • Estimation: POST /v1/finops/estimate
  • Quotas: GET/POST /v1/quotas, PUT/DELETE /v1/quotas/{id}, POST /v1/quotas/reserve, POST /v1/quotas/{id}/commit, POST /v1/quotas/{id}/release, GET /v1/quotas/usage
  • GPU: POST /v1/finops/gpu/samples, GET /v1/finops/gpu/allocation, GET/POST /v1/finops/allocation-policies
  • Reconciliation: POST /v1/finops/reconciliation/imports (provider invoice/export: openai, anthropic, aws_cur, azure, gcp, generic_csv), GET /v1/finops/reconciliation/imports, POST /v1/finops/reconciliation/run, GET /v1/finops/reconciliation/report (estimated vs measured vs billed, variance, alerts)
  • Budgets alerts: GET /v1/finops/alerts
  • Media, GPU and storage prices (LLM_-011): POST /v1/finops/media-prices {unit: audio_second|image|video_second|gpu_second|storage_gb_month, provider, resource, unit_price, currency, effective_from, source} (roles finops, tenant_admin; dated revisions, 409 media_price_exists, 422 media_price_invalid), GET /v1/finops/media-prices?unit= (read roles), POST /v1/finops/media-costs/estimate {items: [{unit, provider, resource, quantity, at}]} (read roles and agent; each item carries its provenance, or amount: null, status: unavailable, reason: price_unknown, never zero)
  • Settings: GET/PUT /v1/finops/settings (tenant currency, monthly budget, variance threshold, reservation lifetime)

Licensing (commercial plane, kit section 13)​

  • GET /v1/license (any authenticated principal): non sensitive status {state: unlicensed | valid | expiring_soon | grace | expired, evaluated_at, edition, product_id, term_months, valid_from, valid_until, grace_days, grace_until, days_remaining, grace_days_remaining, renewal_scheduled, next_valid_from, installation_group_id, restricted_actions, never_restricted, banner: {level: none | info | warning | critical, code, message_i18n}}; no customer id, support reference or token. restricted_actions is ["distribution.upgrade", "marketplace.install"] in state expired only, else empty
  • POST /v1/license/activations (platform_admin): body {token} (signed licence, JWS compact EdDSA, verified locally against EAI_LICENSE_PUBLIC_KEYS, no network call); 201 {activation: activated | already_active, license, support_contract, status}; support_contract is the support contract recorded from the signed licence in the same transaction (table eai.support_contracts, V168): {license_id, support_ref, installation_group_id, customer_id, level (signed edition), valid_from, valid_until, grace_until, superseded_by, superseded_from, version, created_by, created_at, updated_at, state: scheduled | active | grace | superseded | ended}, never a price. Errors: 422 license_invalid_token, license_signature_invalid, license_unknown_key, license_invalid_payload, license_forbidden_field (price, amount, currency, seat, quota, limit or counter field), license_invalid_term (12 or 36 months matching the dates), license_environment_not_included; 409 license_group_mismatch, license_conflict; 403 forbidden, policy_denied. Every refusal is written to the licence history and the audit chain
  • GET /v1/license/history (platform_admin, ?limit&cursor): {installation: {installation_id, installation_group_id, group_bound_at, group_bound_by, created_at}, licenses: [...], support_contracts: [support_contract, ...] (ordered by valid_from; a renewal licence of the group replaces the previous contract from its own valid_from: superseded_by, superseded_from), items: [{id, license_id, kind: activated | rejected | warning | grace_started | expired | action_refused, details, actor, at}], next_cursor}
  • Expiry never stops anything: after the grace period only distribution.upgrade and marketplace.install are refused with 403 license_expired (eai.domains.licensing.guard.require); see docs/operations/en/licensing.md

Context Authority, delegations, mandates and break glass (kit sections 7, 8.1, 8.2)​

Effective rights of every action = user AND agent AND delegation AND resource (OpenFGA or builtin tuples) AND policy (OPA or builtin); a sub-agent always receives a restricted delegation. Contracts: execution-context, delegation-context, policy-decision, action-context, approval-request (contracts/v1). See docs/security/en/identity-context-delegation.md.

  • GET /v1/context/jwks (any authenticated principal): {keys: [JWK]} public keys of the Context Authority (active key, and retired keys inside their verification window); 503 context_authority_unavailable
  • POST /v1/context/issue (tenant principal) {audience?: [string], ttl_seconds?, delegation_id?, run_id?}: 201 {token, context_id, kid, alg: ES256 | EdDSA, issued_at, expires_at, audience, digest, context}. token is a JWS compact whose header and payload are RFC 8785 canonical JSON; payload claims iss, aud, iat, exp, jti, sub, tid, ctx (ctx = Execution Context v1). The lifetime never exceeds the delegation. Errors: 400 context_ttl_exceeded, context_wrong_audience; 403 forbidden, delegation_revoked, delegation_expired, session_revoked, mandate_revoked; 503 context_authority_unavailable (no key, unreadable key, OpenBao unreachable, revoked key: fail closed, refusal audited)
  • POST /v1/context/verify (tenant principal) {token, audience?}: {valid: true, context_id, kid, alg, claims, context}; binds the verified identity context to the audit events of the request. Errors: 401 context_malformed, context_invalid_signature, context_unknown_key, context_wrong_issuer, context_wrong_audience, context_expired, context_revoked (unknown jti, revoked context or revoked delegation); 403 context_tenant_mismatch; 503 context_authority_unavailable. Every refusal is audited (context.verify deny)
  • POST /v1/context/{context_id}/revoke (issuer of the context, tenant_admin, security_officer): {context_id, revoked_at, revoked_by}; 404 context_not_found
  • GET /v1/context/keys (security_officer, auditor): {items: [{kid, alg, status: active | retired | revoked, first_used_at, last_used_at, retire_after, revoked_at, revoked_by, revoke_reason}]} (public metadata, never key material)
  • POST /v1/context/keys/{kid}/revoke (platform_admin) {reason}: the key stops verifying at once; signing with it is refused with 503. 404 signing_key_not_found
  • POST /v1/delegations (tenant principal, not an agent) {delegate: agent:<name> | app:<id> | service:<name> | subagent:<name>, scopes, audience?, resources?, data_classification_max?, max_depth (0..10), redelegation_allowed, ttl_seconds? | expires_at?, session_id?, purpose}: 201 delegation {delegation_id, parent_delegation_id, delegation_chain, root_principal_id, root_principal_kind, on_behalf_of, delegator, delegate, scopes, audience, resources, data_classification_max, depth, max_depth, redelegation_allowed, session_id, mandate_id, break_glass_id, purpose, status: active | expired | revoked, expires_at, revoked_at, revoked_by, revoke_reason, created_by, created_at}. Patterns use * only. Errors: 403 delegation_scope_exceeds (scopes not held by the caller), delegation_classification_exceeds, delegation_root_forbidden; 400 delegation_ttl_exceeded (EAI_DELEGATION_MAX_TTL_SECONDS), invalid_request
  • GET /v1/delegations (?status=active|expired|revoked&root_principal_id&limit&cursor; own delegations, all for tenant_admin, security_officer, auditor), GET /v1/delegations/{id} (adds verification: {valid, reason, chain, problems}; 404 delegation_not_found)
  • POST /v1/delegations/{id}/restrict (root principal, creator, agent, tenant_admin, security_officer) {delegate, scopes?, audience?, resources?, data_classification_max?, max_depth?, redelegation_allowed?, ttl_seconds? | expires_at?, purpose}: 201 child delegation, always covered by its parent. Errors: 403 delegation_scope_exceeds, delegation_classification_exceeds, delegation_expiry_exceeds, delegation_depth_exceeded, delegation_not_redelegable, delegation_revoked, delegation_expired, delegation_invalid
  • POST /v1/delegations/{id}/revoke (root principal, creator, tenant_admin, security_officer) {reason}: {delegation_id, revoked: [ids]}, cascades to every descendant and revokes their execution contexts; the next sensitive action of a run under it is refused with 403 delegation_revoked
  • POST /v1/delegations/revoke-principal (tenant_admin, security_officer) {principal_id, reason}: {principal_id, revoked: [ids]} every delegation rooted at that user
  • POST /v1/delegations/authorize (tenant principal) {action, delegation_id? | run_id?, scope?, resource?: <type>:<id>, relation?, audience?, data_classification?}: {action_id, action, allow, layers: [{layer: chain | session | user | agent | delegation | classification | audience | resource | relationship | policy, allow, reason?}], subject: {principal_id, on_behalf_of, root_principal_id, delegate}, delegation: {delegation_id, chain, depth}, decisions: {policy: {decision_id, allow, engine, obligations}, openfga}, run_id, decided_at}; allow is true only when every layer allows (relationship engine error: deny). Audited as authz.authorize
  • POST /v1/mandates (tenant_admin, developer, security_officer) {agent, service_identity_id, schedule_id?, scopes, audience?, resources?, data_classification_max?, max_depth, purpose, ttl_days (max EAI_MANDATE_MAX_TTL_DAYS)}: 201 {mandate_id, agent, schedule_id, service_identity_id, scopes, audience, resources, data_classification_max, max_depth, purpose, status: pending | approved | rejected | revoked | expired, approval_id, requested_by, approved_at, expires_at, revoked_at, created_at}; approved through POST /v1/approvals/{approval_id}/decide by another person. Errors: 403 delegation_scope_exceeds (wider than the service identity), delegation_classification_exceeds; 404 service_identity_not_found, schedule_not_found; 409 service_identity_disabled; 400 mandate_ttl_exceeded. A scheduled run without an approved mandate is skipped (skipped: mandate_required, audited schedule.run deny)
  • GET /v1/mandates (?agent&limit), GET /v1/mandates/{id} (404 mandate_not_found), POST /v1/mandates/{id}/revoke (requester, tenant_admin, security_officer) {reason} (revokes the delegations of the mandate; 409 mandate_closed)
  • POST /v1/break-glass (tenant principal) {roles, scopes, reason (10 characters minimum), duration_seconds (60..EAI_BREAK_GLASS_MAX_SECONDS), beneficiary?, incident_ref?}: 201 {grant_id, requester, beneficiary, roles, scopes, reason, incident_ref, duration_seconds, status: pending | active | expired | rejected | revoked, approval_id, activated_at, expires_at, revoked_at, revoked_by, created_at}; the approval needs a quorum of 2 distinct approvers (security_officer or tenant_admin), never the requester or the beneficiary (403 separation_of_duties, 409 already_decided). Errors: 400 break_glass_duration_exceeded, invalid_request
  • GET /v1/break-glass (?status&limit), GET /v1/break-glass/{id} (expired automatically past expires_at, audited break_glass.expire; 404 break_glass_not_found), POST /v1/break-glass/{id}/revoke (requester, security_officer, tenant_admin; revokes the delegations created under the grant; 409 break_glass_closed)
  • POST /v1/break-glass/{id}/context (beneficiary) {audience?, ttl_seconds?}: 201 signed context with authorization.break_glass {grant_id, roles, scopes, approval_id, expires_at}, lifetime bounded by the grant; 409 break_glass_not_active, 403 forbidden
  • Runs: POST /v1/agents/{id}/runs accepts delegation_id (a delegation granted to agent:<name>, else an implicit one from the caller: caller scopes intersected with the agent scopes, lower classification); 403 delegation_delegate_mismatch, 503 context_authority_unavailable when EAI_CONTEXT_REQUIRED is on without a key. POST /v1/runs/{id}/steps of kind tool_call, model_call, subagent, act or approval and POST /v1/runs/{id}/subagents re-validate the delegation chain, the session, the mandate and the policy first: 403 delegation_revoked, delegation_expired, delegation_invalid, delegation_required, delegation_resource_denied, delegation_not_redelegable, session_revoked, mandate_revoked, policy_denied (audited deny)
  • Audit events written under a verified context are Audit Event 1.1: on_behalf_of, root_principal_id, delegation {delegation_id, parent_delegation_id, chain, depth, delegate, mandate_id, break_glass_id}, agent {agent_id, name, version, run_id, parent_run_id, depth, subagent}, workload, session, causal {request_id, execution_id, action_id, trace_id, span_id, parent_event_id}, authorization {decision_ids}, approvals {approval_ids}, execution_context {context_id}; hashed in the chain

SOC, AI-SDR and Kill Switch (kit section 11)​

Typed reactions only (BLOCK_REQUEST, RATE_LIMIT, REQUIRE_APPROVAL, CANCEL_RUN, CANCEL_SUBAGENTS, SUSPEND_AGENT, SUSPEND_APPLICATION, DISABLE_TOOL, QUARANTINE_CONNECTOR, REVOKE_CREDENTIAL, DISABLE_MODEL_ROUTE, ISOLATE_NETWORK, TERMINATE_WORKLOAD, SUSPEND_IDENTITY, SUSPEND_TENANT, EMERGENCY_LOCKDOWN, RECOVER), never a command. Contracts security-incident and containment-order (contracts/v1). SOC roles: analyst security_officer; lead security_officer, tenant_admin; evidence readers security_officer, tenant_admin, auditor. Every SOC read is audited (soc.access, soc.evidence.access), every refusal too (soc.access.deny). The Kill Switch Registry (PostgreSQL) is read by every policy decision (403 policy_denied, reason kill_switch:<REACTION>:<type>:<ref>:<id>), by authentication (401 credential_revoked, 403 identity_suspended, 403 tenant_suspended) and by the agents runtime (steps and sub-agents: 403 policy_denied with decision_id and kill_switch_ids; POST /v1/agents/{id}/resume 409 kill_switch_active). See docs/security/en/soc-runbook.md.

  • GET /v1/soc/catalog (tenant principal): {reactions, targets: {<REACTION>: [target types]}, detections: {<kind>: {severity, requirement, playbook: [{reaction, target}]}}, response: {min_confidence, max_auto_blast_radius, manual_approval_blast_radius, always_approval, auto_reactions, targets}, roles}
  • POST /v1/soc/detections (security_officer, agent, or a credential granted the scope soc:detections explicitly) {kind, source: guard | dlp | egress | quota | plugins | runtime | authz | policy | audit | knowledge | mcp | falco | tetragon | opensearch | manual, severity?: sev1..sev4, confidence (0..1), occurred_at?, user?, agent?, run_id?, session_id?, data_classification?, resource?, targets?: {session, run, agent, identity, credential, workload, mcp, plugin, tool, connector, knowledge_base, model, application}, evidence?, fingerprint?}: 201 {detection_id, incident_id, duplicate, correlated, risk: {severity, confidence, score, reasons}, orders: [containment order]}. Same user, else same agent, else tenant level detections within EAI_SOC_CORRELATION_WINDOW_SECONDS share one incident (category soc). Errors: 400 unknown_detection_kind, invalid_request; 403 forbidden
  • GET /v1/soc/detections (?incident_id&kind&limit, evidence readers): {items, next_cursor}
  • POST /v1/soc/webhooks/{source} (falco, tetragon, opensearch; no principal, body: one event or a list of at most 200): headers X-EAI-Tenant (slug), X-EAI-Signature: t=<unix>,v1=<hex HMAC-SHA256(EAI_SOC_WEBHOOK_SECRET, "<t>." + body)>, X-EAI-Delivery; 202 {received, items: [detection result]}. Errors: 401 invalid_signature (also without secret), webhook_replay (outside 5 minutes); 409 webhook_duplicate; 404 unknown source. Every delivery is recorded and audited (soc.webhook.detection)
  • POST /v1/soc/receipts (in-cluster receipt reporter, same signature scheme, no principal) {kill_switch_id, verified, adapter, evidence}: 202 {kill_switch}; only for ISOLATE_NETWORK and TERMINATE_WORKLOAD (409 receipt_not_expected, 404 not_found); verified: true moves the switch to SUSPENDED_VERIFIED, false to VERIFICATION_FAILED
  • POST /v1/soc/orders (analyst or lead) {reaction, target: {type, ref}, incident_id?, reason, params?: {max_per_minute | selector | replicas | instances | kind}, max_blast_radius?, confidence?}: 201 containment order {id, incident_id, reaction, target_type, target_ref, params, blast_radius, max_blast_radius, confidence, severity, mode: manual | automatic, status: applied | pending_approval, response_decision: {allow, reasons, obligations, engine, decision_id}, manifest, reason, requested_by, approved_by, kill_switch?: {id, desired_state: SUSPENDED | RELEASED, observed_state: PENDING | APPLIED_PENDING_RECEIPT | SUSPENDED_VERIFIED | VERIFICATION_FAILED | RELEASED, last_receipt_id, last_verified_at, ...}, existing?}. EMERGENCY_LOCKDOWN and SUSPEND_TENANT always wait for a second person; ISOLATE_NETWORK returns a CiliumNetworkPolicy and TERMINATE_WORKLOAD a scale to zero patch in manifest for the GitOps path. Errors: 400 unknown_reaction, target_not_allowed, invalid_request; 409 containment_refused (blast_radius_exceeded, ...); 422 recover_requires_workflow; 403 forbidden
  • GET /v1/soc/orders (?status&incident_id&limit, evidence readers), GET /v1/soc/orders/{order_id} (adds the switch and receipts; 404 not_found)
  • POST /v1/soc/orders/{order_id}/approve (lead, never the requester): applied order; 409 order_not_pending, separation_of_duties. POST /v1/soc/orders/{order_id}/reject (lead) {reason}
  • GET /v1/soc/kill-switches (?state=active|all, evidence readers), POST /v1/soc/kill-switches/{switch_id}/verify (analyst or lead): probes the switch now and records a receipt
  • POST /v1/soc/recoveries (analyst or lead) {kill_switch_id, reason}: 201 {id, kill_switch_id, reason, status: pending | approved | recovered | rejected, approvals, requested_by, reauthorized_by, reauthorization}; 409 kill_switch_not_active, recovery_already_open. GET /v1/soc/recoveries (?status, evidence readers)
  • POST /v1/soc/recoveries/{recovery_id}/approve (lead) {reason?}: two distinct approvers, never the requester (409 separation_of_duties, already_approved, recovery_not_pending)
  • POST /v1/soc/recoveries/{recovery_id}/reauthorize (one of the approvers): releases the switch, re-evaluates the target with the policy decision point, then restores it (an agent is resumed); 409 recovery_not_approved, 403 reauthorization_by_approver, reauthorization_denied
  • GET /v1/soc/incidents/{incident_id}/evidence (evidence readers): {incident_id, items: [{seq, kind, content, content_sha256, prev_hash, hash, sealed_by, sealed_at}], chain: {valid, evidence_count, custody_count, head}, custody_seq}; each read is a custody entry. POST /v1/soc/incidents/{incident_id}/evidence (analyst or lead) {kind, content}: 201 sealed record
  • POST /v1/soc/incidents/{incident_id}/evidence/export (evidence readers): {incident, items, chain, custody, custody_seq}; 422 secret_detected (findings: [{type, path}], never the value) when the bundle holds a credential. GET /v1/soc/incidents/{incident_id}/custody (evidence readers): {incident_id, items: [{seq, action: seal | access | export, actor, detail, prev_hash, hash, at}], chain}
  • GET /v1/soc/transactions (?status=in_flight|committed|failed|interrupted|reconciled&run_id, evidence readers): side effecting calls with their idempotency key and transaction id. POST /v1/soc/transactions/{transaction_id}/reconcile (lead) {outcome: committed | not_executed, provider_reference?, note?}: 409 transaction_not_reconcilable; a retry with the same key then returns the stored result (committed) or 409 transaction_reconciled_not_executed, never a second call

Client object storage (brick E-13, kit section 6.3)​

The client chooses the backend; every capability is discovered by real calls and stored with its verification time, never assumed. Contract storage-profile (contracts/v1). Providers aws_s3, ibm_cos, seaweedfs, s3_compatible (S3 REST API, auth sigv4 or, for IBM COS, ibm_iam), azure_blob (shared_key, sas, entra_client_secret, workload_identity for platform profiles only), gcs (service_account). A profile never holds a secret value: credential_ref is openbao://<path> (tenant secret written with POST /v1/secrets) or env://EAI_CRED_<NAME> (platform profiles). Endpoints, token endpoints and presigned URLs must be https outside the dev and test environments (422 storage_tls_required); calls leave through the egress guard (403 egress_denied). Capabilities: versioning, object_lock, legal_hold, retention, multipart, presign, sse, kms, storage_classes, lifecycle, replication, integrity, each {supported: true | false | null, method, detail, error?, checked_at}. A usage that needs a capability not verified true, or verified more than EAI_STORAGE_CAPABILITY_MAX_AGE_SECONDS ago, is refused with 409 storage_capability_missing {capability, reason: unsupported | unknown | not_verified | stale, profile, usage} and audited storage.capability.refused. Every action is decided by policy first (storage.profile.create, storage.profile.update, storage.profile.delete, storage.profile.verify, storage.presign.read, storage.presign.write; 403 policy_denied) and audited. Guide: docs/en/guides/storage-administration.md.

  • GET /v1/storage/profiles (tenant_admin, security_officer, developer, auditor; platform_admin also sees the platform profiles; ?scope=tenant|platform&provider&purpose&limit): {items: [profile], next_cursor}
  • POST /v1/storage/profiles (tenant_admin for scope: tenant, platform_admin for scope: platform) {name, scope?, description?, provider, endpoint?, region?, bucket, key_prefix?, auth: {method, account?, tenant_id?, client_id?, service_instance_id?, token_url?, scope?}, credential_ref, encryption?: {mode: provider_default | sse-s3 | sse-kms | encryption-scope | cmek, kms_key_id?, encryption_scope?}, options?: {path_style?, storage_class?}, purposes?: [audit_worm | evidence | documents | media | exports | backups | general], required_capabilities?, presign_prefixes?}: 201 profile {id, tenant_id, scope, name, provider, endpoint, region, bucket, key_prefix, auth, credential_ref, encryption, options, purposes, required_capabilities, presign_prefixes, needed_capabilities, status: unverified | verified | degraded | failed | disabled, capabilities, missing_capabilities, verified_at, last_error, version, created_by, created_at, updated_at}. Errors: 422 storage_secret_in_profile, storage_credential_ref_invalid, storage_profile_invalid, storage_auth_method_unsupported, storage_provider_unsupported, storage_endpoint_required, storage_tls_required, validation_error; 409 storage_profile_exists; 403 storage_scope_forbidden, forbidden
  • GET /v1/storage/profiles/{profile_id} (same readers; 404 storage_profile_not_found, also for a profile of another tenant or a platform profile seen by a tenant)
  • PATCH /v1/storage/profiles/{profile_id} (manager of the scope) {description?, endpoint?, region?, bucket?, key_prefix?, auth?, credential_ref?, encryption?, options?, purposes?, required_capabilities?, presign_prefixes?, status?: active | disabled}: a change of provider, endpoint, region, bucket, auth, credential_ref, encryption or options clears the capabilities (status: unverified); adding the purpose audit_worm to a verified profile without object lock is refused (409 storage_capability_missing)
  • DELETE /v1/storage/profiles/{profile_id} (manager of the scope): 204; the exports already written keep their objects (audit_exports.storage_profile_id set to null)
  • POST /v1/storage/profiles/{profile_id}/verify (tenant_admin, security_officer; platform_admin for a platform profile) {probe_write?: true}: {profile, status: verified | degraded | failed, missing_capabilities, capabilities, error, duration_ms}. Real calls: S3 GetBucketVersioning, GetObjectLockConfiguration, GetBucketEncryption, GetBucketLifecycleConfiguration, GetBucketReplication, a multipart upload initiated then aborted, a presigned GET; Azure Get Container Properties, Get Account Information, a SAS request; GCS buckets.get, a resumable session opened then cancelled, a signed GET; with probe_write a probe object under .eai-probe/ written with the configured encryption, storage class and legal hold, read back and removed (skipped on a bucket with default retention or immutability). degraded lists the capabilities the purposes or required_capabilities need (audit_worm: object_lock, retention, integrity); failed keeps error (storage_credential_invalid, storage_auth_failed, storage_unreachable, storage_backend_error, egress_denied). 409 storage_profile_disabled
  • GET /v1/storage/profiles/{profile_id}/checks (same readers, ?limit): {items: [{id, profile_id, status, capabilities, missing_capabilities, probe_write, error, duration_ms, checked_by, checked_at}]}
  • POST /v1/storage/profiles/{profile_id}/presign (tenant_admin, developer, agent; platform_admin for a platform profile) {scope: read | write, key, expires_seconds? (default 900, at most EAI_STORAGE_PRESIGN_MAX_SECONDS), content_type?, purpose?}: 201 {grant_id, url, method: GET | PUT, scope, key, expires_seconds, expires_at} (S3 SigV4 presigned URL, Azure service or user delegation SAS, GCS V4 signed URL; a read URL cannot write and the reverse). The URL is returned once: only its sha256, key, scope and expiry are stored (storage_presigned_grants). Errors: 403 storage_key_out_of_scope (key outside presign_prefixes), 422 storage_presign_expiry_invalid, 409 storage_capability_missing (presign not verified, IBM IAM or Azure SAS credentials)
  • GET /v1/storage/profiles/{profile_id}/objects (B9.3 storage.list; tenant_admin, developer, agent, auditor; platform_admin for a platform profile) ?prefix (inside presign_prefixes, optional when the profile has exactly one), limit (1 to 500), cursor: {items: [{key, object_key, size, etag, content_type, sha256, version_id, last_modified, storage_class}], next_cursor, prefix} (key is relative to the profile key_prefix, usable with presign and delete; object_key is the stored key); decided by policy first (storage.object.list), audited storage.object.list. Errors: 403 storage_key_out_of_scope, 422 storage_prefix_required, 409 storage_profile_disabled
  • POST /v1/storage/profiles/{profile_id}/objects/delete (B9.3 storage.delete; tenant_admin, developer, agent; platform_admin for a platform profile) {key, version_id?}: {deleted: true, key, version_id}; decided by policy first (storage.object.delete), audited storage.object.delete. A profile with the purpose audit_worm or evidence never deletes through the API (409 storage_object_protected) and a retention refusal of the backend is returned as such. Errors: 403 storage_key_out_of_scope, 409 storage_object_protected, storage_profile_disabled
  • WORM audit export (AUDI-011): POST /v1/audit/export and the audit_worm worker job write through the tenant profile with purpose audit_worm, else the platform profile named EAI_AUDIT_WORM_STORAGE_PROFILE, else the legacy EAI_S3_* store (manifest worm.qualified: false). Through a profile the export needs object_lock and retention verified (409 storage_capability_missing), each object is written in compliance mode and accepted only when the backend reports the retention back (502 storage_worm_not_honoured); the manifest carries worm: {qualified: true, storage_profile, object_lock, version_id, capabilities_verified_at} and audit_exports.worm_qualified is true; 404 storage_profile_not_found when the configured platform profile does not exist

File security (ClamAV and YARA-X before any ingestion)​

Every file that enters the platform follows Upload -> Quarantine -> ClamAV + YARA-X -> Validation -> Docling -> RAG / AI, fail closed. The record is created quarantined before any analysis (a crash leaves it quarantined, never released), the builtin checks run first (size, magic type against allowed_types, declared type against the detected type with block_type_mismatch, encrypted ZIP, OOXML or PDF, archives with allow_archives, archive nesting depth, declared member and total sizes read from the central directory of every ZIP container, nested ones included, without decompressing: EAI_FILE_SCAN_ARCHIVE_MAX_MEMBER_BYTES and EAI_FILE_SCAN_ARCHIVE_MAX_TOTAL_BYTES, unscannable beyond), then every ClamAV gateway and YARA-X worker over HTTPS with failover across all their endpoints (EAI_CLAMAV_URLS, EAI_YARAX_URLS, Bearer keys, EAI_FILE_SCAN_CA_FILE; a gateway LIMIT answer, a 413, INSTREAM size limit exceeded or a file above the advertised limits.stream_max_length is unscannable without failover; signatures of unknown age count as stale). Decision record: ADR-0166. Verdict precedence: malware > suspicious > unscannable > policy_violation > scan_error > clean; the record is released only when clean (YARA-X action: report matches and macros allowed by the policy are reported in reasons and released), else blocked. EAI_FILE_SCAN_MODE: enforce (both engines required, none reachable = 503) or dev (refused in production: an engine left unset is not_configured, a configured engine applies as in enforce). Only a released scan mints the ScanClearance that Docling, OCR, the media store and the transcription path require (same tenant, same sha256). Gated entry points: POST /v1/knowledge-bases/{id}/documents (source knowledge, connector with source_connector), POST /v1/media (media), the multimodal operations with content_base64 and the media written from adapter output (multimodal), POST /v1/gateway/audio/transcriptions (transcription), POST /v1/marketplace/packages and POST /v1/file-security/scans (api). Ingestion errors: 422 file_blocked {scan_id, verdict, reasons}, 503 file_scan_unavailable {scan_id}. Audit events file_security.scan (every scan), file_security.policy.update, file_security.sample.purge; security events security.file.blocked (malware and suspicious verdicts become a SOC detection security_finding, sev1 for malware) and security.file.scan_unavailable. Metrics eai_file_scans_total{status, verdict, source}, eai_file_scan_duration_seconds{engine}, eai_file_scan_engine_up{engine, endpoint} and eai_file_scan_signature_age_hours (worker job file_scan_probe), eai_file_scan_engine_errors_total{engine, reason}. Readers: tenant_admin, security_officer, auditor (and platform_admin); rule writers: security_officer, tenant_admin.

  • GET /v1/file-security/status (readers): {mode: enforce | dev, ready, pipeline: [upload, quarantine, clamav, yara_x, validation, docling, rag], engines: {clamav: {configured, healthy, total, endpoints: [{address, status: up | down, version, signatures: {version, published_at, age_hours, stale}, limits: {max_file_size, max_scan_size, max_files, max_recursion, stream_max_length}, latency_ms, error}]}, yara_x: {configured, healthy, total, endpoints: [{address, status, version, rules: {version, sha256, count, loaded_at}, latency_ms, error}]}}, checked_at}
  • GET /v1/file-security/policy (readers): {locked: {scan_before_ingestion: true, engines_required: [clamav, yara_x], fail_closed: true, block_malware: true, block_unscannable: true}, max_file_bytes, allowed_types, known_types, allow_archives, block_office_macros, block_type_mismatch, retain_samples, quarantine_retention_days, max_signature_age_hours, stale_signatures: warn | block, updated_at, updated_by}
  • PUT /v1/file-security/policy (rule writers) {max_file_bytes? (1024 to EAI_FILE_SCAN_MAX_BYTES), allowed_types? (subset of known_types), allow_archives?, block_office_macros?, block_type_mismatch?, retain_samples?, quarantine_retention_days? (1 to 365), max_signature_age_hours? (1 to 720), stale_signatures? (warn | block)}: effective policy; a locked field is 422 file_security_rule_locked, an unknown field 422 invalid_request; audited
  • POST /v1/file-security/scans (any tenant principal) JSON {content_base64, filename?, mime_type?} or multipart/form-data file (+ filename, mime_type): 201 scan record, even blocked; nothing is ingested. Record: {id, status: quarantined | released | blocked, verdict: clean | malware | suspicious | unscannable | scan_error | policy_violation | null, reasons: ["clamav:<signature>", "yara_x:<rule>", "policy:type_not_allowed", "engine:clamav_unavailable", ...], source: knowledge | media | multimodal | transcription | connector | channel | api, source_ref, filename, mime_type, detected_type, size_bytes, sha256, engines: {clamav: {status: clean | infected | unscannable | error | not_configured, signature, endpoint, version, signatures_version, elapsed_ms}, yara_x: {status: clean | match | error | not_configured, matches: [{rule, namespace, tags, severity, action, verdict}], rules_version, rules_sha256, endpoint, elapsed_ms}}, sample_retained, rescan_of, created_by, created_at, scanned_at, expires_at}
  • GET /v1/file-security/scans (?status&verdict&source&limit&cursor; readers see every scan, other principals their own): {items: [record], next_cursor}; GET /v1/file-security/scans/{scan_id}: record (404 not_found for a scan the principal cannot see)
  • POST /v1/file-security/scans/{scan_id}/rescan (rule writers): 201 new record with rescan_of, from the encrypted sample of a blocked file; never releases the original upload; 409 file_sample_unavailable without a sample
  • DELETE /v1/file-security/scans/{scan_id}/sample (rule writers): {scan_id, sample_retained: false}; audited. Samples are kept encrypted only for blocked files when retain_samples and purged after quarantine_retention_days by the worker job file_scan_samples_purge
  • GET /v1/file-security/stats (?window_hours, default 24): {window_hours, total, by_status, by_verdict, by_source, last_blocked_at}

Marketplace of complete packs (kit section 12)​

A pack is a Plugin manifest with spec.format: pack (contracts/v1/plugin-manifest.schema.json: license, compatibility {platform, sdk, runtimes}, dependencies, requestedPermissions [{scope, reason, sensitive?, classification?}], dataProtection, approvals {install, sensitive, production}, signedArtifact, docs {en, fr}, evaluations, tests, layout.omitted) plus the mandatory folders (agents, subagents, skills/*/SKILL.md, prompts, rules, policies/opa, policies/openfga, mcp/servers, mcp/tools, mcp/schemas, connectors, workflows, rag, evaluations, security, observability/alerts, tests/unit, tests/integration, tests/authz, tests/security, docs/fr, docs/en, examples). Built, signed and published with enterprise-ai plugin create|validate|build|sign|publish|install (SDK Python). A pack ships no dashboard (ADR-0169): an archive entry under observability/dashboards is refused, with an English and French message. Every route is decided by policy first (marketplace.publish, marketplace.promote, marketplace.publisher.add, marketplace.publisher.revoke, marketplace.install.request, marketplace.upgrade.request, marketplace.install, marketplace.upgrade, marketplace.rollback, marketplace.uninstall, marketplace.authorize; 403 policy_denied) and audited, refusals included. Guide: docs/en/guides/marketplace.md.

  • GET /v1/marketplace/packages (tenant principal, ?domain): {items: [{id, name, domain, description, owner, latest_version, created_by, created_at, updated_at}], next_cursor}
  • POST /v1/marketplace/packages (tenant_admin, developer) {archive: base64 .tar.gz, signature: {value: "ecdsa-p256:<base64>" | "ed25519:<base64>", signer}, skill_signatures: {<skill name>: signature of the SKILL.md sha256}, sbom: SPDX 2.3, vulnerability_scan: {tool: trivy, digest, report}, test_report: {digest, results: {unit, integration, authz, security, opa: {passed, failed, errors}}}, evaluation_report: {digest, results: {<name>: {score}}}, oci?: {ref, digest}}: 201 version {id, package, version, manifest, digest, archive_size, files, signature, signature_status: verified, skill_signatures, sbom_digest, test_report, evaluation_report, oci_ref, oci_digest, license, gates: [{gate, passed, problems}], status: published, vulnerability_summary, published_by, created_at}. Gates: schema, signature (pack digest and every SKILL.md against an active publisher key), sbom (every file and its SHA-256), vulnerability_scan (bound to the digest, no CRITICAL or HIGH, no secret), tests, evaluations (above their threshold), licence (MIT, Apache-2.0 or declared), permissions (no unsafe scope such as secrets.*, platform.*, wildcards; every scope used by an object is requested), docs, secrets, provenance (signed in-toto Statement v1 with the SLSA provenance v1 predicate in provenance: {statement, signature: {value, signer}}, subject = the pack digest, the SBOM bound as a byproduct, signed by the pack publisher), privacy (G6: PII detection and masking for a pack handling PII, DLP, residency, externalExportDefault: deny), security (signedArtifactRequired, sandboxRequired, dynamicAuthorization, auditRequired all true), approvals (requiredFor covers install, upgrade, sensitivePermissions, production, resume), configuration (configSchema is a JSON Schema of an object), resilience (G9: bounded tool timeouts, idempotent side effects with an idempotency key, workflows stop or compensate on failure); the manifest must carry namespace, category, publisher, displayName {en, fr}. Errors: 422 publication_gate_failed (details.gates), marketplace_archive_invalid; 409 marketplace_version_exists (a published version is immutable)
  • GET /v1/marketplace/packages/{name} (adds versions), GET /v1/marketplace/packages/{name}/versions/{version}, GET /v1/marketplace/packages/{name}/versions/{version}/sbom ({package, version, sbom_digest, sbom, vulnerability_scan}); 404 marketplace_package_not_found, marketplace_version_not_found
  • POST /v1/marketplace/packages/{name}/versions/{version}/production (tenant_admin, security_officer) {approval_id?}: without approval_id, checks the stored gates, the current signature and the OCI digest, then opens a four eyes approval (marketplace.promote, quorum 2 at least): 202 {approval_id, status: pending_approval, quorum, checks}; with an approved approval_id: 200 version with status: production. Errors: 422 publication_gate_failed (signature_current, oci_artifact, ...), 403 approval_required, approval_mismatch
  • GET /v1/marketplace/publishers (tenant principal): trusted publisher keys {items: [{id, name, algorithm: ed25519 | ecdsa-p256, status, scope: platform | tenant, created_at}]}; POST /v1/marketplace/publishers (tenant_admin, security_officer) {name, public_key: PEM (cosign.pub) }: 201 (422 publisher_invalid_key); POST /v1/marketplace/publishers/{name}/revoke (tenant_admin, security_officer): {publisher, revoked, status} (404 publisher_not_found); a revoked key fails every later preflight and production gate
  • POST /v1/marketplace/preflight (tenant_admin, security_officer) {package, version, environment: dev | test | staging | prod, approved_permissions, config?, secret_refs?: {<connector>: openbao://... | env://...}}: {package, version, environment, passed, checks: [{check, passed, ...}], requested, granted, not_granted, sensitive}; checks package_active, environment, config_schema (config validated against the pack configSchema, 422 config_invalid at install), version_available, production_approved (prod only), platform_compatibility, dependency:<name>, digest, signature, permissions_requested, permissions_safe, secret_ref:<connector>, no_conflict
  • GET /v1/marketplace/installations (?environment), GET /v1/marketplace/installations/{id} ({id, package, version, environment, status: pending_approval | installed | suspended | failed | uninstalled, requested_permissions, granted_permissions, config, secret_refs, preflight, smoke, created_objects, pending, installed_by, installed_at, ...}), GET /v1/marketplace/installations/{id}/events (request, install, upgrade, rollback, uninstall, smoke_failed, authorize_denied); 404 installation_not_found (also for another tenant)
  • POST /v1/marketplace/installations (tenant_admin) body of the preflight: preflight, then an approval marketplace.install (quorum of approvals.install, at least 2 when a granted permission is sensitive or the environment is prod; approvers tenant_admin, security_officer, never the requester): 202 {installation, approval_id, quorum, status: pending_approval, preflight}. Errors: 422 permission_not_requested, preflight_failed (details.preflight); 403 permission_unsafe; 409 installation_exists
  • POST /v1/marketplace/installations/{id}/apply (tenant_admin): once the approval is approved, preflight again, then creates the objects through the registries (agents with the granted scopes only, skills with their signatures, tools blocked when their scopes were not granted, connectors with the secret references, prompts, OPA policies as drafts, MCP servers registered in the MCP gateway when config.mcp_servers.<name>.url is given, a knowledge base per RAG template), named <object>-<environment>, records rules, OpenFGA models, MCP servers, workflows, RAG templates, dashboards, alerts and migrations in assets: [{kind, name, status: active | blocked | pending_endpoint | suspended, status_reason, digest}] (a workflow whose tools are not granted is blocked), then smoke tests (including a real MCP initialize and tools/list of every registered server); a failed smoke undoes the change (422 smoke_failed). 200 installation. Errors: 403 approval_required, approval_mismatch; 409 installation_state, marketplace_object_conflict
  • POST /v1/marketplace/installations/{id}/upgrade (tenant_admin) {version, approved_permissions, config?, secret_refs?}: preflight and approval like an install (202), applied with /apply; the previous version and grant are kept. 409 installation_state (not installed, or not a newer version)
  • POST /v1/marketplace/installations/{id}/rollback (tenant_admin): reinstalls the previous version with its previous grant, removes what the newer version added; 409 rollback_unavailable, installation_state, 422 smoke_failed
  • POST /v1/marketplace/installations/{id}/uninstall (tenant_admin): removes every object the installation created and its relationship tuples: {..., status: uninstalled, removed: [{kind, name}]}
  • POST /v1/marketplace/installations/{id}/authorize (tenant_admin, developer, agent, service) {permission}: runtime check used by the pack MCP servers and connectors; {installation_id, permission, allowed: true, environment} or 403 permission_not_granted (details.requested tells a requested but not granted permission), audited and recorded as authorize_denied (also 403 installation_suspended, permission_suspended, external_export_denied when the permission sends pack data outside the platform and dataProtection.externalExportDefault is deny)
  • POST /v1/marketplace/installations/{id}/status (tenant_admin, security_officer) {status: suspended | installed, reason?, permissions?: [scope], approval_id?}: kill switch of an installation. suspended is immediate: with permissions only those capabilities are suspended (their tools blocked, /authorize refuses them), without it the whole installation (every tool blocked, every agent suspended, MCP servers disabled, contents suspended); 200 installation with suspension, suspended_permissions. installed resumes: without approval_id it opens a marketplace.resume approval (quorum 2, never the requester): 202 {approval_id, status: pending_approval, quorum}; with the approved approval_id: 200 installation, tools and agents back to the granted state. Errors: 409 installation_state, package_suspended; 422 permission_not_granted; 403 approval_required, approval_mismatch
  • POST /v1/marketplace/packages/{name}/status (tenant_admin, security_officer) {status: suspended | active, reason?, approval_id?}: kill switch of a package. suspended suspends every installation of the tenant at once (suspended_installations) and refuses installs, upgrades, publications, promotions and resumes (409 package_suspended); active lifts it only with an approved marketplace.package.resume approval (quorum 2; 202 without approval_id), then resumes the installations it suspended (resumed_installations)
  • GET /v1/marketplace/installations/{id}/supervision (tenant principal): {installation_id, status, environment, suspension, suspended_permissions, activity: {tool_invocations: {<status>: n}, runs: {<state>: n}, authorize_denied}, quality: {evaluations, smoke_passed, run_success_rate}, costs: [{currency, amount}], incidents: {open, items}, assets} aggregated from the tool invocations, runs, refusals and incidents of the objects the installation created
  • POST /v1/marketplace/changes (tenant_admin, developer) {kind: prompt | skill | connector | rule | model | policy | tool | agent | pack, ref, version, title?}: 201 change in stage draft (ADR-0159)
  • GET /v1/marketplace/changes?kind&ref&stage and GET /v1/marketplace/changes/{id} (tenant principal): {id, kind, ref, version, stage, evidence, history: [{stage, actor, at, approval_id?}], pending_approval_id}; 404 change_not_found
  • POST /v1/marketplace/changes/{id}/advance (tenant_admin, developer, security_officer) {evidence?: {tests | staging_smoke | production_smoke: {passed, failed, errors}}, approval_id?}: next stage of draft, test, security_review, business_approval, staging, production, monitoring. Entering security_review needs a passing tests result, production a passing staging_smoke, monitoring a passing production_smoke (422 change_evidence_missing); entering business_approval, staging and production needs an approval (change.security_review by a security officer, change.business_approval by a tenant administrator, change.production quorum 2): 202 {approval_id, target, quorum, status: pending_approval}, then 200 with the approved approval_id (403 approval_required); 409 change_closed; 403 change_not_author for draft to test by someone else than the author
  • POST /v1/marketplace/changes/{id}/reject (tenant_admin, security_officer) {reason}: stage rejected, the change is closed
  • Applications (governed applications, third party agents): GET/POST /v1/applications (POST tenant_admin, developer; kind agent_app, assistant, workflow, external_agent), GET /v1/applications/{app_id} (with deployments and statuses), POST /v1/applications/{app_id}/status {status: suspended|retired, reason} (tenant_admin, security_officer, immediate, audited), POST /v1/applications/{app_id}/deployments {version, environment} (draft plus approval request), POST /v1/applications/{app_id}/deployments/{deployment_id}/activate (only once approved, separation of duties), POST /v1/applications/{app_id}/external-agents {name, scopes} (tenant_admin; API key shown once, role agent, bound to the application: Tool and MCP gateways refuse an ungranted resource or a suspended application)
  • RAG Agent instances (activation and lifecycle of the Navin Enterprise RAG Agent, owned by the Control Plane; writes: tenant_admin or owner of the project, reads: tenant principals, another tenant's instance 404): POST /v1/rag-agent/instances {id?, name, project, organization_id?, business_unit_id?, deployment_model: shared|dedicated, generation_model, embedding_model, version?} (201 draft, Idempotency-Key), GET /v1/rag-agent/instances (status, project, limit), GET /v1/rag-agent/instances/{instance_id} (record with status, serving, operation, run, validation, last_error, resources and the steps of the current run), POST /v1/rag-agent/instances/{instance_id}/validate (real checks: project and caller, generation model chat, embedding model, quota, Qdrant, shared runtime or Navin Manager; 422 rag_agent_validation_failed), POST /v1/rag-agent/instances/{instance_id}/provision, POST /v1/rag-agent/instances/{instance_id}/start, POST /v1/rag-agent/instances/{instance_id}/stop, POST /v1/rag-agent/instances/{instance_id}/restart, POST /v1/rag-agent/instances/{instance_id}/upgrade {version} and DELETE /v1/rag-agent/instances/{instance_id} (202 long operations run by the worker job rag_agent_lifecycle; statuses draft, validating, provisioning, starting, ready, degraded, failed, stopping, stopped, updating, deleting, deleted; steps validation, resources, database, object_storage, qdrant, memory, security, deployment, health, smoke, activation with states pending, running, done, failed, skipped; transition table, 409 rag_agent_transition_invalid, repeats answer changed: false, a retry resumes from the failed step, ready only after health and smoke; dedicated deployment through the platform_manager bridge, failed step service_not_published while the catalog service navin-rag-agent is not published; deletion of the owned resources only, shared ones listed as kept, governed application retired), GET /v1/rag-agent/instances/{instance_id}/health (runtime readiness, knowledge base and Qdrant, model registry: up, degraded, down), POST /v1/rag-agent/instances/{instance_id}/backup and POST /v1/rag-agent/instances/{instance_id}/restore {backup_id} (501 rag_agent_backup_not_supported with the reason of each part); branding owned by the Control Plane (V209, the RAG runtime keeps a replica rewritten when version is newer; the record of an instance carries branding and app_url): GET /v1/rag-agent/instances/{instance_id}/branding (agent_name, sidebar_color, navbar_color, sidebar_text_color, navbar_text_color, logo {content_type, sha256, size_bytes, url} or null, version, updated_at, app_url), PUT /v1/rag-agent/instances/{instance_id}/branding {agent_name?, sidebar_color?, navbar_color?, app_url?, expected_version?} (null back to the default, #RRGGBB with black or white text at 4.5:1 or more, app_url https only 422 rag_agent_app_url_invalid, 409 rag_agent_branding_version_conflict, Idempotency-Key, audited rag_agent.branding.update), POST /v1/rag-agent/instances/{instance_id}/branding/logo (multipart file, PNG or JPEG only, SVG refused, signature, extension and Content-Type must match, 32 to 1024 pixels, 512 KiB, 422 rag_agent_logo_invalid; file security scan fail closed 422 file_blocked or 503 file_scan_unavailable; stored through the storage domain in the documents profile under rag-agent/<tenant>/<instance>/branding/), GET /v1/rag-agent/instances/{instance_id}/branding/logo (bytes with the Content-Type of the validated format, nosniff, CSP default-src 'none') and DELETE /v1/rag-agent/instances/{instance_id}/branding/logo (default logo restored)
  • Compliance packs catalog (activation journey of the portal, read only, tenant principals): GET /v1/compliance/packs (sector?; {available, reason, items[{code, version, sector, name, description, country, countries, activities, frameworks[{code, title, kind, country, issuer, requirements, disabled_by_default}]}], unreadable?}; read from the packs folder of the product, available: false with reason: packs_folder_not_found when it is missing; the frameworks are never applicable by default, the selection is validated by the compliance officer in the runtime)
  • Compliance & Audit Agent instances (ADR-0177 section 8; activation and lifecycle of the Navin Enterprise Compliance & Audit Agent, owned by the Control Plane, a copy adapted from the RAG Agent model; writes: tenant_admin or owner of the project, reads: tenant principals, another tenant's instance 404 compliance_instance_not_found): POST /v1/compliance/instances {id?, name, project, organization_id?, business_unit_id?, sector: generic|banking|insurance|industry, pack?: banking|insurance|industry, deployment_model: shared|dedicated, generation_model, embedding_model, version?} (201 draft, Idempotency-Key), GET /v1/compliance/instances (status, project, sector, limit), GET /v1/compliance/instances/{instance_id} (record with status, serving, sector, pack, operation, run, validation, last_error, resources and the steps of the current run), POST /v1/compliance/instances/{instance_id}/validate (real checks: project and caller, generation model chat, embedding model, quota, Qdrant, shared runtime or Navin Manager; 422 compliance_validation_failed), POST /v1/compliance/instances/{instance_id}/provision, POST /v1/compliance/instances/{instance_id}/start, POST /v1/compliance/instances/{instance_id}/stop, POST /v1/compliance/instances/{instance_id}/restart, POST /v1/compliance/instances/{instance_id}/upgrade {version} and DELETE /v1/compliance/instances/{instance_id} (202 long operations run by the worker job compliance_lifecycle; statuses draft, validating, provisioning, starting, ready, degraded, failed, stopping, stopped, updating, deleting, deleted; steps validation, resources, database, object_storage, qdrant, memory, security, deployment, health, smoke, activation with states pending, running, done, failed, skipped; transition table, 409 compliance_transition_invalid, repeats answer changed: false, a retry resumes from the failed step, ready only after health and smoke; the smoke step reads the version of the runtime in its OpenAPI document GET /api/v1/compliance/openapi.json; dedicated deployment through the platform_manager bridge with sector and pack in the service config, failed step service_not_published while the catalog service navin-compliance-agent is not published; deletion of the owned resources only, shared ones listed as kept, governed application retired), GET /v1/compliance/instances/{instance_id}/health (runtime readiness, knowledge base and Qdrant, model registry: up, degraded, down), POST /v1/compliance/instances/{instance_id}/backup and POST /v1/compliance/instances/{instance_id}/restore {backup_id} (501 compliance_backup_not_supported with the reason of each part); branding owned by the Control Plane (V210, the compliance runtime keeps a replica rewritten when version is newer; the record of an instance carries branding and app_url): GET /v1/compliance/instances/{instance_id}/branding (agent_name, sidebar_color, navbar_color, sidebar_text_color, navbar_text_color, logo {content_type, sha256, size_bytes, url} or null, version, updated_at, app_url), PUT /v1/compliance/instances/{instance_id}/branding {agent_name?, sidebar_color?, navbar_color?, app_url?, expected_version?} (null back to the default, #RRGGBB with black or white text at 4.5:1 or more else 422 compliance_branding_contrast, app_url https only 422 compliance_app_url_invalid, 409 compliance_branding_version_conflict, Idempotency-Key, audited compliance.branding.update), POST /v1/compliance/instances/{instance_id}/branding/logo (multipart file, PNG or JPEG only, SVG refused, signature, extension and Content-Type must match, 32 to 1024 pixels, 512 KiB, 422 compliance_logo_invalid; file security scan fail closed 422 file_blocked or 503 file_scan_unavailable; stored through the storage domain in the documents profile under compliance-agent/<tenant>/<instance>/branding/), GET /v1/compliance/instances/{instance_id}/branding/logo (bytes with the Content-Type of the validated format, nosniff, CSP default-src 'none') and DELETE /v1/compliance/instances/{instance_id}/branding/logo (default logo restored)
  • Developer kit (what a developer gets for one application, agent or project): GET /v1/developer/kit?application_id= (or agent_id, or project: exactly one; tenant_admin, developer, application_owner; tenant scoped, 404 for another tenant's subject). One row per resource with value, state (ready, not_configured, service_not_installed) and action (message_en, message_fr, portal href): LLM gateway (LiteLLM: URL, allowed models, the env variable of the key), identity (Keycloak OIDC issuer, client id, token endpoint, scopes), permissions (OpenFGA store and model ids, OPA, decision endpoint, policies), data protection (DLP inspect and redact, Presidio), AI traceability (Opik, OTLP endpoint), business tools (Secure MCP Gateway: granted tools and servers), documents (Qdrant + secure RAG: the collections the app may read), SDK and user context, tests (Ragas, Promptfoo, garak). Plus env (environment file entries) and next_steps. Never a secret: the LiteLLM key is created on the LLM management page and shown once there

Platform Manager space of the console (ADR-0128)​

Domain eai.domains.platform_manager, every route gated by platform_admin (403 otherwise); 503 platform_manager_not_configured when EAI_PLATFORM_MANAGER_URL is unset. Settings: EAI_PLATFORM_MANAGER_URL (also the operator egress allowlist entry), EAI_PLATFORM_MANAGER_CA_BUNDLE, EAI_PLATFORM_MANAGER_TOKEN_REF=secret://<tenant_id>/<path> (a Manager API token holding auth:delegate, stored with the secrets domain). Each call carries the portal user in X-NM-On-Behalf-Of: the Manager RBAC, approvals and stop rules apply; a Manager refusal is relayed with its status, a code platform_manager_<code> and message_i18n en/fr.

  • GET /v1/platform-manager/clusters: {items: [cluster]}
  • GET /v1/platform-manager/summary: {items: [{cluster {id,name,mode}, services {total,healthy,warning,failed}, hosts, alerts, operations}]} (real counts)
  • GET /v1/platform-manager/clusters/{cluster_id}/services: {items: [ServiceView]}; ServiceView = name, display_name, version, managed, desired_state, observed_state, health_status, operation_status, operation_id, security_state, admin_state (enabled|maintenance), instances {running,total}, drift
  • GET /v1/platform-manager/clusters/{cluster_id}/services/{service}: ServiceView with security_state {tls_enabled, secrets_set, secrets_total} and the Manager sheet
  • GET /v1/platform-manager/clusters/{cluster_id}/services/{service}/dashboard?from=&to=&step= (from and to ISO 8601, default the last hour; step 10 to 86400 seconds, default 60): the Manager monitoring dashboard of the service, relayed unchanged {service, delivery, managed, state, version, admin_state, window {from, to, step}, instances {total, running, stopped, failed, unknown, by_component}, health {status (ok|degraded|down|unknown), rollup, latest_checks, components}, availability {pct, samples, measured, counts, series}, hosts [{host_id, name, fqdn, address, state, components, metrics}], alerts {open, critical, warning, items}, operations, config {version, updated_at, drift_findings, stale_hosts}, connection} (cluster:read in the Manager; unknown service 404 platform_manager_cluster_service_not_found)
  • GET /v1/platform-manager/clusters/{cluster_id}/nodes: {items: [host]}; each host carries hostname, fqdn, ip, state, os, arch, cpu_count, memory_mb, metrics, alerts, last_heartbeat_at and gpus: [{index, name, memory_mb, driver}] (inventory reconciled from the agent facts)
  • GET /v1/platform-manager/clusters/{cluster_id}/operations: {items: [operation]}
  • POST /v1/platform-manager/clusters/{cluster_id}/services/{service}/operations {action: start|stop|restart|rolling_restart|reinstall, components?, hosts?, batch_size, tolerate_failures, approval_id?}: 202 {operation_id, cluster_id, service, action}, audited platform_manager.<action> (allow or deny). Without components and hosts the operation covers every component on every host the Manager detected; without approval_id the Manager uses the approval granted to the caller for this action and service (banking-secure). The portal only sends detected targets: the whole service, one machine or one component.
  • POST /v1/platform-manager/clusters/{cluster_id}/services/{service}/mode {mode: drain|scale_to_zero|security_suspend|emergency_stop|disable|quarantine|maintenance|enable, reason?, drain_timeout_s? (0 to 3600), approval_id?}: 200 {service, mode, operation_id (null when no process changes), effect {new_requests, in_flight, processes, stop_timeout_s, admin_state, security_state}, admin_state, admin_reason, security_state (authorized|revoked), run_authorized, security_lock}, audited platform_manager.mode.<mode> (allow or deny). The Manager enforces the state it leaves behind: 409 platform_manager_service_admin_state for an operation the state forbids, 423 platform_manager_cluster_security_locked while the cluster is under a security lock; the security suspension and lifting it need pki:manage in the Manager. ServiceView also carries admin_state (enabled|disabled|maintenance|quarantined|suspended), admin_reason and run_authorized.
  • GET /v1/platform-manager/catalog: {name, version, categories, items: [{name, display_name {en,fr}, description {en,fr}, category, version, components [{name, display_name, kind, cardinality, modes}], modes [{name, display_name, default}], requires, methods, sandbox_only, connection_type (postgresql|redis|... when the customer may already run it: catalog external.connection_type)?, delivery [install|connect|job|package], layer (functional|infrastructure), licence (SPDX), licence_note {en,fr}?, connect {schemes, default_port, credentials (required|optional|none), health {path, field?, expect_status, timeout_s}, fields [param]}?, job {runs {en,fr}, portal}?, library {coordinates [{ecosystem, name, version, install}], docs}?}]}, the services of the Manager catalog (stack): a server is [install, connect], an on demand job [job], a developer package [package]; the infrastructure layer (PostgreSQL, Redis, etcd, exporters...) stays installable and is shown apart
  • GET /v1/platform-manager/catalog/{service}: install descriptor {name, display_name, description, category, version, methods, default_method, default_mode, sandbox_only, requires [{binding, service, connection_type, optional?, modes?}], connection_type?, sections, modes [{name, display_name, description, default, components [{name, display_name, kind, methods, cardinality {min, max?, recommended?, odd?, multiple_of?}}]}], parameters [{name, type, section, label, description, default?, enum?, min?, max?, required?, secret, generated, recommended}], delivery, layer, licence, licence_note?, connect?, job?, library?}; a secret parameter (password or sensitive) never carries a default; templates and artifacts are not returned (404 platform_manager_service_unknown relayed)
  • POST /v1/platform-manager/clusters/{cluster_id}/services/validate InstallRequest = {services: [{name, mode?, method?, placements {component: [host_id]}, config {}, bindings {binding: {kind: managed|connection, ref}}, version?}] (1 to 20), start (default true), service_check (default true)}: 200 {ok, errors: [{service, field (name|mode|method|placements.<component>|config.<param>|bindings.<binding>|plan), code, message_i18n {en,fr}}], plan {order, stages}}, a dry run of the Manager planner (services:install in the Manager)
  • POST /v1/platform-manager/clusters/{cluster_id}/services InstallRequest: 202 {operation_id, cluster_id, services}, the install operation started by the Manager on the chosen hosts (services:install; 422 platform_manager_services_invalid|platform_manager_topology_invalid|platform_manager_config_invalid with details.manager_details.errors), audited platform_manager.install (allow or deny) with the services, modes, methods, hosts and configured keys, never the configuration values; follow it with GET /v1/platform-manager/clusters/{cluster_id}/operations
  • POST /v1/platform-manager/clusters/{cluster_id}/services/connect/validate ConnectRequest = {name, endpoint (absolute http(s) URL, no user info or query), auth? {method (a method of connect.auth of the descriptor: none, api_key, bearer, basic, client_certificate, client_credentials, login...), fields {} (its fields, for example api_key, token, username and password, client_id and client_secret)}, mtls? {client_cert, client_key} (PEM, mutual TLS), ca_pem? (private certificate authority of the service), credentials_ref? (a reference such as secret://tenant/path, never a secret value), tls_verify (default true), config {}}: 200 {ok, errors: [{service, field (name|endpoint|auth.method|auth.fields.<field>|auth|mtls.<field>|ca_pem|tls_verify|credentials_ref|config.<field>), code (service_unknown|delivery_not_connectable|service_already_installed|endpoint_invalid|endpoint_scheme|auth_required|auth_method_unknown|required|credentials_refused|pem_invalid|client_certificate_invalid|ca_invalid|production_https_required|production_tls_verify_required|production_auth_required|credentials_ref_invalid|unknown|...), message_i18n {en,fr}}], health {url, status, health (ok|warning|critical), latency_ms, error, auth {method, result (accepted|refused|unexpected|unreachable|not_checked|not_applicable), status, url, error}}|null}, a dry run of Configure & Connect against the connect section of the descriptor: the Manager runs the health check and an authenticated request of the method on the service itself, and credentials the service refuses are an error (services:install in the Manager)
  • POST /v1/platform-manager/clusters/{cluster_id}/services/connect ConnectRequest: 201 ServiceView with delivery: connect, managed: false, no instance and connection {endpoint, credentials_ref, tls_verify, ca_pem_set, config, auth {method, fields (public ones), secrets_set, client_cert_set}, mtls {client_cert_set}|null, health_details}: an existing service of the customer registered without hosts, its secret fields sealed by the Manager (never returned), health and credentials checked by the Manager (at registration and every minute), never installed, started, stopped or reinstalled (409 platform_manager_service_unmanaged for an operation); 422 platform_manager_connect_invalid with details.manager_details.errors; audited platform_manager.connect (allow or deny) with the endpoint, TLS, the method and the names of its fields and the configured keys, never a credential, the configuration values nor the reference
  • DELETE /v1/platform-manager/clusters/{cluster_id}/services/{service}/connection: 200 {service, disconnected: true}, forgets a connected service (the service itself is untouched; 409 platform_manager_service_not_connected for an installed service, platform_manager_service_in_use while a consumer is bound to it), audited platform_manager.disconnect (allow or deny). ServiceView carries delivery (install|connect) and connection (null for an installed service)
  • GET /v1/platform-manager/connections?type=postgresql|redis|oidc: {items: [{id, name, type, params, secret {secret: true, set, name}, description, last_test, created_at, updated_at}]}, the existing PostgreSQL, Redis and OIDC issuers (Keycloak) of the customer declared as Manager connections (connections:read in the Manager); the secret value is never returned
  • POST /v1/platform-manager/connections/validate ConnectionTest = {type: postgresql|redis|oidc, params {} (postgresql: host, port, database, username, sslmode, ca_pem; redis: hosts [host:port], mode, sentinel_master, tls, username, database, ca_pem; oidc: issuer), secret? (write only)}: 200 {ok, latency_ms, server_version, error_code, message_i18n {en,fr}}, the real protocol test by the Manager before saving, nothing stored (connections:manage; 422 platform_manager_connection_params_invalid with details.manager_details.fields), audited platform_manager.connection.validate with the type, the parameter keys and whether a secret is set, never the secret
  • POST /v1/platform-manager/connections ConnectionTest + {name, description?}: 201 the connection (secret sealed by the Manager, read back as {secret: true, set: true}; 409 platform_manager_connection_name_taken), audited platform_manager.connection.create. An install binds a service to it with bindings {<binding>: {kind: connection, ref: <connection id>}}
  • POST /v1/platform-manager/connections/{connection_id}/test: 200 the protocol test of a saved connection (its sealed secret stays in the Manager), audited platform_manager.connection.test
  • DELETE /v1/platform-manager/connections/{connection_id}: 204, forgets a connection (409 platform_manager_connection_in_use while a service is bound to it), audited platform_manager.connection.delete
  • GET /v1/platform-manager/console: {configured, console_url, consoles: [{key: opik|grafana|keycloak|litellm|opensearch, setting, configured, url, problem: null|https_required|credentials_in_url|manager_https_required|manager_credentials_in_url, source: setting|manager|null, service, cluster_id, cluster_name}], discovery: {ok, code}}: base URL of the Manager console for the deep links (EAI_PLATFORM_MANAGER_CONSOLE_URL, else EAI_PLATFORM_MANAGER_URL) and the operator consoles (EAI_OPIK_UI_URL, EAI_GRAFANA_URL, EAI_KEYCLOAK_CONSOLE_URL, EAI_LITELLM_UI_URL, EAI_OPENSEARCH_DASHBOARDS_URL; no dashboard server console, the dashboards are in the Manager console and the portal, ADR-0169); a console without its setting comes from the Manager service of the same name (opik, keycloak, litellm, opensearch; never Grafana, not in the catalog): the endpoint of a connected service or the quick link of an installed one, read on behalf of the user (a Manager refusal gives discovery.ok false and leaves the settings in force); https without credentials only, else url null; never returns a credential (ADR-0162)
  • GET /v1/platform-manager/clusters/{cluster_id}/services/{service}/config: current Manager configuration {version, properties, recommendations, groups, stale_hosts, ...} (secret values masked by the Manager, configs:read); GET /v1/platform-manager/clusters/{cluster_id}/services/{service}/config/versions: {items: [{version, note, created_by, created_at}]}. Editing, preview and rolling apply stay in the Manager console
  • GET /v1/platform-manager/clusters/{cluster_id}/services/{service}/config/diff?from=&to= (versions >= 1): {cluster_id, service, from, to, items: [{param, from, to}]}, one entry per changed parameter; a secret parameter is {param, secret: true, changed: true, from {secret, set}, to {secret, set}}, never its value (configs:read in the Manager; unknown version 404 relayed).
  • IT vault (eai.domains.platform_manager.vault, table eai.it_vault_entries V196, platform_admin only, never calls the Manager): the credentials of the installed services. Only the name is stored in clear; URL, login, password and API key are one AES-256-GCM document (key HKDF-SHA256 of the platform master key, info it-vault, random nonce, the entry id as associated data). VaultEntry = {id, name, url, login, has_password, has_api_key, source: manual|installed:<service>, created_by, updated_by, created_at, updated_at} (never the password nor the key)
  • GET /v1/platform-manager/vault/entries: {items: [VaultEntry]} by name
  • POST /v1/platform-manager/vault/entries {name (1..120), url?, login?, password?, api_key?}: 201 VaultEntry (409 it_vault_name_taken), audited it_vault.create with the name only
  • GET /v1/platform-manager/vault/entries/{entry_id}: VaultEntry (404 it_vault_entry_not_found)
  • PATCH /v1/platform-manager/vault/entries/{entry_id} {name?, url?, login?, password?, api_key?} (omitted field kept, empty password or key cleared): VaultEntry, audited it_vault.update with the changed field names
  • DELETE /v1/platform-manager/vault/entries/{entry_id}: 204, audited it_vault.delete
  • POST /v1/platform-manager/vault/entries/{entry_id}/reveal: {id, password, api_key} in clear for show and copy, audited it_vault.reveal
  • POST /v1/platform-manager/vault/import: {created: [name], skipped: [name]}, adds the services the platform is configured with (PostgreSQL, Redis, Keycloak, OpenBao, OpenFGA, OPA, LiteLLM, Qdrant, NATS, Temporal, LiveKit, Opik, ClickHouse, S3, Platform Manager, installer account of the portal; from the EAI_* settings, URL without credentials) that are not in the vault yet, never overwrites an entry; also run at the API start
  • GET /v1/platform-manager/clusters/{cluster_id}/support-bundles: {items: [{id, status, size, sha256, contents, redactions, operation_id, created_at, finished_at}]}; POST /v1/platform-manager/clusters/{cluster_id}/support-bundles (no body): 202 {cluster_id, operation_id, bundle_id}, a sanitized support bundle built by a Manager operation (cluster:manage in the Manager), audited platform_manager.support_bundle (allow or deny); the archive is downloaded from the Manager console.
  • Backup and DR (Backup & Recovery Manager, kit 20.5 and 20.6, ADR-0162). Reads need backup:read in the Manager, restores restore:execute, DR dr:manage: GET /v1/platform-manager/backup/plans ({items: [policy], alerts: [late or failed policy]}), GET /v1/platform-manager/backup/jobs, GET /v1/platform-manager/backup/backups (artefacts with sha256, size, last_verification), GET /v1/platform-manager/backup/restores (restore plans with status, approvals, compatibility), GET /v1/platform-manager/dr/plans (rpo_target_seconds, rto_target_seconds, null until the client sets them), GET /v1/platform-manager/dr/exercises (rpo_observed_seconds, rto_observed_seconds, rpo_met, rto_met, null when not measured)
  • POST /v1/platform-manager/backup/restores {artifact_id, environment: isolated|staging|production, target_versions?}: 201 restore plan (planned or incompatible with compatibility.problems); POST /v1/platform-manager/backup/restores/{plan_id}/approve: two approvers other than the requester (403 platform_manager_restore_self_approval, 409 platform_manager_restore_not_approvable for an incompatible or closed plan); POST /v1/platform-manager/dr/exercises {plan_id}: 201 exercise requested; POST /v1/platform-manager/dr/exercises/{exercise_id}/approve: another person approves (403 platform_manager_restore_self_approval). Each write audited platform_manager.restore.plan|restore.approve|dr.exercise.request|dr.exercise.approve (allow or deny). Under delegation the Manager compares the delegated person, not the service account. Executing a restore, completing an exercise and a failover stay in the Manager console
  • Getting started (first page of the space). GET /v1/platform-manager/setup: {states: [ok, missing, down, error], core: [check], manager}; check = {key: api|database|migrations|redis|keycloak, state, required, setting, message_i18n {en,fr}, action_i18n} (Redis and Keycloak are optional: required false); manager = {state, problem: null|url_missing|token_ref_missing|credential_missing|unreachable|egress_denied|token_invalid|delegation_forbidden|user_unknown|refused, setting, message_i18n, url, url_set, token_ref, token_ref_set, suggested_token_ref, secret_path, settings, credential_found, reachable, delegated_user, user_known, clusters, manager_code, status, required_permissions, runtime_change: false, restart_required: true}. Never a secret (the token reference only, never the token); never fails when the Manager is absent. The settings EAI_PLATFORM_MANAGER_URL and EAI_PLATFORM_MANAGER_TOKEN_REF are read from the environment: changing them needs a restart.
  • POST /v1/platform-manager/clusters {name (1 to 80), mode: production|sandbox, description?, profile?: development|standard|banking-secure|air-gapped|hybrid}: 201 the Manager cluster (clusters:create in the Manager, 409 platform_manager_cluster_name_taken), audited platform_manager.cluster_create (allow or deny).
  • POST /v1/platform-manager/clusters/{cluster_id}/hosts/ssh-bootstrap {hosts [fqdn] (1 to 200), ssh_user, ssh_port (default 22), private_key, host_key_fingerprints {host: "SHA256:..."}, use_sudo (default true)}: 202 {operation_id, cluster_id, hosts}, a Manager operation host.ssh_bootstrap installing and registering the node agent (hosts:manage; 422 platform_manager_validation_error for a missing fingerprint). The private key is relayed to the Manager, which keeps it in memory for the bootstrap tasks; the Control Plane never stores it. Audited platform_manager.ssh_bootstrap (allow or deny) with hosts, user, port, sudo and fingerprints, never the key.
  • POST /v1/platform-manager/clusters/{cluster_id}/enrollment-tokens {expires_in_hours (1 to 720, default 24), max_uses (1 to 10000, default 10)}: 201 {id, cluster_id, token, expires_at, max_uses, register_command} (nm-agent register --server ... --token ... --ca-sha256 ... to run on each machine), shown once; audited platform_manager.enrollment_token with id, expires_at, max_uses, never the token nor the command. GET /v1/platform-manager/clusters/{cluster_id}/enrollment-tokens: {items: [{id, prefix, expires_at, max_uses, uses, created_at, revoked_at}]}; DELETE /v1/platform-manager/clusters/{cluster_id}/enrollment-tokens/{token_id}: 204, audited platform_manager.enrollment_token_revoke.
  • GET /v1/platform-manager/hosts/{host_id}: the machine with facts, agent_version, last_heartbeat_at, state; GET /v1/platform-manager/hosts/{host_id}/preflight: {items: [{check, status, message_i18n, details, checked_at}]}; POST /v1/platform-manager/hosts/{host_id}/preflight, POST /v1/platform-manager/hosts/{host_id}/facts/refresh, POST /v1/platform-manager/hosts/{host_id}/decommission (no body): 202 {operation_id, host_id} (hosts:manage; decommission 409 platform_manager_host_has_components), audited platform_manager.preflight|facts_refresh|decommission (allow or deny).

Shared runtime semantics (every SDK and runtime MUST follow them)​

  • Heartbeat: POST /v1/runs/{id}/heartbeat body {"progress": {...}, "step": "..."} returns {"run_id": "...", "state": "<run state>", "agent_status": "active|suspended|killed", "action": "continue|stop", "reason": null}. action is stop when the run is cancelled, suspended or terminal, or when the agent is suspended or killed (kill switch). A runtime that receives stop ends the turn, writes a checkpoint and exits without new model or tool calls.
  • Agent runtime architecture (Navin): AgentLoop receives messages (channels, sessions, schedules, events), prepares the context, manages sessions, resumes after a crash from the last checkpoint, handles stop requests, cron schedules and the heartbeat; AgentRunner runs one turn: model call, then tool calls, repeated until the final answer, with explicit continuation rules (work remaining, user already said go, verify before finishing); sub-agents run in parallel and report results; the run plan advances automatically to the next ready step; a message bus links channels and the engine. LangGraph and Temporal are optional adapters, not the engine.
  • Background jobs: python -m eai.worker runs the periodic platform jobs (scheduler tick, event outbox drain, quota reservation expiry, stale heartbeats, retention) with a PostgreSQL advisory lock so a single replica is active.

Telephony (ADR-0173)​

  • Telephony: Navin Enterprise configures the engines installed by the Navin Manager (Asterisk through AMI and ARI, Kamailio through JSONRPC, LiveKit through its API); writers platform_admin and tenant_admin, readers also support_engineer and auditor; secrets are write only (has_* flags); every change is audited.
  • Telephony active calls: GET /v1/telephony/active-calls (List the active calls), POST /v1/telephony/active-calls/{linkedid}/hangup (Hang up an active call), POST /v1/telephony/active-calls/{linkedid}/transfer (Transfer an active call)
  • Telephony human agents: GET /v1/telephony/agents (List the human agents), POST /v1/telephony/agents (Create a human agent), GET /v1/telephony/agents/{item_id} (Read a human agent), PATCH /v1/telephony/agents/{item_id} (Change a human agent), DELETE /v1/telephony/agents/{item_id} (Delete a human agent)
  • Telephony fraud blocks: GET /v1/telephony/blocks (List the blocks), POST /v1/telephony/blocks (Block the tenant or an extension), POST /v1/telephony/blocks/{block_id}/lift (Lift a block)
  • Telephony calls: GET /v1/telephony/calls (List the calls), GET /v1/telephony/calls/{call_id} (Read a call)
  • Telephony conference sessions: GET /v1/telephony/conference-sessions (List the conference sessions), GET /v1/telephony/conference-sessions/{session_id} (Read a conference session)
  • Telephony conferences: GET /v1/telephony/conferences (List the conference rooms), POST /v1/telephony/conferences (Create a conference room), GET /v1/telephony/conferences/{item_id} (Read a conference room), PATCH /v1/telephony/conferences/{item_id} (Change a conference room), DELETE /v1/telephony/conferences/{item_id} (Delete a conference room)
  • Telephony configuration versions: GET /v1/telephony/config-versions/{version_id} (Read a configuration version)
  • Telephony AMI event streams: GET /v1/telephony/event-streams (List the AMI event connections)
  • Telephony extensions: GET /v1/telephony/extensions (List the extensions), POST /v1/telephony/extensions (Create an extension), GET /v1/telephony/extensions/{item_id} (Read an extension), PATCH /v1/telephony/extensions/{item_id} (Change an extension), DELETE /v1/telephony/extensions/{item_id} (Delete an extension)
  • Telephony fraud alerts: GET /v1/telephony/fraud-alerts (List the fraud alerts), POST /v1/telephony/fraud-alerts/{alert_id}/acknowledge (Acknowledge a fraud alert)
  • Telephony infrastructure (platform_admin): GET /v1/telephony/infrastructure (List the telephony infrastructure of the Navin Manager), POST /v1/telephony/infrastructure/pools (Create or refresh a PBX pool from the Manager inventory)
  • Telephony IVR flows: GET /v1/telephony/ivr-flows (List the IVR flows), POST /v1/telephony/ivr-flows (Create an IVR flow), GET /v1/telephony/ivr-flows/{flow_id}/versions (List the versions of an IVR flow), POST /v1/telephony/ivr-flows/{flow_id}/versions (Create a version of an IVR flow), GET /v1/telephony/ivr-flows/{flow_id}/versions/{version} (Read a version of an IVR flow), POST /v1/telephony/ivr-flows/{flow_id}/versions/{version}/publish (Publish a version of an IVR flow), GET /v1/telephony/ivr-flows/{item_id} (Read an IVR flow), PATCH /v1/telephony/ivr-flows/{item_id} (Change an IVR flow), DELETE /v1/telephony/ivr-flows/{item_id} (Delete an IVR flow)
  • Telephony IVR prompts: GET /v1/telephony/ivr-prompts (List the IVR prompts), POST /v1/telephony/ivr-prompts (Upload an IVR prompt), GET /v1/telephony/ivr-prompts/{prompt_id} (Read an IVR prompt), DELETE /v1/telephony/ivr-prompts/{prompt_id} (Delete an IVR prompt), POST /v1/telephony/ivr-prompts/{prompt_id}/download (Get a download link of an IVR prompt)
  • Telephony phone numbers (DID): GET /v1/telephony/numbers (List the phone numbers), POST /v1/telephony/numbers (Create a phone number), GET /v1/telephony/numbers/{item_id} (Read a phone number), PATCH /v1/telephony/numbers/{item_id} (Change a phone number), DELETE /v1/telephony/numbers/{item_id} (Delete a phone number)
  • Telephony PBX pools: GET /v1/telephony/pbx-pools (List the PBX pools), POST /v1/telephony/pbx-pools (Declare a PBX pool), GET /v1/telephony/pbx-pools/{pool_id} (Read a PBX pool), PATCH /v1/telephony/pbx-pools/{pool_id} (Change a PBX pool), DELETE /v1/telephony/pbx-pools/{pool_id} (Delete a PBX pool), POST /v1/telephony/pbx-pools/{pool_id}/drain (Drain or restore a dispatcher destination), POST /v1/telephony/pbx-pools/{pool_id}/preview (Preview the configuration of a pool), POST /v1/telephony/pbx-pools/{pool_id}/publish (Publish the configuration of a pool), POST /v1/telephony/pbx-pools/{pool_id}/reconcile (Reconcile the routing of a pool), POST /v1/telephony/pbx-pools/{pool_id}/rollback (Roll a pool back to a version), GET /v1/telephony/pbx-pools/{pool_id}/versions (List the configuration versions of a pool)
  • Telephony configuration preview: POST /v1/telephony/preview (Preview the configuration of the tenant)
  • Telephony queues: GET /v1/telephony/queues (List the call queues), POST /v1/telephony/queues (Create a call queue), GET /v1/telephony/queues/{item_id} (Read a call queue), PATCH /v1/telephony/queues/{item_id} (Change a call queue), DELETE /v1/telephony/queues/{item_id} (Delete a call queue), GET /v1/telephony/queues/{queue_id}/members (List the members of a queue), POST /v1/telephony/queues/{queue_id}/members (Add a member to a queue), PATCH /v1/telephony/queues/{queue_id}/members/{member_id} (Change the penalty of a queue member), DELETE /v1/telephony/queues/{queue_id}/members/{member_id} (Remove a member from a queue)
  • Telephony quotas: GET /v1/telephony/quotas (Read the telephony quotas), PUT /v1/telephony/quotas (Set the telephony quotas)
  • Telephony recordings: GET /v1/telephony/recordings (List the recordings), GET /v1/telephony/recordings/{recording_id} (Read a recording), DELETE /v1/telephony/recordings/{recording_id} (Delete a recording), POST /v1/telephony/recordings/{recording_id}/download (Get a download link), PUT /v1/telephony/recordings/{recording_id}/legal-hold (Set the legal hold of a recording)
  • Telephony routing rules: GET /v1/telephony/routing-rules (List the routing rules), POST /v1/telephony/routing-rules (Create a routing rule), GET /v1/telephony/routing-rules/{item_id} (Read a routing rule), PATCH /v1/telephony/routing-rules/{item_id} (Change a routing rule), DELETE /v1/telephony/routing-rules/{item_id} (Delete a routing rule)
  • Telephony runtime settings: GET /v1/telephony/runtime-settings (Read the runtime settings), PUT /v1/telephony/runtime-settings (Set the runtime settings)
  • Telephony settings: GET /v1/telephony/settings (Read the telephony settings), PUT /v1/telephony/settings (Set the telephony settings)
  • Telephony live statistics: GET /v1/telephony/stats (Read the live statistics)
  • Telephony SIP trunks: GET /v1/telephony/trunks (List the SIP trunks), POST /v1/telephony/trunks (Create a SIP trunk), GET /v1/telephony/trunks/{item_id} (Read a SIP trunk), PATCH /v1/telephony/trunks/{item_id} (Change a SIP trunk), DELETE /v1/telephony/trunks/{item_id} (Delete a SIP trunk)
  • Telephony AI voice agents: GET /v1/telephony/voice-agents (List the AI voice agents), POST /v1/telephony/voice-agents (Create an AI voice agent), GET /v1/telephony/voice-agents/{item_id} (Read an AI voice agent), PATCH /v1/telephony/voice-agents/{item_id} (Change an AI voice agent), DELETE /v1/telephony/voice-agents/{item_id} (Delete an AI voice agent)
  • Telephony voicemail: GET /v1/telephony/voicemail (List the voicemail messages), DELETE /v1/telephony/voicemail/{message_id} (Delete a voicemail message), POST /v1/telephony/voicemail/{message_id}/listen (Listen to a voicemail message), POST /v1/telephony/voicemail/{message_id}/read (Mark a voicemail message read)
  • Telephony WebRTC softphone: POST /v1/telephony/webrtc/credentials (Get WebRTC credentials)
  • Telephony machine routes, outside the SDKs (called by LiveKit, by Asterisk or by the voice workers with their own token): POST on /v1/telephony/calls/{call_id}/events (Report a call event), POST on /v1/telephony/calls/{call_id}/tools/{name} (Run a lookup tool), POST on /v1/telephony/calls/{call_id}/transfer (Transfer a call to a human), POST on /v1/telephony/livekit/webhook (Receive a LiveKit webhook), GET on /v1/telephony/media/prompts/{tenant_id}/{prompt_file} (Play an IVR prompt), GET on /v1/telephony/voice-agents/{voice_agent_id}/runtime (Read the runtime of a voice agent)

Backend conventions (all backend contributors)​

Layout under services/control-plane/:

src/eai/main.py create_app(); includes every router found in eai/domains/*/ (module attribute `router`)
src/eai/config.py Settings (pydantic-settings, env prefix EAI_)
src/eai/db.py pool, `tx(principal=None)` context manager -> psycopg connection with `SET LOCAL app.tenant_id`, `fetch_all/fetch_one/execute`
src/eai/migrate.py applies sql/postgres/V###__name.sql in order into schema `eai`, table eai.schema_migrations(version, checksum)
src/eai/auth.py Principal(id, kind, tenant_id, roles, scopes, clearance, project, environment); dependency `current_principal`; `require_role(*roles)`, `require_scope(scope)`
src/eai/errors.py ApiError(code, status, message_en, message_fr, details); handler producing the error envelope
src/eai/i18n.py t(key, lang, **params) with en/fr catalogs in src/eai/locales/{en,fr}.json
src/eai/audit.py audit(conn, principal, action, resource_type, resource_id, decision, outcome, details=None) -> hash chained row
src/eai/policy.py evaluate(principal, action, resource, context) -> Decision(allow, reasons, obligations, decision_id); records the decision
src/eai/pagination.py page(rows, limit, key) helpers
src/eai/domains/<name>/ __init__.py exposes `router` (APIRouter, prefix already /v1/...), plus service/repo modules
sql/postgres/V###__*.sql repo root level, idempotent-safe, every tenant table has tenant_id uuid NOT NULL + RLS policy using current_setting('app.tenant_id', true)::uuid
tests/ conftest.py gives fixtures: pg (real PostgreSQL via pgserver, migrations applied once), client (FastAPI TestClient), make_tenant(slug) -> Tenant(id, slug, headers(role=...)), admin_headers

Migration number ranges (no collisions between contributors): core V001-V019, LLM V020-V029, FinOps V030-V039, agents/tools V040-V059, data/multimodal/quality/risk V060-V089. PostgreSQL here has no pgcrypto: use gen_random_uuid() and sha256() natively. Tests run on a real PostgreSQL (pgserver 16), never mocks for the database. External systems (LiteLLM, vLLM, OpenBao, OpenFGA, OPA, Keycloak, Qdrant, NATS, Temporal, LiveKit, Opik, ClickHouse) are reached through small adapter interfaces in eai/integrations/<system>.py with an in-process fallback implementation used when the system URL is not configured (so everything is testable here), and a real HTTP implementation used in production.