Skip to main content

ControlPlane API & Projector

The ControlPlane API and Projector together form the UI Layer of the Control Plane — an external, read-only access layer that makes the platform state available to clients outside of Kubernetes. They implement a CQRS (Command Query Responsibility Segregation) pattern: the Projector handles the write side of the read model by syncing Kubernetes resources into PostgreSQL, while the ControlPlane API handles the read side by serving that data through a GraphQL interface.

Architecture​

ControlPlane API Container Diagram

The diagram above shows the system from a container perspective. The flow below details how data moves through the pipeline:

Why this pattern?

Kubernetes custom resources are the source of truth for the Control Plane. However, the Kubernetes API is not designed for the kind of queries a UI needs — filtered lists, paginated results, cross-resource joins, and team-scoped access. By projecting the relevant state into a relational database, the ControlPlane API can serve these queries efficiently without putting load on the Kubernetes API server.

The Projector​

The Projector is a Kubernetes controller built on controller-runtime. Unlike the domain operators, it is strictly read-only — it watches custom resources and writes their state to PostgreSQL, but never modifies anything in the cluster.

Watched Resources​

The Projector watches resources from the following domains:

DomainResourcesDescription
AdminZoneDeployment targets with gateway and IDP configuration
OrganizationGroup, TeamOrganizational structure and team membership
ApplicationApplicationApplications with their identity and zone assignments
APIApiExposure, ApiSubscriptionExposed APIs and their subscriptions
ApprovalApproval, ApprovalRequestApproval workflows and pending requests
AgenticMcpServer, AgentCard, McpExposure, McpSubscriptionMCP servers and A2A agent cards, their exposures, and subscriptions

Processing Pipeline​

Every watched resource follows the same processing pipeline, implemented using Go generics:

  1. Watch Event — Kubernetes notifies the Projector of a create, update, or delete
  2. SyncEventHandler — On delete events, the handler caches the last-known object so the Projector can derive its identity even after the resource is gone
  3. ReadOnlyReconciler — Fetches the live resource from the cluster (or retrieves it from the delete cache)
  4. Translate — Maps the Kubernetes resource to a domain-specific data transfer object (DTO). Resources that are incomplete or irrelevant are skipped
  5. Upsert / Delete — Persists or removes the record in PostgreSQL, resolving foreign-key references to parent resources

Dependency Resolution​

Resources in the database form a hierarchy — an Application references a Team and a Zone, an ApiExposure references an Application, and so on. When the Projector processes a resource whose parent has not been synced yet, it returns a dependency missing error and retries after a short delay (configurable via DEPENDENCY_DELAY).

Subscriptions (API, Event, Agentic and File) treat their target exposure differently: if the target cannot be resolved, the subscription is still stored without a target link, and the Projector then reports a dependency missing error so the subscription is retried until the target is linked. If the target stays absent or inactive for a long time, the subscription keeps retrying with that same delay.

To avoid repeated database lookups for foreign keys, the Projector uses a high-performance in-memory cache (based on Ristretto) combined with request deduplication. This means concurrent reconciliations for resources with the same parent only trigger a single database query.

Schema Migration​

The Projector manages the database schema automatically. On startup, it runs a migration that creates or updates all tables, indexes, and constraints. This means you do not need to run manual migration scripts — just deploy the new version and the schema adapts.

The ControlPlane API​

The ControlPlane API is a read-only GraphQL server that serves the data projected into PostgreSQL. It is built with gqlgen and uses the ent ORM for type-safe database access.

Data Model​

The API exposes the following entities and their relationships:

Each entity carries common fields such as id, createdAt, lastModifiedAt, statusPhase (Ready, Pending, Error, or Unknown), statusMessage, environment, and namespace.

API, event, and agentic subscriptions can exist without a target exposure. In that case, their GraphQL target field returns null without an error.

File subscription and exposure details are returned through the reduced FileSubscriptionInfo and FileExposureInfo views. Their serviceUrl and serviceExternalUrl fields are nullable: GraphQL returns null when the corresponding URL is unset on the file subscription or exposure.

GraphQL Queries​

All top-level queries support cursor-based pagination (Relay-style), filtering, and ordering:

  • teams — List teams with their applications and group membership
  • applications — List applications with their zone, exposures, and subscriptions
  • apiExposures — List exposed APIs with visibility, upstreams, and approval configuration
  • apiSubscriptions — List subscriptions with their target exposure and approval state
  • apis / eventTypes — List registered APIs and event types. Other teams can register the same base path or event type. Only the oldest entry is active; the others are inactive. Filter on active: true to get one entry per base path or event type.
  • mcpServers / agentCards — List registered MCP servers and A2A agent cards in the catalogue (visible to all authenticated callers, like a shared catalogue). name is the resource name derived from the base path (for example mcp-weather-v1). As for apis, only the oldest entry for a base path is active. Filter on active: true to get one entry per base path. displayName is the title from the specification (for example Weather Server), or the resource name if the specification has no title. hash changes when the specification content changes, so clients can detect changes without downloading specificationUrl.
  • agenticExposures / agenticSubscriptions — List agentic exposures (MCP/A2A/Telecontext variants) and their subscriptions with approval state
  • approvals / approvalRequests — List approval workflows and pending requests
  • zones — List all available zones (no pagination, visible to all users)
  • node(id) / nodes(ids) — Relay-compatible node lookups for any entity

