# ADR-0025 — Opaque object keys and staged clinical objects

The repository's architectural decision, rationale and consequences.

# ADR-0025 — Opaque object keys and staged clinical objects

**Status:** accepted · **Date:** 2026-10-31

## Context [#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 [#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 [#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 [#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](https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-presigned-url.html),
[SigV4 query authentication](https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html),
and [HeadObject](https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html)
contracts. Deployment and hosted-provider proof remain outside this local ticket.

## Verified against production R2 (2026-10-31) [#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 [#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:

| kind            | declared parent                             | staging 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_bundle` | none                                        | unchanged: `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: …").

