Privacy Budget Management

A production spatial-analytics platform serving hundreds of tenants emits thousands of differentially private releases a day — heatmaps, choropleth counts, trajectory statistics — and every one of them leaks a sliver of information about the people underneath the pixels. Privacy budget management is the operational discipline that keeps that cumulative leakage bounded: a live ledger that debits an ε\varepsilon cost from a per-subject, per-tenant, per-query-type account on each release, composes those costs correctly across the whole pipeline, and refuses the release that would push an account past its cap. Where the differential privacy for geospatial data mechanisms decide how much noise a single query gets, the budget ledger is their operational counterpart — it decides whether the query runs at all, and it is the component auditors ask about first.

This guide sits in the Core Fundamentals & Architecture for Spatial Privacy reference. It covers running the ε\varepsilon ledger as a service: choosing the privacy unit and ledger keys, standing up a Rényi differential privacy (RDP) accountant, debiting each release and converting the accumulated RDP curve to a single (ε,δ)(\varepsilon, \delta) figure, enforcing hard caps with pre-emptive alerting, and re-allocating budget when spatial re-scoring reclassifies a dataset. What happens after an account hits zero — degradation, coarsening, and cohort rotation — is the separate concern of the child guide on privacy budget exhaustion — detection and recovery; this page keeps the meter honest so exhaustion is the rare, orderly event it should be.

Privacy budget ledger lifecycle — debit, gauge against cap, warn, then allow or block, over a per-subject ledger table Top band, left to right: a "Release request" box carrying a ledger key and mechanism spec flows into an "RDP accountant" box that adds a Renyi divergence curve to the running total. That feeds a "gauge" showing running epsilon as a filled bar against the cap, with a coral warning tick at the eighty-percent mark. The gauge feeds a "cap decision" node whose two outputs are an "Allow — debit committed" chip and a "Block — refuse query" chip. Bottom band: a five-column ledger table titled per-subject / per-tenant epsilon ledger, with header cells ledger key, sum rho (zCDP), epsilon at delta 1e-5, percent of cap, and status. Four data rows show a subject heatmap at 55 percent OK, the same subject re-scored to a trajectory at 87 percent WARN, a tenant aggregate at 100 percent BLOCK, and a second subject heatmap at 34 percent OK. Privacy budget ledger — debit each release, gauge against the cap, warn, then allow or block release lifecycle Release request ledger key + mechanism spec RDP accountant add Renyi curve to running total running ε vs cap 0 warn 80% cap Cap check prospective ε ≤ cap? Allow debit commit Block refuse query per-subject / per-tenant ε ledger ledger key (subject·query·window) Σρ zCDP ε @ δ=1e-5 % of cap status subject:9f2a · heatmap · 2026-W28 0.021 0.83 55% OK subject:9f2a · trajectory · 2026-W28 0.180 2.61 87% WARN tenant:acme · aggregate · 2026-W28 0.240 3.02 100% BLOCK subject:c71b · heatmap · 2026-W29 0.008 0.51 34% OK Re-scoring a snapshot key (heatmap) into a trajectory key raises the per-release cost and pushes the same subject toward the cap.

Key concept. The privacy budget is not a per-query knob; it is a cumulative quantity that only ever grows within a window. Correct accounting composes the Rényi divergence of every release additively, converts the accumulated curve to (ε,δ)(\varepsilon, \delta) exactly once, and enforces the cap on the prospective spend — the value the release would reach — never after the fact.

Prerequisites and Accounting Environment

