Clinical pagination and durable mirrors
OPRP 0.1.0-dev.9 provides stable paged search, immutable version-history pages and scoped baseline snapshots. Each SDK includes a durable one-patient mirror that stages baseline pages, catches up changes and commits local resources with their cursor. Outages, restart, deletion and retained-history gaps can be recovered without presenting an incomplete baseline as a complete record.
The reference implementation is a bounded experimental slice. Local caching permission, storage encryption, background scheduling and downstream clinical workflows are application/operator responsibilities. A mirror is a scoped replica for reading, not another authoritative Host or an automatic conflict resolver.
Request and response contracts
search_resources, resource_history and sync_snapshot accept optional limit from 1 to 100 (default 100) and optional page_cursor (default null). Begin with null; continue with the returned opaque page cursor and the same limit. Obtain a new access JWT within the same grant as needed. Every page rechecks current authorization, sender keys and patient/type permissions.
| Operation | Page data | Metadata |
|---|---|---|
search_resources |
resource is a searchset Bundle with paged entry. Its total counts the authorized type's captured current non-deleted records. |
snapshot_id, has_more, page_cursor. |
resource_history |
versions are ascending immutable versions, including metadata-only deletion entries. |
Captured total, snapshot_id, has_more, page_cursor. |
sync_snapshot |
resources: [{resource,version}] from all granted read types. |
Captured total, snapshot_id, has_more, page_cursor, required visibility_epoch and the same feed cursor on every baseline page. |
The final page has has_more: false and page_cursor: null. snapshot_id identifies that immutable read session; it is not clinical identity or portable authorization. The baseline's feed cursor is distinct from page_cursor. Stage every baseline page and save that feed cursor before applying subsequent change pages.
The SDK signatures are searchResources(patient,type,limit,pageCursor), resourceHistory(patient,type,id,limit,pageCursor) and syncSnapshot(patient,limit,pageCursor) in TypeScript. Python uses search_resources, resource_history, snapshot; .NET uses SearchResources, ResourceHistory, Snapshot. Existing short reads still receive a first page, but callers must now inspect has_more. Broad legacy list_labs remains unpaged; use scoped baseline pages for mirroring that category.
Stable views and bounded allocation
The Host materializes the authorized result in one SQLite transaction. A scoped baseline captures resources and its feed watermark in that transaction. Later changes do not reorder or change its pages; accepted writes after that watermark are recovered through the feed. Search/history also remain immutable while they are enumerated. Repeating a continuation yields the same captured entries, although fresh protected messages/cursor encryption can differ.
Opaque AES-GCM page cursors bind the Host, verified application/signing key, patient, grant, scopes, visibility epoch, operation/type/resource, session, offset and requested limit. They contain no global change position or authority to bypass a new token. Sessions/keys persist through normal Host restart. A changed query/limit/grant returns page_context_changed; unavailable/expired sessions return page_expired; modified cursors fail authentication. Revocation and representative withdrawal take precedence over continuation.
Reference limits:
- Fixed session expiry is ten minutes or grant end, whichever is earlier. Reading pages does not extend it.
- Each session contains at most 10,000 captured entries and 8 MB of serialized items; larger reads fail
read_session_too_large. - At most 32 active sessions per application and 256 globally. New allocation beyond this fails
read_session_limit; existing continuations remain usable. - A page returns at most the requested count and about 700 KB of serialized data. Large entries can reduce the returned count. An individual entry that cannot fit fails
resource_too_large. - Change pages also observe a serialized byte budget and may return fewer than
limitevents. Their next cursor still advances only over delivered authorized events or the completed captured window.
Expired sessions/items are removed during subsequent first-page allocation. Stored session bodies contain clinical data; Host at-rest controls must cover them. There is no streaming export, unbounded search or production distributed session store here. An allocation failure does not grant extra scopes or permit an incomplete baseline to be treated as complete.
Mirror lifecycle
DurableMirror is pinned to one application/signing identity and patient. It additionally binds cached content to the current Host/credential, grant and sorted scope set. Changing any of those clears the local view before it can be read and requires a fresh baseline. Same-key Host credential renewal also restages this first implementation; a future refinement can preserve verified compatible bindings.
- Persist an empty or staging state. Save each baseline page, snapshot ID, expected total, baseline feed cursor and next page cursor atomically. Duplicate staged IDs, inconsistent metadata or an incomplete final count fail.
- After the complete baseline, atomically replace the active resource set and save its feed cursor. Staging data never appears as a complete view.
- Catch up feed pages before
syncreturns. Commit full event representations or versioned deletion tombstones and the next cursor together. Lower versions do not resurrect newer data; equal versions with conflicting content/deletion state fail. - On restart, resume staged pages or the last committed feed cursor. A lost network response cannot advance local state. Persistent writes must succeed before state is published.
- On a feed gap or expired/context-changed feed cursor, clear the old view and build a new scoped baseline. An expired baseline session clears staging and returns
mirror_restart_required; call sync again to start a new one.
Only one sync worker is permitted per mirror instance. Python/TypeScript use SQLite; .NET uses an atomic caller persistence hook with immutable proposed state. All reference adapters store plaintext and require application at-rest protection. Multiple processes/instances sharing the same store are unsupported. Python whole-state persistence and the reference .NET file adapter are capped at 8 MB; custom .NET hooks choose their own bounds; it can reach its limit before a Host session does because metadata/tombstones/staging also consume space. The SQLite TypeScript adapter does not provide a size quota or automatic tombstone cleanup.
Outbound durable writes remain separate. A mirrored event does not complete a pending write, a human workflow or a clinical dependency. Use the stable mutation receipt to resolve uncertain writes. A signed notification is a wake-up hint: call mirror sync, then complete the receiver's matching inbox revision after persistence succeeds.
Reading and failures
resources() / Resources() returns active non-deleted resource/version entries only after a complete sync. It blocks during synchronization/staging and after failure. A network outage marks an already complete cache stale; an explicit resources(true), resources(allow_stale=True) or Resources(allowStale: true) can opt into that stale view. Applications must label its freshness appropriately.
Local reads check cached grant expiry and current locally known credential/status validity. An authorization/protocol failure observed from the Host clears cached content; local storage failures keep reads blocked in the current process. The next successful sync can resume at the last durable position. Known scope/Host/grant changes invalidate the old view even after a mirror restart.
An offline cache cannot know a revocation that has not reached it. These APIs do not claim instantaneous remote erasure, and mirror state is not permission to disclose records indefinitely. Consumers must decide whether offline caching is authorized and reconnect for current permission. The Host now invalidates the grant-specific visibility generation on field/security-label transitions. Mirrors clear and restage the baseline after visibility_changed; old paged reads fail page_context_changed. See visibility.
SDK examples
TypeScript:
import { DurableMirror } from '@convey/oprp-sdk';
const mirror = new DurableMirror('mirror.sqlite', client, patient);
try {
await mirror.sync(50);
const visible = mirror.resources();
} finally { mirror.close(); }
Python:
from oprp_sdk import DurableMirror
mirror = DurableMirror('mirror.sqlite', client, patient)
try:
mirror.sync(50)
visible = mirror.resources()
finally:
mirror.close()
.NET:
var file = new AtomicStateFile("mirror.json");
var mirror = new DurableMirror(client, patient, file.Save, file.Load());
await mirror.Sync(50);
var visible = mirror.Resources();
Initialize the Client with verified discovery, credentials, constrained trust/fresh status and Host-granted read scopes first. A mirror never asks the patient to log in itself. Use secure persistence adapters for actual records; see SDK runtime/provider limits.
Executable evidence and next work
Focused tests cover reads/history beyond 100 entries, immutable results during writes, byte budgets, quotas, expiration/tampering, Host restart, concurrent baseline mutation, staging restart, deletion, retention-gap replacement, narrowed authority, storage failure, stale reads and grant expiry/revocation. Packaged SDK conformance checks TypeScript/Python/.NET mirrors with encrypted exchanges, paged history, restart, deletion, gaps, outage blocking and revocation. These are synthetic local tests, not Internet deployment or full FHIR conformance evidence.
The design uses FHIR R4 paging and interactions as a reference. The encrypted operation and materialized-session format are OPRP choices, not FHIR REST pagination links.
Next: add local terminology validation, broader generated fault cases, reviewed native .NET crypto and secure persistence providers.