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

# Pin, lock, and run a governed data-residency migration

> Walk the org-wide data-residency surface end to end — read the current config, pin a region, verify the per-plane posture, lock it, and run a governed unlock → migrate → re-lock sequence — with worked cURL for every step.

# Pin, lock, and run a governed data-residency migration

The org-wide data-residency control pins PII, audit logs, and recordings
to a geographic boundary, reports the per-plane posture honestly, and
freezes the pin behind a deliberate lock. This page walks the full
sequence — **pin → verify → lock → (governed unlock) → migrate → re-lock**
— with the four refusal codes you can hit along the way and the cURL to
run at each step.

<Warning>
  Data residency is a **tenant-owned** control: you pin the region, you
  enforce it, you lock it, and you document it. Orbit records and
  reports the posture and refuses unsafe mutations; the decision and its
  documentation are yours. Nothing on this page is legal advice — run it
  past qualified counsel before you certify a posture.
</Warning>

## The org-wide pin surface, and what it does not cover

The control surface lives under
`/api/v1/compliance/data-residency`:

| Verb | Endpoint | Effect |
| - | - | - |
| `GET` | `/compliance/data-residency` | Current config, the region catalog, and the per-plane residency matrix. Read-only; returns 200 with `config: null` when nothing is pinned. |
| `PUT` | `/compliance/data-residency` | Pin or update the region, enforcement flag, and justification. Refuses a region change when the pin is locked. |
| `POST` | `/compliance/data-residency/lock` | Freeze the pin so the region can no longer be changed silently. Requires an enforced pin first. |
| `POST` | `/compliance/data-residency/unlock` | Deliberately free the pin so a governed migration can change it. |

Every write needs an **owner or admin** API key, and every change lands
in your audit log.

This org-wide pin is one of two residency surfaces. It covers the
**org-wide data planes** (PII, audit logs, compliance documents, and
the messaging / CDP planes projected below). It does **not** move the
**voice region** — call recordings, voicemail, and live media follow the
workspace voice-region pin documented in
[Voice data residency](/compliance/voice-data-residency). The
[Data residency overview](/compliance/data-residency-overview) maps
which surface answers which channel's question; set the voice-region pin
and the org-wide pin deliberately, never assume one covers the other.

## Step order: read → pin → verify → lock

The sequence is four deliberate steps. Each one is verifiable from the
GET before you move on — do not assume a step landed just because the
call returned 200.

<Steps>
  <Step title="Read the current config">
    Call `GET /api/v1/compliance/data-residency`. A fresh org returns 200
    with `data.config: null`, the region catalog, a resolved bucket, an
    API-region status block, and the per-plane data-plane matrix. Check
    the catalog before you pin — a region's
    `data.regions[].availability` must read `available` before the pin
    can be enforced; a `preview` region pin is advisory-only by design.
  </Step>

  <Step title="Pin the region (PUT)">
    Issue a `PUT` with `{ "region": "<code>", "enforced": true,
            "justification": "…" }`. A first-time pin returns 201, an update
    returns 200, and the response body is the full GET view (plus the
    audit-logged write). Enforcement is what turns the recorded prefer-
    ence into the region-scoped storage boundary, so an advisory pin
    (`enforced: false`) registers intent but never becomes "resident."
  </Step>

  <Step title="Verify with a GET before you move on">
    Re-read `GET /api/v1/compliance/data-residency` and inspect
    `data.config.region`, `data.config.enforced`, and the per-plane
    matrix in `data.data_planes`. Read it back before you lock or
    migrate — an accidental `enforced: false` body, or a
    `preview`-region pin you then try to enforce, returns
    `RESIDENCY_REGION_UNAVAILABLE` on the enforcing PUT.
  </Step>

  <Step title="Lock the pin (POST …/lock)">
    Issue `POST /api/v1/compliance/data-residency/lock` with an optional
    `{ "notes": "…" }` annotation. The lock refuses with
    `RESIDENCY_NOT_ENFORCED` (409) unless an **enforced** pin exists —
    lock-as-a-guard only makes sense on a boundary that actually scopes
    storage. Once locked, same-region updates to `justification` or
    `enforced` still succeed; only a region change is refused.
  </Step>
</Steps>

## The four refusal codes, walked in the sequence

