Problem
The mobile offline queue captures an evidence upload's full credential material and replays it verbatim, with no way to refresh expiring credentials. In app/mobile/src/services/syncQueue.ts:
export interface EvidenceUploadPayload {
aidId: string;
url: string;
method?: 'POST' | 'PUT' | 'PATCH';
headers?: Record<string, string>;
body?: string;
}
// ...
case 'evidence-upload': {
const { url, method = 'POST', headers, body } = action.payload as EvidenceUploadPayload;
const response = await fetch(url, { method, headers, body }); // ← replays the stored url/headers
...
}
The queued action is persisted to AsyncStorage (persistQueue) and retried on an exponential backoff up to MAX_RETRY_DELAY_MS = 15 * 60 * 1000 with maxRetries (default 5), so a single evidence upload can be replayed across many minutes/hours using the original url and headers.
Consequence: if the upload url is a signed/pre-signed URL — or headers carries a short-lived token — the credential expires while the device is offline. When connectivity returns, every retry posts to an expired URL and receives 403/expired, the action transitions to failed after maxRetries, and the evidence upload is stranded. Because retries reuse the same expired credential, the backoff cannot recover; only a manual re-dispatch with a fresh URL would. The backend moved large uploads to signed URLs (tracked as #244), which makes this the normal path, not an edge case.
Root cause
The queue stores a complete, frozen HTTP request (URL + headers + body) and has no "refresh credential before retry" hook; credential expiry is not modelled in the retry lifecycle.
Why this is architecturally hard
- The queue is intentionally credential-agnostic, which is the bug. It treats every action as a pure function of its payload; supporting refresh requires a per-action
refresh(): Promise<Payload> capability and a way to detect credential-expiry failures (403/401 with a specific shape) distinct from genuinely invalid uploads.
- Evidence may be large PII. The
body is persisted to AsyncStorage, so re-signing must be possible without re-hydrating/re-sending the whole payload redundantly, and any refresh must not leak or duplicate the body.
- It interacts with saver mode and idempotency.
flushPendingNetworkActions runs with saverMode throttling and claim-submission idempotency; adding a refresh step must not re-upload an already-uploaded body twice or break the isRetryableError classification.
- The backend is the source of truth for signed URLs. A correct fix likely needs a backend "re-issue signed URL" endpoint the queue can call on refresh, which is a cross-service contract, not a mobile-only change.
Proposed design
Add an optional per-action refresh hook (e.g. evidence-upload calls a re-sign endpoint) invoked when a retry fails with a credential-expiry error, and classify 401/403-with-expiry as retryable-with-refresh. Persist only a stable aidId/claimId and fetch a fresh signed URL at flush time, rather than freezing the URL in the payload.
Acceptance criteria
Service
Tests
Out of scope
Encrypted-at-rest AsyncStorage and the mobile offline-queue coverage tests are separate issues.
Getting started
Files: app/mobile/src/services/syncQueue.ts, app/mobile/src/services/aidApi.ts, app/mobile/src/contexts/SyncContext.tsx.
cd app/mobile
pnpm test
pnpm lint
Good first files to read: services/syncQueue.ts (runAction/flushPendingNetworkActions/EvidenceUploadPayload) and services/aidApi.ts for the current upload path.
Problem
The mobile offline queue captures an evidence upload's full credential material and replays it verbatim, with no way to refresh expiring credentials. In
app/mobile/src/services/syncQueue.ts:The queued action is persisted to AsyncStorage (
persistQueue) and retried on an exponential backoff up toMAX_RETRY_DELAY_MS = 15 * 60 * 1000withmaxRetries(default 5), so a single evidence upload can be replayed across many minutes/hours using the originalurlandheaders.Consequence: if the upload
urlis a signed/pre-signed URL — orheaderscarries a short-lived token — the credential expires while the device is offline. When connectivity returns, every retry posts to an expired URL and receives 403/expired, the action transitions tofailedaftermaxRetries, and the evidence upload is stranded. Because retries reuse the same expired credential, the backoff cannot recover; only a manual re-dispatch with a fresh URL would. The backend moved large uploads to signed URLs (tracked as #244), which makes this the normal path, not an edge case.Root cause
The queue stores a complete, frozen HTTP request (URL + headers + body) and has no "refresh credential before retry" hook; credential expiry is not modelled in the retry lifecycle.
Why this is architecturally hard
refresh(): Promise<Payload>capability and a way to detect credential-expiry failures (403/401 with a specific shape) distinct from genuinely invalid uploads.bodyis persisted to AsyncStorage, so re-signing must be possible without re-hydrating/re-sending the whole payload redundantly, and any refresh must not leak or duplicate the body.flushPendingNetworkActionsruns withsaverModethrottling and claim-submission idempotency; adding a refresh step must not re-upload an already-uploaded body twice or break theisRetryableErrorclassification.Proposed design
Add an optional per-action
refreshhook (e.g.evidence-uploadcalls a re-sign endpoint) invoked when a retry fails with a credential-expiry error, and classify 401/403-with-expiry as retryable-with-refresh. Persist only a stableaidId/claimIdand fetch a fresh signed URL at flush time, rather than freezing the URL in the payload.Acceptance criteria
Service
maxRetries.Tests
Out of scope
Encrypted-at-rest AsyncStorage and the mobile offline-queue coverage tests are separate issues.
Getting started
Files:
app/mobile/src/services/syncQueue.ts,app/mobile/src/services/aidApi.ts,app/mobile/src/contexts/SyncContext.tsx.Good first files to read:
services/syncQueue.ts(runAction/flushPendingNetworkActions/EvidenceUploadPayload) andservices/aidApi.tsfor the current upload path.