The ledger is arithmetic, not cryptography, so the reference implementation needs only Python’s standard library plus numpy for vectorising the RDP curve over a grid of Rényi orders. Everything below runs against real DP releases produced by the spatial noise mechanisms — Gaussian and Laplace primarily — so the ledger consumes a mechanism specification (a noise multiplier or an ε0\varepsilon_0), not raw data. Three environmental invariants must hold before the meter can be trusted:

  • A declared privacy unit. Composition bounds mean nothing until you fix what neighbouring datasets differ by: one event, one data subject over a window, or one tenant. The unit determines both the per-release sensitivity and the account the debit lands in, and it is the single most common source of a ledger that under-counts leakage.
  • RDP as the accumulation currency. Basic and advanced composition on (ε,δ)(\varepsilon, \delta) pairs are convenient to explain but loose to accumulate. Real accounting composes in Rényi space — where composition is exact addition — and converts to (ε,δ)(\varepsilon, \delta) only at read time. We track the RDP curve on a fixed grid of orders α\alpha, exactly as production accountants (TensorFlow Privacy, Opacus) do.
  • A cap wired to a concrete control. The (ε,δ)(\varepsilon, \delta) ceiling is not a taste decision. It descends from the compliance framework mapping — GDPR Article 25 data-minimisation expressed as a maximum cumulative ε\varepsilon per data subject, for instance — and from the risk tier assigned by the spatial sensitivity scoring models. A dense urban catchment where one nightly dwell point is highly identifying warrants a tighter cap than a sparse rural fleet.

Throughout, δ\delta is fixed at the release target (commonly 10510^{-5} or smaller than the reciprocal of the population) and ε\varepsilon is the quantity the ledger tracks and bounds.

Step 1: Define the Privacy Unit and Ledger Keys

The ledger key is the compound identity an ε\varepsilon cost is charged against. Charging everything to one global account is safe but useless — it exhausts in minutes and blocks the platform. Charging per query with no memory is the classic composition bug: an adversary issues many overlapping spatial queries, differences the answers, and peels the noise straight back off. The defensible middle is a compound key across four axes — data subject, tenant, query type, and temporal window — so that the meter isolates the leakage that actually composes against one person while letting independent subjects and independent epochs spend independently.

python
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class LedgerKey:
    """Compound identity a privacy cost is debited against.

    `subject` is the privacy unit — a data-subject id for user-level DP, or a
    tenant id when the guarantee is at the organisation boundary. `query_type`
    separates release families whose sensitivity differs (a snapshot heatmap
    vs. a trajectory statistic). `window` scopes composition to one epoch so
    budget can be released on a schedule instead of monotonically depleting.
    """

    subject: str        # e.g. "subject:9f2a" or "tenant:acme"
    query_type: str     # e.g. "heatmap" | "trajectory" | "aggregate"
    window: str         # e.g. ISO week "2026-W28"


def key_for(subject_id: str, query_type: str, window: str) -> LedgerKey:
    """Normalise inputs so equivalent releases collapse onto one account.

    Casing and whitespace differences must never fork a subject into two
    accounts — that would silently halve the effective composition bound.
    """
    return LedgerKey(
        subject=subject_id.strip().lower(),
        query_type=query_type.strip().lower(),
        window=window.strip(),
    )

The choice of subject is the privacy guarantee. If the unit is the data subject, neighbouring datasets differ by all of one person’s contributions and the cap bounds what any single individual can leak across every release combined. If the unit is the tenant, the guarantee protects the organisation but says nothing about individuals within it — appropriate for B2B mobility aggregates, dangerous for patient data. Fixing this before the first debit is non-negotiable; the differential privacy for geospatial data section covers how the unit interacts with sensitivity, and it is the assumption every number below inherits.

Step 2: Instantiate an RDP Accountant

A mechanism is (α,εRDP(α))(\alpha, \varepsilon_{\text{RDP}}(\alpha))-RDP if the Rényi divergence of order α\alpha between its outputs on neighbouring datasets is at most εRDP(α)\varepsilon_{\text{RDP}}(\alpha). Two facts make RDP the right accumulation currency. First, composition is addition: releasing mechanisms M1,,MkM_1, \dots, M_k yields (α,iεi(α))\big(\alpha, \sum_i \varepsilon_i(\alpha)\big)-RDP at every order. Second, the common spatial mechanisms have closed-form RDP curves — the Gaussian mechanism with noise multiplier σ\sigma (the ratio of noise standard deviation to 2\ell_2 sensitivity) is (α,α/(2σ2))\big(\alpha, \alpha/(2\sigma^2)\big)-RDP for all α>1\alpha > 1, which is equivalent to ρ\rho-zCDP with ρ=1/(2σ2)\rho = 1/(2\sigma^2). We track the accumulated curve on a fixed grid of orders and let each mechanism contribute its own curve.

