Skip to content

0005. On-Device Job Data Cleanup and Retention

Date: 2026-07-17

Author: Eugene Nagorny

Stakeholders: App team, security (pen-test remediation), QA

Status: Accepted

Context

The app persists per-job data to AsyncStorage via redux-persist, keyed by orderShortId: CP1/CP12 gas-safety forms (customer and business details, safety-check answers, two base64 signatures), appliance records, EV charger and air conditioning form details, per-photo payloads, engineer free-text notes, and repair/callout visit outcomes. Before this decision, none of it was ever deleted. Two problems follow:

  1. PII retention. Once the backend confirms a submission, the on-device copy is redundant — yet a file-system pull of an engineer's device would reveal every customer they had ever served. This is the plaintext-PII class flagged by the DEP-96 pen test, and every byte kept is a byte the planned encrypted-MMKV migration must carry across.
  2. Unbounded growth. Every completed job leaves permanent entries across roughly ten persisted maps, and every declined regional offer a permanent dismissedJobs entry. Storage only ever grows.

A constraint shapes the whole design: the moment a job is confirmed ready for audit, the checklist screen is still mounted. The screen re-renders from Redux synchronously, but the isCompletedJob(job) gate that makes forms and photos inaccessible reads job.state from the RTK Query cache, which updates only after an asynchronous refetch. Anything deleted in that window is visible on the screen the engineer is looking at.

Decision

Job data is cleaned up in three layers (overview comment: src/services/jobs/retention/retention.ts):

  1. Purge at submission. Reducers handling success(CHANGE_JOB_STATE_TO_READY_FOR_AUDIT) — cp1, appliance, jobs (photos and notes), evChargersForm, airConditioningForm, repair, callout — immediately drop the PII payloads. Entries are reset, not deleted: completion statuses and structural fields (cp1Required, repairOutcome, section statuses) survive so the still-mounted checklist keeps rendering every step as done during the submit transition. Only server-confirmed submissions are purged; failed or in-progress ones keep their data so retry and resume work.
  2. Retention sweep. On every launch after rehydration, PRUNE_EXPIRED_JOB_DATA deletes the whole per-job residue (statuses, empty form shells, read markers) for jobs more than 30 days past audit confirmation, plus dismissedJobs date buckets older than the same window. This is what keeps storage bounded. Jobs without a server-confirmed finished status are never touched.
  3. Migration 16. A one-shot persist migration applies the layer-1 purge to jobs completed before this shipped. It gates on the same status.finished signal and never synthesises slices absent from persisted state.

Adding a new job type or persisted per-job slice: if it stores per-job data keyed by orderShortId, it must (a) handle success(CHANGE_JOB_STATE_TO_READY_FOR_AUDIT) to drop its PII while keeping whatever the mounted checklist reads, and (b) be added to the slice list in pruneExpiredJobDataFromState. Miss either and the new type quietly re-introduces indefinite retention — exactly what happened to the EV and aircon forms in the first iteration of this work.

Alternatives Considered

  • Purge on individual form/section submit. Rejected: a CP1 or photo section can be submitted before the whole job goes for audit, and the engineer may re-open it to review or amend in that window; purging at individual submit would show blank forms mid-job.
  • Delete entries outright at audit instead of resetting fields. Rejected: during the submit transition the checklist falls back to empty defaults (finished: false), visibly un-completing steps and re-enabling navigation into blank forms. Deletion also crashes getJobStatusPhotosById, which reads cp1Photos unguarded.
  • TTL-only cleanup (no purge at submission). Rejected: PII would sit on-device for the full retention window after every job — up to a month of customers on a stolen or imaged device — failing the data-minimisation goal outright.
  • Sweep on a short TTL (e.g. next launch) to replace the 30-day window. Rejected: same-day edges — useFinishJobEarly shows its CTA without checking job state, so wiping declinedToFinishEarly while the job date is still today can resurface it, and audit rejections handled off-app get an ops grace window. The residue is a few hundred bytes per job, so holding it 30 days costs nothing.
  • Gating the sweep on isCompletedJob(job). Not possible: it needs job.state from the RTK cache, which is persist-blocklisted and empty at cold start; jobs past the diary window are never fetched again. The persisted status.finished timestamp is the only surviving signal.

Consequences

  • A file-system pull of a device shows no CP1 form PII, signatures, form details, notes, or outcome free text for any job the server has confirmed — immediately after upgrade for historical jobs (migration) and at submission for new ones.
  • AsyncStorage is bounded: completed-job residue and dismissed-offer buckets disappear 30 days after audit confirmation.
  • The encrypted-MMKV migration (APY-2180) inherits already-minimised data.
  • Purged data is unrecoverable on-device by design. Audit rejection and any post-audit review must rely on the backend copy of record.
  • Photo files in camera/cache directories are out of scope: the purge drops the last reference (the URI) without deleting the file, leaving cleanup to OS cache eviction. Making AC-level "no PII on file-system pull" literal requires file deletion at purge time — tracked on the ticket as a follow-up decision.
  • The purge logic is distributed across seven reducers plus the migration; each site carries a pointer to the overview in src/services/jobs/retention/retention.ts.
  • QA can verify all three layers on-device via the non-prod Storage drawer in the debug panel (Disk/Redux tabs over persist:root).

Relevant Jira Tickets

  • APY-2300 — Purge submitted CP1 & job-photo PII from on-device storage
  • APY-2180 — Implement RN mmkv for secure PII storage
  • DEP-96 — [Pen Test Eng App] Sensitive Data in Memory