> ## Documentation Index
> Fetch the complete documentation index at: https://docs.popsink.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Control plane and data plane

> What each plane owns, and exactly which metadata crosses between them.

Every Popsink installation is two halves. The **data plane** is the runtime that
moves your data; the **control plane** is the fleet manager that knows your
deployments, users and entitlements. In SaaS both are operated by Popsink. In
self-hosted, BYOC and on-prem, you run the data plane and it attaches to a
control plane — ours at `control-plane.popsink.com`, or your own.

The interesting question for a security review is not the diagram, it is the
wire: **what actually crosses between them**. This page answers it field by
field.

## Who owns what

<CardGroup cols={2}>
  <Card title="Control plane" icon="sliders">
    Organizations, users, environment memberships, deployment registry,
    licensing, usage and billing. It is the **source of truth for identity** —
    accounts and roles are defined here, never in the data plane.
  </Card>

  <Card title="Data plane" icon="server">
    Pipelines, connectors, datamodels, subscriptions, the broker, the schema
    registry and every connector worker pod. It is the **only place your
    records exist** — the control plane never sees them.
  </Card>
</CardGroup>

## Direction of travel

<Note>
  **The data plane always initiates. The control plane never calls in.**
</Note>

There is no webhook, callback or polling API that the control plane invokes on a
data plane. Everything the control plane wants to say travels back as the
*response* to a call the data plane made. This is what makes the network
requirement one-directional:

| Direction                   | Required                                                    |
| --------------------------- | ----------------------------------------------------------- |
| Data plane → control plane  | **Yes** — outbound HTTPS (TCP 443) to `controlPlaneUrl`     |
| Control plane → data plane  | **No** — no inbound rule, no public endpoint, no VPN needed |
| User's browser → data plane | Yes — your own ingress, on `ingressUrl`                     |

The only apparent exception is the browser: the control plane issues HTTP 302
redirects that send *the user's browser* to your `ingressUrl` (login return,
connector OAuth callbacks). That is a redirect the browser follows, not a
connection the control plane opens.

## The channels

Five distinct exchanges, all authenticated with the deployment JWT:

| Exchange                                      | Method & path                                                      | When                                   |
| --------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------- |
| [Heartbeat](#the-heartbeat)                   | `POST /deployment/{deploymentId}/heartbeat`                        | every **10 min**                       |
| [Config sync](#config-sync)                   | `GET /deployment/{deploymentId}/config`                            | on drift, on SSO login, on healthcheck |
| [Usage metrics](#usage-metrics)               | `POST /deployment/{deploymentId}/worker-metrics`                   | every **30 s**                         |
| [Identity](#identity-and-login)               | `/auth/jwt/verify`, `/auth/api-keys/verify`, `/deployment-users/*` | per login / per user change            |
| [Connector OAuth](#connector-oauth-brokering) | `POST /oauth/{provider}/{start,exchange,refresh}`                  | per OAuth grant / token refresh        |

Only one data-plane replica performs the periodic ones — a PostgreSQL advisory
lock elects the sender, so scaling `replicaCount` does not multiply heartbeats.

### Authentication

The `deploymentJwtToken` from the onboarding wizard is sent verbatim as
`Authorization: Bearer <jwt>` on every call above. It is an HS256 token whose
claims are just the deployment's identity:

```json theme={null}
{
  "sub": "<deploymentId>",
  "aud": "popsink:deployment",
  "deployment_id": "<deploymentId>",
  "iat": 1760000000
}
```

<Warning>
  The deployment JWT **has no `exp` claim and is never rotated automatically** —
  it represents long-lived infrastructure. Rotating it means updating the
  Kubernetes secret and restarting the data-plane pods. Treat it as a credential
  of the same weight as a database password.
</Warning>

The control plane resolves the deployment from the token and rejects the request
with `403` if the `deploymentId` in the path does not match — a stolen token
cannot be pointed at someone else's deployment.

## The heartbeat

The heartbeat is the main channel. It is a status report *and* the trigger for
config reconciliation, in a single round trip every ten minutes.

### What the data plane sends

| Field                 | Content                                                                                                                                |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `status`              | `"live"` — the data plane is up and serving                                                                                            |
| `config_hash`         | SHA-256 of the locally rebuilt environment/member config                                                                               |
| `timestamp`           | ISO-8601 UTC                                                                                                                           |
| `data_plane_version`  | The running version (`Chart.AppVersion`), so the control plane can flag stale installs                                                 |
| `ingress_url`         | Your public URL. The control plane stores it (overwriting what the wizard recorded) and uses it to build browser redirects back to you |
| `user_versions`       | Map of `user_id` → integer version. **IDs and integers only — no emails**                                                              |
| `supported_env_roles` | Roles this data plane understands, e.g. `["admin", "user", "reader"]`                                                                  |
| `worker_images`       | Worker image drift report — see below                                                                                                  |
| `entity_snapshot`     | Inventory of pipelines — see below                                                                                                     |

`worker_images` reports which connector images your workers actually run, so the
control plane can tell you a worker is behind the chart:

```json theme={null}
{
  "total_workers": 33,
  "distinct_tags": 5,
  "oldest_tag": "2026.07.16.1",
  "oldest_tag_age_days": 41,
  "superseded_workers": 8,
  "images": [
    { "image": "popsink-connect-ibmi-source:2026.07.16.1",
      "workers": 7, "declared": "popsink-connect-ibmi-source:2026.08.24",
      "age_days": 41 }
  ]
}
```

If the data plane cannot inventory its workers, the field is **omitted** rather
than sent empty. This holds for `worker_images`, `data_plane_version` and
`supported_env_roles` alike: an omitting heartbeat never clears what the control
plane already stored, so a transient read failure cannot look like a regression.

<Note>
  `supported_env_roles` is a capability declaration. A deployment that has never
  reported it is assumed to understand only `admin` and `user` — which is why
  assigning the `reader` role to someone on an older data plane silently grants
  write access instead. Upgrade the data plane before relying on `reader`.
</Note>

`entity_snapshot` is a flat inventory of your pipelines, stored against the
deployment and used by the Snowflake marketplace integration. Per object it
carries identifiers, the user-chosen name and a state — and nothing else:

| Object        | Fields                                                                                                                        |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Connectors    | `id`, `name`, `connector_type`, `way`, `worker_state`, `team_id`, `team_name`, `env_id`, `env_name`                           |
| Datamodels    | `id`, `name`, `source_topic`, `enabled`, `status`, `source_connector_id`, `team_id`, `team_name`, `env_id`, `env_name`        |
| Subscriptions | `id`, `name`, `datamodel_id`, `datamodel_name`, `target_connector_id`, `target_connector_name`, `enabled`, `status`, team/env |

Note what is *absent*: no connector configuration, no host, no credentials, no
schema, no column list, no transform code.

### What the control plane answers

| Field                  | Meaning                                                                    |
| ---------------------- | -------------------------------------------------------------------------- |
| `received`             | Acknowledgement                                                            |
| `config_hash`          | The control plane's own hash of the same config                            |
| `config_sync_required` | `true` when the two hashes differ — the data plane then fetches the config |
| `user_sync_info`       | `users_to_update[]` (with emails and flags) and `users_to_delete[]`        |
| `organization_name`    | Display name, shown in the data-plane UI                                   |
| `deployment_name`      | Display name, shown in the data-plane UI                                   |
| `license_active`       | Whether the organization holds an active license                           |
| `license_expires_at`   | Absolute expiry, for display and grace-period reasoning                    |

### Licensing

The control plane computes the verdict; **the data plane enforces it.** The
control plane never blocks anything itself.

The verdict is cached in the data-plane database so every replica shares it, and
it defaults to `false` — an older or misbehaving control plane can never *imply*
"licensed". Enforcement is deliberately forgiving:

| Situation                          | Data-plane behaviour                           |
| ---------------------------------- | ---------------------------------------------- |
| `license_active: true`             | Never blocks                                   |
| No verdict cached yet (cold start) | Never blocks — fails **open**                  |
| Expired                            | Blocks only once past a **7-day grace period** |
| Affirmatively unlicensed           | Blocks                                         |

When it blocks, the data plane answers `403` with `{"code": "license_inactive"}`.
The grace period is `LICENSE_GRACE_PERIOD_SECONDS`, and enforcement ships dark
unless `LICENSE_ENFORCEMENT_ENABLED` is on.

<Note>
  The practical reading for an operator: a control plane you cannot reach does not
  take your pipelines down. Heartbeats fail, the deployment shows `offline`, config
  stops reconciling and **new SSO logins fail** — but data keeps moving, and the
  cached license verdict holds for a week.
</Note>

## Deployment status

The control plane derives your deployment's status entirely from heartbeat
arrival. There is no probe.

| Status     | Badge    | Meaning                                                                               |
| ---------- | -------- | ------------------------------------------------------------------------------------- |
| `building` | Building | SaaS only — the control plane is provisioning. Flips to `live` on the first heartbeat |
| `live`     | Live     | A heartbeat arrived within the staleness window                                       |
| `offline`  | Offline  | No heartbeat yet, or none recently                                                    |

A **self-hosted deployment starts `offline`**: the control plane provisions
nothing and can only wait for you to run `helm install`. That is why the
self-hosted wizard sits on *Awaiting connections…* until the first heartbeat
lands, then flips the deployment to **Live**.

<Note>
  Heartbeats go out every **10 minutes**. The control plane sweeps every
  **5 minutes** and marks a deployment `offline` when the last heartbeat is older
  than **13 minutes** — so a single missed heartbeat is tolerated, two are not.
</Note>

The practical consequence for upgrades: after `helm upgrade`, allow up to a
minute for the status to return to **Live**, and expect a brief `offline` window
if a rollout takes longer than 13 minutes.

Because the heartbeat reports `data_plane_version`, the control plane can compare
what you run against the chart version it publishes as supported — which is why
the install wizard never hardcodes a chart version and you should not pin one
either. See [Upgrading a fleet](/deployment/architecture/topology#upgrading-a-fleet).

## Config sync

Environments, members and users flow **control plane → data plane**, never the
other way. The control plane owns them; the data plane holds a replica.

Reconciliation is hash-based rather than push-based:

<Steps>
  <Step title="Both sides hash the same structure">
    The data plane rebuilds its local environment/member config and hashes it.
    The control plane hashes its own. The two builders are deliberately
    identical — same field names, same role vocabulary — so the hashes converge.
  </Step>

  <Step title="The heartbeat compares them">
    The data plane sends its `config_hash`; the response carries the control
    plane's and `config_sync_required`.
  </Step>

  <Step title="On drift, the data plane pulls">
    `GET /deployment/{deploymentId}/config` returns the full environment list
    with members and nested user records. The data plane applies it and deletes
    environments the control plane no longer lists.
  </Step>

  <Step title="Persistent drift backs off">
    If the same hash pair repeats, the re-sync is skipped for a few ticks rather
    than looping every cycle. Any change to either hash resumes it immediately.
  </Step>
</Steps>

<Warning>
  The config payload **contains member email addresses**. Both planes explicitly
  refuse to log it in full; if you ship data-plane logs to a SIEM, this is the one
  control-plane payload that carries PII, and it is never written out.
</Warning>

## Usage metrics

Every **30 seconds** the data plane pushes aggregated CDC counters, which is
what the control plane's Usage tab and billing are computed from:

```json theme={null}
{
  "metrics": [
    { "id": "9f1c…", "from_ts": 1764590400000, "to_ts": 1764590430000,
      "topic": "d1a2b3c4d5.e6f7a8b9c0.c1d2e3f4a5.public.customers",
      "events": { "total": 1420, "r": 1200, "c": 180, "u": 35, "d": 5 },
      "created_at": "2026-09-08T10:00:30Z" }
  ]
}
```

`from_ts` / `to_ts` are epoch milliseconds, and `events` are **counts per
operation code** — read (snapshot), create, update, delete — emitted sparsely,
so a key absent means zero. Row contents are not part of the payload, and there
is no field that could carry one. The `id` is generated by the data plane and
reused as the primary key on ingest, so a retried batch is idempotent rather
than double-counted.

This is the sole input to the control plane's Usage tab and to billing; there is
no second metering channel.

<Note>
  There is no infrastructure telemetry in this channel: no CPU, memory or disk
  metrics, no log shipping, no stack traces, no per-request tracing. If you want
  OpenTelemetry, it is a separate opt-in export to a collector **you** configure
  via `OTEL_EXPORTER_OTLP_ENDPOINT` — it does not go to the control plane.
</Note>

## Identity and login

In self-hosted mode the control plane is the identity provider. This is a design
choice worth understanding before an install, because it means **user login
depends on control-plane reachability**.

| Flow                | Path                                                                                                                                                                |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SSO login           | Data-plane UI redirects the browser to the control plane, which returns a token; the data plane verifies it via `GET /auth/jwt/verify`, then reconciles memberships |
| API-key login       | `POST /auth/api-keys/verify` returns the organization and the caller's per-environment roles                                                                        |
| User created/edited | The data plane proxies to `PATCH /deployment-users/{id}` — it is not authoritative and answers `503` if the control plane is down                                   |
| User lookup         | `GET /deployment-users/{id}` or `POST /deployment-users/search` on a local cache miss                                                                               |
| Deployment switch   | `GET …/navigation` and `POST …/generate-switch-token` let a user hop between deployments in one org                                                                 |

These three calls are the only ones that carry a **user email outbound**
(`navigation`, `generate-switch-token`, `PATCH /deployment-users`). Emails flow
predominantly *inbound*, because the control plane is where accounts live.

Do not confuse the two tokens involved:

| Token                | Audience                   | Represents     | Lifetime          |
| -------------------- | -------------------------- | -------------- | ----------------- |
| `deploymentJwtToken` | `popsink:deployment`       | The deployment | **Never expires** |
| User access token    | `popsink:dataplane-access` | A single user  | **8 hours**       |

The user access token is what the control plane hands the browser after SSO; the
data plane verifies it and exchanges it for its own session. It carries the
user's id, email, target `deployment_id` and whether they are an org admin.

<Tip>
  If your data plane is reachable only on an IP literal or a `kubectl port-forward`
  address, the SSO redirect cannot come back to it. Use the paste-token page at
  `/auth/token-login`, and keep the chart's `adminCredentials` account as the
  break-glass local login.
</Tip>

## Connector OAuth brokering

For OAuth-based connectors (HubSpot, Google Ads, Salesforce…), the control plane
holds the provider `client_secret` and the registered `redirect_uri`. Your data
plane never registers an app with the provider and never holds the provider
secret.

The cost of that arrangement is explicit: the connector's **OAuth refresh token
transits the control plane on every refresh**, because the control plane is the
only party that can exchange it. The refresh token originated at the control
plane in the first place — it is handed to the data plane at grant time, and
posted back to `/oauth/{provider}/refresh` to obtain a short-lived access token.

## What never leaves the data plane

Verified against every outbound payload builder:

| Never transmitted                  | Where it stays                                                                 |
| ---------------------------------- | ------------------------------------------------------------------------------ |
| Source rows, message payloads      | Your broker and your target, inside your network                               |
| Schemas, column names, data types  | Your schema registry (Kora) and the datamodel definition                       |
| Transform / SMT definitions        | The data-plane database                                                        |
| Connector connection credentials   | The data-plane database, encrypted at rest with `connectorConfigEncryptionKey` |
| Error tables, dead-letter contents | Your broker                                                                    |

Two caveats we would rather state than have you discover in an audit:

<Warning>
  **1. Kafka topic names embed the source `schema.table`.** Topic names are
  `{hashed-prefix}.{schema}.{table}` — the prefix is a SHA-1 digest of the
  deployment/environment/connector IDs, but the trailing segment is plaintext.
  Topic names appear in both `entity_snapshot` and the usage metrics, so **your
  table and schema names are visible to the control plane**.

  **2. User-chosen names are visible too** — connector, datamodel, subscription,
  team and environment names, since they are what the control-plane UI displays.
</Warning>

If either is unacceptable for your data classification, name your tables,
schemas and pipelines accordingly, or run in standalone mode.

## Standalone: no control plane at all

Every exchange on this page is gated on `deploymentMode: SELF_HOSTED`. With
`deploymentMode: STANDALONE`, the data plane makes **zero calls to any control
plane** — the heartbeat and metrics tasks return immediately at startup, and no
`deploymentJwtToken` is required. This is the mode behind air-gapped
installations.

The trade-offs are real and worth naming:

| Aspect        | `SELF_HOSTED`                                   | `STANDALONE`                             |
| ------------- | ----------------------------------------------- | ---------------------------------------- |
| Egress        | Outbound 443 to the control plane               | None                                     |
| Identity      | Control-plane SSO, central users                | Local accounts only                      |
| Fleet view    | All deployments in one control-plane UI         | Each install is an island                |
| Usage/billing | Reported automatically                          | Offline reporting                        |
| Connectors    | Full catalogue                                  | A restricted subset                      |
| Upgrades      | Version compliance visible in the control plane | Fully manual, via signed offline bundles |

<Card title="Air-gapped deployments" icon="shield-halved" href="/deployment/overview">
  How offline, signed update bundles work.
</Card>

## What this means for your firewall

Everything above is initiated **outbound, by the data plane**. That gives three
rules, and they are the whole story:

* **allow egress** from the cluster to `controlPlaneUrl` over HTTPS — it carries
  the heartbeat, config sync, metrics and login verification;
* **allow it namespace-wide**, not just for the API pod: connector workers
  refresh their own OAuth tokens;
* **open nothing inbound.** The control plane never initiates a connection, so
  there is no rule to write in that direction and no address to allow-list.

The one value that is not a firewall rule but behaves like one is `ingressUrl`:
it must match the public URL your users actually reach, and DNS must resolve it
to your ingress controller, because it is what login redirects and OAuth
callbacks return to.

The full checklist, including image-registry egress, is in
[Kubernetes requirements](/deployment/install/requirements#network). If the deployment
stays `offline`, the cause is almost always blocked egress plus a wrong
`deploymentId` / `deploymentJwtToken` — the symptom-to-cause table is on the
[troubleshooting page](/deployment/operate/troubleshooting).

## Further reading

<CardGroup cols={2}>
  <Card title="Deployments and environments" icon="layer-group" href="/deployment/architecture/topology">
    How to map deployments onto your regions and networks.
  </Card>

  <Card title="Install a deployment" icon="cloud-arrow-up" href="/deployment/install/kubernetes">
    Create the deployment in the control plane, then install the chart.
  </Card>

  <Card title="Deployment options" icon="server" href="/deployment/overview">
    SaaS, BYOC, on-prem and air-gapped compared.
  </Card>

  <Card title="Security & compliance" icon="lock" href="/securityandcompliance">
    Encryption, audit logging, certifications.
  </Card>
</CardGroup>