python
import math
from dataclasses import dataclass
from typing import Tuple

import numpy as np

# Standard accounting grid: fractional low orders sharpen the tight-delta
# regime, integer orders cover the bulk, a few large orders catch high delta.
DEFAULT_ORDERS: np.ndarray = np.concatenate([
    np.array([1.25, 1.5, 1.75]),
    np.arange(2.0, 64.0),
    np.array([128.0, 256.0]),
])


@dataclass(frozen=True)
class GaussianMechanism:
    """Gaussian release. noise_multiplier = noise_std / L2-sensitivity."""

    noise_multiplier: float

    def rdp(self, orders: np.ndarray) -> np.ndarray:
        if self.noise_multiplier <= 0:
            raise ValueError("noise_multiplier must be positive")
        # (alpha, alpha / (2 sigma^2))-RDP  <=>  rho-zCDP with rho = 1/(2 sigma^2)
        return orders / (2.0 * self.noise_multiplier ** 2)


@dataclass(frozen=True)
class PureDPMechanism:
    """Pure eps0-DP release (e.g. geometric counts) via eps0-DP => (eps0^2/2)-zCDP."""

    epsilon0: float

    def rdp(self, orders: np.ndarray) -> np.ndarray:
        return (self.epsilon0 ** 2 / 2.0) * orders


@dataclass(frozen=True)
class LaplaceMechanism:
    """Exact Renyi divergence of the Laplace mechanism (Mironov 2017).

    epsilon0 = sensitivity / scale. Tighter than the pure-DP zCDP bound above,
    which matters when a spatial release leans on many Laplace count queries.
    """

    epsilon0: float

    def rdp(self, orders: np.ndarray) -> np.ndarray:
        a, e = orders, self.epsilon0
        term = ((a / (2 * a - 1)) * np.exp((a - 1) * e)
                + ((a - 1) / (2 * a - 1)) * np.exp(-a * e))
        return np.log(term) / (a - 1.0)


class RDPAccountant:
    """Accumulates an RDP curve additively and converts to (eps, delta)."""

    def __init__(self, orders: np.ndarray = DEFAULT_ORDERS) -> None:
        self.orders = np.asarray(orders, dtype=float)
        self._rdp = np.zeros_like(self.orders)

    def compose(self, mechanism, count: int = 1) -> None:
        """Debit `count` identical releases — pure additive composition."""
        self._rdp = self._rdp + count * mechanism.rdp(self.orders)

    @property
    def rdp(self) -> np.ndarray:
        return self._rdp.copy()

    def get_epsilon(self, delta: float) -> Tuple[float, float]:
        """Convert the accumulated curve to (eps, best_order) at a fixed delta.

        Mironov's RDP->DP bound: eps = rdp(alpha) + ln(1/delta)/(alpha - 1),
        minimised over the order grid. Reporting the minimising order is a
        useful audit signal — it should track the tightness of the release.
        """
        if not (0.0 < delta < 1.0):
            raise ValueError("delta must be in (0, 1)")
        conv = self._rdp + math.log(1.0 / delta) / (self.orders - 1.0)
        idx = int(np.argmin(conv))
        return float(conv[idx]), float(self.orders[idx])

The three mechanism classes make the accounting currency-agnostic: a spatial dashboard that mixes a Gaussian heatmap with a handful of Laplace count queries composes them on the same grid, and the get_epsilon conversion happens once over the summed curve rather than being pessimistically re-applied per query. This is exactly why RDP beats naïve (ε,δ)(\varepsilon, \delta) bookkeeping — the conversion loss is paid a single time, not kk times.

Step 3: Debit a Release and Convert RDP to Epsilon-Delta

The ledger wraps one RDPAccountant per key and exposes the operational surface: spend() to debit a release, remaining() to report headroom, and a prospective-spend check so the cap is enforced before the debit is ever committed. Converting the accumulated curve to (ε,δ)(\varepsilon, \delta) is where the composition story pays off: because the whole history lives as one RDP curve, the current spend for a key is a single get_epsilon call, no matter how many releases it aggregates.

