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.0m ≈ 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.0m; the defensible default is one diameter,CELL_EDGE_M = 1128.0m (), 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: and at latitude , 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), andOPTED_OUT(opted out of sale/share → suppress before the coordinate reaches any sale/share sink). An unknown or missing state resolves toLIMITED, 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.”
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.
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()
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_subjectretroactively over the retention window to purge any precise release, and re-emit affected records as suppressed. Fail closed — an un-synced state resolves toLIMITED, neverCONSENTED. - Generalization that is still precise. An operator drops
cell_edge_mto 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 belowMIN_CELL_M, and the validation harness assertscell_edge_m >= 564. Remediation: raise the edge to the 1,128 m default; if utility genuinely requires finer cells, that data path needsCONSENTEDsubjects, 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_generalizealready widens the longitude step; verify it is applied and that themax(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_untiland 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.
Related
- Compliance Framework Mapping — the parent guide translating GDPR, HIPAA, CCPA, and GLBA into technical parameters.
- Mapping HIPAA Requirements to Geospatial Datasets — the sibling obligation mapping for protected health location data.
- Spatial Sensitivity Scoring Models — the tiering that feeds the gate’s SPI classification.
- Threat Mapping for GIS Data — the linkage vectors a generalised release must still defend against.
- Core Fundamentals & Architecture for Spatial Privacy — where consent-gated routing sits in the end-to-end pipeline.
Up: Compliance Framework Mapping · Core Fundamentals & Architecture for Spatial Privacy