Skip to content

Tenant entitlements API

A tenant entitlement answers "what is this customer entitled to, and why" for both acquisition paths. The self-serve path derives add-ons from a Stripe subscription's price ids; an enterprise tenant has no subscription at all, so this table is where a sales/admin operator records its plan and add-ons — and, crucially, the per-customer instance values the catalog cannot hold (a plan describes a product; params_json holds a contract: {"seats":1500}).

One row per (tenant, catalog_slug); each row is a plan or an addon and references a product catalog slug. Exactly one plan row per tenant is enforced. These rows feed the one resolver that computes a tenant's effective entitlement:

effective(tenant) = plan_config[plan] floor        (the tier default)
                  ∪ active add-on grants            (from the product catalog)
                  ∪ the tenant's own params_json    (per-customer override, highest precedence)

The invite path reads the resolved seat cap from this resolver. A tenant with no entitlement row behaves exactly as before (an enterprise tenant stays uncapped; a self-serve tenant keeps its plan_config cap) — there is no behaviour change until a row is written.

Functional grants (grants)

Besides seats/budget, each row's catalog product may carry functional grants (grants_json, e.g. {"myra_hosted_models": true}). The resolver folds the grants of every non-expired row, regardless of source — the read endpoint returns them under effective.grants. Grants fail closed: an unknown slug, a corrupt grant blob, or a malformed expires_at contributes nothing (never a permissive default); an absent row and a malformed row both deny.

The EU-Gov add-on (eu_gov) is the first such grant: it grants myra_hosted_models, which lets a self-serve tenant use Myra-hosted (non-commercial, provider-class myra) models. Enforcement is server-side in the model-routing allowlist path (a Myra-hosted model is refused for a self-serve tenant without the grant — eu_gov_model_not_allowed, fail closed; commercial providers are always allowed). Granting/revoking the eu_gov row re-resolves the tenant's gateways immediately (no cache-TTL wait). The eu_gov grant here is the manual (enterprise/admin) acquisition path; the self-serve checkout line for EU-Gov and its source='stripe' grant write are a later ticket.

Trial-plan exemption. The 7-day free trial (self_serve_trial) is exempt from this gate: its plan (plan_config.models_json) entitles two Myra-hosted EU models (qwen3.8-27b, gemma-4-31b-it) by design, so a trial tenant may use them without the eu_gov add-on. The exemption is keyed on the exact server-stamped plan value and is bounded by the trial's own plan model list (it cannot reach models outside its entitlement); a paid self-serve tenant (self_serve_custom) without the add-on is still denied Myra-hosted providers on every path (offer, send, auto-route, degrade, cross-vendor failover, save-time pins). The exemption is not a grant: the trial does not "hold" eu_gov.

The paid Custom plan and EU-Gov (owner decision 2026-09-27). The Custom plan's model list is claude-haiku-4-5, claude-sonnet-5 (commercial) plus the Myra-hosted fleet qwen3.8-27b, gemma-4-31b-it, mistral-small-4 (migration 0327). The plan list is the union; the EU-Gov grant decides the Myra half — without it a Custom workspace sees and uses only the commercial models. EU-Gov is bought per seat, but all or none per workspace (every seat is an EU-Gov seat, or none is), so the grant is workspace-wide by construction. A trial that converts to Custom without EU-Gov loses its Myra models.

