Building a Spatial Privacy Audit Report Template

A defensible spatial-privacy release report must record, at minimum, the dataset hash, the technique applied, its parameters (ε\varepsilon/δ\delta or kk), the sensitivity Δf\Delta f derivation, the coordinate reference system, the composition history of prior releases, the validation results, and a named sign-off — and it should be emitted as a machine-readable JSON “privacy receipt” by the pipeline itself rather than written by hand after the fact.

An anonymization step that leaves no record is not auditable, and an unauditable release cannot be defended when a regulator asks what did you do, to which data, and how do you know it worked. The failure mode is familiar: a dataset is released, months pass, a re-identification claim surfaces, and the team discovers nobody wrote down which ε\varepsilon was used, whether the coordinates were in degrees or metres, or how many earlier extracts drew from the same source. By then the answer is unrecoverable. A privacy receipt makes the answer a build artifact, produced automatically at release time, so the record exists before anyone needs it. This template turns the regulator’s question into a schema, extending the compliance mapping for GDPR/CCPA location data into a concrete artifact.

Required Fields permalink

Each field below is load-bearing; omitting any one leaves a gap an auditor will find. The report is small deliberately — enough to reconstruct and challenge the release, no more.

Field Type Purpose
dataset_hash string (sha256) Binds the report to the exact released bytes
source_id string Identifies the input dataset and lineage
record_count integer Scope of the release
crs string (EPSG) Coordinate reference system, e.g. EPSG:4326
technique enum k_anonymity, laplace_dp, gaussian_dp, masking
k integer or null Anonymity-set floor when k-anonymity is used
epsilon number or null Privacy budget for this release (DP)
delta number or null Failure probability for approximate DP
sensitivity number or null Δf\Delta f used to calibrate noise
sensitivity_derivation string How Δf\Delta f was bounded (units, assumptions)
composition_history array Prior releases and cumulative budget spent
validation object Re-id score, min group size, or error bound
signed_off_by string Named reviewer accountable for the release
timestamp string (ISO 8601) When the release was produced

The single piece of math the report needs to carry is the budget relationship it documents — for the DP case, the noise scale is fixed by

b=Δfε,b = \frac{\Delta f}{\varepsilon},

so a report that states ε\varepsilon without the Δf\Delta f derivation is not reproducible. For k-anonymity releases the analogous invariant is Pr[re-identify]1/k\Pr[\text{re-identify}] \le 1/k. The composition_history matters because sequential releases from one source add up: basic composition gives a cumulative εtotal=iεi\varepsilon_{\text{total}} = \sum_i \varepsilon_i, and that running total belongs in the receipt so the privacy budget (epsilon) allocation can be tracked across a program, not just one job.

JSON schema for the privacy receipt permalink

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "SpatialPrivacyReceipt",
  "type": "object",
  "required": [
    "dataset_hash", "source_id", "record_count", "crs",
    "technique", "composition_history", "validation",
    "signed_off_by", "timestamp"
  ],
  "properties": {
    "dataset_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"},
    "source_id": {"type": "string"},
    "record_count": {"type": "integer", "minimum": 0},
    "crs": {"type": "string", "pattern": "^EPSG:[0-9]+$"},
    "technique": {
      "type": "string",
      "enum": ["k_anonymity", "laplace_dp", "gaussian_dp", "masking"]
    },
    "k": {"type": ["integer", "null"], "minimum": 2},
    "epsilon": {"type": ["number", "null"], "exclusiveMinimum": 0},
    "delta": {"type": ["number", "null"], "minimum": 0, "maximum": 1},
    "sensitivity": {"type": ["number", "null"], "exclusiveMinimum": 0},
    "sensitivity_derivation": {"type": ["string", "null"]},
    "composition_history": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["release_id", "epsilon_spent"],
        "properties": {
          "release_id": {"type": "string"},
          "epsilon_spent": {"type": "number", "minimum": 0}
        }
      }
    },
    "validation": {
      "type": "object",
      "properties": {
        "reidentification_risk": {"type": ["number", "null"]},
        "min_group_size": {"type": ["integer", "null"]},
        "empirical_error": {"type": ["number", "null"]}
      }
    },
    "signed_off_by": {"type": "string"},
    "timestamp": {"type": "string", "format": "date-time"}
  }
}