python
from dataclasses import dataclass, field
from typing import Dict, List, Tuple


@dataclass
class BudgetCap:
    epsilon: float               # hard ceiling on cumulative eps per key
    delta: float                 # fixed conversion delta (e.g. 1e-5)
    warn_fraction: float = 0.8   # emit a warning once this fraction is reached


@dataclass
class SpendReceipt:
    key: LedgerKey
    epsilon: float               # cumulative eps for the key AFTER this debit
    remaining: float             # cap.epsilon - epsilon
    fraction: float              # epsilon / cap.epsilon
    warned: bool                 # crossed warn_fraction on this release


class BudgetExhausted(RuntimeError):
    """Raised when a release would push a key past its cap. Fail closed."""


class PrivacyBudgetLedger:
    """A live cumulative-epsilon ledger keyed by (subject, query_type, window)."""

    def __init__(self, cap: BudgetCap,
                 orders: np.ndarray = DEFAULT_ORDERS) -> None:
        self.cap = cap
        self.orders = np.asarray(orders, dtype=float)
        self._acc: Dict[LedgerKey, RDPAccountant] = {}
        self.history: List[Tuple[LedgerKey, np.ndarray]] = []
        self.warnings: List[SpendReceipt] = []

    def _accountant(self, key: LedgerKey) -> RDPAccountant:
        if key not in self._acc:
            self._acc[key] = RDPAccountant(self.orders)
        return self._acc[key]

    def epsilon_of(self, key: LedgerKey) -> float:
        """Cumulative eps for a key at the configured delta (0 if untouched)."""
        if key not in self._acc:
            return 0.0
        return self._acc[key].get_epsilon(self.cap.delta)[0]

    def remaining(self, key: LedgerKey) -> float:
        """Headroom before the cap. Clamped at 0 — never report negative budget."""
        return max(0.0, self.cap.epsilon - self.epsilon_of(key))

    def _prospective_epsilon(self, key: LedgerKey, mechanism,
                             count: int) -> float:
        """The eps this key WOULD reach if the release were committed."""
        trial = RDPAccountant(self.orders)
        if key in self._acc:
            trial._rdp = self._acc[key].rdp   # copy of the committed curve
        trial.compose(mechanism, count)
        return trial.get_epsilon(self.cap.delta)[0]

epsilon_of reads the meter; remaining reports headroom clamped at zero so a downstream planner never sees a negative budget and mis-schedules a release. The _prospective_epsilon helper is the linchpin of correct enforcement — it computes the post-release figure on a copy of the committed curve, so a rejected release leaves the account untouched. That separation is what lets the next step enforce the cap without ever committing a debit it then has to unwind.

Step 4: Enforce Caps and Emit Pre-Exhaustion Warnings

A cap enforced after a debit is a cap that has already been breached. The spend() method therefore evaluates the prospective spend first and raises BudgetExhausted before touching the accountant — failing closed, exactly as the parent architecture requires: an unavailable answer is acceptable, an unprotected answer is a breach. Warnings fire at a configurable fraction of the cap (warn_fraction, defaulting to 0.8) so operators can coarsen resolution or provision a fresh cohort before the meter blocks, rather than reacting to a refused query.

python
    def would_exceed(self, key: LedgerKey, mechanism, count: int = 1) -> bool:
        """True if committing this release would breach the cap."""
        return self._prospective_epsilon(key, mechanism, count) \
            > self.cap.epsilon + 1e-12

    def spend(self, key: LedgerKey, mechanism, count: int = 1) -> SpendReceipt:
        """Debit a release, enforcing the cap pre-emptively.

        The prospective check runs BEFORE the accountant is mutated, so a
        refused release never corrupts the committed curve. Only after the
        cap clears is the RDP debit made permanent.
        """
        prospective = self._prospective_epsilon(key, mechanism, count)
        if prospective > self.cap.epsilon + 1e-12:
            raise BudgetExhausted(
                f"release on {key} would spend eps={prospective:.4f} "
                f"> cap {self.cap.epsilon:.4f}")

        acc = self._accountant(key)
        acc.compose(mechanism, count)                       # commit the debit
        self.history.append((key, mechanism.rdp(self.orders) * count))

        eps = acc.get_epsilon(self.cap.delta)[0]
        fraction = eps / self.cap.epsilon
        warned = fraction >= self.cap.warn_fraction
        receipt = SpendReceipt(key, eps, self.cap.epsilon - eps,
                               fraction, warned)
        if warned:
            self.warnings.append(receipt)                   # pre-exhaustion alert
        return receipt

