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 acceptsorgs/<org>/reports/<reportId>/pdf.bin. - Link bundles:
orgs/<org>/links/<linkId>.bin; staging also acceptsorgs/<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:
| 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: …").