Fail-closed exports: when your report should refuse to run

Audit exports that silently ship wrong totals are worse than none. The reconciliation pattern we use so an export fails loudly when the numbers disagree.

Most reports are built to succeed. You query some tables, sum some columns, write a file, return a download link. If a number is wrong, nobody finds out until a customer, or an auditor, does.

For a commute-emissions platform whose exports go to external auditors, that wasn’t acceptable. The client needed to know that if an export was produced, its totals were right. So we built it the other way around: the export has to prove the numbers agree before it’s allowed to exist.

The shape of the problem

The system logs individual commute trips and maintains pre-aggregated daily rollups per user, site and organisation, across dimensions like emissions, transport mode and data quality. Rollups make dashboards fast. They also create a second source of truth.

Over time, any of these will happen:

  • A bug in the rollup write path skips a dimension.
  • A backfill updates raw trips but not the aggregates.
  • A concurrent write races and one update is lost.

In each case the dashboard shows one number and the raw data would produce another. An export that reads from the rollups would ship the wrong number with full confidence.

Rule 1: the export re-derives totals independently

The export pipeline does not reuse the aggregation code that produces rollups. It has its own, deliberately simple, derivation that reads raw trip records and sums them per day:

def derive_daily_totals(trips: Iterable[Trip]) -> dict[date, Totals]:
    totals: dict[date, Totals] = defaultdict(Totals)
    for trip in trips:
        day = trip.started_at.astimezone(ORG_TZ).date()
        totals[day].emissions_g += trip.emissions_g
        totals[day].trips += 1
        totals[day].by_mode[trip.mode] += trip.emissions_g
    return totals

Then it loads the stored rollups for the same range and compares:

def reconcile(derived, stored, tolerance_g=0) -> list[Divergence]:
    issues = []
    for day in sorted(set(derived) | set(stored)):
        d, s = derived.get(day), stored.get(day)
        if d is None or s is None or abs(d.emissions_g - s.emissions_g) > tolerance_g:
            issues.append(Divergence(day=day, derived=d, stored=s))
    return issues

If issues is non-empty, the export stops. It doesn’t pick the “better” number. It doesn’t write a partial file. It records the divergence, alerts us, and returns an error to the user that says the data needs attention.

Sharing the aggregation logic between the write path and the check would defeat the purpose. If the same function has the same bug in both places, they’ll agree and both be wrong. The check must be an independent implementation, even if that feels like duplicated code.

Rule 2: the package proves what it contains

When reconciliation passes, the export produces a ZIP of 13 files: per-entity CSVs, a method note, the reconciliation report itself, and a manifest. Every file is SHA-256 hashed and the hashes are listed in the manifest:

{
  "generated_at": "2026-09-30T02:14:11Z",
  "range": { "from": "2026-07-01", "to": "2026-09-30" },
  "files": {
    "trips.csv": "9f86d081884c7d659a2feaa0c55ad015...",
    "daily_totals.csv": "2c26b46b68ffc68ff99b453c1d304134...",
    "reconciliation.json": "ba7816bf8f01cfea414140de5dae2223..."
  }
}

An auditor can verify that nothing changed between generation and delivery. The reconciliation report is inside the package, so the evidence that the totals matched travels with the totals.

Rule 3: long jobs don’t fight the gateway

An export over a quarter of data for a large organisation takes longer than API Gateway’s 29-second synchronous limit. The usual answers are a queue and a worker, or Step Functions. Both are fine, but for one job type they’re more moving parts than needed.

We used a self-invoking Lambda:

def handler(event, context):
    if event.get("phase") != "run":
        job = ExportJob.create(status="queued", params=event["params"])
        lambda_client.invoke(
            FunctionName=context.function_name,
            InvocationType="Event",  # async
            Payload=json.dumps({"phase": "run", "job_id": job.id}),
        )
        return {"statusCode": 202, "body": {"job_id": job.id}}

    job = ExportJob.get(event["job_id"])
    run_export(job)  # reconcile, build ZIP, upload to S3

The first invocation returns a job ID in milliseconds. The second runs with the full 15-minute Lambda budget. The client polls a status endpoint, and when the job is done it receives a presigned S3 URL that expires in an hour.

Rule 4: raw records are never deleted

Rollups can be rebuilt. Raw trips cannot. Every correction, cancellation or dispute creates a new record that references the original; nothing is updated in place. That’s what makes the independent re-derivation possible at all. If you overwrite raw data, you’ve lost the ability to check anything.

What this costs

The export is slower than one that reads rollups directly, and the reconciliation code is extra work to maintain. In exchange, the client has never shipped a wrong number to an auditor, and when divergences did occur during development, the export caught them before anyone outside the team saw them.

For systems where someone external relies on your numbers, that trade is worth it every time. If you need an export, ledger or report that has to be right, talk to us.

Where we've done this

Climate techAustralia

A serverless commute-emissions platform with rideshare, audit-grade exports, a portal and a mobile app, taken from first milestone to production-ready

AWS CDK across 18 DynamoDB tables and two Cognito pools, COPPA-grade consent for school programmes, QR-verified rideshare with scheduled settlement, fail-closed audit exports, and a React portal plus React Native app, hardened for a live pilot.

  • 18DynamoDB tables, all retained and protected
  • 16CloudWatch alarms routed to the client
  • AWS CDK
  • Lambda (Python 3.13)
  • API Gateway
  • DynamoDB

More from the blog

Need this built, not just explained?

Tell us what you're building. You'll get a straight answer on scope, timeline and cost, usually within one business day.