A driver that spends until the meter blocks shows the intended behaviour — steady debits, a warning as the account crosses 80%, then a hard refusal:

python
def drive_until_blocked() -> None:
    ledger = PrivacyBudgetLedger(BudgetCap(epsilon=3.0, delta=1e-5))
    key = key_for("subject:9f2a", "heatmap", "2026-W28")
    release = GaussianMechanism(noise_multiplier=4.0)
    while not ledger.would_exceed(key, release):
        r = ledger.spend(key, release)
        flag = "  <-- WARN" if r.warned else ""
        print(f"eps={r.epsilon:5.3f}  remaining={r.remaining:5.3f}{flag}")
    print("cap reached; further releases refused")


if __name__ == "__main__":
    drive_until_blocked()

The warning fraction is where operational alerting hooks in: push each SpendReceipt with warned=True to the on-call channel, and page when remaining() for a high-traffic tenant falls below the cost of a single scheduled release. Detecting and recovering from the exhaustion that follows — degradation, coarsening, cohort rotation — is the province of the child guide on privacy budget exhaustion — detection and recovery.

Step 5: Handle Window and Epoch Resets

A monotonic ledger is a ledger that eventually blocks everything. Real deployments release budget on a schedule — a fresh ε\varepsilon allowance per temporal window (an ISO week, a billing month) — which is sound only because the window axis is baked into the key: releases in different epochs are charged to different accounts, so resetting one epoch cannot retroactively unprotect another. The reset is a targeted drop of every key in the closed window; it must never touch a live epoch.

The subtler case is spatial re-scoring. A dataset first classified as a snapshot (a single-pass heatmap) becomes a trajectory the moment its temporal frequency rises, and a trajectory is categorically harder to protect — its per-release sensitivity is larger, so the same nominal query costs more ε\varepsilon. When the sensitivity scoring re-tiers a subject mid-window, the budget already spent under the snapshot key must carry to the trajectory key rather than resetting the meter, or re-classification becomes a free budget refill an adversary could trigger deliberately.

python
    def reset(self, window: str) -> int:
        """Release budget for one closed epoch. Returns the number of keys freed.

        Only keys whose `window` matches are dropped; every other epoch's
        cumulative spend is preserved, so a reset can never unprotect a subject
        in a still-open window.
        """
        drop = [k for k in self._acc if k.window == window]
        for k in drop:
            del self._acc[k]
        return len(drop)

    def rescope(self, old: LedgerKey, new: LedgerKey) -> None:
        """Carry spend when spatial re-scoring reclassifies a release family.

        A snapshot (heatmap) that becomes a trajectory must not reset its meter.
        The already-committed RDP curve is transferred onto the new key, whose
        future releases will be debited at the higher trajectory sensitivity.
        """
        acc = self._acc.pop(old, None)
        if acc is not None:
            target = self._accountant(new)
            target._rdp = target.rdp + acc.rdp   # accumulated leakage carries over

rescope transfers the accumulated RDP curve wholesale, so the subject’s cumulative ε\varepsilon is unchanged at the instant of re-classification; only the cost of subsequent releases rises, because the new query_type carries the trajectory mechanism’s larger sensitivity. This is the budget re-allocation the architecture calls for when a tile’s temporal frequency crosses the snapshot-to-trajectory boundary — the same re-scoring trigger described in the spatial sensitivity scoring models.

Step 6: Validate the Composition Math

