Zum Inhalt springen
Docs

Befehlspalette

Zu einer Seite oder Ansicht springen oder ein Todo per Name finden.

Änderungen

ADR-0030: MD audit package format

The repository's architectural decision, rationale and consequences.

Markdown

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.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

  • 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).
Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.