Reference source · 0.1.0-dev.11
portability.md
docs/portability.md · Download original file
# Full export, independent backups and restore
The `0.1.0-dev.4` prototype implements a signed full archive, an encrypted offline backup, atomic restore into an empty record, and duplicate-safe delayed writes after separately establishing destination authorization. This is a working portability slice for the reference Patient/laboratory profile. Incremental export, general FHIR/attachments, resumable large archives remain work. Coordinated live migration is implemented separately for this same narrow profile; see [Host migration](host-migration.md).
## Permission and Host boundary
`export_record` is an encrypted request with `patient` and `access_token`. It requires `oprp/record.export`, a separate grant scope. Demographic or laboratory read permission does not imply export. The normal requested-scope, duration, Host policy, pending approval and representative limits apply. A Host can preauthorize export under its own patient agreement or require a fresh authenticated approval. Export does not prescribe a patient portal.
The response is `{status: "ok", archive}`. TypeScript exposes `client.exportRecord(patient)`. The Host-owned `exportRecord(patient, now)` is an internal integration method; its wire dispatcher performs the permission check. Administrative callers must authenticate their own export authority. The prototype does not offer an unauthenticated download route or publish archives through a discoverable URL.
Restore currently uses trusted local `host.importRecord(archive, authenticatedPatient, now)`. The Host must authenticate the owner or an independently authorized representative before calling it. The reference method's owner assertion is not authentication. It requires an exact record binding and refuses to overwrite or merge an existing Patient. A standardized requester-driven import/admission endpoint and separately scoped transfer/delegate authority are still needed.
## Archive profile and contents
The outer archive has exactly `payload`, `signature`, `source_credential` and `status_evidence`. Payload is base64url compact UTF-8 JSON. The source Host signs ASCII `OPRP-ARCHIVE-0.1.` followed by that encoded payload with its ML-DSA-65 key. Its credential is hashed into the manifest and evaluated under independently chosen authority policies; an archive cannot supply a new trusted root for itself.
The manifest includes:
| Field | Meaning |
| --- | --- |
| `v`, `profile` | Draft version `0.1`; `oprp-reference-r4-patient-labs`. |
| `archive_id`, `source`, `patient`, `created_at` | Stable package identity, source Host, record binding and signed creation time. |
| `credential_digest` | SHA-512 fingerprint of the encoded source credential object. |
| `resources` | Current Patient and laboratory Observations, original IDs/versions and per-entry SHA-512 digests. |
| `receipts` | Application/patient/operation IDs, original logical request/digest, accepted result and actor attribution. |
| `retained_events` | Retained immutable source changes, original event IDs/positions, resource content and versions. |
| `audits` | Selected record-associated audit rows present at export time. |
| `prior_archives` | Previously imported signed packages retained as independent history evidence. |
| `coverage` | Complete current record within this narrow profile, complete retry receipts, explicitly incomplete clinical history. |
| Optional `handoffs` | Signed custody chains linking the exporting Host to exact source archives preserved in `prior_archives`. |
| `content_digest` | SHA-512 of the compact manifest without this field. |
All current content, receipts, retained events and selected audit rows come from one consistent database transaction. Per-resource digests cover `{resource, version}`. The aggregate digest covers manifest membership and metadata; the signature covers the complete encoded manifest. Digests alone do not provide identity authentication.
This uses FHIR JSON resources plus OPRP metadata, not a FHIR transaction Bundle or a claim of universal FHIR archive compatibility. [FHIR R4 Bundle](https://hl7.org/fhir/R4/bundle.html) remains an input to the eventual broader interchange mapping. The current scope exports all record-associated audit rows, including application/actor identifiers; the separate export permission covers that sensitive evidence. Fine-grained audit export policy is unresolved.
## Coverage and unsupported content
The adapter currently stores one Patient and laboratory Observations; Observation versions remain one because updates/deletes are not implemented. Patient IDs, reference-Host versions and unknown JSON fields survive restore. The importer does not rewrite foreign extensions into a narrower Patient model or claim their clinical validation. It validates package identity, allowed resource types, patient references, lab category, membership, uniqueness, digests, receipt tuple/digest, event ordering and source signature/evidence. It is not a complete FHIR validator or US Core validator.
`coverage.complete_record` means the entire current record held by this **reference profile**, not every clinical record scattered across providers. `complete_history` is always false: initial versions and previously pruned changes were not retained. The archive explicitly preserves the retained evidence it does have. Clinical signatures embedded in unknown fields are preserved as bytes/JSON but not independently clinically validated. Generic resource and binary/attachment support must be implemented before claiming broader coverage. Photo/attachment fields and derived-resource references outside the supported profile cause explicit rejection; they are not silently omitted or represented as backed-up bytes.
The manifest is bounded to 512 KiB, with bounded lists and at most four nested archive ancestors. Retained ancestors and optional signed handoffs count toward that limit, so a record that once fit a source archive may outgrow re-export after a move. This keeps export inside the current 1 MiB protected-body limit. Oversized packages fail; there is no truncation, compression, paging or resumable archive stream. Source receipt context is now stored atomically with every mutation. An older database containing receipts without patient/application/logical-request metadata cannot export safely and returns `legacy_receipt_context_missing`; automatic reconstruction from opaque hashes is not attempted.
## Independent encrypted backup
`encryptBackup(archive)` generates a random 32-byte recovery key and returns it separately from the backup. It encrypts the entire archive using AES-256-GCM with a random 12-byte nonce and 16-byte tag. Associated data is the compact JSON object `{"v":"0.1","suite":"OPRP-BACKUP-A256GCM"}`. The outer file contains only `v`, `suite`, `nonce`, `ciphertext` and `tag`; patient IDs, resources, source identity and credential evidence are inside the ciphertext. Its length remains visible.
This is symmetric authenticated encryption with a full-entropy key, separate from the public-key protected transport. The recovery key is not derived from a weak password or a source-provider account. Store it in patient-controlled recovery storage, independently from the archive and source Host. Loss of that key makes the encrypted backup unrecoverable; copying it alongside the archive into the same exposed location defeats the separation. A production SDK must integrate secure key storage/recovery. No password, secret-sharing or hardware-storage UX is implemented yet.
`decryptBackup(backup, recoveryKey)` authenticates and decodes the archive. `verifyArchive(archive, trust, now, expectedPatient)` then verifies signature, historical source credential/status, known current revocations and package integrity. Neither step needs the source private key, original patient account, source database, source availability or a mandatory Convey service. The caller supplies independently admitted public-root policies. Keep exported plaintext and recovery keys out of logs and apply application-owned storage protection. The demo uses synthetic files with mode 0600 and deletes them at completion.
Historical status is checked at creation time; source credential expiry afterward does not prevent reading a legitimate old backup. That evidence does not prove an independent timestamp or knowledge of later compromise. Known revoked source keys/issuers fail verification by default; offline unknown revocation and long-term compromise adjudication require further design. See [credential lifecycle](credential-lifecycle.md).
## Atomic restore and delayed retries
The destination validates the entire archive before importing. One transaction inserts current resources/versions, preserved operation receipts/context and the signed source package. A collision or failure rolls the transaction back. An identical archive import is idempotent; changing content under a previously accepted archive ID fails. Another archive cannot silently replace the already occupied record.
Source event positions and audit IDs remain inside signed evidence; they are not inserted as local sequence numbers. The destination appends baseline resource events to its own feed. Clients obtain a new destination snapshot/cursor. Re-export retains the earlier signed package in `prior_archives`, so the old verification evidence remains inspectable rather than being relabeled as the destination's own original history.
The archive does not contain source grants, access/refresh tokens, pending approval tasks, policies, Host private keys, feed secrets or replay caches. Destination policy/representative validation and new bounded authorization occur separately. A source JWT is not a destination credential.
After destination authorization, an application can resend an earlier uncertain mutation using the original patient, application subject, operation ID and unchanged logical content. Imported receipts return the original lab ID or Patient version without changing the record again. Changed content under that ID fails. Current destination permission is checked before receipt recovery. Write-only acknowledgements reveal submitted fields and assigned identifiers, rather than unrelated full Patient content from an imported receipt. Operation-equivalence remains byte/order sensitive under the current JSON profile.
## Backup copies and live migration
Restoring a package creates a copy. It does not update the patient's address, stop source writes or make two active Hosts safe. [Coordinated Host migration](host-migration.md) now captures a baseline, resends final catch-up under a durable source fence, verifies a signed release/activation chain, switches discovery generation and resumes late queues under destination authorization. Its cancellation and failed-handoff rules preserve one writer with intact custody state. Incremental archives, resumable streaming, general profiles, aliases and rollback-resistant/distributed recovery remain portability work. Source deletion is a separate retention/authorization decision.
## Runnable evidence
Run `npm run recovery`. The [demo](../scripts/recovery.ts) creates a synthetic encrypted backup, shuts down the source, removes its database, advances beyond credential expiry, verifies offline, imports into a new Host, establishes new authorization and replays previously accepted writes without duplicates. It then re-exports with original evidence intact.
The [archive tests](../tests/archive.test.ts) additionally cover export scope/patient boundaries, unknown-field preservation, wrong recovery key, ciphertext and signature tampering, untrusted roots, known revocation, wrong owner, malformed receipts, atomic collision rollback, existing destinations, unsupported attachments/size and write-only disclosure. [Structural archive/backup schema](../schemas/archive.schema.json). Neither test success nor authenticated content integrity establishes clinical correctness or production security certification.
## Expanded clinical archive profile
Prototype `0.1.0-dev.9` also accepts `oprp-reference-r4-expanded`. It contains the legacy Patient/laboratory entries plus the six admitted [clinical types](fhir-coverage.md). Deleted current entries retain their previous clinical content with `deleted: true`; `clinical_history` records all accepted immutable versions and protocol attribution. The Host validates contiguous clinical versions, agreement of the current entry with its last version, digests and metadata receipt bindings before atomic import. Overall history coverage remains false because legacy/external histories are not complete.
Restore/migration preserve deletion state and unchanged operation receipts. Current scoped reads/snapshots hide deleted resources, while authorized historical read remains possible. Logical deletion does not erase retained medical content. Python/.NET SDK helpers independently verify archive signature/digest/patient membership and backup encryption; they do not claim the Host importer's entire clinical/history/handoff validation profile. The same archive size/depth bounds apply.
## Visibility profile update
The Host advertises `visibility_profile: "oprp-visibility-1"`. [Visibility and cache invalidation](visibility.md) defines trusted grant policy, exact security labels, `SUBSETTED` field projections, write/export protection and required baseline/change `visibility_epoch`. Changed visibility invalidates prior cursors and clears SDK mirrors before a new baseline; disconnected clients cannot learn unknown permission changes. [Negative conformance](negative-conformance.md) records authenticated rejection tests. Host policy and approval delivery remain local integration choices.