The one failure mode that voids the entire guarantee is composition that silently under-counts. The harness below asserts the invariants that must hold in CI: RDP composes additively, the grid conversion is tight to — and never beats — the closed-form zCDP bound, RDP accounting is never looser than basic composition, the cap is enforced monotonically with a warning before it blocks, and re-scoring carries spend rather than resetting it.

python
def zcdp_to_dp(rho: float, delta: float) -> float:
    """Closed-form zCDP -> (eps, delta): eps = rho + 2*sqrt(rho*ln(1/delta))."""
    return rho + 2.0 * math.sqrt(rho * math.log(1.0 / delta))


def basic_composition(eps: float, k: int) -> float:
    """Naive linear composition of k identical (eps, delta) releases."""
    return k * eps


def advanced_composition(eps: float, delta_prime: float, k: int) -> float:
    """Dwork-Rothblum-Vadhan advanced composition (for the eps' comparison)."""
    return (math.sqrt(2 * k * math.log(1.0 / delta_prime)) * eps
            + k * eps * (math.exp(eps) - 1.0))


def _validate() -> None:
    orders, delta = DEFAULT_ORDERS, 1e-5

    # (a) RDP composition is exact addition.
    single = GaussianMechanism(noise_multiplier=4.0)
    acc = RDPAccountant(orders)
    k = 20
    for _ in range(k):
        acc.compose(single)
    assert np.allclose(acc.rdp, k * single.rdp(orders)), "RDP must add up"

    # (b) Grid conversion is tight to, and never beats, the zCDP closed form.
    rho = 1.0 / (2.0 * single.noise_multiplier ** 2)        # per-release zCDP
    eps_grid, _ = acc.get_epsilon(delta)
    eps_zcdp = zcdp_to_dp(k * rho, delta)
    assert eps_grid + 1e-9 >= eps_zcdp, "grid cannot beat the continuous optimum"
    assert eps_grid - eps_zcdp < 0.10 * eps_zcdp, "grid must stay tight"

    # (c) RDP accounting is never looser than basic composition.
    eps_single = zcdp_to_dp(rho, delta)
    assert eps_grid <= basic_composition(eps_single, k) + 1e-9
    # Advanced composition is tighter than basic only when per-release eps is
    # small; here eps_single is large, so basic is the tighter (eps, delta) bound.
    assert basic_composition(eps_single, k) <= advanced_composition(
        eps_single, delta, k)

    # (d) Exact Laplace RDP is finite and positive across the grid.
    lap = LaplaceMechanism(epsilon0=0.5)
    assert np.all(np.isfinite(lap.rdp(orders))) and np.all(lap.rdp(orders) > 0)

    # (e) The ledger enforces the cap monotonically and warns before blocking.
    ledger = PrivacyBudgetLedger(BudgetCap(epsilon=3.0, delta=delta,
                                           warn_fraction=0.8))
    key = key_for("subject:9f2a", "heatmap", "2026-W28")
    last, warned_once = 0.0, False
    while not ledger.would_exceed(key, single):
        r = ledger.spend(key, single)
        assert r.epsilon >= last - 1e-12, "cumulative eps must be non-decreasing"
        last, warned_once = r.epsilon, warned_once or r.warned
    assert ledger.epsilon_of(key) <= 3.0 + 1e-9, "cap must never be breached"
    assert warned_once, "must warn before the cap blocks"

    # (f) A release past the cap is refused, not silently allowed.
    try:
        ledger.spend(key, single)
    except BudgetExhausted:
        pass
    else:
        raise AssertionError("exhausted budget must raise")

    # (g) Re-scoring carries spend; a window reset frees only that epoch.
    l2 = PrivacyBudgetLedger(BudgetCap(epsilon=5.0, delta=delta))
    snap = key_for("subject:c71b", "heatmap", "2026-W28")
    l2.spend(snap, single)
    spent = l2.epsilon_of(snap)
    traj = key_for("subject:c71b", "trajectory", "2026-W28")
    l2.rescope(snap, traj)
    assert abs(l2.epsilon_of(traj) - spent) < 1e-9, "re-scoring must carry spend"
    assert l2.epsilon_of(snap) == 0.0
    assert l2.reset("2026-W28") == 1 and l2.epsilon_of(traj) == 0.0

    print("all privacy-budget-ledger invariants hold")