The surface uses exactly four refusal codes. Each one fires on a
specific verb, so check the verb before you diagnose. The dedicated
[Troubleshooting: RESIDENCY\_\* errors](/troubleshooting/data-residency-errors)
page expands the recovery for each; the walk below lines them up with
the sequence you actually run.

| Code | HTTP | Fires on | One-line recovery |
| - | - | - | - |
| `RESIDENCY_NOT_FOUND` | 404 | `POST /unlock` when no config exists | Pin with `PUT` first — unlock-on-unpinned is a caller error, not a state |
| `RESIDENCY_NOT_ENFORCED` | 409 | `POST /lock` when no enforced pin exists | Enforce first: `PUT` with `enforced: true` |
| `RESIDENCY_REGION_UNAVAILABLE` | 409 | An enforcing `PUT` against a `preview` region | Pick a `available` region, or pin `enforced: false` to register intent |
| `RESIDENCY_LOCKED` | 409 | A `PUT` that changes the region on a locked pin | `POST /unlock` first, then re-issue the `PUT` |

<Note>
  A fixation on "region must be enforced before it matters" drives three
  of the four codes: `RESIDENCY_NOT_ENFORCED` guards the lock,
  `RESIDENCY_REGION_UNAVAILABLE` guards the enforcing PUT, and the
  `storedAndProcessedInRegion` projection on the plane reads treats an
  advisory pin as intent-only. An advisory pin is a useful way to
  register a region request before its boundary is graduated to GA — it
  just is not residency. None of the refusals is a transient error; a
  retry that does not change the state reads the same refusal forever.
</Note>

## When the per-plane reads need explicit confirmation

The org-wide matrix is one object-wide view — but two data planes also
expose their own projection, because an integrating buyer reads
residency where it integrates rather than on a generic settings page.
Read these before you certify a posture to a buyer or a supervisory
authority.

| Plane read | Path | Shape |
| - | - | - |
| Messaging residency | `GET /api/v1/messaging/data-residency` | `pinnedRegion`, `enforced`, the messaging entry of the per-plane matrix, `supportedRegions`, the composed `storedAndProcessedInRegion` verdict, and an `api_region` block that compares where the API pod actually runs against the region the pin targets — plus the region-pinned ingress endpoint and whether it is live. Requires the messaging scope (`messaging:read`, owner/admin/developer). |
| CDP residency | `GET /api/v1/cdp/data-residency` | The same shape, with `planes` covering the four CDP ingest surfaces (contacts & resolved profiles, tracked events, identity graph, computed traits). Requires `contacts:read`. |

Confirm the messaging read after onboarding a regulated SMS buyer and
the CDP read before you certify an ingested-profile posture. Both
project the **same** singleton config the compliance surface manages —
they are reads, not per-plane knobs, and they answer with the same
enforced pin the `GET /compliance/data-residency` returns. The one
common mistake is treating the messaging/CDP `storedAndProcessedInRegion`
projection as a write answer: it reports the current posture, and only
`PUT /compliance/data-residency` changes it.

## The governed migration sequence

Region migration is a deliberate governance event, not an edit. Treat it
with a two-officer mindset: the officer who unlocks should be the one
who re-locks at the end, and the `notes` and `justification` bodies are
the written record of why.

1. **Announce and document.** Record the reason for the migration, the
   target region, and the sign-off (customer contract, regulatory
   driver, or procurement change) before any API call. The unlock and
   re-pin will land in the audit log on both sides; the officer's
   written justification is the only narrative between them.
2. **Unlock the pin.** `POST /compliance/data-residency/unlock` with a
   `{ "notes": "…" }` body naming the reason. The call **records the
   change only** — it performs no data movement and wires no carrier.
   That distinction is load-bearing for audit: the surface records
   policy, and the audit log reads as a governance ledger.
3. **Re-pin the target.** `PUT` the new region with `enforced: true` and
   a `justification` that names the migration. Same-region enforcement
   (`enforced: true` → `enforced: true`) is also allowed on a locked pin
   if you ever need to refresh the justification without unlocking.
4. **Run the data-plane migration out-of-band.** Moving bytes between
   regions is a service-operations step, not part of this surface —
   contact Devotel to schedule it. Until the bytes move, the planes that
   do not follow the pin report the gap honestly in the matrix
   (`residentRegion` stays on the actual store region and the plane's
   `note` says so).
