Documenting Cross-Border Transfer of Spatial Data

Record every transfer as a row naming the dataset, the exporting and receiving jurisdictions, the mechanism relied on and the date it was assessed — and generate that row from the deployment configuration rather than from a spreadsheet, because the thing that determines where data actually goes is the bucket region, not the policy document.

Cross-border transfer is the compliance area where the documented position and the running system diverge most reliably. The register says European data stays in Europe; the pipeline writes a cache to a bucket in another region because that was the default when it was created. Both statements are made in good faith and only one of them is true. This guide sits under regulatory obligations for location data, within Spatial Data Audit Reporting & Compliance Governance. It describes engineering practice rather than legal advice.

The places spatial data crosses a border without anyone deciding toTransfers occur through storage regions, processing services, backups and third-party access, most of which are configuration rather than decisions.A dataset with a stated regionwhere does it actually goDeliberate transferusually documentedPartner deliverynamed recipientPublic downloadanyone, anywhereStorageconfigurationBucket regionset at creationReplicationoften cross-regionProcessingconfigurationManaged serviceregiondefaults applyCI runner locationrarely consideredSupport accesscontractualVendor engineerswherever they are
Only the first branch usually appears in a transfer register. The other three are where the data actually goes.

Automated Python Implementation

The generator below reads the deployment configuration and emits transfer register rows. Deriving them from configuration rather than maintaining them by hand is what keeps the register true.

#!/usr/bin/env python3
"""Generate a cross-border transfer register from deployment configuration."""
import datetime
import json

# Where each named region physically is. Maintained deliberately; a region whose
# jurisdiction is unknown is a finding, not a default.
REGION_JURISDICTION = {
    "eu-west-1": "IE", "eu-central-1": "DE", "eu-west-2": "GB",
    "us-east-1": "US", "us-west-2": "US", "ap-southeast-2": "AU",
}

# Mechanisms a transfer may rely on, and whether each needs periodic review.
MECHANISMS = {
    "same_jurisdiction": {"review_months": None},
    "adequacy": {"review_months": 12},
    "standard_clauses": {"review_months": 12},
    "explicit_consent": {"review_months": 6},
    "none": {"review_months": 0},
}


def jurisdiction_of(region):
    if region not in REGION_JURISDICTION:
        raise KeyError(f"region {region!r} has no recorded jurisdiction")
    return REGION_JURISDICTION[region]


def transfers_for(dataset, deployment):
    """Every place this dataset lands, from the deployment configuration."""
    home = jurisdiction_of(deployment["primary_region"])
    destinations = {}

    destinations[deployment["primary_region"]] = "primary storage"
    for region in deployment.get("replica_regions", []):
        destinations[region] = "replication"
    for service in deployment.get("processing", []):
        destinations[service["region"]] = f"processing: {service['name']}"
    for backup in deployment.get("backups", []):
        destinations[backup["region"]] = "backup"

    rows = []
    for region, purpose in sorted(destinations.items()):
        to = jurisdiction_of(region)
        rows.append({
            "dataset": dataset["id"],
            "contains_personal_data": dataset["contains_personal_data"],
            "from_jurisdiction": home,
            "to_jurisdiction": to,
            "region": region,
            "purpose": purpose,
            "mechanism": ("same_jurisdiction" if to == home
                          else dataset.get("mechanism", "none")),
        })
    return rows


def review_findings(rows, today=None):
    """Rows that need attention: no mechanism, or an overdue review."""
    today = today or datetime.date.today()
    findings = []
    for row in rows:
        if not row["contains_personal_data"]:
            continue
        if row["mechanism"] == "none" and row["from_jurisdiction"] != row["to_jurisdiction"]:
            findings.append(
                f"{row['dataset']}: {row['from_jurisdiction']} to "
                f"{row['to_jurisdiction']} ({row['purpose']}) with no mechanism recorded")
    return findings


def register(datasets, deployments):
    rows = []
    for dataset in datasets:
        rows.extend(transfers_for(dataset, deployments[dataset["deployment"]]))
    return {
        "generated_on": datetime.date.today().isoformat(),
        "rows": rows,
        "findings": review_findings(rows),
    }

Two behaviours make this worth running rather than filing.

An unknown region raises. A region absent from the jurisdiction map means the configuration has grown a destination nobody has classified, and that is precisely the case where a silent default is most damaging. Failing forces the map to be extended deliberately.

Every landing place is a row, including the ones nobody thinks of as transfers. Backups and replication are the destinations most often missing from hand-maintained registers, because they were configured once by someone thinking about durability rather than about jurisdiction.