if __name__ == "__main__":
    _validate()

Invariant (b) is the one worth internalising: the discrete order grid can only ever report an ε\varepsilon at or above the true continuous optimum, so a grid conversion that comes in below the closed-form zCDP figure is a bug in the accountant, not a tighter bound. Invariant © quantifies why the whole exercise is worth it — for these parameters RDP composition reports a cumulative ε\varepsilon several times smaller than naïve linear composition of the same kk releases, which is the difference between a ledger that lasts a week and one that blocks by lunchtime.

Threat Model Considerations

The budget ledger closes an information-theoretic channel — composition leakage — so its threats are about tricking the accountant rather than breaking cryptography:

  • Composition / query-correlation attacks. The canonical DP attack: issue many overlapping spatial queries, difference the answers, and average away the noise. Correct per-subject accounting is the only defence — if two correlated releases are charged to separate accounts (because a key was mis-normalised, or the privacy unit was set to event rather than subject), the attack succeeds even though every individual query was “properly” noised.
  • Budget-timing / side-channel probing. An adversary who can observe whether a query is refused learns the account is near its cap, which itself leaks information about how much a subject has been queried. Return a uniform “unavailable” response for both cap-blocks and genuine errors, and avoid response-latency differences between the prospective-check path and the debit path.
  • Re-scoring reset abuse. If re-classification reset the meter, an adversary who can influence temporal frequency (spoofing extra pings to tip a snapshot into a trajectory) could trigger free budget refills. The rescope carry-over closes this — spend is monotonic across a re-tier.
  • Account-splitting via key forks. Casing, whitespace, or transliteration differences that fork one subject into two accounts halve the effective composition bound. Canonicalise keys at the boundary (as key_for does) and treat a sudden rise in distinct-subject cardinality as an alert.
  • Delta shopping. Reporting ε\varepsilon at an adversary-chosen δ\delta understates the real spend. Fix δ\delta to the release target up front and never let a caller vary it per query.
  • Windowing that outpaces correlation. Resetting the window faster than the real-world correlation horizon (resetting weekly when a subject’s home is stable across months) lets cumulative leakage exceed the nominal cap over the correlated period. Size the window to the sensitivity horizon, not to a convenient billing cycle.

Validation & Compliance Checklist

Wire each control into CI and the compliance dashboard with a measurable pass criterion rather than a code review:

  1. Additive composition — PASS if composing kk identical releases equals k×k \times the single-release RDP curve elementwise, for a 104\ge 10^4-iteration property test. This is the core correctness invariant; fail the build on any deviation.
  2. Conversion soundness — PASS if get_epsilon never returns a value below the closed-form zCDP bound for a pure Gaussian sequence, and stays within 10% of it. A conversion that “beats” the continuous optimum is under-counting.
  3. Cap enforcement — PASS if no key’s cumulative ε\varepsilon ever exceeds cap.epsilon, and every over-cap release raises BudgetExhausted rather than returning a result. Fail closed, always.
  4. Pre-exhaustion alerting — PASS if a warned=True receipt is emitted at or before warn_fraction of the cap on every account that reaches it, and the warning precedes the first block.
  5. Monotonic re-scoring — PASS if rescope leaves cumulative ε\varepsilon unchanged at the instant of re-classification and raises the per-release cost thereafter. Assert that no re-tier ever lowers a subject’s spend.
  6. Window isolation — PASS if reset(window) frees exactly the keys in that epoch and leaves every other window’s spend intact. Tie the cap value and window length to a clause in the compliance framework mapping so each is defensible in an audit.

The _validate() harness in Step 6 exercises invariants 1–6 directly and is safe to run in CI.

Failure Modes & Remediation

