Caching Fare Tables on Offline Validators

When an edge validator loses its backhaul link, it still has to price taps — and it can only do that from a fare table it already holds locally. This page implements a versioned local fare-table cache: signed table bundles fetched while the link is up, a signature-and-version check before any bundle is trusted, an atomic swap so a validator never prices against a half-written table, and a staleness bound that refuses a table too old to be safe. It is a leaf of the Fallback Routing Strategies discipline inside Core Architecture & Fare Taxonomy, and it is what lets an offline reader compute a defensible Decimal fare instead of failing open or failing shut.

Fare-table cache flow

An update never overwrites the live table in place. The validator fetches a candidate bundle, verifies its signature and version against what it already trusts, writes it to a side path, and only then swaps it in with an atomic rename. At tap time the pricing path reads whatever the live symlink points at, checks it against the staleness bound, and prices — or, if the only cached table is too stale, routes to a degraded fare rather than pricing on stale data.

Versioned fare-table cache: verify, atomic swap, price offline On the update path a candidate signed bundle is fetched, then its signature and version are verified. A bad signature or a lower version is rejected and the live table is left untouched. A valid, newer bundle is written to a side path and atomically swapped to become the live table. On the pricing path a tap reads the live table and checks its age against the staleness bound: within the bound the tap is priced offline in Decimal; beyond the bound the tap routes to a degraded fare. The update and pricing paths meet only at the live table. bad sig / lower ver valid, newer within bound too stale Fetch signed bundle Verify signature & version REJECT keep live table Write to side path + atomic swap Live fare table versioned + verified Tap reads live table check staleness bound Price offline Decimal fare DEGRADED_FARE table too stale

Step 1 — A signed, versioned table bundle

A fare table shipped to an unattended validator is a security artifact, not just data: a tampered table can silently over-charge or under-charge every rider on that gate. Bundle the table with a monotonic version, an issued-at timestamp, and a detached signature over the canonical bytes, then verify all three before the table is ever consulted. The example uses HMAC for a self-contained, runnable demonstration; a production deployment signs with the asymmetric key material governed by AFC System Security Boundaries so a compromised validator cannot forge a bundle.

import hmac
import json
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal
from hashlib import sha256

logger = logging.getLogger("afc.fare_table_cache")


class FareTableError(Exception):
    """Raised when a bundle fails verification or a fare cannot be resolved."""


@dataclass(frozen=True)
class FareTableBundle:
    version: int
    issued_at: datetime            # timezone-aware UTC
    fares: dict[str, str]          # zone-pair -> fare string, e.g. "Z1:Z3" -> "3.25"
    signature: str                 # hex HMAC-SHA256 over canonical payload

    def canonical_payload(self) -> bytes:
        """Deterministic bytes the signature is computed over."""
        body = {
            "version": self.version,
            "issued_at": self.issued_at.astimezone(timezone.utc).isoformat(),
            "fares": {k: self.fares[k] for k in sorted(self.fares)},
        }
        return json.dumps(body, separators=(",", ":"), sort_keys=True).encode("utf-8")


def sign_bundle(version: int, issued_at: datetime, fares: dict[str, str], key: bytes) -> FareTableBundle:
    """Produce a signed bundle (used by the central distribution service)."""
    unsigned = FareTableBundle(version, issued_at, fares, signature="")
    sig = hmac.new(key, unsigned.canonical_payload(), sha256).hexdigest()
    return FareTableBundle(version, issued_at, fares, signature=sig)

Signing over a canonical serialization — sorted keys, no incidental whitespace — is what makes verification reproducible: the validator re-serializes the received fields the same way and any tampered byte changes the digest. Storing fares as strings in the bundle keeps them out of float on the wire; they become Decimal the instant they are priced.

Step 2 — Verify, atomically swap, and price offline

The cache does three jobs. On update it verifies a candidate bundle and, only if it is authentic and strictly newer, swaps it in atomically by writing a temp file and os.replace-ing it over the live path — a rename is atomic on POSIX, so a reader either sees the whole old table or the whole new one, never a torn file. On pricing it enforces the staleness bound and looks up the fare in Decimal.

