CCPA Mobility Data Obligations

The CPRA amendments to the CCPA gave “precise geolocation” a number an engineer can code against: any location that identifies a consumer within a radius of 1,850 feet (≈564 m) is Sensitive Personal Information (SPI), which triggers the consumer’s right to limit its use and the standing right to opt out of its sale or sharing. A raw GPS fix is, by that definition, always precise geolocation — so a mobility pipeline that ingests device pings cannot treat CCPA as a policy footnote; it has to make a routing decision on every coordinate. This page turns those obligations into a concrete ingestion gate: flag any coordinate resolvable below the 1,850 ft radius as Tier 2+, honour the subject’s consent and opt-out state at the routing boundary, and either pass precise geometry through or generalise it to a cell coarser than the threshold. It sits under the compliance framework mapping guide within the broader Core Fundamentals & Architecture for Spatial Privacy reference, and it consumes the same risk tiers produced by the spatial sensitivity scoring models and defends against the linkage vectors catalogued in threat mapping for GIS data.

Parameter Configuration and Calibration

Unlike GDPR minimisation or HIPAA’s population floor, CCPA gives you an explicit geometric constant, so calibration starts from the statutory radius and works outward to a defensible grid cell. Three knobs govern the gate.

  • PRECISE_GEO_RADIUS_M — the statutory SPI threshold (564.0 m ≈ 1,850 ft). This is the classifier boundary, not a tunable: any coordinate whose released resolution places the subject inside a circle of this radius is SPI under Cal. Civ. Code §1798.140. A raw device fix (6 decimal degrees ≈ 0.11 m) is always well inside it, so every unmodified ping enters as SPI and must be gated. Do not lower this to buy utility — the number is fixed by statute.
  • MIN_CELL_M / CELL_EDGE_M — the generalization cell edge. To make a release not precise geolocation, the cell must be coarser than the threshold. The absolute floor is one radius, MIN_CELL_M = 564.0 m; the defensible default is one diameter, CELL_EDGE_M = 1128.0 m (=2×564= 2 \times 564), so a single released cell spans more than the entire 1,850 ft precise-geo circle and no consumer resolves within it. Snapping happens in decimal degrees, so the edge is converted per-axis: Δlat=L/111,320\Delta_\text{lat} = L / 111{,}320 and Δlon=L/(111,320cosφ)\Delta_\text{lon} = L / (111{,}320 \cos\varphi) at latitude φ\varphi, because a degree of longitude shrinks toward the poles.
  • ConsentState — the routing decision variable. CCPA gives the subject two distinct levers, and the gate encodes both plus a fail-closed default: CONSENTED (affirmative permission to use precise geo → route precise), LIMITED (the consumer exercised limit the use of my sensitive personal information → generalise, never precise), and OPTED_OUT (opted out of sale/share → suppress before the coordinate reaches any sale/share sink). An unknown or missing state resolves to LIMITED, so a subject whose preference has not synced is minimised rather than exposed.

The retention consequence is the fourth parameter. CCPA §1798.105 gives a right to deletion and §1798.100 requires a disclosed retention window, so each gated coordinate carries a retain_until stamp; a deletion request purges both the raw fix and any derived precise release, while generalised cells at CELL_EDGE_M may survive as aggregate geometry because they no longer identify the subject.

CCPA/CPRA obligation Gate control Concrete parameter
Precise geolocation is SPI (< 1,850 ft radius) Classify every raw fix as Tier 2+ PRECISE_GEO_RADIUS_M = 564.0
Right to limit use of SPI Generalise instead of routing precise Cell edge ≥ MIN_CELL_M (564 m); default 1,128 m
Right to opt out of sale/share Suppress at ingestion routing OPTED_OUT → drop before sale/share sink
Right to deletion + disclosed retention Per-record retention stamp; purge on request retain_until; delete raw + precise derivatives

Reference Implementation

CCPAGeoGate is the single enforcement point. It takes a coordinate and a subject’s ConsentState, decides whether to route precise geometry, generalise to a compliant cell, or suppress, and emits an immutable GateDecision for the audit log every time. The generalization step snaps to a grid whose edge is validated against the statutory floor, so the gate cannot be misconfigured into emitting a cell that is still “precise.”

python
from __future__ import annotations

import math
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional, Tuple

PRECISE_GEO_RADIUS_M: float = 564.0     # 1,850 ft — CPRA precise-geolocation radius
MIN_CELL_M: float = PRECISE_GEO_RADIUS_M  # absolute floor: a cell edge below this is still SPI
CELL_EDGE_M: float = 2 * PRECISE_GEO_RADIUS_M  # 1,128 m default: one full diameter, comfortably coarse
_M_PER_DEG_LAT: float = 111_320.0       # metres per degree latitude (WGS84 mean)


class ConsentState(Enum):
    """The CCPA/CPRA levers a mobility subject can pull, plus a fail-closed default."""
    CONSENTED = "consented"    # affirmative permission to use precise geolocation
    LIMITED = "limited"        # exercised "limit the use of my sensitive personal information"
    OPTED_OUT = "opted_out"    # opted out of sale/share — must not reach a sale/share sink