Budget accounting fails quietly — it does not crash, it under-counts, and the leak surfaces only when someone re-identifies a subject. The high-frequency production failures:

  • Per-query accounting instead of cumulative. Each release is individually noised but nothing tracks the running total, so composition leakage is unbounded. Detection: assert every release path routes through spend() and that no code path produces a DP release without a debit. Recovery: gate the release API behind the ledger so an un-debited release is structurally impossible.
  • Wrong privacy unit. The unit is set to event-level while the guarantee is claimed at subject-level; the cap bounds far less than advertised. Detection: reconcile the claimed guarantee in the compliance mapping against the subject axis actually used. Recovery: re-key to the correct unit and recompute historical spend before issuing further releases.
  • Naïve (ε,δ)(\varepsilon,\delta) composition. Summing ε\varepsilon per release blocks the platform far too early and tempts operators to raise the cap to compensate — inflating real risk. Detection: compare the deployed accountant’s cumulative ε\varepsilon against the RDP harness for the same release sequence. Recovery: switch accumulation to RDP and convert once at read time.
  • Silent δ\delta drift. A caller varies δ\delta per query and the reported ε\varepsilon no longer reflects the real spend. Detection: assert a single fixed δ\delta across all get_epsilon calls. Recovery: pin δ\delta to the release target in BudgetCap and reject per-call overrides.
  • Re-scoring that resets the meter. A snapshot-to-trajectory re-tier drops the old account, refilling budget. Detection: the invariant-5 CI check. Recovery: route every re-classification through rescope so spend carries.
  • Cap breach masked by a race. Two concurrent releases both pass the prospective check, then both commit, jointly overshooting the cap. Detection: reconcile committed ε\varepsilon against the cap after each debit. Recovery: serialise spend() per key (a per-key lock or single-writer actor) so the prospective check and the commit are atomic.

Frequently Asked Questions

Why compose in Rényi space instead of adding up epsilon per query?

Because RDP composition is exact addition of the Rényi curve, and the lossy conversion to a final (epsilon, delta) is paid exactly once over the summed curve rather than once per release. Adding (epsilon, delta) pairs directly (basic composition) is correct but drastically loose — for a sequence of Gaussian releases it can report a cumulative epsilon several times larger than the RDP bound, which blocks the platform far sooner than the true privacy loss requires.

What is the right ledger key for a multi-tenant spatial platform?

A compound key over data subject, tenant, query type, and temporal window. The subject axis is the privacy unit and determines the guarantee; the tenant axis isolates one customer's spend from another's; the query-type axis separates release families whose sensitivity differs (a snapshot heatmap versus a trajectory statistic); and the window axis scopes composition to an epoch so budget can be released on a schedule. Charging everything to one global account exhausts immediately, and charging per query with no memory reopens composition attacks.

How should the cap change when a snapshot dataset becomes a trajectory?

The cap itself does not change, but the per-release cost does. A trajectory has larger sensitivity than a snapshot, so the same nominal query debits more epsilon. Critically, the budget already spent under the snapshot key must carry over to the trajectory key rather than resetting — otherwise re-classification becomes a free budget refill an adversary could trigger by inflating temporal frequency. Transfer the accumulated RDP curve on re-scoring and let only future releases cost more.

When should the ledger warn versus block?

Warn at a configurable fraction of the cap — 0.8 is a sensible default — so operators can coarsen release resolution or provision a fresh cohort before the meter blocks. Block, by raising an exhaustion error, only when the prospective spend of a release would exceed the cap, and enforce that check before the debit is committed so a refused release never corrupts the account. The block must fail closed: an unavailable answer is acceptable, an unprotected one is a breach.

Does clamping or post-processing a released value cost extra budget?

No. Any function applied to a differentially private release without re-touching the raw data is post-processing and consumes zero additional budget — clamping negative counts to zero, smoothing a heatmap, or rounding coordinates are all free. Budget is debited only when the raw data is queried through a mechanism. This is why the ledger charges at the mechanism call and never at downstream transforms.

This guide is part of the Core Fundamentals & Architecture for Spatial Privacy reference — start there for how scoring, routing, budgeting, and audit fit into one pipeline.

Up: Core Fundamentals & Architecture for Spatial Privacy