Python Implementation permalink

A dataclass captures the pipeline metadata and one function emits the receipt as validated JSON. The hash is computed over the released output, not the source, so the receipt attests to what actually leaves the building.

from __future__ import annotations

import hashlib
import json
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone


@dataclass
class ReleaseMetadata:
    """Metadata captured by an anonymization pipeline for one release.

    Populated as the pipeline runs so the receipt reflects the actual
    parameters used, not values re-typed by hand afterwards.
    """

    source_id: str
    crs: str  # e.g. "EPSG:4326"; recorded because sensitivity is CRS-dependent
    technique: str  # k_anonymity | laplace_dp | gaussian_dp | masking
    signed_off_by: str
    k: int | None = None
    epsilon: float | None = None
    delta: float | None = None
    sensitivity: float | None = None
    sensitivity_derivation: str | None = None
    # Prior releases from the same source: (release_id, epsilon_spent).
    composition_history: list[dict] = field(default_factory=list)
    validation: dict = field(default_factory=dict)


def emit_privacy_receipt(
    released_bytes: bytes,
    record_count: int,
    meta: ReleaseMetadata,
) -> str:
    """Emit a machine-readable spatial-privacy receipt as JSON.

    Args:
        released_bytes: The exact serialized output being published. Hashed so
            the receipt is falsifiable — it binds to these bytes only.
        record_count: Number of records in the release (scope of exposure).
        meta: Pipeline-captured parameters for the applied technique.

    Returns:
        A JSON string conforming to the SpatialPrivacyReceipt schema.

    Raises:
        ValueError: If a DP technique is declared without epsilon/sensitivity,
            or k-anonymity without k — a receipt must never omit its guarantee.
    """
    if meta.technique in {"laplace_dp", "gaussian_dp"}:
        if meta.epsilon is None or meta.sensitivity is None:
            raise ValueError("DP release requires epsilon and sensitivity.")
    if meta.technique == "k_anonymity" and meta.k is None:
        raise ValueError("k-anonymity release requires k.")

    # Content hash over the *released* artifact makes the receipt verifiable.
    dataset_hash = hashlib.sha256(released_bytes).hexdigest()

    # Cumulative budget: this release plus all prior spend (basic composition).
    epsilon_total = (meta.epsilon or 0.0) + sum(
        h.get("epsilon_spent", 0.0) for h in meta.composition_history
    )

    receipt = {
        "dataset_hash": dataset_hash,
        "source_id": meta.source_id,
        "record_count": record_count,
        "crs": meta.crs,
        "technique": meta.technique,
        "k": meta.k,
        "epsilon": meta.epsilon,
        "delta": meta.delta,
        "sensitivity": meta.sensitivity,
        "sensitivity_derivation": meta.sensitivity_derivation,
        "composition_history": meta.composition_history,
        "epsilon_total": round(epsilon_total, 6),
        "validation": meta.validation,
        "signed_off_by": meta.signed_off_by,
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }
    # Sorted keys give a stable, diffable artifact suitable for immutable store.
    return json.dumps(receipt, indent=2, sort_keys=True)

The receipt should then be validated against the JSON schema (with jsonschema.validate) and written to append-only storage next to the release. Because the guarantee fields are required, a pipeline that forgets to record its kk or ε\varepsilon fails loudly rather than shipping an undocumented release — the enforcement point that separates a template from a habit.

Two design decisions in the emitter deserve emphasis. First, the hash is taken over released_bytes, the serialized artifact that actually leaves the pipeline, rather than the in-memory frame or the source. This makes the receipt falsifiable: anyone holding the published file can recompute the SHA-256 and either confirm or refute that this receipt describes it, with no trust in the producer required. Second, epsilon_total is derived, not supplied — it is computed from this release plus the recorded history, so a caller cannot understate cumulative spend by simply typing a smaller number. Where multiple releases are correlated rather than independent, basic additive composition is conservative; tighter accounting under zero-concentrated or Rényi DP can be substituted, but the receipt should always store the method used alongside the total so a reviewer knows which bound applies.