class Routing(Enum):
    PRECISE = "precise"        # raw coordinate passes through unmodified
    GENERALIZED = "generalized"
    SUPPRESSED = "suppressed"


@dataclass(frozen=True)
class GateDecision:
    """Immutable audit record — one is logged for every coordinate the gate sees."""
    routing: Routing
    reason: str
    consent: ConsentState
    lat: Optional[float]       # released latitude (cell centre if generalized, None if suppressed)
    lon: Optional[float]
    cell_edge_m: Optional[float]
    retain_until: float        # epoch seconds; deletion request purges on/before this
    ts: float = field(default_factory=time.time)


class CCPAGeoGate:
    """Consent-gated routing for CCPA/CPRA mobility data.

    Every raw fix is treated as precise geolocation (Tier 2+ SPI) on entry. The
    subject's consent state decides the fate: route precise, generalise to a
    cell coarser than the 1,850 ft radius, or suppress. Nothing bypasses the
    gate, and no path returns without appending a GateDecision to the log.
    """

    def __init__(self, cell_edge_m: float = CELL_EDGE_M, retention_days: int = 30) -> None:
        # Refuse to construct a gate that would emit a still-precise cell.
        if cell_edge_m < MIN_CELL_M:
            raise ValueError(f"cell edge {cell_edge_m} m < statutory floor {MIN_CELL_M} m")
        self.cell_edge_m: float = cell_edge_m
        self.retention_s: float = retention_days * 86_400.0
        self.log: list[GateDecision] = []

    def _generalize(self, lat: float, lon: float) -> Tuple[float, float]:
        """Snap to a grid cell of edge cell_edge_m and return the cell centre.

        Longitude degrees are scaled by cos(lat) so the physical cell edge is
        honoured near the poles rather than collapsing to a sliver.
        """
        dlat = self.cell_edge_m / _M_PER_DEG_LAT
        dlon = self.cell_edge_m / (_M_PER_DEG_LAT * max(math.cos(math.radians(lat)), 1e-6))
        cell_lat = (math.floor(lat / dlat) + 0.5) * dlat   # + 0.5 → centre, not corner
        cell_lon = (math.floor(lon / dlon) + 0.5) * dlon
        return cell_lat, cell_lon

    def route(self, lat: float, lon: float, consent: ConsentState) -> GateDecision:
        """Classify, route, and log a single coordinate. The one entry point."""
        now = time.time()
        retain_until = now + self.retention_s

        if consent is ConsentState.OPTED_OUT:
            # Sale/share opt-out is enforced here, before any downstream sink sees geometry.
            decision = GateDecision(Routing.SUPPRESSED, "opt-out of sale/share",
                                    consent, None, None, None, retain_until)
        elif consent is ConsentState.CONSENTED:
            # Affirmative consent is the ONLY path that releases precise geolocation.
            decision = GateDecision(Routing.PRECISE, "affirmative consent to precise geo",
                                    consent, lat, lon, None, retain_until)
        else:
            # LIMITED or unknown → minimise to a cell coarser than 1,850 ft.
            clat, clon = self._generalize(lat, lon)
            decision = GateDecision(Routing.GENERALIZED, "limit-use of SPI",
                                    consent, clat, clon, self.cell_edge_m, retain_until)

        self.log.append(decision)   # audit trail is non-optional on every path
        return decision

    def delete_subject(self, predicate) -> int:
        """Honour a §1798.105 deletion request: drop matching precise/derived records."""
        before = len(self.log)
        self.log = [d for d in self.log if not predicate(d)]
        return before - len(self.log)

Validation Checkpoint

The failure mode CCPA punishes is an opt-out subject whose precise coordinate still reaches a sale/share sink, so the harness asserts that directly, then checks the cell-size floor and the completeness of the audit log. Run it in CI — a passing linter does not prove a compliant router.

python
def _validate() -> None:
    gate = CCPAGeoGate(cell_edge_m=1128.0, retention_days=30)
    lat, lon = 37.774929, -122.419416   # a precise San Francisco fix

    d_out = gate.route(lat, lon, ConsentState.OPTED_OUT)
    d_lim = gate.route(lat, lon, ConsentState.LIMITED)
    d_yes = gate.route(lat, lon, ConsentState.CONSENTED)
    d_unk = gate.route(lat, lon, ConsentState.LIMITED)  # unknown maps to LIMITED upstream

    # 1. Opt-out subjects NEVER emit precise (or any) geometry.
    assert d_out.routing is Routing.SUPPRESSED
    assert d_out.lat is None and d_out.lon is None

    # 2. Limited/unknown subjects are generalised, never routed precise.
    assert d_lim.routing is Routing.GENERALIZED
    assert d_lim.lat is not None and (d_lim.lat, d_lim.lon) != (lat, lon)

    # 3. The generalised cell edge is at least the statutory radius (>= 564 m).
    assert d_lim.cell_edge_m is not None and d_lim.cell_edge_m >= MIN_CELL_M

    # 4. Only affirmative consent releases the raw coordinate.
    assert d_yes.routing is Routing.PRECISE
    assert (d_yes.lat, d_yes.lon) == (lat, lon)

    # 5. Every decision is logged — no silent bypass.
    assert len(gate.log) == 4
    assert all(isinstance(d, GateDecision) for d in gate.log)

    # 6. A construction below the floor is rejected outright.
    try:
        CCPAGeoGate(cell_edge_m=100.0)
    except ValueError:
        pass
    else:
        raise AssertionError("sub-floor cell edge must be rejected")

    # 7. Deletion purges the subject's precise release.
    removed = gate.delete_subject(lambda d: d.routing is Routing.PRECISE)
    assert removed == 1 and all(d.routing is not Routing.PRECISE for d in gate.log)

    print("all CCPA geo-gate invariants hold")


