---
title: Ejection Path
description: How a Loop tenant resumes its Ads Sync connections single-tenant on the open-source core, with re-enrollment of credentials and state.
---

The Ejection Path is the informal promise that a Loop tenant can leave the managed offering and run the same syncs single-tenant on the open-source core. It holds because Loop and the Ads Sync Reference Deployment are two consumers of one seam: the same `@patronage/ads-sync/run` Qualified Run, the same `controlSchemaSql` control schema, the same Airbyte destination schemas, and the same connector images. Only the custody adapter and the scheduler differ.

This page describes the procedure. It is a runbook you execute, not a button, and it is not a guarantee of any timeline, price, or feature parity. What Loop adds on top of the seam (multi-connection scheduling, fan-out, approvals, run history, tenant policy) does not come with you. What comes with you is the data, the schema contract, and the ability to keep syncing.

The posture is informal on purpose. The glossary term is the stake: as long as the Reference Deployment and Loop invoke runs only through the seam, ejection stays a documented procedure instead of a migration project. If Loop ever needed a private seam API, the seam would be wrong.

## what stays the same

| concern | Loop | single-tenant | on ejection |
| --- | --- | --- | --- |
| Run seam | `createQualifiedRunAdapter` | `createQualifiedRunAdapter` | Same code, same invariants |
| Control schema | `ads_sync.*` from `controlSchemaSql` | `ads_sync.*` from `controlSchemaSql` | Same tables |
| Destination schemas | One isolated Airbyte Direct Load schema per Sync Connection | Same | Same tables and rows |
| Reporting views | `ads_sync_reporting.*` from `reportingViewSql` | Same | Same queries keep working |
| Connector images | Built from the shipped Dockerfiles | Built from the shipped Dockerfiles | Same digests |
| Committed state | `ads_sync.sync_state_commits` per connection | Same | Re-enrolled as a seed |

## what changes

| concern | Loop | single-tenant |
| --- | --- | --- |
| Custody | Per-tenant, version-pinned secrets in the Cloudflare secrets store; the client references connections, never secret names | Your Worker secrets, read by `workerSecretsCustodyAdapter`; config names secrets |
| Postgres role | Per-tenant, least-privilege schema-scoped writer and reader | One role with database-level `CREATE`; Airbyte owns DDL |
| Scheduling | Multi-connection fan-out | One Cron trigger, one Sync Connection per Reference Deployment |
| Run trigger | Loop tools and policy | `POST /runs` with the Ads Sync Deployment Token, or the Cron trigger |

## procedure

Do the [quickstart](/tutorial/quickstart) once against a scratch database first. Then follow these steps for the real move.

### 1. export from Loop

Ask Loop for the tenant's Ads Sync export. It contains, per Sync Connection:

- the connection definition (provider, `connectionId`, `destinationSchema`, selected streams, `streamGroup`, and the configured catalog JSON);
- the latest committed state from `ads_sync.sync_state_commits`, as the Airbyte STATE JSON;
- the committed watermark and the last successful window;
- a `pg_dump` of the connection's destination schema and of `ads_sync_reporting`, when you are not keeping the same Postgres.

Loop never exports credential values. Refresh tokens stay tenant-local under Loop custody and are not part of any export.

### 2. choose the database

You have two options:

- **Keep the same Postgres.** Loop's tenant schemas are standard Postgres schemas. Grant your new role database-level `CREATE` and read access to the existing destination schema. The Airbyte tables and the reporting views are already there.
- **Move to your own Postgres.** Restore the dump, then apply the schema exactly as in the quickstart step 3. The statements are idempotent, so applying them over a restored dump is safe.

Either way, one database serves both the Worker read path (Hyperdrive) and the destination Container write path.

### 3. re-enroll credentials

Create the provider credential yourself: a new OAuth grant for Google Search Console or Google Ads, a new system-user token for Meta Ads. Put it in the provider's source-config secret with `wrangler secret put`, as in the quickstart step 6. Read the [Google Search Console caveats](/explanation/google-search-console-caveats) for the Testing-mode token limit.

Set `POSTGRES_DESTINATION_CONFIG_JSON` for your role and database, and `ADS_SYNC_RUNNER_TOKEN` for `/runs`. The secret names are the same names Loop's custody adapter resolves internally; the values are yours alone from now on.

### 4. carry the watermark over

The Reference Deployment does not send Airbyte state to the connector. Every run it plans is windowed, and a windowed Qualified Run passes no `state`: the window's start and end dates bound the read, and the `append_dedup` sync mode keeps the destination correct. You do not need the exported STATE JSON for the Reference Deployment.

What carries incremental continuity on ejection is the watermark. It is keyed by connection id alone and carries over as it is:

```bash
psql "$DATABASE_URL" -c "select connection_id, watermark_end from ads_sync.sync_watermarks"
psql "$DATABASE_URL" -c "select connection_id, stream_name, committed_at from ads_sync.sync_state_commits order by committed_at desc limit 5"
```

A custom consumer of `@patronage/ads-sync` that runs the seam without a window is different: there the custody adapter serves the provider state secret (for example `GOOGLE_SEARCH_CONSOLE_SOURCE_STATE_JSON`) as `seedState`, and the seam uses it when the control store has no committed state for the connection's catalog and source-config hashes. Only that consumer shape needs the exported STATE JSON.

### 5. re-enroll the connection

Edit `ads-sync.config.ts` in the Reference Deployment to the exported definition: same `connectionId`, same `destinationSchema`, same selected streams and stream group, the exported catalog under `config/`. Keeping `connectionId` and `destinationSchema` identical is what makes the watermark, the state, and the Direct Load tables line up. The Reference Deployment runs one connection; for more, run more deployments.

### 6. deploy and verify

Deploy as in the quickstart step 5, then:

```bash
curl -s -X POST -H "Authorization: Bearer $ADS_SYNC_RUNNER_TOKEN" "$WORKER_URL/runs"
curl -s -H "Authorization: Bearer $ADS_SYNC_RUNNER_TOKEN" "$WORKER_URL/runs"
```

A correct re-enrollment produces one of two results: `no_new_final_data` when Loop's last window already reached the current Final-Data Horizon, or a `queued` run whose `windowStart` equals the watermark you brought over. A run that starts from `horizon - windowStepDays` means the watermark did not carry; check `connectionId` before anything else.

Compare row counts in the destination schema and the reporting views before and after the first run. Then ask Loop to disable the tenant's connection so two schedulers do not write the same schema.

## what this is not

- Not automatic. Every step above is yours to run.
- Not a data-plane guarantee. Loop is not obliged to keep any particular export format stable across releases; the schema contract is the stable part.
- Not multi-tenant. Ejection lands you in single-tenant custody (ADR 0047). If you need per-tenant custody again, that is Loop.
