Skip to content

Config approvals API (four-eyes)

When an organization enables config approval, a configuration change made by a Fachadmin (a tenant_admin or ki_manager) does not take effect immediately. It is recorded as a pending change and must be approved by a second, different administrator before it is applied — the four-eyes principle. The person who requested a change can never approve their own change. Denying or letting a request expire discards it; nothing is applied.

This lane governs changes to the organization's central prompt library — surfaced in the UI as Workspace › Library › Prompts (the /tenant-prompts surface) — to its agents — creating, editing, or restoring a saved agent, and creating an agent schedule — to its projects / knowledge areas (the /projects surface): creating, editing, or deleting a project — and to a subset of the organization's own workspace settings — surfaced in the UI as User Management › Organisation › Edit › General (formerly the account popover's "Workspace settings" popup, now retired): budget (budget_usd), identity & branding (assistant_name, brand_product_name), and access & governance (config_approval_required) — see Tenants & Gateways. (default_user_type was briefly in this set but is platform-admin-only — a tenant_admin can no longer change it, so it is never held for approval.) It is a per-organization control, off by default — existing organizations keep today's immediate-apply behaviour until they turn it on. One exception: a tenant_admin's budget_usd change is always held for approval, even while this switch is off for every other field — a spend-affecting change is never applied immediately for a non-admin.

Agent, project, and tenant changes are re-validated at approval time, never replayed. A saved agent, and a project that pins a default model/provider, route every one of their conversations to that pair. Because a request may sit in the queue for up to 7 days, an approved agent or project change is re-checked against the organization's current residency and provider-allowlist rules at the moment of approval — not applied from a stale snapshot. If a model that was allowed when the change was requested has since become residency- or allowlist-blocked, the approval is refused (apply_failed, reason surfaced) and nothing is written. The requester's own authority is also re-checked at that moment: if the requester has since been removed, downgraded to a read-only role, moved to another organization, or lost the required role on the target (agent ownership; or, for a project, the editor/owner role — including the owner-only gate on a project's access tier or retention period), the approval is refused (requester_unauthorized). A held tenant change is re-validated the same way: budget_usd must still be >= 0 (a row queued before the guard shipped carries an unguarded negative — refused apply_failed, reason negative_budget, never silently persisted) and is re-checked against the tenant's current platform_cap_usd (a platform admin may have lowered it since the request was captured — refused apply_failed, reason over_cap), and default_user_type is re-checked against the live user-type registry (a slug retired since the request → refused apply_failed, reason invalid_default_user_type). (default_user_type became platform-admin-only, so a tenant_admin can no longer create a held change for it. This default_user_type re-validation now applies only when draining a legacy approval row captured before the deploy; no new ones are minted.)

Base URL: https://<your-gateway-host>/admin/v1

Required role for every endpoint: admin, tenant_admin, or ki_manager; a lower role is 403, an unauthenticated caller 401. Every call is re-gated and tenant-scoped server-side — the client is never the authorization boundary.

Turning it on

Config approval is a per-organization switch, set on the tenant:

PATCH /admin/v1/tenants/<tenant-id>
{ "config_approval_required": 1 }

1 = on, 0 = off (the default). The change is audited (tenant.config_approval_changed). While it is on, every prompt-library create/update/delete/restore, agent create/update/restore, agent-schedule create, project create/update/delete, and workspace-settings change (assistant name, workspace name, this very toggle) by a Fachadmin is held for approval; while it is off, those calls apply immediately as before. budget_usd is the one field held regardless of this toggle (see above). (System-provisioned example agents seeded by the workflow templates are fixed-config and are not gated.)

What happens when a change is held

With the switch on, a Fachadmin's prompt-library mutation returns 202 Accepted instead of its usual 200/201, and nothing is written to the live library yet:

{ "status": "pending_approval", "approval_id": "…" }

The change is now a pending row in the approval queue. It stays valid for 7 days; after that an approve is refused (see below).

A platform admin (a Myra operator, not one of the organization's own Fachadmins) is not subject to the gate. The control governs the customer's own administrators.

GET /config-approvals

List this organization's config approvals, newest first. Tenant-scoped: only the caller's own organization's rows are ever returned.

Optional ?status= filter — one of pending, applying, applied, denied, apply_failed, expired, or all (default: all). Any other value is rejected 400 (fail-closed — the value never reaches the query). expired is a derived display status (a still-pending row past its 7-day window); it is not a stored value. applying is a short-lived interim state for an agent change that is being applied with re-validation; a row that strands there (e.g. a worker restart mid-apply) is swept to apply_failed (reason claim_timeout) on the next decision — it is never applied twice.

entity_type is tenant_prompt, agent, agent_schedule, chat_project, or tenant; operation is create, update, delete, or restore (tenant is always update). The list never exposes the held payload — only entity_type, operation, and a short summary. For an entity create the summary is its name (agent, prompt, or project); for a tenant (workspace settings) change it names each changed governance field with its old → new value — e.g. Budget: unlimited -> 500.00 USD; Workspace name: "Acme" -> "Acme GmbH" — so the approver sees what they are approving rather than a bare —.

{
  "approvals": [
    {
      "id": "…",
      "tenant_id": "…",
      "entity_type": "tenant_prompt",
      "operation": "create",
      "target_id": null,
      "summary": "Complaint reply",
      "status": "pending",
      "apply_error": null,
      "requested_by": "…",
      "requested_at": 1753000000,
      "expires_at": 1753604800,
      "decided_by": null,
      "decided_at": null,
      "applied_at": null
    }
  ]
}

The list never contains another organization's rows, and never exposes the full held payload — only entity_type, operation, and a short summary (e.g. the prompt name).

POST /config-approvals/<id>/approve

Approve a pending change. The four-eyes rule and the apply are one atomic step: the change is applied only if the approver is a different person from the requester, the request has not expired, and it is still pending.

Outcome Response
Applied 200 { "decision": "approve", "status": "applied", "target_id": "…" }
Approver is the requester 403 { "error": "self_approval" } — a requester can never approve their own change
Already decided (approved/denied) 409 { "error": "already_decided" }
Past the 7-day window 409 { "error": "expired" }
The target no longer exists / the change can't be applied 409 { "error": "apply_failed", "reason": "not_found" }
(Agent) the model is now residency/allowlist-blocked, or the change no longer validates 409 { "error": "apply_failed", "reason": "<residency/capability code>" } — nothing is written
(Agent) the requester lost the right to make this change 409 { "error": "apply_failed", "reason": "requester_unauthorized" }
(Agent) another approval of the same row is already applying 409 { "error": "in_progress" }
(Tenant) budget_usd now exceeds platform_cap_usd (lowered since the request was captured) 409 { "error": "apply_failed", "reason": "over_cap" } — nothing is written
(Tenant) budget_usd is negative (a row queued before the guard) 409 { "error": "apply_failed", "reason": "negative_budget" } — nothing is written
(Tenant, legacy in-flight rows only) default_user_type was retired from the registry since the request was captured 409 { "error": "apply_failed", "reason": "invalid_default_user_type" }
Malformed approval id (non-string, empty, or over 64 characters) 400 { "error": "bad approval id" }
Not this organization's approval, or an unknown (well-formed) approval id 404 { "error": "not found" } (never leaks another tenant's existence)

How the apply runs, by domain. A prompt-library change is applied in one transaction with the status flip — either fully applied or not at all. An agent, project, or tenant change follows the same rule via a short-lived applying claim: the four-eyes claim is taken (still requested_by <> approver), the change is re-validated against current state and applied (the shared, single code path a direct change uses — no duplicate logic, no stale replay), then the row is finalized to applied or apply_failed. A claim is only ever taken from a pending row, so an approved change is never applied twice; a claim that strands is swept to apply_failed, and the requester re-submits. When a project is deleted, any still-pending approvals that targeted it are discarded (their held copy of the project's configuration does not outlive the workspace). A tenant approve applies only the field(s) the held request actually touched — every OTHER field on the tenant row is re-read fresh at apply time and left untouched (never overwritten from a stale captured snapshot), matching how an immediate PATCH already preserves omitted fields.

POST /config-approvals/<id>/deny

Deny (discard) a pending change — nothing is applied. Unlike approve, the requester may deny their own pending change (withdrawing it is not a four-eyes violation).

Outcome Response
Denied 200 { "decision": "deny", "status": "denied" }
Already decided 409 { "error": "already_decided" }
Malformed approval id (non-string, empty, or over 64 characters) 400 { "error": "bad approval id" }
Not this organization's approval, or an unknown (well-formed) approval id 404 { "error": "not found" }

Data protection

A pending approval stores the proposed change (the prompt text, the proposed agent configuration, a project's configuration and instructions, or — for a tenant approval — the submitted workspace-settings field(s): a budget figure, the assistant/workspace name, or the default-role/four-eyes-toggle value) until it is decided. These rows are tenant-scoped, are removed when the organization is deleted (contract-end purge), and a requester's rows are removed when that user is erased (Art. 17); an approver's identity on other people's rows is anonymized rather than deleted, preserving the governance record. The approval history (who requested, approved, or denied which change, when, plus the proposed payload) is included in the contract-end tenant export, under config_approvals — see the tenant data export.