Subscription Scopes​

ApiSubscription, EventSubscription, and AgenticSubscription expose two separate scope fields:

FieldMeaning
requestedScopesLatest scopes requested in the subscription spec.
activeScopesLast approved scope set successfully written to downstream Kubernetes resources, projected from status.activeScopes.

For API and agentic subscriptions, the requested scopes come from spec.security.m2m.scopes; for event subscriptions, they come from spec.scopes. Active scopes advance after the controller successfully writes the ConsumeRoute resources or event Subscriber. They do not imply downstream gateway or delivery readiness. API subscriptions with failover advance the set only after all required ConsumeRoute writes succeed.

Pending or rejected scope-change requests preserve the previous active set. Successful cleanup following rejection or suspension of the Approval clears it. An approved removal of all scopes also clears the active set. Ordinary reconciliation populates the status and projection fields.

EventSubscription.scopes and SubscriberMachine2MachineAuthentication.scopes (used by API and agentic subscriptions) are deprecated. They continue to return requested scopes for compatibility. Clients should use the new top-level fields. Approval.requestedScopes remains the snapshot associated with that approval.

{
apiSubscriptions {
edges {
node {
requestedScopes
activeScopes
approval { state requestedScopes }
}
}
}
}

Team Isolation​

The ControlPlane API enforces strict team-level isolation through a privacy layer built into the ORM. Every database query is automatically filtered based on the authenticated caller's identity:

Caller TypeWhat They Can See
TeamOnly resources belonging to their own team
GroupResources belonging to any team within their group
AdminAll resources, unrestricted

This filtering is applied transparently — the GraphQL schema is the same for all callers, but the results are scoped automatically. No additional configuration is needed.

For approvals and approval requests, the isolation uses an OR rule: a resource is visible if the caller's team is either the subscriber's team or the exposure owner's team. This allows both sides of an approval to see the workflow.

Cross-Tenant Safety​

When navigating relationships that cross team boundaries (for example, from a subscription to the target exposure owned by another team), the API returns reduced types (TeamInfo, ApplicationInfo, ApiExposureInfo, ApiSubscriptionInfo, EventExposureInfo, EventSubscriptionInfo, AgenticExposureInfo, AgenticSubscriptionInfo) that expose only safe, non-sensitive fields. This prevents data leakage through graph traversal.

Approval.subscription and ApprovalRequest.subscription return the non-null SubscriptionInfo interface, implemented by ApiSubscriptionInfo, EventSubscriptionInfo, and AgenticSubscriptionInfo. Select the common fields id: ID! and ownerApplication: ApplicationInfo! directly or through a fragment on SubscriptionInfo. Use concrete fragments for variant-specific fields:

query {
approvals {
edges {
node {
subscription {
id
ownerApplication { id name }
... on ApiSubscriptionInfo { basePath }
... on EventSubscriptionInfo { eventType }
... on AgenticSubscriptionInfo { basePath }
}
}
}
}
}
Deprecation

On the six *Info types, ownerApplicationName and ownerTeam are deprecated in favour of the nested ownerApplication field. Use ownerApplication.name and ownerApplication.ownerTeam instead. The deprecated fields are still populated today but will be removed in a future release.

Authentication​

The ControlPlane API supports two security modes, configured via security.mode:

ModeJWT requiredSignature validatedDefault viewerUse case
jwtYesYesFrom JWT claimsProduction
mockYesNoFrom JWT claimsLocal development / integration testing

The default mode is jwt (secure by default). A deployment in jwt mode without any configured trustedIssuers will panic at startup — this is intentional fail-closed behaviour.

Both modes require a JWT bearer token in the Authorization header. The token's issuer must match one of the configured trustedIssuers (only enforced in jwt mode). The API extracts the caller's identity (team, group, or admin role) from the JWT claims and uses it for team isolation.

See the Admin Journey: ControlPlane API page for configuration examples and Keycloak integration guidance.

Domain Interactions​

  • All domain operators — The Projector reads custom resources managed by Admin, Organization, Application, API, Approval, and Agentic operators. It depends on these operators to create and reconcile the source resources.
  • Identity domain — The ControlPlane API relies on JWT tokens issued by the identity provider configured in the platform. The token claims determine the caller's team and access scope.