Departments API
A department is a level below the tenant that controls only which gateways its members may route through: an allowed-gateway set plus an optional default gateway. A user belongs to exactly one department or to none; a user without a department is unrestricted. A project may be owned by a department and may lock its gateway. The effective rule for every request is: the gateway's own (tenant-folded) configuration ∧ the user's department set ∧ the project lock — the strictest wins.
All endpoints require an authenticated admin session and the DEPARTMENTS_MANAGE permission (gate class tenant_admin: held by admin and tenant_admin, delegable to a custom role). Spend limits are separate: sending a non-null budget_usd or budget_period, or a member_budget_mode (the default limit per member), on POST, or any budget_usd / budget_period / member_budget_mode on PATCH (even the stored value, and null to clear the cap), needs BUDGETS_MANAGE (held by admin, tenant_admin and finance); an explicit null on POST is the same as omitting the key (no cap, no permission needed), and every other field needs DEPARTMENTS_MANAGE (except project governance, which a project's owner may also use for their own project, and the two read-only department views: GET /users/{id}/department needs USERS_MANAGE, GET /me/department any signed-in account); a request that does both needs both, and a caller lacking the one it needs gets 403 { "error": "forbidden" }. So finance can set a team's cap and nothing else, while a custom role holding only DEPARTMENTS_MANAGE can no longer edit budgets. The self-management and outrank rules below apply to the budget keys too: finance cannot change the budget of its own department or of one with a tenant admin or admin member. The permission check runs before the department is read, so a caller without either permission cannot probe for existence. Reading the members' ranks happens just before the write transaction, so a role change racing the request can slip through that window (the write itself stays atomic). The base URL is https://ai-api-admin.myra.eu/admin/v1.
Tenant isolation. A non-platform caller only addresses its own tenant; a department of another tenant is answered with 404 (no existence oracle) — and so is an unknown or malformed {id}: all three are the identical 404 { "error": "not found" } and each is recorded as one access.denied security event (status 404, no entity; the id is visible in path; see ids of another tenant). A non-platform caller that holds the route's permission but whose account belongs to no tenant is refused 403 { "error": "forbidden", "code": "caller_has_no_tenant" } on every {id} route before any department is read, never given another tenant's row (a caller without the route's permission, e.g. a ki_manager on a department route, gets 403 permission_required first); a failed department read is 503 and writes no access.denied row. Only a platform admin addresses a department of any tenant by id. A project of another tenant is answered with the audited 404 recorded as access.denied (see projects). A gateway of another tenant named in a department's set is refused with 422 gateway_not_in_tenant. A user of another tenant is answered with 404 { "error": "not found" }, exactly like an unknown user (the shared user-access gate; no existence oracle). A platform admin must name the tenant on list/create (tenant_id; missing or "" → 400 tenant_id_required, null, a non-string or not a tenant id → 400 invalid_tenant_id, no such tenant → 404 tenant_not_found; an uppercase tenant id is accepted. Any other caller is pinned to its own tenant: for a non-admin, a well-formed, empty or null tenant_id is ignored (the caller's own tenant is used); a malformed one (not a UUID, or a number/object/boolean) is rejected with 400 invalid_tenant_id).
Storage faults. A failed read the answer depends on — including the check that a platform admin's tenant_id names a live tenant, and the tenant read while judging a lock's PII twin — is 503 { "error": "temporarily unavailable; retry later" } (retry); only a tenant that does not exist (or was deleted) is 404 tenant_not_found. See Storage faults.
Self-management. Below tenant_admin, a DEPARTMENTS_MANAGE holder may not change their own department, edit or delete the department they belong to, → 403 department_self_management. Nor may they assign or unassign a user who outranks them (a tenant admin or platform admin), or change the allowed set or default gateway, or the spend limit (budget_usd / budget_period / member_budget_mode, including clearing it), of a department such a user belongs to (a rename is fine; a key sent at all counts as a change, even with the stored value) → 403 department_target_outranks. Project governance (below) follows its own rule: department administration (a tenant admin) or the project's owner — a delegated holder who does not own the project gets 403 project_governance_forbidden.
Audit. Every mutation writes an audit event, in the same database transaction as the change (an audit write that fails rolls the change back; there is never a change without its event or the reverse): department.created, department.updated, department.deleted, user.department_changed, user.department_pinned_none, project.governance_changed. department.updated carries before and after: before always holds name, description, allowed_gateway_ids, default_gateway_id, budget_usd (number or null), budget_period (string or null), member_budget_mode (string) and member_budget_usd (number or null); after holds only the fields the request patched, where null means cleared. after.allowed_gateway_ids and after.default_gateway_id (also on department.created) carry the stored gateway ids, not the spelling sent.
Input rules (trust boundary)
Every body is validated before any storage access (admin/department_input); a violation is 400 with a machine code. Every id field (gateway, department, tenant) must be a canonical UUID: 36 characters in the 8-4-4-4-12 hex-group layout, either case. A string that merely has dashes in those four places but other non-hex groups (e.g. 36 dashes) is not an id and is rejected like any other malformed id.
| Field | Accepted | Rejected |
|---|---|---|
| body | a JSON object | an array, a scalar, null, a malformed body → 400 invalid_body |
name |
string, trimmed, 1–255 code points, valid UTF-8 | missing / null / empty after trim → name_required; non-string or invalid UTF-8 → invalid_name; too long → name_too_long |
description |
string ≤ 2000 code points, or null (clear) |
non-string / too long / invalid UTF-8 → description_* |
allowed_gateway_ids |
a JSON array of gateway ids (UUID strings, any letter case; the department stores and returns each gateway's own id, never the spelling sent), ≤ 200, duplicates collapse; [] = allows nothing |
an object, a string, null, a non-UUID element → invalid_allowed_gateway_ids; > 200 → allowed_gateway_ids_too_many; missing on create → allowed_gateway_ids_required |
default_gateway_id |
a gateway id (any letter case, stored as the gateway's own id), or null (no default) |
any other type → invalid_default_gateway_id |
budget_usd (per-team spend cap) |
a number 0–1000000 (0 freezes the team), or null (no cap); absent key leaves it unchanged |
any other shape — string, negative, out of range, NaN/inf, object → 400 invalid_budget_usd (a malformed cap is never read as "no cap") |
budget_period (per-team cap period) |
daily, weekly, monthly or total, or null (defaults to monthly at enforcement); absent key leaves it unchanged |
any other value → 400 invalid_budget_period |
member_budget_mode + member_budget_usd (default limit per member, distinct from the team pool above) |
sent as a pair: member_budget_mode is exactly inherit (take the workspace default), none (no limit) or amount; member_budget_usd is a number 0–1000000 (0 freezes members without a personal limit) and only with amount. Both absent = unchanged. A personal limit on a member (PUT /users/{id}/budget-cap) beats this default |
a member_budget_usd without a mode → member_budget_mode_required; a mode that is not exactly one of the three strings (null, "", wrong case, a number) → invalid_member_budget_mode; amount without a valid number, or a number with inherit / none, or negative / out of range / NaN / string → invalid_member_budget_usd (never read as inherit or "no limit") |
parent_id (hierarchy) |
a department id of the same tenant, or null (a root); absent leaves it unchanged |
any other shape (non-string, empty, not a UUID) → 400 invalid_parent_id |
cost_centre (reporting label) |
a string of 1–64 code points (trimmed), valid UTF-8, no control characters, or null (clear); absent leaves it unchanged |
non-string / empty / blank / control characters / invalid UTF-8 → 400 invalid_cost_centre; over 64 code points → 400 cost_centre_too_long (an empty string is not "clear") |
department_id (assignment / governance) |
a department id, or null; the key is required |
missing key or "" → department_id_required (an absent key is never read as "clear"); other type → invalid_department_id |
gateway_locked (governance) |
true / false; required |
missing → gateway_locked_required; non-boolean → invalid_gateway_locked |
expected_gateway_id (governance) |
required when locking: the gateway id the admin saw pinned; optional when unlocking | missing, null or "" while locking → expected_gateway_id_required (unlocking: null is accepted, "" → invalid_expected_gateway_id); present but not a gateway id (locking or not) → invalid_expected_gateway_id |
Meaning checks (after the shape is accepted):
| Rule | Error |
|---|---|
| every allowed gateway belongs to the department's tenant | 422 gateway_not_in_tenant |
every allowed gateway is a production gateway |
422 gateway_not_production |
| the default gateway is one of the allowed gateways | 422 default_not_allowed |
a plain gateway is allowed only together with its PII-masking twin (slug pair <base> ↔ <base>-pii) |
422 department_pii_twin_incomplete (+ gateway_id, twin_gateway_id) |
| the department name is unique in the tenant | 409 department_name_taken |
parent_id names an existing department of the same tenant (a foreign or unknown id is the same answer) |
422 department_parent_not_found |
| the new parent is not the department itself or one of its descendants | 422 department_parent_cycle |
| the tree stays at most 5 levels deep (the moved subtree counts) | 422 department_depth_exceeded |
Departments
List departments
GET /admin/v1/departments[?tenant_id=<ID>]
Callable with DEPARTMENTS_MANAGE or BUDGETS_MANAGE (so finance can see the caps it manages); the department detail read is not widened and stays DEPARTMENTS_MANAGE. Returns an array of departments: id, tenant_id, name, description, default_gateway_id, budget_usd (number or null), budget_period (string or null), member_budget_mode (inherit / none / amount), member_budget_usd (number or null), allowed_gateway_ids (array), member_count, project_count, idp_managed (boolean, see below), created_at, updated_at (Unix seconds), and warnings — PII-twin findings for the current gateways: { "kind": "pii_twin_missing" | "pii_twin_partial", "gateway_id", "twin_gateway_id" }. pii_twin_missing appears when a masking twin was created after the department was saved; pii_twin_partial (only the masking gateway is allowed) is informational.
Hierarchy fields. parent_id (string or null) and cost_centre (string or null) are returned by the list, the detail and the update. The hierarchy is reporting only: a department's gateway set, default gateway and spend limit are never inherited from, or applied to, its parent or children. Changing parent_id or cost_centre needs DEPARTMENTS_MANAGE (a finance user with only BUDGETS_MANAGE gets 403). Integrity is checked inside the write transaction under the tenant lock, so concurrent re-parenting cannot create a cycle.
Spend roll-up (list, BUDGETS_MANAGE holders only). Each entry additionally carries spend_usd (the department's own spend in its current budget period), spend_period (daily / weekly / monthly / total), rollup_spend_usd (own spend plus every descendant that uses the same period type) and rollup_excluded_ids (descendants with a different period type, which are not summed). If the spend cannot be read, the values are null and spend_unavailable is true — never 0. Callers holding only DEPARTMENTS_MANAGE do not see these fields.
idp_managed (list, detail and the PATCH answer; always true or false) is true when the department's membership comes from the identity provider: the tenant's IdP group mapping names the department as a target ({"kind":"department","id":…}, ids compared case-insensitively), or at least one member was assigned by the IdP (department_source = 'idp', via SCIM, SAML or OIDC). The admin UI then shows a "Managed by your IdP" badge and the member list read-only. It is derived on every read, never stored and never accepted as input. A stored mapping that is not a JSON object, or a mapping value that is not a well-formed department target, counts as no target. A failed read of the IdP configuration answers a retryable 503 (on the PATCH answer, after the write committed: the sanitised 500 with the request id), never a silent false.
Spend report (CSV)
GET /admin/v1/departments/export.csv[?tenant_id=<ID>] — requires BUDGETS_MANAGE (403 otherwise). text/csv, one row per department: id, name, parent_id, cost_centre, budget_usd, budget_period, spend_period, spend_usd, rollup_spend_usd. Cells starting with a formula character (=, +, -, @) are neutralised. A spend read fault is a 5xx, not a row of zeros.
PII-twin pairs
GET /admin/v1/departments/pii-twins[?tenant_id=<ID>] — the tenant's PII-masking pairs, for a form that wants to warn before saving: one entry { "gateway_id", "twin_gateway_id" } per production gateway that cannot mask PII and has a masking twin (slug pair <base> ↔ <base>-pii), a JSON array ([] when there is none). Same permission and tenant rules as the list (a platform admin names the tenant: missing or "" → 400 tenant_id_required, malformed → 400 invalid_tenant_id, unknown → 404 tenant_not_found). The rule itself is unchanged: create and update still refuse an incomplete set with 422 department_pii_twin_incomplete.
Create a department
POST /admin/v1/departments
{ "name": "R&D", "description": null, "allowed_gateway_ids": ["<gw-id>", "<gw-pii-id>"], "default_gateway_id": "<gw-id>", "budget_usd": 500, "budget_period": "monthly", "parent_id": null, "cost_centre": "CC-100", "tenant_id": "<platform admin only>" }
201 { "id": "...", "warnings": [] }.
Get a department
GET /admin/v1/departments/{id} — the list fields plus members (id, email, name, pool_exempt boolean), projects (id, name, default_gateway_id, gateway_locked, lock_state) and may_govern_projects.
lock_stateis the project's lock as the server classifies it:none,locked(locked on its gateway pin) ormalformed(locked without a usable pin, or a stored value outside the known ones — it denies all routing).gateway_lockedistruefor bothlockedandmalformed, so it always matches what the governancePUTexpects asexpected_gateway_locked.may_govern_projects—truewhen the caller is department administration (DEPARTMENTS_MANAGEand thetenant_admin/ platform admin role, not in an impersonation session) and can therefore govern — for example release — every project listed here without being its owner or member. A delegatedDEPARTMENTS_MANAGEholder getsfalse.
Update a department
PATCH /admin/v1/departments/{id} — any of name, description, allowed_gateway_ids (full replacement), default_gateway_id, budget_usd (number, 0 to freeze, or null to clear), budget_period (daily/weekly/monthly/total or null), parent_id, cost_centre, the member_budget_mode + member_budget_usd pair, pool_exempt_member_ids (see below). An absent key leaves the value unchanged; an empty patch is 400 no_fields. Removing a gateway that a project of the department pins or locks is refused with 409 project_gateway_outside_department (checked inside the write transaction). On success the response is 200 with the updated department (the list fields, including warnings).
Pool exemption. pool_exempt_member_ids (array of user ids, at most 1000) is the full set of members exempt from this department's pool cap; sending it replaces the stored set (an empty array clears it), written in one transaction with the rest of the patch. It needs BUDGETS_MANAGE (sending it at all, even unchanged, without it is 403). Duplicate ids collapse (case-insensitive). Rejections: 400 invalid_pool_exempt_member_ids (null, not an array, or a non-UUID entry; an absent and a malformed value are not the same answer), 400 pool_exempt_member_ids_too_many (more than 1000 entries) and 422 pool_exempt_not_member (an id that is not a member of this department assigned directly; a member who only inherits the department through the tenant default cannot be exempted).
Locked departments
A department has a boolean locked (default false; returned by list / get; settable on create and PATCH as a strict boolean — a string, number or null is 400 invalid_locked). The effective gateway set of a member is:
- department not locked: the department's
allowed_gateway_ids∪ the gateways granted to the member's groups (group gateway grants); - department locked: the department's
allowed_gateway_idsonly — group grants are ignored.
A user without a department stays unrestricted. Project pins and project locks remain checked against the department set alone; a group grant never makes a gateway pinnable. Deleting a gateway removes the group grants that name it. department.updated audits locked in before / after.
Failure behaviour. If the group grants cannot be read the answer is the retryable 503 department_policy_unavailable, never a narrower or wider set; a policy answer from the last 5 minutes may be served from the per-server cache, so while the read is failing a just-removed member, a revoked grant or a lock change can still show the previous answer for up to those 5 minutes. The grants add one more read to the policy resolution, so very large group fan-outs enlarge that cached answer.
Delete a department
DELETE /admin/v1/departments/{id} — only when it has no live member and no live owned project; otherwise 409 department_not_empty; a department that still has child departments is 409 department_has_children (move or delete the children first). The tenant default department (below) cannot be deleted: 409 department_is_tenant_default — clear or change the default first. On success the response is 200 { "ok": true }. Department administration can release the department's projects first with the governance PUT (department_id: null) — it needs no access to the project itself; the Departments view offers this as Remove from department in the department editor.
Assigning a user
PUT /admin/v1/users/{id}/department
The department must belong to the user's tenant (422 department_not_found); a tenant-less user cannot belong to one (400 user_has_no_tenant). Response:
{ "ok": true, "department_id": "...", "impact": { "agents": [...], "scheduled_tasks": [...], "workflows": [...] }, "impact_complete": true }
impact lists the user's own agents, schedules and workflows pinned to a gateway the new department does not allow — they are refused at run time (recreate them on an allowed gateway). impact_complete: false means the list could not be read (the assignment itself is saved). A user moved to another tenant (POST /users/{id}/move) gets a new account there without a department; the department stays with the old account in the old tenant.
A user's department view
Two read-only views of a user's department as the routing policy sees it — the same predicate every inference request, token mint and route check uses, labelled for display. The admin New token dialog uses the first one to offer only gateways the target user's department allows; My access uses the second.
GET /admin/v1/users/{id}/department— requiresUSERS_MANAGEand the same user-access check asGET /users/{id}(a user of another tenant →404, the same as an unknown user; a user who outranks the caller in the caller's own tenant is refused403as on every user route; a soft-deleted user →404 { "code": "user_deleted" }).GET /admin/v1/me/department— the caller's own view; any signed-in account (401unauthenticatedwithout a session).
On both routes, a user the view cannot resolve is 404 { "error": "user not found", "code": "user_not_found" }.
{ "department": null }
{ "department": { "id": "<department-id>", "name": "R&D", "status": "active", "locked": false,
"allowed_gateways": [ { "id": "<gw-id>", "slug": "internal",
"sources": [ { "kind": "department" },
{ "kind": "group", "group_id": "<group-id>",
"group_name": "Contract reviewers", "expires_at": null } ] } ],
"default_gateway_id": "<gw-id>" } }
department: null— the user has no department and is unrestricted.status—active(a department with allowed gateways),blocked(the department allows no gateway: no AI use),unresolved(the user's department no longer resolves — deleted, another tenant's, or a malformed binding; nothing is allowed,idandnamearenull).allowed_gateways— sorted by slug; each id is the stored gateway id. An allowed id that is no longer a gateway of the tenant keeps its id withslug: null(never dropped). Empty forblocked/unresolved.locked—truewhen the department is locked: group gateway grants then add nothing and everysourceslist holds only{ "kind": "department" }.sources(per gateway) — why the user may use it:{ "kind": "department" }(the department allows it) and/or{ "kind": "group", "group_id", "group_name", "expires_at" }(a group the user belongs to grants it;expires_atis Unix seconds ornull). The department entry comes first, then groups by name. It mirrors the routing policy's own per-gateway source and never relabels a group grant as the department. If the granting group row vanished between the two reads, the entry stayskind: "group"withgroup_idandgroup_namenull.default_gateway_id— the department's default gateway, ornull.
A failed read — including the extra read that names the granting groups — is 503 (retry) — never department: null. The view is informational: the server still decides at every mint and request.
A user's groups
GET /admin/v1/users/{id}/groups — the groups the person belongs to, for the user page's Identity card. Requires USERS_MANAGE and the same user-access check as GET /users/{id} (another tenant's or an unknown user → 404; a soft-deleted user → 404 { "code": "user_deleted" }). No body, no query. Only groups of the user's own tenant are listed; an account without a workspace gets [].
from_idp—truewhen the identity provider (SCIM or a sign-in claim) put the person in the group;falsefor a membership added by hand. The raw source is not sent.- A stored row without an id or a name is left out, never sent as a nameless group. The list is always a JSON array (
[]when empty). - A failed read is
503(retry), never an empty list.
Effective policy
Two read-only views of what actually applies to a person or a department, assembled from the same readers the request path uses (no second rule set). The Governance > Guardrail baseline page uses them.
GET /admin/v1/users/{id}/effective-policy— requiresUSERS_MANAGEandTENANT_SETTINGS_MANAGE(the Guardrail baseline page's permission; the answer names guardrail rules) and the same user-access check asGET /users/{id}(another tenant's user →404, a soft-deleted user →404).GET /admin/v1/departments/{id}/effective-policy— requiresDEPARTMENTS_MANAGEandTENANT_SETTINGS_MANAGE; another tenant's department →404.
A caller without the permission gets 403 { "error": "forbidden" } before anything is read. A failed storage read is 503 (retry), never an empty policy.
{ "subject": { "kind": "user", "id": "<id>", "label": "ada@example.com" },
"scope": "department",
"department": { "id": "<id>", "name": "Legal" },
"gateways": [ { "id": "<gw-id>", "slug": "internal", "default": true,
"sources": [ { "kind": "department" } ],
"config_status": "ok",
"rules": { "baseline": 2, "role": 0 },
"models": [ { "provider": "mistral", "model": "mistral-small-4" } ],
"fail_closed": false } ],
"guardrails_fail_closed": false }
scope—unrestricted(a user without a department: every production gateway of the tenant),department,blocked(no usable gateway) orunresolved(the department binding is malformed or no longer resolves; nothing is allowed).gatewayslists production gateways only; a non-production gateway never routes and is never listed.- A gateway whose configuration cannot be resolved has
config_status: "unavailable"andrules,modelsandfail_closedset tonull— absence is never reported as "0 rules" or "no models". rulescounts request-phase detectors that are enabled:baseline(the tenant baseline plus the gateway's own) androle(extra detectors for the person's role).modelsare the models the gateway offers after the chat, residency and plan checks and the role's model allow-list.- Not included: personal keywords, rate limits and budget.
Project governance
Who may govern a project (change its owning department, lock / unlock its gateway, delete it while locked, release a conversation from it while locked):
- Department administration —
DEPARTMENTS_MANAGEand thetenant_admin(or platform admin) role: any department of the project's tenant. - The project's owner — a user whose effective role on the project is
owner(direct membership or a group grant; an organisation share never makes anyone an owner), with an authoring platform role (notviewer/demouser/finance), and not in an impersonation session. An owner may move the project out to no department, keep its current department, or move it into the department they belong to themselves — any other department is refused with403 department_not_yours(an unknown department id gets the same answer, so it is no existence oracle). An owner may lock and unlock the project's gateway under the same rules as an admin: the lock is always the project's current pin, which must be allowed by the project's department.
Everyone else is refused: no access to the project, a project of another tenant, an unknown or malformed id → 404 { "error": "not found" }, recorded as access.denied (see projects; no existence oracle); access below owner, a read-only platform role, or (on the options GET) an owner's impersonation session → 403 project_governance_forbidden; the PUT refuses every impersonation session with 403 impersonation_forbidden. A delegated DEPARTMENTS_MANAGE holder (custom role) governs only projects they own. A failure to read the caller's project role is 503 (retryable), never a refusal or a pass.
Options for the caller
GET /admin/v1/projects/{id}/governance — same access rule as the PUT. Returns what this caller may choose:
{ "role": "admin" | "owner",
"departments": [ { "id", "name", "default_gateway_id", "allowed_gateway_ids": [...], "assignable": true } ],
"own_department_missing": false }
For department administration every department of the tenant is listed (assignable: true). For an owner: their own department (assignable: true) and, when different, the project's current department (assignable: false — shown and keepable, not choosable). own_department_missing: true means the owner's department no longer exists (it is never offered and never read as "no department"). A read failure is 503.
Changing governance
PUT /admin/v1/projects/{id}/governance
{ "department_id": "<department-id>" | null,
"expected_department_id": "<the project's current department>" | null,
"gateway_locked": true,
"expected_gateway_locked": false,
"expected_gateway_id": "<the project's current gateway>" }
department_id— the owning department (null= an ordinary project: visible to its owner and explicit shares). The department's members see the project as viewer (they can use it; editing needs an explicit share). The project's gateway pin must be in the department's set → otherwise422 project_gateway_outside_department. Unknown department →422 department_not_found(department administration) /403 department_not_yours(owner).expected_department_id— required: the department the caller saw on the project (null= it had none). Absent or""→400 expected_department_id_required; anything else but a department id ornull→400 invalid_expected_department_id; no longer the project's department (changed meanwhile) →409 project_department_changed, nothing written.expected_gateway_locked— required: whether the project was locked when the caller saw it (true/false). Absent →400 expected_gateway_locked_required; anything but a boolean →400 invalid_expected_gateway_locked; no longer the project's lock state →409 project_lock_changed, nothing written. A change can therefore never undo a lock or unlock it did not see — in particular one that waited for four-eyes approval.gateway_locked— locks the project's current gateway pin (default_gateway_id). The lock never changes the pin: change it withPATCH /projects/{id}first (four-eyes-gated where the tenant opted in). Locking needs a pin (422 project_has_no_gateway_pin) and the pin the caller saw (409 project_pin_changedwhen it changed meanwhile). When the lock is turned on, the pin passes the same pin checks as a project PATCH (residency / allowlist / local-only), and locking a gateway that cannot mask PII while its masking twin exists is refused under a PII mandate (422 department_pii_twin_incomplete), otherwise returned as a warning. Keeping an existing lock (the project is locked on its pin andexpected_gateway_lockedistrue— re-checked under the row lock) does not re-run those lock-on checks, nor the check that the locked gateway is still a production gateway: the pin cannot change under a lock, so a project can be moved to another department or released while its lock is kept even after a later setting (a PII mandate, the locked gateway switched to test) would refuse a new lock. The pin must still be allowed by the new department, and the gateway must still exist. A malformed lock is never "kept" — keeping it runs every check (a non-production gateway →422 gateway_not_production); release it withgateway_locked: false.
All refusals of the department rule and the stale checks are decided inside the write transaction, under locks on the department, the owner's user row and the project — a concurrent department change cannot slip between check and write.
Response: { "ok": true, "department_id", "gateway_locked", "gateway_id", "warnings": [...], "impact": { "agents": [...], "scheduled_tasks": [...], "workflows": [...], "others_affected": 0 }, "impact_complete": true } — when locking, impact.agents lists agents that bind the project as a knowledge area on another gateway (their knowledge tools then return the lock error), impact.scheduled_tasks the schedules that run such an agent on another gateway, and impact.workflows the workflows whose agent steps use such an agent. For an owner, only items the owner created are named; items of other users are only counted in others_affected (department administration sees every item by name and others_affected: 0). The lists are informational (the lock is already written); impact_complete: false means a list could not be read and may be incomplete. The audit event project.governance_changed records via: "admin" | "owner".
A gateway that was deleted or stopped being a production gateway between the checks and the write is refused with 422 gateway_not_in_tenant / 422 gateway_not_production (the same applies to department create / update).
A request that changes nothing (same department, same lock state) answers 200 with "unchanged": true and is neither written nor queued.
Four-eyes approval
Where the organization has config approval enabled, a governance change goes through the same four-eyes approval as a project gateway change (CEO decision 2026-10-13), with the same capture rule: a change by a holder of GOVERNANCE_MANAGE (a tenant_admin, a ki_manager, or a custom role granting it) — owner or department administration alike — is held; a change by a project owner without it (a member) applies at once, exactly as their gateway change does; a platform admin is never held.
Before anything is queued, the request is checked completely — every refusal above, including the decisions the write makes inside its transaction (stale department / lock, the owner's department rule, an unknown department, the department's gateway set) — so a request that would fail is answered now, not sent to an approver. If the approval queue cannot be written (or the four-eyes setting cannot be read) the answer is 503 { "error": "approval gate unavailable" } (uncoded) when the fault was transient, otherwise the sanitised 500 { "error": "internal error", "request_id": "…" }; nothing is queued or written either way. A held request answers 202 { "status": "pending_approval", "approval_id", "warnings": [...], "impact": {...}, "impact_complete" } — nothing is written; impact previews what the lock would affect (the same lists as above). The approver's inbox shows the operation Department / gateway lock (operation: "governance") and a summary naming the project and every change, old → new.
At approval the change is re-checked against the project's current state (the same checks as above, including the keep-a-lock rule), under the requester's authority (they must still be allowed to govern the project; an owner must still belong to the department they chose): if the department or the lock state changed while it waited → apply_failed with project_department_changed / project_lock_changed; the pin → project_pin_changed; the owner left that department → department_not_yours; the department was deleted → department_not_found; its gateway set no longer allows the pin → project_gateway_outside_department. Nothing is written in any of these cases. The audit event of an approved change carries four_eyes: true and the requester as actor.
The governance PUT refuses an impersonation session (403 impersonation_forbidden), so a held change is always the real requester's.
Limit requests
A member whose department has a spend cap (budget_usd) can ask for a raise. A request is a message with a lifecycle (open then approved or rejected) stored in the same table as the wallet budget requests; the two kinds never mix (the wallet endpoints list, link and reject wallet requests only). Limit requests work on every plan, invoiced and manual tenants included (no wallet or subscription is needed). Amounts are USD cents (amount_minor, approved_amount_minor). Session-authenticated; impersonation sessions are refused.
| Endpoint | Who | Body |
|---|---|---|
POST /admin/v1/limit-requests |
any member with a department | target_kind (department_pool), target_id (the caller's own department), amount_minor, duration (period or permanent), optional note |
GET /admin/v1/limit-requests |
approver | the open requests the caller may decide (with the requester and the target department's figures, below). scope is tenant for a tenant admin or billing purchaser (all open requests) and department for a department approver or manager (only their department's requests, see Routing) |
GET /admin/v1/limit-requests/route |
any member with a department | where the caller's own request would go: kind (department, manager or fallback), approvers (at most 3 display names; a user without a name shows the e-mail address, one with neither is counted but not named), total (all routed approvers), department_name. No input; the department is read from the caller's account. No department: 409 no_department |
GET /admin/v1/limit-requests/mine |
any member | the caller's own requests, open and decided |
POST /admin/v1/limit-requests/{id}/approve |
approver | optional approved_amount_minor (default: the full request; at most the requested amount) |
POST /admin/v1/limit-requests/{id}/decline |
approver | reason (required, at most 500 characters) |
Approver. A tenant admin or higher, or a holder of the billing purchase permission, decides any request of the workspace. Nobody decides their own request (403 self_approval); a billing purchaser who is not a tenant admin is also refused for their own department (403 own_department) unless they are that department's approver (below). A four-eyes amount threshold is a follow-up and not built.
Routing (who a request goes to, computed when it is read; nothing is stored on the request):
- Department approvers: active members of the request's department who hold the permission Approve limit requests for your own department (
LIMIT_REQUESTS_APPROVE_DEPARTMENT) through a custom role: assigned by hand, or through an identity-provider mapping to that role (an IdP group mapped to a role named e.g. "Department approver", see Sign-in & provisioning). Tenant admins hold the permission through their system role but are not department approvers: only an explicit custom-role grant routes requests. The requester is never their own approver. - Otherwise the requester's manager from SCIM (enterprise
manager, see SCIM), when that manager is active, in the same workspace and in the same department as the request. - Otherwise the fallback: the tenant admins and billing purchasers (as before).
The routed approvers are mailed on creation (the fallback set when nobody is routed) and named in the requester's dialog (/route). A department approver or manager sees and decides only the open requests of their own department (a manager: only those of their direct reports); any other request id answers 404 not_found, exactly like an unknown id, whatever its status (no existence oracle). A fault while reading the routing answers 503 (retry); it never admits and never refuses.
/me carries limit_request_approver (boolean): whether the caller may open the approver inbox (tenant admin, billing purchaser, department approver, or a manager with direct reports). It is a hint for the navigation only: the routes re-check, and a fault while computing it degrades to the tenant admin / purchaser answer.
Rejected input (nothing is stored): target_kind absent 400 target_kind_required; unknown, wallet or non-string 400 target_kind_invalid; user_cap is accepted (see Personal limit requests); target_id absent or not a UUID 400; another department, another tenant's or unknown 404 not_found. amount_minor not a whole number from 1 to 100 000 000 (the cap ceiling in cents) 400 amount_invalid; duration absent or unknown 400; reason/note malformed or too long 400; a body that is not a JSON object 400. target_id and the {id} of approve / decline must be the id exactly as the API returned it: the same id in another letter case answers 404 not_found like an unknown id (a malformed body id stays 400). A caller without a department gets 409 no_department, a department without a cap 409 no_cap_to_raise, a period request on a department whose budget period is total 409 period_not_resetting, a second open request 409 request_open (checked before the rate limit, so it spends no slot), and more than 3 requests per user or 10 per workspace per hour 429 request_rate_limited. Approve answers 409 not_open for a decided request, 409 cap_ceiling when the cap plus active raises plus the approved amount would exceed the ceiling, and 400 approved_exceeds_request.
Personal limit requests
target_kind: "user_cap" asks for more of the caller's own personal limit. target_id must be the caller's own user id; any other id answers 404 not_found. The request is stored in the same table as department requests, and period rows keep the personal period key (for example u:2026-11) in period_type.
- Deciders. Tenant admins and holders of the budget management permission (
BUDGETS_MANAGE). Nobody decides their own request (403 self_approval). Every decider, tenant admins included, is bound by the rank rule of user budgets (PUT /users/:id/budget-cap): approving never grants more than editing the cap directly, so neither a peer tenant admin nor a finance user decides a tenant admin's request (403 user_budget_forbidden_rank). permanent. The person rung is set tobudget_mode = amountandbudget_usd = base in effect + raise.period. A read-time override on top of the winning cap until the personal period ends. A freeze (cap0) cancels it; an unreadable override fails closed.- Refusals.
cap_frozen,cap_changed(the cap changed since the request was made),user_gone,no_cap_to_raise,cap_unreadable. - Mail. The deciders are mailed on creation (tenant admins and
BUDGETS_MANAGEholders, whether through a system role such as Finance or a custom role; never the requester, and only those the rank rule lets decide) and the requester on the decision. Rows are listed withtarget_kindso the queue labels them "· personal".
What approval does.
permanent: in one transaction the approved amount is added to the department's storedbudget_usdand adepartment.updatedaudit row is written (same shape as a department update).period: no department row changes. The approved request is the raise. It counts from approval until the end of the current budget period (expires_at, unix seconds) and only while the department's budget period is still the one it was approved for (a monthly raise never leaks into a daily cap; an unset period reads as monthly). After that it is ignored, with no background job. Switching the period away and back (monthly → daily → monthly) within the same month makes the raise count again, because the rule is evaluated live against the current period. Several active raises add up, clamped to the ceiling.
The department read (list, get) carries budget_override_usd (the active raise, null for a department without a cap) and budget_override_expires_at (when the earliest active raise ends, or null). The effective cap enforced on requests is budget_usd + budget_override_usd, never above the cap ceiling (the sum is clamped, so raising the stored cap later cannot push it past the ceiling). The limit-request routes answer 401 for a missing or invalid session. Approvers mailed on creation are the holders of the billing purchase permission (the owner role, or a custom role that carries it) except the requester; a holder who is not a tenant admin and belongs to the requester's department is not mailed, because they could not decide it.
The approver inbox's department figures (GET /admin/v1/limit-requests only, never /mine: members see no department amounts). Each row adds department_name (string), spent_minor (the department's own spend in the current budget period, USD cents, the same counter the budget check reads), cap_minor (the effective cap above, USD cents) period_ends_at (unix seconds: when a period raise approved now would end; null for permanent or a period that never resets) and period_last_day (YYYY-MM-DD, the last calendar day that raise counts, in the server's calendar; null alongside period_ends_at). null has a meaning per field: cap_minor: null = the department has no cap; spent_minor: null = the spend could not be read (never reported as 0; the rest of the row still answers); all of them null = the department no longer exists. If the department list cannot be read the inbox answers 503. The client rejects a row whose figures are not a non-negative integer or null, whose department_name is not a string of at most 255 characters, or whose period_last_day is not a real YYYY-MM-DD day.
Data handling. The decider's decline reason is erased with the requester when a member is erased, like the note. The requester is mailed (English or German) when the request is decided; approvers are mailed on creation.
Tenant default department
A workspace can name one default department (default_department_id on the tenant, set with PATCH /admin/v1/tenants/{id} — see Tenants & gateways). The department a user is held to (the effective department) is computed at read time in one place:
- an explicit assignment (
PUT /users/{id}/departmentwith a department) wins; - a user an admin explicitly cleared (
department_id: null) is pinned to no department and stays unrestricted, even when a default exists; - any other user without an assignment follows the tenant default; with no default set they are unrestricted (as before).
Every enforcement point (the inference gate, token minting, the team budget cap and the "my limits" view) uses the effective department. The user's stored assignment (department_id in the user views) is not changed: a user who follows the default still reads department_id: null. A default that points at a department that no longer exists, or at another workspace's department, fails closed (no gateway is allowed) rather than open. Changing the gateways, default gateway or budget of the default department is limited to a tenant admin or platform admin (403 department_is_tenant_default for other callers), because it widens or narrows every unassigned user at once.
There is no API in this release to turn a pinned user back into one who follows the default (planned as a follow-up); assigning a department explicitly remains possible.
Audit of assignments. user.department_changed records the raw assignment (department_id), the effective department (effective_department_id) and the source (department_source) before and after. Clearing a user's department while a tenant default applies pins them to no department and is audited as user.department_pinned_none, because the user's effective department changes from the default to none even though the stored assignment was already empty.
SCIM reactivation. A user deprovisioned by SCIM keeps the stored department. When the same user is reactivated (SCIM active: true, or a re-provision), a department that was set by hand (source manual, or a legacy assignment without a source) is cleared and the assignment becomes identity-provider-owned (department_source idp), so the tenant default applies until the IdP sync assigns the IdP's current department. This holds regardless of the tenant's department precedence. A user whose department already comes from the IdP, or who never had one, is unchanged, and so is a user who was never deprovisioned. The scim.reactivate audit entry records the department before and after when it changed.
Project access. Access to a department's projects follows the stored assignment, not the tenant default: a user who only follows the default is held to the default department's gateways and budget, but does not become a member of its projects.
Four-eyes. Where config approval holds a tenant default change, applying it after approval writes only the generic approval audit event, not tenant.default_department_changed.
Identity-provider departments
A group_mapping of the SAML / OIDC SSO configuration (and the one SCIM uses) may map an IdP attribute value to a department ({ "kind": "department", "id": "…" }, see Tenants & gateways). At SAML and OIDC sign-in and on SCIM create / update the user's department is then derived from the attribute and stored with department_source = "idp".
At sign-in (SAML and OIDC). Only a well-formed attribute that matches exactly one department target changes anything:
| Attribute | Result |
|---|---|
| absent (not sent) | no change |
| malformed (not a list of strings) | no change, a warning is logged |
| present, maps to no department target | no change (sign-in only ever sets; see The mapping owns the department) |
| present, maps to exactly one department | sets it, subject to the precedence below |
| present, maps to more than one distinct department (ambiguous) | no change, a warning is logged |
At SAML and OIDC sign-in the department is applied by id after the group sync, best effort: a failure is logged and never blocks the login. Absence, malformed values, ambiguity and a value matching no department target all mean "no change": sign-in only ever sets.
The mapping owns the department. When the tenant's group_mapping holds any department target, the mapping is the only identity-provider source of the department: sign-in and SCIM both apply only a mapped target, and the SCIM name match of Department assignment is switched off. For SCIM a target is matched only when group_attribute is department and the SCIM department value maps to a department target; that department is assigned by id. Every other SCIM value (unmapped, mapped to a group, absent, null or removed) changes nothing, the same as at sign-in, so the two paths never overwrite each other. A stored mapping that cannot be read as an object leaves the department unchanged. Without any department target in the mapping, the SCIM name match (including its clear on an absent department) works as described there. At most one department write happens per request. A failed read of the stored mapping answers a retryable 503 and writes nothing. A malformed department is still 400 invalidValue.
Saving the mapping. A configuration save (SAML or OIDC) that carries department targets is checked before anything is written: a target id that is not a department of this tenant is 400, a failed lookup is a retryable 503, and adding, changing or removing department targets needs the DEPARTMENTS_MANAGE permission (403 without it). A rejected save changes nothing.
Precedence (department_source_precedence on the tenant). A set is applied when the user has no source or an idp source. A department an admin set (manual, including an admin clear that pins the user to no department) is overwritten only when the precedence is idp. A clear never touches a manual or unsourced department, whatever the precedence. The identity provider never writes manual; an admin assignment (single or bulk) always does.
A mapped department id that no longer exists (or belongs to another workspace) is treated like an unknown name: the user is left without an explicit department (department_id = null, source idp, so the tenant default applies, subject to the precedence above), the audit entry records unknown_department: true, and sign-in or provisioning is never blocked. A transient database fault answers a retryable 503 on SCIM and is retried at the next sign-in.
SAML department_attribute. While the mapping holds a department target (or the stored mapping cannot be read), the SAML department_attribute name sync is not applied at sign-in: the mapping owns the department. Without department targets the attribute works as described in Authentication.
Audit and cache. A change writes saml.user_department_changed / oidc.user_department_changed for SAML and OIDC sign-in (after.source = saml or oidc, with the client IP and path) and scim.user_department_changed for SCIM (after.source = scim), each with department_id and department_source before and after, in the same transaction as the change; the SCIM and SAML/OIDC paths share one writer. A change also drops the user's cached department policy. An unchanged result writes nothing.
Removing the mapping. Removing all department targets from the mapping leaves departments the IdP already set in place; clear them with Assigning a user.
Enforcement
| Where | What is checked | Refusal |
|---|---|---|
Every inference request (/v1, playground/chat tokens, Microsoft 365 / Office lanes, agent runs, scheduled tasks, workflows, chat bridge, delegated sub-agents) |
the project lock of the conversation's (or x-project-id) project, for every request; the department of each bound identity (the user / agent owner and the human invoker or bridge driver) |
403 department_gateway_not_allowed, 403 project_gateway_locked, 503 department_policy_unavailable (retryable) |
| Project data attached to a request (instructions, knowledge, files, project memories) | the lock on that project's own row | the turn is refused (403 project_gateway_locked); a file / knowledge tool returns a lock error to the model |
Conversation create / re-route (POST/PATCH /conversations) |
route selection only among allowed gateways; an explicit pick outside → specific code | 403 department_gateway_not_allowed / project_gateway_locked, 409 department_pii_twin_not_allowed, 409 department_no_gateway |
| Moving a conversation into / out of a locked project | moving in requires the lock gateway; moving out needs department administration or the project's owner (a failure to read the caller's project role → project_tier_unresolved) |
403 project_gateway_locked |
Deleting a locked project (DELETE /projects/{id}, and a held delete at four-eyes approval) |
department administration or the project's owner, re-decided against the current lock at approval time | 403 project_gateway_locked |
Continuing a shared conversation (POST /conversations with source_share_token) |
a continuation bound to a locked project runs on the lock gateway (the server substitutes it for the posted default); a non-member never continues a locked project's conversation; the caller's department must allow the gateway | 403 share_source_project_forbidden, 403 department_gateway_not_allowed, 403 gateway_outside_owner_tenant (any gateway outside the caller's own workspace — details) |
| Token minting (personal, user, user-bound gateway token, playground, connect, device approval) and agent / scheduled-task / workflow creation | the bound user's (owner's) department | 403 department_gateway_not_allowed |
| Summarize, chat file upload | department of the session user + the conversation's project lock (the same lock read as inference, including its last-known-good fallback) | as above |
| Playground web search | department of the session user (the search is not bound to a conversation) | 403 department_gateway_not_allowed |
| Conversation search and title embeddings (semantic search) | the embedding endpoint is taken only from a gateway the user's department allows; a locked project's conversation titles are never embedded; an unreadable department policy embeds nothing | — (search falls back to title matching) |
Gateway list (GET /tenants/{id}/gateways) |
each row carries routable for the session user (the chat picker hides false) |
— |
Gateway index (GET /gateways) |
each row carries routable for the session user (Profile, Connect-OX and shared-conversation Continue offer only true; the admin New token dialog for another user ignores it and offers only the gateways of that user's department view — their department is checked again at mint) |
— |
| Gateway delete | refused while a department allows / defaults to it or a project is locked to it | 409 gateway_in_use_by_department |
Department pool cap. The pool counts every member's spend under the department's period (daily, weekly, monthly or total). Changing budget_period does not reset anything: the pool is metered under all four period keys continuously, so the new period starts from what was already spent in it. Spend recorded before this release counts only under the period that was active when it was recorded; after a period change the new period's pool may start lower than the true historical spend. A member listed in pool_exempt_member_ids is not refused by the pool cap (admitted even when the pool is used up); their spend still counts toward the department's pool; their own limits still apply. The exemption is tied to the department it was granted in: if the member leaves and re-joins before the next save of the exemption set, they regain it; if they move to another department it no longer applies. Only members assigned directly can be exempted. The /me spend_limits.team entry carries exempt: true for an exempt member.
Fail-closed. A department that no longer exists, belongs to another tenant, or has an empty set denies every gateway; a user without a department is the only unrestricted case. A database error with no recent answer is 503. On a read failure the gateway serves the last successful department / lock answer it saw on that server within 5 minutes (accepted residual: a change from the last 5 minutes can be missed on one server during a database failure).
Out of scope. Gateway service tokens without a bound user and anonymous requests on an authentication-optional gateway carry no department. Myra-fleet features that use no gateway (TTS, STT, OCR, image analysis, HTML conversion, retrieval embeddings, the complexity classifier) are outside departments and locks.