Zum Inhalt springen
Docs

Befehlspalette

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

Änderungen

ADR-0025 — Opaque object keys and staged clinical objects

The repository's architectural decision, rationale and consequences.

Markdown

Status: accepted · Date: 2026-10-31

Context

An offline device uploads encrypted bytes before their patient, wound, or photo records necessarily exist on the server. Uploading bytes must neither create a clinical record nor make those bytes downloadable. Retries must not overwrite a previously attached object.

ADR-0001 preserves user filenames for the template's file gallery. Clinical objects require opaque identifiers instead: filenames, patient names, and other identifying text must not enter keys or URLs.

Decision

Clinical keys use the active Organization and a UUID. The shared leaf contract validates both the explicit stage layout and the compact report/link layouts:

  • Photos: orgs/<org>/photos/<photoId>/<crop|thumb|full>.bin.
  • Consent signatures: orgs/<org>/signatures/<consentId>/signature.bin.
  • Reports: orgs/<org>/reports/<reportId>.pdf.bin; staging also accepts orgs/<org>/reports/<reportId>/pdf.bin.
  • Link bundles: orgs/<org>/links/<linkId>.bin; staging also accepts orgs/<org>/links/<linkId>/zip.bin.
  • Exports: orgs/<org>/exports/<exportId>/zip.bin.

These keys amend ADR-0001 for clinical objects only. File-gallery naming remains as described there. Object bytes are ciphertext produced by the device; MIME is application/octet-stream. The server receives no decryption key. It cannot prove encryption from a byte signature; C3 proves encryption using a synthetic fixture that decrypts only with its test key.

objects.stage runs the clinical boundary, including clinicalObjectAccess, then commits an object_stage intent under a key advisory lock. The intent names its uploader, device, ciphertext size, SHA-256, kind, variant, and seven-day expiry. Repeating an unchanged live intent from the same user/device is allowed. Attached, changed, or foreign intents cannot be staged again. An expired intent returns transient OBJECT_PENDING until cleanup removes it; the same device can then re-stage its retained bytes. Tenant grants deliberately do not permit renewing expires_at.

Staging permits at most 100 MiB per object and, per User within an Organization, 200 unattached stages totalling 512 MiB. Expired intents still count until cleanup removes them. A User advisory lock serializes quota checks across their devices; retries of an existing intent consume no additional quota. Exceeding the quota returns TOO_MANY_REQUESTS.

The returned PUT signs Content-Length, the SHA-256 header, and If-None-Match: *. Storage verifies the ciphertext checksum and refuses replacement. A target lasts at most five minutes and never beyond the intent's remaining lifetime. The mobile engine persists upload progress per blob. A retry stages that object again; a 412 means a prior create already won. Attachment still verifies the stored object.

attachStagedObjects(tx, ctx, objects) uses the handler's transaction. It locks keys in sorted order, verifies Organization, uploader/device, metadata, expiry, and HEAD size/checksum, then stamps attached_at. A missing object throws ObjectPending (OBJECT_PENDING, transient); mismatches throw ObjectInvalid (OBJECT_INVALID, permanent). A handler rollback also rolls back every stamp. The sync ledger makes replay idempotent; callers do not attach a second time. Sync push supplies the reference metadata from the mutation, independently of the stage row. Missing or expired stages also defer with OBJECT_PENDING. The device atomically marks the referenced blobs for re-upload when it receives that result, preserving the ciphertext and the original mutation id.

Storage HTTP/network failures and missing or malformed HEAD verification headers are typed ObjectStorageUnavailable failures. Attachment maps these to OBJECT_PENDING; only a verified size or checksum mismatch is permanently invalid. A provider must return the SHA-256 checksum for HEAD with x-amz-checksum-mode: ENABLED. MinIO proves this locally; a provider that omits it remains pending until its checksum capability is configured or supported.

A signed GET lasts five minutes. A download requires an attached stage and an actual clinical reference. Patient scope comes from that reference, never from client input. Staging and downloading use the kind's permission: photo:capture for photos/signatures, report:create for reports, link:create for link bundles, and export:legal for exports. The common procedure checks the baseline patient:read-full-names capability (all current care roles have it), then each object service checks its specific permission before resolving its owner. There is no blanket photo-capture requirement for reports or exports. Export bundles require legal-export permission and remain available in export-only access state. Intimate full-frame photo downloads are refused here; ticket 41 owns the reasoned, audited full-access path.

objectsCleanup accepts the scheduler's existing jobs.tick system handle and removes a bounded batch of expired unattached objects and rows under the same key locks. It retains intents for fifteen minutes after expiry for PUTs already in flight (the mobile upload timeout is two minutes). DELETE precedes row removal: a storage failure retains the intent, and a transaction failure allows idempotent deletion on the next tick. A failed storage deletion backs off that key for five minutes and continues the batch. The system job moves its expiry inside the cleanup grace window while keeping it in the past, so attachment still defers and subsequent batches can reach other tenants. Database failures and job cancellation still abort. Attached rows are never cleanup candidates.