import os
from pathlib import Path


class VersionedFareTableCache:
    def __init__(self, live_path: Path, verify_key: bytes, max_staleness_hours: int = 72) -> None:
        self.live_path = live_path
        self.verify_key = verify_key
        self.max_staleness = max_staleness_hours

    def _verify(self, bundle: FareTableBundle) -> None:
        expected = hmac.new(self.verify_key, bundle.canonical_payload(), sha256).hexdigest()
        if not hmac.compare_digest(expected, bundle.signature):
            raise FareTableError(f"signature mismatch on version {bundle.version}")
        if bundle.issued_at.tzinfo is None:
            raise FareTableError("bundle issued_at must be timezone-aware")

    def current_version(self) -> int:
        if not self.live_path.exists():
            return -1
        raw = json.loads(self.live_path.read_text("utf-8"))
        return int(raw["version"])

    def install(self, bundle: FareTableBundle) -> bool:
        """Verify and atomically swap in a newer bundle. Returns True if installed."""
        self._verify(bundle)  # authenticity gate before anything touches disk
        if bundle.version <= self.current_version():
            logger.info("rejecting version %s; not newer than live %s",
                        bundle.version, self.current_version())
            return False

        payload = {
            "version": bundle.version,
            "issued_at": bundle.issued_at.astimezone(timezone.utc).isoformat(),
            "fares": bundle.fares,
            "signature": bundle.signature,
        }
        tmp = self.live_path.with_suffix(f".tmp.{bundle.version}")
        tmp.write_text(json.dumps(payload), encoding="utf-8")
        os.replace(tmp, self.live_path)  # atomic rename; no torn read
        logger.info("installed fare table version %s", bundle.version)
        return True

    def price(self, zone_pair: str, now: datetime) -> Decimal:
        """Price a tap from the live table, or raise if it is too stale."""
        if not self.live_path.exists():
            raise FareTableError("no cached fare table present")
        raw = json.loads(self.live_path.read_text("utf-8"))
        issued = datetime.fromisoformat(raw["issued_at"])
        age_hours = (now - issued).total_seconds() / 3600
        if age_hours > self.max_staleness:
            raise FareTableError(
                f"table version {raw['version']} is {age_hours:.1f}h old; "
                f"exceeds {self.max_staleness}h bound"
            )
        fare_str = raw["fares"].get(zone_pair)
        if fare_str is None:
            raise FareTableError(f"no fare for zone pair {zone_pair}")
        return Decimal(fare_str)

The order of guards in install is deliberate: verify authenticity first, then check the version, then touch disk. A bundle that fails its signature never reaches os.replace, so a forged or corrupted download can never become the live table — the reject path leaves the previously trusted table exactly where it was. hmac.compare_digest is used instead of == so signature checking is constant-time and does not leak via timing.

Validation & Test Cases

Prove the two behaviors that matter: a fresh, authentic table prices correctly, and a tampered or stale table is refused rather than silently trusted.

from datetime import datetime, timedelta, timezone
from decimal import Decimal
import tempfile
from pathlib import Path

KEY = b"edge-validator-shared-key"
now = datetime(2026, 7, 17, 9, 0, tzinfo=timezone.utc)

with tempfile.TemporaryDirectory() as d:
    live = Path(d) / "fare_table.json"
    cache = VersionedFareTableCache(live, KEY, max_staleness_hours=72)

    fresh = sign_bundle(5, now - timedelta(hours=1),
                        {"Z1:Z1": "2.75", "Z1:Z3": "3.25"}, KEY)
    assert cache.install(fresh) is True

    # Fresh authentic table prices in Decimal
    assert cache.price("Z1:Z3", now) == Decimal("3.25")

    # A lower version is not installed (monotonic guard)
    older = sign_bundle(4, now, {"Z1:Z3": "9.99"}, KEY)
    assert cache.install(older) is False
    assert cache.price("Z1:Z3", now) == Decimal("3.25")  # unchanged

    # Tampered bundle: fares mutated after signing -> signature mismatch, rejected
    tampered = FareTableBundle(6, now, {"Z1:Z3": "0.00"}, signature=fresh.signature)
    try:
        cache.install(tampered)
        assert False, "tampered bundle must be rejected"
    except FareTableError as exc:
        assert "signature mismatch" in str(exc)

    # Stale table: issued 100h ago exceeds the 72h bound -> degraded, not priced
    stale_cache = VersionedFareTableCache(live, KEY, max_staleness_hours=72)
    try:
        stale_cache.price("Z1:Z3", now + timedelta(hours=100))
        assert False, "stale table must be refused"
    except FareTableError as exc:
        assert "exceeds" in str(exc)

