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.
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)
packages/api/src/features/exports/. Each decision has a ticket 72 test in
exports.jobs.test.ts or exports.procedures.test.ts.
- Request.
exports.requestruns the clinical gate withexport:legal(owner and admin) inexportmode. Only the gate's role refusal is answered asEXPORT_FORBIDDENand logged as a deniedexport.requestedevent; 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 holdsmanifest.json,trail.jsonandrecords.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.pendingunder 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 toobject_keys. Before reading an intimate full frame, a separate committed transaction records itslegal_exportaccess 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 purposesphoto-<variant>andreport-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.jsonholds patients, wounds, prescriptions, plan versions and photo metadata, with_ctfields andwk1arrays decrypted. The manifest lists every file with its volume and SHA-256, and groups files per wound. - Download and expiry.
exports.downloadreturns 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 logsexport.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 fromobject_keys. Storage deletion is idempotent, so a crash between the two steps is retried safely.
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_accessevent per legal-export frame (reasonlegal_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).