The grace period is an operational assumption, not a storage-enforced upload deadline. A custom client can start a PUT before signature expiry and continue past that period. A late completion could recreate an orphan after row deletion. A persistent cleanup tombstone or a provider-enforced maximum upload duration is required to eliminate that case; the current schema has neither. This limitation remains a coordinator follow-up and prevents claiming a complete cleanup guarantee.

Transaction and storage constraints

The ticket requires HEAD verification in the attaching transaction. HEAD and cleanup DELETE therefore use a ten-second storage timeout while holding the key lock. This is a deliberate exception to the usual no-network-in-transactions rule: moving either request outside that lock allows attachment to race deletion. A schema-backed cleanup tombstone would allow a different design, but schema changes are owned by the serial schema tickets.

The shared storage capability cannot sign checksum headers or return HEAD checksums, and ticket 22 cannot edit packages/storage or manifests. The owned objects.s3.ts adapter uses the existing storage environment and Node's HMAC implementation for SigV4, with no new dependency. The procedure/job creates one handle for its request/tick and passes it to services; tests inject the same ObjectStorage interface. The harness exercises its signed PUT, HEAD, GET, DELETE, and checksum rejection against MinIO. The coordinator can later move this capability into the shared storage handle without changing service APIs.

The scheduler passes its existing database and tick clock into objectsCleanup. No schema, RLS, package export, or storage package changes are needed.

Evidence

The six ticket acceptance titles are tests in packages/api/src/features/objects/objects.service.test.ts. Additional tests cover expiry-bound signatures, cleanup grace, and the shared key contract. Mobile transport tests assert the checksum and immutable-write headers and ciphertext body, retry, a lost acknowledgement, and offline staging. The engine test covers re-upload after cleanup and acknowledgement of the original mutation. The normal Postgres integration run starts a disposable local MinIO container for C3 and SigV4 verification. An explicit WK_OBJECTS_HARNESS_SLOT reuses a running local harness instead; it is no longer required to exercise signing. The fixture removes its own container and anonymous volumes after the test.

S3 checksum and signature behavior follows the AWS presigned URL, SigV4 query authentication, and HeadObject contracts. Deployment and hosted-provider proof remain outside this local ticket.

Verified against production R2 (2026-10-31)

Probe against bucket wundklare-storage (EU jurisdiction): a PUT with x-amz-checksum-sha256 returned 200; HEAD with x-amz-checksum-mode: ENABLED returned the identical base64 SHA-256; a second PUT with If-None-Match: * returned 412. Checksum-bound attach and immutable uploads therefore work on R2 as designed. The probe object was deleted.

Amendment (2026-11-01, ticket 58): stage against the existing parent

Problem. objects.stage resolved the owner from the child named in the key (photo, consent, report, share link). The device uploads before it pushes the mutation that creates that child (the engine runs uploadPendingBlobs before push; a share link is created by shareLinks.create, which itself requires the attached bundle). Staging therefore returned NOT_FOUND for every offline-created child, and a share link could never be created at all: the link row needs the staged object and the stage needed the link row.

Decision. The stage intent declares the parent that already exists (stageObjectInput.parent), and staging authorizes against it:

kinddeclared parentstaging checks
photo{ woundId }photo:capture, scope of the wound's patient
signature{ patientId }photo:capture, scope of the patient
report_pdf{ patientId } or { patientId, woundId }report:create, scope of the patient; the wound must belong to it
share_bundle{ reportId }link:create, scope of the report's patient
export_bundlenoneunchanged: export:legal, the export job row

A parent of the wrong shape is OBJECT_INVALID; an unknown parent is NOT_FOUND; a parent outside scope is PATIENT_OUT_OF_SCOPE. The parent is not stored on object_stage: it only gates who may reserve an upload, and it is never a source of patient scope for downloads.

Attachment is unchanged and stays the authority: the create/attach transaction validates the real child row, its parent and the patient scope (photo.attach, consent.record, visit.record, report.create, shareLinks.create), exactly as above. Downloads still resolve the child reference.

On the device, EnqueueInput.blobs[].parent is persisted with the blob and passed by the object transport to objects.stage. A child whose parent was itself created offline (a photo on a new wound) fails to stage until the parent's mutation is applied, then uploads on the next sync; the attaching mutation waits for the upload as before.

Evidence. packages/api/src/features/objects/objects.offline-stage.test.ts runs each kind through the real objects.stage procedure against MinIO, uploads first, then pushes consent.record, photo.capture+photo.attach, report.create through sync.push, and calls shareLinks.create; it also covers the out-of-scope, unknown and wrong-shape parents. Mobile: sync.engine.test.ts and sync.objects.orpc.test.ts ("58: …").

Auf GitHub bearbeitenDie Dokumentation ist auf Englisch.