The tampered-bundle case is the security assertion: the attacker reused a valid signature but changed the fare to 0.00, and because the signature is verified over the actual fares, the recomputed HMAC no longer matches and install refuses it — the live version-5 table stays in place. The stale case is the availability assertion: rather than pricing riders against a table old enough to predate a fare change, price raises so the caller can route to a degraded fare instead.

Edge Cases

  • Clock rollback at the edge. Staleness uses now - issued_at, so a validator whose clock jumps backwards can make a current table look impossibly fresh or a fresh one look stale. Bound the check on a monotonic-ish trusted time source and reject negative ages as a clock fault, not as freshness.
  • First boot with no table. A validator that has never synced has nothing to price against; price raises no cached fare table present, which must route to the degraded path, never to a zero fare.
  • Version reuse after rollback. If central distribution ever reissues a lower version to force a rollback, the monotonic guard will refuse it. Roll back by issuing a higher version carrying the older fares, so the audit trail stays strictly increasing.
  • Partial download. A truncated fetch fails signature verification before it can be installed, so a half-downloaded bundle is indistinguishable from a tampered one — which is the safe outcome.

Integration Note

This cache is the on-device data source the parent Fallback Routing Strategies assume exists: when a validator drops offline and buffers taps for later reconciliation, it prices those taps from this versioned table so the buffered events carry a defensible fare rather than a placeholder. The signature material and the rule that an offline reader cannot mint trust for itself come from AFC System Security Boundaries. When even a valid table cannot resolve a fare — an unknown zone pair, or a table past its staleness bound — control passes to Building Graceful Degradation for Offline Fare Readers, which computes the conservative degraded fare this page’s DEGRADED_FARE branch points at.

FAQ

Why swap the table with an atomic rename instead of writing it in place?
Because a validator prices taps continuously, and an in-place write can be read mid-update as a torn file — half the old table, half the new — which produces garbage fares. Writing the new bundle to a temp file and calling os.replace makes the swap atomic on POSIX: a concurrent reader sees either the complete old table or the complete new one, never a partial state. The old file is only unlinked once no reader holds it.
What stops a tampered fare table from being installed on an unattended validator?
Signature verification runs before anything touches disk. The bundle carries an HMAC (asymmetric signature in production) over a canonical serialization of its version, timestamp, and fares; the validator re-serializes the received fields and recomputes the digest with hmac.compare_digest. Any mutated byte changes the digest, so a forged or corrupted bundle is rejected and the previously trusted table is left untouched. The signing key is governed by the AFC security boundary, not held on the edge device.
What happens when the only cached table is too old to trust?
The price method compares the table's age against a configured staleness bound and raises rather than pricing against data that may predate a fare change. The caller catches that and routes the tap to a degraded fare through the offline fare-reader fallback, so the rider still gets a defensible charge and the event is flagged for reconciliation once connectivity returns. Pricing silently on a stale table is the failure this bound exists to prevent.
Why store fares as strings in the bundle instead of numbers?
To keep money out of binary float across serialization. JSON numbers deserialize to float, which cannot represent most cent values exactly, so a fare could drift a fraction of a cent on every round-trip through the cache. Storing "3.25" as a string and constructing a Decimal from it only at pricing time guarantees the fare on the validator is bit-for-bit the fare the agency published.

Part of Fallback Routing Strategies, within Core Architecture & Fare Taxonomy.