5. **Re-lock.** `POST /compliance/data-residency/lock` with notes
   referencing the migration ticket. Re-read the GET and confirm
   `data.config.locked: true` and `data.config.region` now name the new
   boundary — the sequence ends in the state you want, not the state the
   unlock left behind.

Each step returns the full GET view so you can verify without a second
round trip; the write verbs land in the audit log on both sides of the
unlock/re-pin pair, so there is no "forgot to re-lock" silent state.

## Worked cURL for each step

Set the base and header once; only the verb changes.

```bash theme={null}
export BASE="https://api.orbit.devotel.io/api/v1"
export KEY="X-API-Key: dv_live_sk_..."
```

Read the current config — a fresh org answers 200 with `config: null`:

```bash theme={null}
curl -s -H "$KEY" "$BASE/compliance/data-residency"
```

Pin the EU region with enforcement — 201 on first pin, 200 on update:

```bash theme={null}
curl -X PUT "$BASE/compliance/data-residency" \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{
    "region": "eu",
    "enforced": true,
    "justification": "EU umbrella customer — GDPR Art. 44 boundary contract"
  }'
```

Verify what you actually wrote (check `data.config.region`,
`data.config.enforced`, and the plane rows in `data.data_planes`):

```bash theme={null}
curl -s -H "$KEY" "$BASE/compliance/data-residency"
```

Lock the enforced pin — refuses with 409 `RESIDENCY_NOT_ENFORCED` if
the config was not enforced:

```bash theme={null}
curl -X POST "$BASE/compliance/data-residency/lock" \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{ "notes": "Freeze EU boundary ahead of ISO27001 renewal audit." }'
```

Attempting a region change against the locked pin is refused with 409
`RESIDENCY_LOCKED` — same-region updates to `justification` are the
exception and succeed.

For a governed migration, unlock with a reason, re-pin the new region,
then re-lock:

```bash theme={null}
# 1. Unlock (audited governance event — records the change only)
curl -X POST "$BASE/compliance/data-residency/unlock" \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{ "notes": "Governed migration: uk → eu per customer contract amendment 2026-03" }'

# 2. Re-pin the target region (still enforced)
curl -X PUT "$BASE/compliance/data-residency" \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{
    "region": "eu",
    "enforced": true,
    "justification": "Customer contract amendment 2026-03 — migrate from UK nest to EU umbrella"
  }'

# 3. Confirm the new posture before re-locking
curl -s -H "$KEY" "$BASE/compliance/data-residency"

# 4. Re-lock — audit the lock with the migration ticket reference
curl -X POST "$BASE/compliance/data-residency/lock" \
  -H "$KEY" -H "Content-Type: application/json" \
  -d '{ "notes": "Re-lock after UK→EU migration ticket MIG-2026-031 complete." }'
```

Read the per-plane projections next to the surfaces they govern:

```bash theme={null}
# Messaging plane read — check storedAndProcessedInRegion
curl -s -H "$KEY" "$BASE/messaging/data-residency"

# CDP plane read — the four-ingest-surface matrix
curl -s -H "$KEY" "$BASE/cdp/data-residency"
```

A 409 with a non-region-change (same-region enforcement, lock repeat)
replay still returns 200 idempotently — the refusals above fire only on
a state the surface deliberately protects, never on noise.

## See also

* [Data residency overview](/compliance/data-residency-overview) — which
  surface answers which channel's residency question, including the
  messaging subprocessor answer.
* [Voice data residency](/compliance/voice-data-residency) — the
  workspace voice-region pin that covers recordings, voicemail, and live
  media. Set beside the org-wide pin; never instead of it.
* [Messaging data residency](/concepts/messaging-data-residency) — the
  concept behind `GET /messaging/data-residency` and the composed
  `storedAndProcessedInRegion` verdict.
* [Troubleshooting: RESIDENCY\_\* errors](/troubleshooting/data-residency-errors)
  — the reference for each refusal code and what to bundle for support.
* [Data placement and residency](/concepts/data-placement-and-residency) —
  the WHERE-isolation vs. WHERE-geographic vs. region-of-compute concept
  this pin composes with.
