Draft: Move the Orbit usage-quota check into Rails
What does this MR do and why?
Moves the Orbit (GitLab Knowledge Graph) usage-quota check out of the Rust orbit-server service and into GitLab Rails, ahead of the Orbit query endpoints.
Why: on self-managed, orbit-server runs on customer infrastructure and cannot hold CustomersDot admin credentials. CustomersDot authenticates self-managed callers by license checksum (X-License-Token). Rails already has that path via Gitlab::SubscriptionPortal::Client. orbit-server does not.
Key design decision
The Rails gate deliberately reproduces orbit-server's existing decision table instead of improving on it, so this MR is behaviour-neutral. Any change to what is actually enforced stays a separate, deliberate step. The gate:
- Denies only on a CustomersDot 402.
- Fails open on 403, on 422, on a missing namespace, on a missing user, and on transport errors.
- Skips the check entirely on SaaS when there is no namespace to bill, matching what
orbit-serverdoes whenroot_namespace_idis absent. - Sends
event_type: orbit_workflow_completionandfeature_qualified_name: orbit_<source_type>, matching what CustomersDot already receives fromorbit-server.
Note for reviewers: orbit-server fails open on a CustomersDot 403, whereas the tracking issue's plan said to block on it. This MR follows orbit-server so the move stays behaviour-neutral. Whether a 403 should deny is tracked as follow-up, together with 422 handling and the CustomersDot fail-close policy on self-managed.
Call sites gated
These are the only Rails paths that reach orbit-server's execute_query RPC, which is the only RPC orbit-server meters:
POST /orbit/queryandPOST /orbit/query/:name— always.POST /orbit/agent/commands/:nameand the MCPtools/callhandler — only for thequery_graphcommand, because the same helpers also serve schema and catalog commands thatorbit-servernever metered. The predicate is the one the adjacentorbit_queryrate limiter already uses.
Error contract on denial
Matches exactly what Workhorse already writes when orbit-server denies, so clients see one contract either way:
- REST: HTTP 402 with body
{"code":"quota_exhausted","message":"GitLab credits exhausted","reason":"GITLAB_CREDITS_EXHAUSTED"} - MCP: HTTP 200 with a JSON-RPC result
{"content":[{"type":"text","text":"GitLab credits exhausted"}],"isError":true}
Also included
- An
http_timeoutoption onAi::UsageQuotaService, threaded toverify_usage_quota. Orbit passes 1 second. Without it the check inheritsGitlab::HTTPdefaults of 10s open and 20s read with no circuit breaker. When no timeout is given, the portal call's argument list is unchanged, so Duo Chat, Duo Workflows, and DAP agent-status checks are untouched. - Self-managed instances without an online cloud license skip the CustomersDot call, since CustomersDot rejects them with a 401 and the request only buys an error log per query.
Feature flag
orbit_quota_check_in_rails, type beta, disabled by default.
Not in this MR
Removing the gate call in orbit-server, and any CustomersDot changes, are tracked separately. orbit-server's quota client itself is deliberately being kept, because the indexer and planned GB/volume billing have no per-request Rails path to hang a check on.
Files changed (3 commits)
- "Allow an explicit timeout on the CustomersDot quota call" —
ee/app/services/ai/usage_quota_service.rb,ee/lib/gitlab/subscription_portal/clients/rest.rb, plus spec. - "Add an Orbit quota gate that mirrors orbit-server" — new
ee/lib/analytics/knowledge_graph/quota_gate.rb, new feature flag YAML, plus spec. - "Enforce the Orbit quota at the query entry points" —
ee/lib/api/orbit/data.rb,ee/lib/api/orbit/mcp.rb, plus request specs.
Test results
ee/spec/lib/analytics/knowledge_graph/quota_gate_spec.rb: 13 examples, 0 failures.ee/spec/services/ai/usage_quota_service_spec.rb: 36 examples, 0 failures.ee/spec/requests/api/orbit/data_spec.rbandee/spec/requests/api/orbit/mcp_spec.rb: 297 examples, 0 failures, 26 pending (the pending ones are pre-existing skips in a shared granular-token example that does not apply to user-boundary routes).
References
- Tracking issue: https://gitlab.com/gitlab-org/gitlab/-/work_items/630306
- Design doc MR: https://gitlab.com/gitlab-org/architecture/usage-billing/design-doc/-/merge_requests/37
- Original orbit-server quota-gate MR: gitlab-org/orbit/knowledge-graph!1304 (merged)
Screenshots or screen recordings
No UI change. This MR is backend and API only.
How to set up and validate locally
- The flag is off by default. Enable it in the Rails console:
Feature.enable(:orbit_quota_check_in_rails). - Stub or point CustomersDot so
/api/v1/consumers/resolvereturns 402. - Run a query against
POST /api/v4/orbit/querywith a valid token and confirm the 402 body:{"code":"quota_exhausted","message":"GitLab credits exhausted","reason":"GITLAB_CREDITS_EXHAUSTED"}. - Confirm the same denial through MCP
tools/callwithinvoke_command/query_graphreturns the JSON-RPCisErrorenvelope with HTTP 200. - Confirm a
tools/listcall is not gated. - Disable the flag and confirm the endpoints behave exactly as before.
MR acceptance checklist
This checklist encourages us to confirm any changes have been analyzed to reduce risks in quality, performance, reliability, security, and maintainability.