Stored pins that lose access. A model saved on an agent, a scheduled prompt task or as the tenant's Copilot document-AI model that the plan or the EU-Gov gate later denies (trial conversion, a revoked or lapsed grant, a plan change) no longer fails every run: the run uses the pinned gateway's Auto choice instead (only models the plan and grant permit; local-only when the project or agent area is local-only — no permitted model there → the run is refused as before, never moved to a cloud provider). Each substitution is audited (model_pin_substituted, at most one row per object per day) and recorded on the request row as meta.aig_substituted_from. An explicit per-request model (chat, /v1, the agent preview) is not substituted — it keeps its 403. New saves of a denied model are refused: agents, projects (every change of default_model — a model-only pin, the shape the app saves, is judged against the workspace's plan and grant; a gateway + model pin additionally against that gateway) and scheduled tasks answer 400 with code: plan_model_not_allowed (not in the plan list) or eu_gov_model_not_allowed (Myra-hosted without the grant), judged on the retired-id successor the runs would carry. A project's pinned gateway + model that is denied starts new conversations on that gateway's Auto choice.

Platform infrastructure is exempt. The rule covers the models a customer chooses (chat and completions, agents, scheduled tasks, projects, conversations, /compact). Platform services that run on Myra-hosted models for every workspace are exempt (owner decision 2026-09-27): OCR and document extraction, image analysis (vision), embeddings (bge-m3) and reranking for knowledge search, the prompt-injection guard and the Llama Guard content-safety guardrail, text-to-speech normalisation and speech in/out, image generation and editing, and the platform default inner model of the agentic web fetch. A gateway's own agentic_fetch.model override is not exempt: on a self-serve workspace it must be in the plan (and Myra-hosted only with EU-Gov) — a gateway PATCH setting a non-permitted override is refused (400, same codes), and a stored non-permitted override runs on the platform default.

The SPA /me payload carries myra_hosted_models as the effective may-use-Myra decision (the raw add-on grant OR the trial-plan exemption), for display only — the picker hides Myra-hosted rows a send would refuse, and shows the trial's EU models — the client is never the authority. This is not "add-on purchased": a consumer that needs the raw grant reads it from effective.grants (below), not from /me.

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

Every endpoint requires the platform admin role (permission PLANS_MANAGE); a lower role is 403, an unauthenticated caller 401. Access is re-gated server-side — the client is never the authorization boundary. Every write is audited (audit_log, entity tenant_entitlement).

::: info SPA seat editor The tenant Edit pop-up's Seats & Budget section is the SPA's writer for an enterprise tenant's seat cap: it GETs this endpoint, merges the new seats into the existing plan row's params_json (preserving a budget_usd override), and PUTs it back. Because the endpoint is PLANS_MANAGE-gated (gate_class="platform"), a tenant admin — who sees the section read-only — cannot write it: a tenant-admin PUT/GET on their own tenant is refused 403 before the body is read, fail-closed (contract proof: agf2717-entitlements-authz.spec.ts). The read-only rendering in the SPA is UX only, not the boundary. :::

::: info Scope: the manual path only This API writes source='manual' rows. stripe_price_id is always NULL for a manual row, and a body that carries one is rejected. The source='stripe' path (a webhook that persists a subscription's price ids) is a later ticket and requires widening the row's unique key to include source first (today it is (tenant_id, catalog_slug), so a stripe upsert would clobber a manual grant); these endpoints never write a stripe row and refuse to revoke one. :::

Read a tenant's entitlements

GET /admin/v1/tenants/{tenant_id}/entitlements

Returns the tenant's rows plus the resolved effective entitlement:

{
  "plan": "enterprise",
  "entitlements": [
    {
      "id": "…-uuid",
      "tenant_id": "t_123",
      "catalog_slug": "enterprise",
      "kind": "plan",
      "params": { "seats": 1500 },
      "source": "manual",
      "stripe_price_id": null,
      "granted_at": 1757800000,
      "expires_at": null,
      "granted_by": "u_admin"
    }
  ],
  "effective": { "seats": 1500, "grants": { "myra_hosted_models": true } }
}

effective.grants is the folded functional-grant set (see Functional grants); it is {} when the tenant holds no grant-bearing add-on.

  • 404 if the tenant does not exist.
  • 503 if the entitlement rows cannot be read (retry; never a partial answer).

Create or update an entitlement

PUT /admin/v1/tenants/{tenant_id}/entitlements

Upserts one source='manual' row, keyed by (tenant_id, catalog_slug). Body:

{
  "catalog_slug": "enterprise",
  "kind": "plan",
  "params_json": { "seats": 1500, "budget_usd": 5000 },
  "expires_at": 1893456000
}

Accepted shape

Field Required Rules
catalog_slug yes Non-empty string. Must exist in the product catalog and its catalog kind must match kind.
kind yes "plan" or "addon".
params_json yes A JSON object whose only keys are the allowlist below. {} is valid (a plan assignment with no instance value).
params_json.seats no A JSON number, integer, 1..100000.
params_json.budget_usd no A JSON number >= 0 (finite).
expires_at no Integer unix seconds in the future, or null (open-ended).

Server-set, never taken from the body: source="manual", stripe_price_id=NULL, granted_by (the calling admin), granted_at (now), id (preserved on update, minted on create).

What is rejected (fail closed — nothing is persisted)

Response Cause
400 catalog_slug / kind missing or wrong type; kind not plan/addon.
400 Unknown catalog_slug.
400 kind does not match the catalog kind of that slug (e.g. an addon slug sent as a plan).
400 params_json absent, a JSON array, or a non-object scalar.
400 An unknown params_json key (only seats, budget_usd are allowed).
400 seats not a number, non-integer, or outside 1..100000; budget_usd not a number, negative, or +inf.
400 stripe_price_id present in the body (a manual row never carries one).
400 expires_at non-integer or not in the future.
409 A plan PUT whose slug differs from the tenant's existing plan row (exactly one plan row per tenant).
409 The write would drop the tenant's effective seat cap below its current active-user count (seats_below_active_users).
404 The tenant does not exist.
503 The product catalog is cold/unavailable (an unknown-slug verdict cannot be trusted, so the write is asked to retry rather than falsely rejected as 400).
503 The active-user count cannot be read (fail closed — never write when the count is unknown).

A present but wrong value is rejected at the write boundary, never coerced — an absent field and a malformed field are different answers there. At the read boundary a tampered stored blob is re-validated, but the degrade differs by plan: for a self-serve tenant a malformed params_json falls back to the finite plan floor (safe), whereas for an enterprise/manual tenant whose only seat cap is a params_json.seats override, a malformed blob currently degrades to uncapped (floor = nil) — on that path the absent and malformed cases share the permissive outcome. It is bounded (it needs a DB tamper past the migration-0291 CHECK, and a seat cap is a headcount limit, not an authz boundary) and is being fixed to fail closed to the finite floor.

On success: 200 { "ok": true, "id": "…" }.

Seats can never drop below active users

Seats govern the tenant's headcount ceiling, so a write may never leave the tenant billed for — and capped at — fewer seats than it has active users (users may be fewer than seats, never the reverse). The server computes the effective seat cap that would result from this write (through the very same resolver the invite path enforces) and, when that cap would fall below the tenant's active-user count, refuses with 409 seats_below_active_users and persists nothing. This is a decrease control, not an absolute per-write floor: a tenant that is already over-seated (e.g. users added past the cap on a provisioning path) may still raise seats back toward its headcount or change an unrelated add-on — only a genuine lowering across the line is refused. The active-user count is resolved server-side; the client is never the authority. If the count cannot be read, the write fails closed (503) rather than proceed on an unknown headcount.

The accepted seats upper bound here is 100000 — the platform (admin) ceiling for a manually provisioned contract. The self-serve price calculator advertises a smaller cap (its own self-serve boundary); that lower figure is a deliberate subset of this authoritative range, not a second source of truth.

Revoke an entitlement

DELETE /admin/v1/tenants/{tenant_id}/entitlements/{catalog_slug}

Removes a source='manual' row.

  • 404 if no such row (or the tenant does not exist).
  • 409 if the row is source='stripe' (owned by the subscription, not the admin) — checked first.
  • 409 seats_below_active_users if revoking the row would drop the tenant's effective seat cap below its active-user count (e.g. deleting a seat-raising override the current headcount depends on). Deactivate users first, or raise seats on the surviving rows. Revoking an add-on that never carried seats is unaffected.
  • 503 if the tenant or the active-user count cannot be read (fail closed — never revoke on an unknown state).

On success: 200 { "ok": true }.

Plan-value validation on the tenant record

Writing tenant.plan (via POST/PATCH /admin/v1/tenants) is validated against the product-catalog plan slug set (the plan_value of each plan row, or the slug for a plan with no plan_value). The check is change-gated — an unrelated PATCH, or re-saving the current plan, is never rejected, so existing tenants stay editable. A present unknown plan value is 400; a cold catalog is 503 (retry), never a false 400.