Verification permalink

Confirm the receipt is complete and internally consistent before publishing:

import jsonschema

def verify_receipt(receipt_json: str, schema: dict) -> dict:
    """Validate a receipt and assert internal budget consistency."""
    receipt = json.loads(receipt_json)
    jsonschema.validate(receipt, schema)  # structural conformance

    # A DP receipt must never claim a smaller total than this release alone.
    if receipt.get("epsilon"):
        assert receipt["epsilon_total"] >= receipt["epsilon"], (
            "epsilon_total below this release's epsilon — composition error"
        )
    assert len(receipt["dataset_hash"]) == 64, "hash not a full sha256"
    return {"valid": True, "hash": receipt["dataset_hash"]}

Field-to-clause checklist for the audit binder:

  • dataset_hash, source_id, technique, timestamp → GDPR Article 30 record of processing.
  • validation, composition_history, sensitivity_derivation → GDPR Article 35 data protection impact assessment (residual risk and safeguards).
  • signed_off_by → accountability under Article 5(2).
  • crs present and matching the sensitivity units → reproducibility of the guarantee.

Edge Cases & Adjustments permalink

  • Missing composition history: if prior releases are unknown, the true cumulative budget is unbounded — record composition_history as incomplete and treat the release as high-risk rather than assuming zero prior spend.
  • CRS mismatch: a Δf\Delta f derived in metres (a UTM zone) but applied to EPSG:4326 degrees silently miscalibrates noise; the crs field exists so a reviewer catches this.
  • Hash of source vs. output: always hash the released bytes. Hashing the source lets a post-hoc edit to the output go undetected.
  • Technique selection is upstream: the receipt documents the choice but does not make it — whether kk-anonymity or DP was the right control is decided during masking vs. differential privacy technique selection and evidenced by a privacy risk scoring framework.
  • Amended or re-issued releases: if a release is corrected and re-published, mint a new receipt with a fresh hash and timestamp and reference the superseded release_id in composition_history, rather than editing the original — the audit trail must remain append-only so a reviewer can see both versions and the reason for the change.
  • Non-DP masking with no formal guarantee: for masking releases there is no ε\varepsilon or kk to record, so the validation object must carry an empirical measure instead — a re-identification risk score or a minimum crowd size — otherwise the receipt asserts a transformation with no evidence it reduced risk.

Storing receipts is only half the value; querying them is the other half. Because every receipt is JSON with a stable key order, a fleet of releases becomes a queryable ledger: you can sum epsilon_total per source_id to enforce a program-wide budget ceiling, list every release whose signed_off_by is a departed employee, or flag any receipt missing a validation result. That ledger is what lets a data protection officer answer, in minutes rather than weeks, the question a regulator actually asks — show me every location release you made from this source, and prove each one was within policy.

FAQ permalink

Why include a dataset hash in a privacy audit report?

The hash binds the report to the exact bytes released. Without it, the report is unfalsifiable — nobody can prove which file the stated ε\varepsilon or kk applied to. A content hash of the released output lets any later reviewer confirm the artifact in hand is the one the report describes, which is essential for GDPR Article 30 records and Article 35 impact assessments.

What is a privacy receipt?

A machine-readable JSON record emitted by the anonymization pipeline that states, for one release, what data was transformed, by which technique, with which parameters, spending how much cumulative budget, and validated to what result. It turns a manual audit narrative into a schema-validated artifact that can be diffed, queried, and stored immutably.

How does the report support GDPR Article 30 and 35?

Article 30 requires records of processing activities; the receipt’s identity, technique, and timestamp fields populate that register directly. Article 35 requires a data protection impact assessment for high-risk processing; the validation results and composition history evidence the residual risk and the safeguards applied — exactly what a DPIA must show.


Related

Back to Compliance Mapping for GDPR/CCPA Location Data