Where the register and the deployment usually disagreeFour transfer paths compared by how often they appear in a hand-maintained register and how they arise.In a typical registerArises fromPartner deliveryyesa decision, documentedReplica regionrarelya durability settingManaged service regionrarelya default at creationVendor support accessalmost nevera contract clause
The bottom three arise from configuration, which is why the register should be generated from it.

Validation and Pipeline Integration

def test_unknown_region_is_refused():
    try:
        jurisdiction_of("xx-nowhere-1")
    except KeyError:
        return
    raise AssertionError("an unclassified region must not default silently")


def test_replica_regions_produce_rows():
    deployment = {"primary_region": "eu-west-1", "replica_regions": ["us-east-1"]}
    dataset = {"id": "d", "contains_personal_data": True, "deployment": "x"}
    rows = transfers_for(dataset, deployment)
    assert any(r["to_jurisdiction"] == "US" for r in rows)


def test_personal_data_without_a_mechanism_is_a_finding():
    deployment = {"primary_region": "eu-west-1", "replica_regions": ["us-east-1"]}
    dataset = {"id": "d", "contains_personal_data": True, "deployment": "x"}
    assert review_findings(transfers_for(dataset, deployment))

Regenerate the register on every deployment change and diff it against the committed copy, exactly as with a generated compatibility matrix. A new replica region then appears as a diff in a pull request, reviewed by whoever approves the change, rather than being discovered during an audit.

What a generated transfer row needs before it is acceptablePersonal data, jurisdiction difference and a recorded mechanism decide whether a transfer row is a finding.Does the dataset contain personaldata?per the classificationContinue assessingthe row mattersyesnoDoes it stay in one jurisdiction?No transferrecord it anywayyesnoA mechanism is requiredrecorded, dated, reviewed
Most rows resolve at the first two tests; the ones that reach the third are the register's real content.

Keeping the Register Honest Over Time

A generated register is accurate the day it is generated. Three things erode it, and each has a cheap countermeasure.

Jurisdictions and mechanisms change by decision elsewhere. An adequacy finding is granted or withdrawn, and every transfer relying on it changes status without anything in your system changing. Recording the mechanism per row with the date it was assessed makes that set queryable: when a mechanism’s status changes, the affected rows are a filter rather than an investigation.

Regions are added faster than they are classified. Cloud providers open regions continuously, and a deployment can adopt one before anyone records where it is. The raising behaviour above catches this at generation time, which is the only moment when the cost of classifying it is a single line.

Third-party processors change their own footprint. A managed service that ran in one region begins replicating to another, and nothing in your configuration reflects it. This is the hardest case and it is not solvable from configuration alone; the countermeasure is a contractual notification requirement and a periodic re-read of the processor’s published subprocessor list.

The register’s value is not the document. It is that generating it forces every destination to be classified, and that a change to the deployment produces a visible diff in something a reviewer reads. A register maintained by hand asserts the intended architecture; one generated from configuration describes the real one, and the gap between them is the finding.

Public Download Is a Transfer to Everywhere

Registers built around named recipients handle partner deliveries well and have nothing sensible to say about an open data portal, which is the destination most spatial teams actually use.

The framing that works is to treat a public download as a transfer to an unbounded set of jurisdictions, which changes the question. There is no mechanism to record per recipient because there is no recipient list, so the control has to be on the data rather than on the transfer: if a dataset can be published openly, the assessment described in regulatory obligations for location data has already concluded that it carries no personal data or has been mitigated to the point where it does not.

That makes the register row for a public dataset short and useful: destination unbounded, mechanism not applicable, and a pointer to the assessment that permitted the publication. A row of that shape is also the one an auditor finds most reassuring, because it demonstrates that the question was asked before the data left rather than being managed afterwards.

The failure it guards against is the common one of an internal dataset being added to a portal by a process that never consulted the classification — at which point the register, generated from configuration, shows a public distribution for a dataset marked as containing personal data, and the finding surfaces on the next run.

Long-Term Compliance Best Practices

  • Generate from configuration, never maintain by hand. The bucket region is the fact; the policy document is a claim.
  • Raise on an unclassified region. A silent default is how a destination enters the system unnoticed.
  • Include backups and replicas. They are transfers, and they are the rows most often missing.
  • Date every mechanism assessment. When a mechanism’s status changes elsewhere, the affected rows must be findable.
  • Diff the register in review. A new destination should be visible to whoever approves the deployment change.