# ADR-0030: MD audit package format

The repository's architectural decision, rationale and consequences.

# ADR-0030: MD audit package format

**Status:** Accepted (coordinator decision, 2026-11-01).

The package must contain the legally required material, including intimate full
originals, and be encrypted with a separate password. zip.js 2.24.0 writes
AES-256 AE-2 entries and accepts a streaming destination. The initial Node-only
spike is in [the measurements](../measurements/2026-12-export-throughput.md).

## Decision [#decision]

Use independently complete AES-256 ZIP volumes with stored entries. Record a
volume as complete only after its multipart upload has completed and its metadata
has been committed under the job lease. On retry, retain completed ciphertext;
abort and rebuild an incomplete upload. Do not claim that a rebuilt incomplete
volume is byte-identical: zip.js generates a fresh random salt. Identical bytes
can be guaranteed only for completed ciphertext that is retained and reused.
Never force or reuse AES salts to satisfy a byte comparison.

The decision accepts the complete-volume format, not an unmeasured throughput
guarantee. End-to-end local R2 and workerd measurements must establish a safe
volume size and handling of an individual source larger than the per-tick budget.
Recovery must cover a lost multipart completion acknowledgement and orphan
cleanup. Entry names and ZIP directory metadata are not encrypted by standard ZIP
encryption; names must not contain clinical identifiers.

## Implementation (ticket 72) [#implementation-ticket-72]

`packages/api/src/features/exports/`. Each decision has a ticket 72 test in
`exports.jobs.test.ts` or `exports.procedures.test.ts`.

- **Request.** `exports.request` runs the clinical gate with `export:legal`
  (owner and admin) in `export` mode. Only the gate's role refusal is answered as
  `EXPORT_FORBIDDEN` and logged as a denied `export.requested` event; every other
  refusal passes through. A patient scope is checked after the insert has taken
  the ADR-0033 retention lock. Art. 15 (`patient_subject_access`) needs a patient.
- **Password.** 24 base64url characters (144 bits), returned once. Job state keeps
  it only wrapped under the server KEK with the AAD `org:<org>:export-<id>:v<n>`,
  so it cannot be moved to another export. The wrap is cleared when the build
  finishes or fails.
- **Volumes.** The first tick fixes the partition (`state.plan`): report PDFs, then
  crop and full originals, packed greedily up to 32 MiB; a larger single source
  gets a volume of its own. The last volume holds `manifest.json`, `trail.json`
  and `records.json`. Each volume is a complete AES-256 (AE-2) ZIP with stored
  entries, written by zip.js into an R2 multipart upload in 8 MiB parts under a
  deterministic key per export and volume. Entry names are opaque ids. The
  first tick also captures the records and trail sequence in a repeatable-read
  snapshot. If that sequence has unsealed events, the final volume defers
  without consuming an attempt until the sealer has covered them.
- **Checkpoints and recovery.** One export per tick under a 60-second lease, with a
  15-second budget, drained on interruption. Before the upload starts, the key is
  written to `state.pending` under the lease. Recovery lists uploads for that key,
  including one whose creation response was lost. After the upload
  completes, one transaction records the volume (key, ciphertext SHA-256, plaintext
  SHA-256 per entry) and appends the key to `object_keys`. Before reading an
  intimate full frame, a separate committed transaction records its `legal_export`
  access and trail event; retries reuse that record. Every
  write is fenced by the lease token. A later tick aborts and removes a pending
  upload, which also covers a lost completion acknowledgement, and rebuilds that
  volume with fresh salts. Completed volumes are never rewritten,
  so their bytes stay identical. A failed build is retried by the next tick. After
  five failed ticks without volume progress, or when the AVV or access state refuses an export, the job fails
  and removes every object it wrote.
- **Contents.** Originals and PDFs are decrypted with an export-owned key reader
  (`exports.keys.ts`) that uses the same wire formats as capture and reports. ODK
  wraps use purposes `photo-<variant>` and `report-pdf`, and the OIK uses ECIES.
  The trail excerpt contains the in-scope events and their payload hashes, the
  links of the active chain (v2 after cutover), the head, and the latest
  verification. V2 commitments verify the chain envelope, but verifying a body
  commitment also requires its retained secret. `records.json` holds patients, wounds, prescriptions, plan
  versions and photo metadata, with `_ct` fields and `wk1` arrays decrypted. The
  manifest lists every file with its volume and SHA-256, and groups files per wound.
- **Download and expiry.** `exports.download` returns a presigned GET for at most
  five minutes, bounded by the remaining package lifetime, and refuses requests
  with less than 30 seconds remaining. It returns the URL
  for one volume and logs `export.downloaded`. It refuses an unfinished export
  (`CONFLICT`) and an expired one (`LINK_EXPIRED`) on every request. Every tick
  deletes the objects of expired packages under the ADR-0033 per-object lock and
  removes the keys from `object_keys`. Storage deletion is idempotent, so a crash
  between the two steps is retried safely.

## Open work [#open-work]

- No end-to-end measurement in workerd against local R2 exists yet. If a volume
  cannot be built within the 15-second budget, the build fails after five
  attempts rather than looping. Photo ciphertext is limited to 25 MiB at stage
  validation and checked again by the export because AES-GCM requires the whole
  ciphertext and plaintext in memory. Choose volume size and source handling
  only after those measurements.
- Export rows have no trail action of their own for completion. The trail holds
  the request (accepted or denied), one `photo.full_access` event per legal-export
  frame (reason `legal_export`), and each download.
- The owner decision of ADR-0033 on retaining packages still applies to erasure
  before expiry. An unexpired package blocks erasure (`EXPORT_RETAINED`).