if __name__ == "__main__":
    _validate()
CCPA consent-gated routing — precise, generalise, or suppress, then audit-log Left to right. Two input boxes, an incoming coordinate and a subject consent state, both feed into the CCPAGeoGate in the centre, which flags any coordinate resolvable below the 1,850 foot (564 metre) radius as Tier 2-plus sensitive personal information. Three labelled branches leave the gate: a consented subject routes to "Route precise geo" at the top, a limited subject routes to "Generalise to a cell of at least 564 metres" in the middle, and an opted-out subject routes to "Suppress before sale or share sink" at the bottom. All three branches converge on an audit log box on the right that records the decision and its reason for every coordinate. Consent-gated routing for CCPA mobility data consented limited opted out Incomingcoordinate Subjectconsent state CCPAGeoGate flag < 1,850 ft (564 m) as SPI · Tier 2+ Route precise geo consent present Generalise to ≥ 564 m cell Suppress before sale/share sink Audit log decision + reason per coordinate

Incident Response and Edge Cases

CCPA breaches on mobility data are usually configuration drift, not cryptographic failure, so the response playbook is about routing correctness and provable erasure.

  • Late-arriving opt-out (consent race). A subject opts out of sale/share while their pings are already mid-pipeline, so precise fixes leak for the window before the preference syncs. Remediation: treat the consent store as authoritative at the gate, not at collection; on an opt-out event, run delete_subject retroactively over the retention window to purge any precise release, and re-emit affected records as suppressed. Fail closed — an un-synced state resolves to LIMITED, never CONSENTED.
  • Generalization that is still precise. An operator drops cell_edge_m to 300 m to preserve heatmap utility; the “generalised” cell now resolves subjects inside the 1,850 ft radius and remains SPI. Detection: the constructor rejects any edge below MIN_CELL_M, and the validation harness asserts cell_edge_m >= 564. Remediation: raise the edge to the 1,128 m default; if utility genuinely requires finer cells, that data path needs CONSENTED subjects, not a shrunken grid.
  • Polar longitude collapse. At high latitude a fixed degree-based cell shrinks in the east–west direction and the physical cell edge falls below 564 m on the longitude axis. Detection: audit the metric edge, not the degree edge, in every released cell. Remediation: the cos(lat) scaling in _generalize already widens the longitude step; verify it is applied and that the max(cos, 1e-6) clamp prevents a divide-by-zero singularity at the pole.
  • Deletion that misses derived aggregates. A §1798.105 request purges the raw fix but a precise release already flowed into a downstream cache or model. Remediation: propagate deletion by retain_until and record ID to every sink, and prefer releasing generalised cells (which no longer identify the subject) so most derived data survives deletion without re-processing. Reconcile against the threat mapping for GIS data linkage catalogue to confirm no cached join re-identifies a deleted subject.

Frequently Asked Questions

Is a raw GPS ping always "precise geolocation" under CPRA?

Yes. Precise geolocation is any location that identifies a consumer within a radius of 1,850 feet (about 564 m), and a raw device fix at 6 decimal degrees resolves to roughly 0.11 m — orders of magnitude inside that circle. So every unmodified mobility ping enters the gate as Sensitive Personal Information and must be routed by consent state, not passed through by default.

Why default the generalization cell to 1,128 m instead of 564 m?

564 m is the statutory radius, so it is the absolute floor — a cell edge below it still resolves a subject inside the precise-geo circle. Setting the edge to one full diameter (1,128 m) makes a single released cell span more than the entire 1,850 ft circle, so no consumer can be pinned within the threshold. The floor keeps you legal; the diameter default keeps you defensible against edge-of-cell arguments.

How does "limit use of SPI" differ from "opt out of sale/share" in the gate?

They are two different levers. Limiting use of SPI means the business may still process the data for permitted purposes but not use precise geolocation, so the gate generalises to a compliant cell. Opting out of sale or sharing means the coordinate must not reach a sale/share sink at all, so the gate suppresses it at ingestion. A subject can exercise either, both, or neither, and the routing differs for each.

Where should the opt-out be enforced — collection or routing?

At the routing gate, treating the consent store as authoritative on every coordinate. Enforcing only at collection leaves a race window where in-flight pings leak after a late opt-out. Enforcing at the gate, plus a retroactive deletion sweep over the retention window on any opt-out event, closes that window and produces an audit record proving the coordinate never reached a downstream sink.

Up: Compliance Framework Mapping · Core Fundamentals & Architecture for Spatial Privacy