Collector Workflow: Offline-First Data Collection
Status: Final Version: 2.0 Last Updated: 2026-01-11
Purpose
Define the end-to-end workflow for field collectors to gather ESG data using mobile devices with offline-first architecture, including draft storage, queue synchronization, feedback loops, chain-of-custody tracking, and conflict resolution. This document provides comprehensive guidance for implementing a production-ready mobile data collection system.
Table of Contents
- Workflow Overview
- State Machine
- Offline-First Architecture
- Step-by-Step Workflow
- Queue Management
- Retry Logic
- Conflict Resolution
- Chain-of-Custody Tracking
- Error Handling
- Background Jobs
- Performance Considerations
- Security Requirements
- Testing Checklist
- Cross-References
Workflow Overview
High-Level Flow
┌─────────────────────────────────────────────────────────────────────┐
│ MOBILE APP (Offline-First) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Fetch Templates (Online) ──> Store in SQLite │
│ │
│ 2. Create Draft ──> Auto-save to SQLite │
│ │ │
│ ├─> Edit Draft ──> Auto-save (Offline-safe) │
│ │ │
│ └─> Add Evidence ──> Queue File Upload │
│ │
│ 3. Submit (Queue) ──> Mark as QUEUED │
│ │
│ 4. Sync Service (Background) │
│ │ │
│ ├─> Upload Submissions ──> POST /api/v1/collector/submissions │
│ │ (Idempotency Key = UUID) │
│ │ │
│ ├─> Upload Evidence ──> POST /api/v1/collector/evidence │
│ │ │
│ └─> Fetch Updates ──> GET /api/v1/collector/sync │
│ (Incremental since last sync) │
│ │
│ 5. Review Feedback ──> If REJECTED, fix and resubmit │
│ │
└─────────────────────────────────────────────────────────────────────┘
│
│ HTTPS (JWT Auth)
▼
┌─────────────────────────────────────────────────────────────────────┐
│ BACKEND (Quarkus + Kotlin) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Receive Submission ──> Idempotency Check (Redis) │
│ │
│ 2. Store in PostgreSQL ──> State: RECEIVED │
│ │
│ 3. Queue Validation Job ──> Async Processing │
│ │
│ 4. Validation Engine ──> 6 Validation Types │
│ │ │
│ ├─> Pass ──> State: VALIDATED ──> Queue Processing │
│ │ │
│ └─> Fail ──> State: REJECTED ──> Store Error Details │
│ │
│ 5. Processing Pipeline ──> State: PROCESSED │
│ │
│ 6. Review & Approval ──> State: APPROVED or REJECTED │
│ │
│ 7. Sync Endpoint ──> Return updated states to mobile │
│ │
└─────────────────────────────────────────────────────────────────────┘
Key Principles
- Local-First: All data entry happens in local SQLite database first
- Client-Generated UUIDs: Mobile app generates UUIDs for submissions and evidence
- Idempotency: Same UUID = same submission (prevent duplicates on retry)
- Incremental Sync: Only fetch changes since last sync timestamp
- Conflict-Free: Server state always wins (no merge conflicts)
- Audit Trail: Every state transition logged with timestamp and actor
State Machine
Submission States
┌────────────────────────────────────────────────────────────────────┐
│ SUBMISSION STATE MACHINE │
└────────────────────────────────────────────────────────────────────┘
DRAFT ──┐
│
│ Collector taps "Submit"
│
▼
QUEUED ──┐
│
│ Network available, upload starts
│
▼
UPLOADING ──┐
│
│ Server receives, returns 201
│
▼
RECEIVED ──┐
│
│ Validation job processes
│
├─> VALIDATED ──┐
│ │
│ │ Processing job
│ │
│ ▼
│ PROCESSED ──┐
│ │
│ │ Reviewer approves/rejects
│ │
│ ├─> APPROVED (Final)
│ │
│ └─> REJECTED ──┐
│ │
└─> VALIDATION_FAILED ──────────────┘
│
│ Collector fixes
│
▼
RESUBMITTED
(New UUID)
State Descriptions
| State | Location | Description | Next States | Actor |
|---|---|---|---|---|
| DRAFT | Mobile only | Collector editing, auto-saved locally | QUEUED | Collector |
| QUEUED | Mobile only | Ready for upload, waiting for network | UPLOADING | System |
| UPLOADING | In-flight | HTTP request in progress | RECEIVED, ERROR | System |
| RECEIVED | Backend | Successfully ingested, queued for validation | VALIDATED, VALIDATION_FAILED | System |
| VALIDATED | Backend | Passed all 6 validation types | PROCESSED | System |
| VALIDATION_FAILED | Backend | Failed validation (e.g., out of range) | RESUBMITTED | Collector |
| PROCESSED | Backend | Calculations complete, ready for review | APPROVED, REJECTED | Reviewer |
| APPROVED | Backend | Reviewer approved, locked for reporting | N/A (Final) | Reviewer |
| REJECTED | Backend | Reviewer rejected with feedback | RESUBMITTED | Collector |
| RESUBMITTED | Backend | New version superseding previous | RECEIVED | Collector |
State Transition Rules
Allowed Transitions: - DRAFT → QUEUED (collector submits) - QUEUED → UPLOADING (network available) - UPLOADING → RECEIVED (server responds 201) - UPLOADING → QUEUED (retry on network error) - RECEIVED → VALIDATED (validation passes) - RECEIVED → VALIDATION_FAILED (validation fails) - VALIDATED → PROCESSED (processing completes) - PROCESSED → APPROVED (reviewer approves) - PROCESSED → REJECTED (reviewer rejects) - VALIDATION_FAILED → RESUBMITTED (collector fixes and resubmits) - REJECTED → RESUBMITTED (collector fixes and resubmits)
Forbidden Transitions: - Cannot skip RECEIVED → directly to APPROVED - Cannot transition from APPROVED (final state) - Cannot transition from DRAFT → RECEIVED (must go through QUEUED) - Backend cannot transition to DRAFT or QUEUED (mobile-only states)
Offline-First Architecture
Core Design Principles
- Data Entry Never Blocks on Network
- All form inputs save immediately to SQLite
- No "Loading..." spinners during data entry
-
Validation happens client-side first (instant feedback)
-
Background Synchronization
- Sync service runs every 5 minutes when app is active
- Background sync every 30 minutes when app is backgrounded
-
Manual "Sync Now" button for immediate upload
-
Network-Aware Queue Management
- Monitor network connectivity (WiFi, Cellular, Offline)
- Pause uploads when network unavailable
- Resume automatically when network restored
-
Respect metered connections (prompt user for cellular uploads)
-
Local Storage Schema
// Kotlin Room Database Schema
@Entity(tableName = "submissions")
data class SubmissionEntity(
@PrimaryKey
val uuid: String, // Client-generated UUID
val reportingPeriodId: Long,
val siteId: Long,
val metricId: String,
val activityDate: String, // ISO 8601 date
val value: Double?,
val unit: String?,
val metadata: String, // JSON string
val state: String, // DRAFT, QUEUED, UPLOADING, etc.
val createdAt: Long, // Unix timestamp (ms)
val updatedAt: Long,
val queuedAt: Long?, // When moved to QUEUED
val uploadedAt: Long?, // When successfully uploaded
val lastSyncAt: Long?, // Last sync timestamp
val version: Int, // Version number (for resubmissions)
val supersedes: String?, // UUID of previous version
val errorMessage: String?, // Validation/upload error
val errorDetails: String?, // JSON error details
val validationWarnings: String?, // JSON warning list
val reviewerFeedback: String?, // Feedback from reviewer
val retryCount: Int = 0, // Number of upload retries
val lastRetryAt: Long? // Last retry timestamp
)
@Entity(tableName = "evidence")
data class EvidenceEntity(
@PrimaryKey
val uuid: String, // Client-generated UUID
val submissionUuid: String, // Foreign key to submission
val filename: String,
val mimeType: String,
val filePath: String, // Local file path
val fileSize: Long, // Bytes
val contentHash: String, // SHA-256 hash
val evidenceType: String, // PHOTO, PDF, CERTIFICATE
val uploadStatus: String, // PENDING, UPLOADING, UPLOADED, ERROR
val uploadedUrl: String?, // S3 URL after upload
val createdAt: Long,
val uploadedAt: Long?,
val retryCount: Int = 0
)
@Entity(tableName = "metric_templates")
data class MetricTemplateEntity(
@PrimaryKey
val metricId: String,
val reportingPeriodId: Long,
val name: String,
val description: String,
val isMandatory: Boolean,
val validationRules: String, // JSON validation rules
val fetchedAt: Long // Cache timestamp
)
Sync Strategy
Incremental Sync Algorithm
class SyncService(
private val database: AppDatabase,
private val apiClient: CollectorApiClient,
private val connectivityMonitor: ConnectivityMonitor
) {
suspend fun performSync() {
if (!connectivityMonitor.isOnline()) {
Log.d("SyncService", "Offline, skipping sync")
return
}
try {
// Step 1: Upload queued submissions
uploadQueuedSubmissions()
// Step 2: Upload pending evidence
uploadPendingEvidence()
// Step 3: Fetch updates from server
fetchServerUpdates()
} catch (e: Exception) {
Log.e("SyncService", "Sync failed: ${e.message}", e)
// Continue to retry on next sync cycle
}
}
private suspend fun uploadQueuedSubmissions() {
val queued = database.submissionDao()
.getByState("QUEUED")
.sortedBy { it.queuedAt } // FIFO order
for (submission in queued) {
try {
// Update state to UPLOADING
database.submissionDao().updateState(submission.uuid, "UPLOADING")
// POST to API with idempotency key
val response = apiClient.submitData(
submissionUuid = submission.uuid,
reportingPeriodId = submission.reportingPeriodId,
siteId = submission.siteId,
metricId = submission.metricId,
activityDate = submission.activityDate,
value = submission.value,
unit = submission.unit,
metadata = submission.metadata,
idempotencyKey = submission.uuid
)
// Mark as uploaded
database.submissionDao().update(submission.copy(
state = "RECEIVED",
uploadedAt = System.currentTimeMillis(),
retryCount = 0
))
Log.i("SyncService", "Uploaded submission ${submission.uuid}")
} catch (e: NetworkException) {
// Network error - revert to QUEUED for retry
database.submissionDao().updateState(submission.uuid, "QUEUED")
Log.w("SyncService", "Network error, will retry later")
break // Stop processing queue, retry on next sync
} catch (e: ApiException) {
// API error (4xx/5xx) - mark as error
database.submissionDao().update(submission.copy(
state = "ERROR",
errorMessage = e.message,
errorDetails = e.details,
retryCount = submission.retryCount + 1
))
Log.e("SyncService", "API error: ${e.message}")
// Continue to next submission
}
}
}
private suspend fun uploadPendingEvidence() {
val pending = database.evidenceDao().getByUploadStatus("PENDING")
for (evidence in pending) {
try {
database.evidenceDao().updateUploadStatus(evidence.uuid, "UPLOADING")
val file = File(evidence.filePath)
val response = apiClient.uploadEvidence(
submissionUuid = evidence.submissionUuid,
file = file,
evidenceType = evidence.evidenceType,
contentHash = evidence.contentHash
)
database.evidenceDao().update(evidence.copy(
uploadStatus = "UPLOADED",
uploadedUrl = response.url,
uploadedAt = System.currentTimeMillis()
))
Log.i("SyncService", "Uploaded evidence ${evidence.uuid}")
} catch (e: NetworkException) {
database.evidenceDao().updateUploadStatus(evidence.uuid, "PENDING")
break
} catch (e: ApiException) {
database.evidenceDao().update(evidence.copy(
uploadStatus = "ERROR",
retryCount = evidence.retryCount + 1
))
}
}
}
private suspend fun fetchServerUpdates() {
val lastSyncTimestamp = database.settingsDao().getLastSyncTimestamp()
val response = apiClient.sync(since = lastSyncTimestamp)
for (update in response.data) {
val existing = database.submissionDao().getByUuid(update.submissionUuid)
if (existing != null) {
// Update local record with server state
database.submissionDao().update(existing.copy(
state = update.state,
validationWarnings = update.validationWarnings,
reviewerFeedback = update.reviewerFeedback,
lastSyncAt = System.currentTimeMillis()
))
// Show notification for important state changes
if (update.state == "REJECTED") {
showNotification(
title = "Submission Rejected",
body = update.reviewerFeedback ?: "Please review and resubmit"
)
}
}
}
// Update last sync timestamp
database.settingsDao().setLastSyncTimestamp(response.syncTimestamp)
Log.i("SyncService", "Synced ${response.data.size} updates")
}
}
Step-by-Step Workflow
Step 1: Fetch Templates (Online)
Trigger: Collector opens app with network connection.
Purpose: Download metric templates and validation rules for offline data entry.
API Call:
GET /api/v1/collector/templates?reporting_period_id=10&site_id=123
Authorization: Bearer {jwt_token}
Mobile Implementation:
class TemplateRepository(
private val apiClient: CollectorApiClient,
private val database: AppDatabase
) {
suspend fun fetchTemplates(periodId: Long, siteId: Long): Result<List<MetricTemplate>> {
return try {
val templates = apiClient.getTemplates(periodId, siteId)
// Store in local database for offline access
database.metricTemplateDao().insertAll(
templates.map { it.toEntity(fetchedAt = System.currentTimeMillis()) }
)
// Pre-create draft submissions for mandatory metrics
val draftSubmissions = templates
.filter { it.isMandatory }
.map { metric ->
SubmissionEntity(
uuid = UUID.randomUUID().toString(),
reportingPeriodId = periodId,
siteId = siteId,
metricId = metric.metricId,
activityDate = "",
value = null,
unit = metric.unit,
metadata = "{}",
state = "DRAFT",
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
queuedAt = null,
uploadedAt = null,
lastSyncAt = null,
version = 1,
supersedes = null,
errorMessage = null,
errorDetails = null,
validationWarnings = null,
reviewerFeedback = null,
retryCount = 0,
lastRetryAt = null
)
}
database.submissionDao().insertAll(draftSubmissions)
Result.success(templates)
} catch (e: Exception) {
Log.e("TemplateRepository", "Failed to fetch templates", e)
Result.failure(e)
}
}
suspend fun getLocalTemplates(periodId: Long): List<MetricTemplate> {
// Fallback to cached templates if offline
return database.metricTemplateDao()
.getByPeriod(periodId)
.map { it.toModel() }
}
}
Caching Strategy: - Templates cached for 24 hours - Fetch fresh templates on app startup if cache expired - Fallback to cached templates if offline
Step 2: Create/Edit Draft (Offline-Safe)
Trigger: Collector enters data in form.
Purpose: Save data locally with instant feedback, no network required.
Mobile Implementation:
class SubmissionViewModel(
private val database: AppDatabase,
private val validator: LocalValidator
) : ViewModel() {
private val _validationErrors = MutableLiveData<Map<String, List<String>>>()
val validationErrors: LiveData<Map<String, List<String>>> = _validationErrors
fun saveDraft(submission: SubmissionEntity) {
viewModelScope.launch {
// Client-side validation (instant feedback)
val errors = validator.validate(submission)
if (errors.isNotEmpty()) {
_validationErrors.value = errors
return@launch
}
// Auto-save to SQLite
database.submissionDao().upsert(submission.copy(
state = "DRAFT",
updatedAt = System.currentTimeMillis()
))
_snackbarMessage.value = "Draft saved"
}
}
}
class LocalValidator(private val database: AppDatabase) {
fun validate(submission: SubmissionEntity): Map<String, List<String>> {
val errors = mutableMapOf<String, List<String>>()
val template = database.metricTemplateDao().getByMetricId(submission.metricId)
val rules = Json.decodeFromString<ValidationRules>(template.validationRules)
// Required field validation
if (rules.required.contains("activityDate") && submission.activityDate.isEmpty()) {
errors["activityDate"] = listOf("Activity date is required")
}
if (rules.required.contains("value") && submission.value == null) {
errors["value"] = listOf("Value is required")
}
// Data type validation
if (submission.value != null) {
when (rules.dataType) {
"integer" -> {
if (submission.value % 1 != 0.0) {
errors["value"] = listOf("Must be a whole number")
}
}
"positive" -> {
if (submission.value < 0) {
errors["value"] = listOf("Must be positive")
}
}
}
}
// Range validation
if (submission.value != null && rules.minValue != null && submission.value < rules.minValue) {
errors["value"] = listOf("Must be at least ${rules.minValue}")
}
if (submission.value != null && rules.maxValue != null && submission.value > rules.maxValue) {
errors["value"] = listOf("Must be at most ${rules.maxValue}")
}
// Date validation
if (submission.activityDate.isNotEmpty()) {
val date = try {
LocalDate.parse(submission.activityDate)
} catch (e: Exception) {
null
}
if (date == null) {
errors["activityDate"] = listOf("Invalid date format (use YYYY-MM-DD)")
} else if (date.isAfter(LocalDate.now())) {
errors["activityDate"] = listOf("Cannot be in the future")
}
}
return errors
}
}
Auto-Save Strategy: - Debounce text input changes (500ms delay) - Save immediately on field blur - Save before app backgrounding - No save if validation fails (show errors instead)
Step 3: Queue for Upload
Trigger: Collector taps "Submit" button.
Purpose: Mark submission as ready for upload, trigger immediate sync if online.
Mobile Implementation:
class SubmissionViewModel(
private val database: AppDatabase,
private val syncService: SyncService,
private val connectivityMonitor: ConnectivityMonitor
) : ViewModel() {
fun queueSubmission(uuid: String) {
viewModelScope.launch {
val submission = database.submissionDao().getByUuid(uuid)
if (submission == null) {
_errorMessage.value = "Submission not found"
return@launch
}
// Final validation before queuing
val errors = validator.validate(submission)
if (errors.isNotEmpty()) {
_validationErrors.value = errors
return@launch
}
// Mark as queued
database.submissionDao().update(submission.copy(
state = "QUEUED",
queuedAt = System.currentTimeMillis()
))
_snackbarMessage.value = "Submission queued"
// Trigger immediate sync if online
if (connectivityMonitor.isOnline()) {
syncService.performSync()
} else {
_snackbarMessage.value = "Queued for upload when online"
}
}
}
}
Queue Priority: - FIFO (First In, First Out) order - No prioritization (all submissions equal) - Process one at a time to avoid race conditions
Step 4: Upload to Backend
Trigger: - Network connectivity detected - Manual "Sync" button tap - Background sync timer (every 5-30 minutes)
Purpose: Send queued submissions to backend with idempotency guarantees.
Backend Implementation (Quarkus + Kotlin):
@Path("/api/v1/collector")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
class CollectorResource(
private val submissionService: SubmissionService,
private val idempotencyService: IdempotencyService,
@Inject
private val securityIdentity: SecurityIdentity
) {
@POST
@Path("/submissions")
@RolesAllowed("COLLECTOR")
fun submitData(
@HeaderParam("Idempotency-Key") idempotencyKey: String,
request: SubmissionRequest
): Response {
// Idempotency check
val cached = idempotencyService.getCachedResponse(idempotencyKey)
if (cached != null) {
return Response.ok(cached).build()
}
// Extract tenant and user from JWT SecurityIdentity
val tenantId = securityIdentity.getAttribute<Long>("tenantId")
?: throw UnauthorizedException("Tenant ID not found in token")
val userId = securityIdentity.getAttribute<Long>("userId")
?: throw UnauthorizedException("User ID not found in token")
// Create submission
val submission = submissionService.create(
tenantId = tenantId,
submissionUuid = request.submissionUuid,
reportingPeriodId = request.reportingPeriodId,
siteId = request.siteId,
metricId = request.metricId,
activityDate = request.activityDate,
value = request.value,
unit = request.unit,
metadata = request.metadata,
submittedByUserId = userId
)
// Queue async validation (SmallRye Reactive Messaging)
validationEmitter.send(ValidateSubmissionMessage(submission.id))
val response = SubmissionResponse(
submissionUuid = submission.submissionUuid,
state = submission.state,
createdAt = submission.createdAt
)
// Cache response for idempotency
idempotencyService.cacheResponse(idempotencyKey, response, ttl = Duration.ofHours(24))
return Response.status(Response.Status.CREATED).entity(response).build()
}
}
@ApplicationScoped
class SubmissionService(
private val submissionRepository: SubmissionRepository,
private val metricDefinitionRepository: MetricDefinitionRepository
) {
@Transactional
fun create(
tenantId: Long,
submissionUuid: String,
reportingPeriodId: Long,
siteId: Long,
metricId: String,
activityDate: LocalDate,
value: Double?,
unit: String?,
metadata: JsonNode,
submittedByUserId: Long
): MetricSubmission {
// Check for duplicate UUID (should be prevented by idempotency, but double-check)
val existing = submissionRepository.findBySubmissionUuid(submissionUuid)
if (existing != null) {
throw DuplicateSubmissionException("Submission with UUID $submissionUuid already exists")
}
// Lookup metric definition
val metricDefinition = metricDefinitionRepository.findByMetricId(metricId)
?: throw MetricNotFoundException("Metric $metricId not found")
// Create submission record
val submission = MetricSubmission(
tenantId = tenantId,
submissionUuid = submissionUuid,
reportingPeriodId = reportingPeriodId,
siteId = siteId,
metricDefinitionId = metricDefinition.id,
activityDate = activityDate,
value = value,
unit = unit,
metadata = metadata,
state = SubmissionState.RECEIVED,
submittedByUserId = submittedByUserId,
submittedAt = Instant.now(),
createdAt = Instant.now(),
updatedAt = Instant.now()
)
// Persist with Panache
submissionRepository.persist(submission)
return submission
}
}
Step 5: Upload Evidence Files
Trigger: Collector attaches photo or PDF to submission.
Purpose: Upload evidence files to S3 with virus scanning and chain-of-custody tracking.
Mobile Implementation:
class EvidenceRepository(
private val apiClient: CollectorApiClient,
private val database: AppDatabase
) {
suspend fun queueEvidenceUpload(
submissionUuid: String,
file: File,
evidenceType: String
): Result<String> {
return try {
// Calculate content hash
val contentHash = calculateSHA256(file)
// Create evidence record
val evidenceUuid = UUID.randomUUID().toString()
val evidence = EvidenceEntity(
uuid = evidenceUuid,
submissionUuid = submissionUuid,
filename = file.name,
mimeType = getMimeType(file),
filePath = file.absolutePath,
fileSize = file.length(),
contentHash = contentHash,
evidenceType = evidenceType,
uploadStatus = "PENDING",
uploadedUrl = null,
createdAt = System.currentTimeMillis(),
uploadedAt = null,
retryCount = 0
)
database.evidenceDao().insert(evidence)
// Trigger immediate upload if online
if (connectivityMonitor.isOnline()) {
uploadEvidence(evidenceUuid)
}
Result.success(evidenceUuid)
} catch (e: Exception) {
Log.e("EvidenceRepository", "Failed to queue evidence", e)
Result.failure(e)
}
}
private suspend fun uploadEvidence(evidenceUuid: String) {
val evidence = database.evidenceDao().getByUuid(evidenceUuid) ?: return
try {
database.evidenceDao().updateUploadStatus(evidenceUuid, "UPLOADING")
val file = File(evidence.filePath)
val response = apiClient.uploadEvidence(
submissionUuid = evidence.submissionUuid,
file = file,
evidenceType = evidence.evidenceType,
contentHash = evidence.contentHash
)
database.evidenceDao().update(evidence.copy(
uploadStatus = "UPLOADED",
uploadedUrl = response.url,
uploadedAt = System.currentTimeMillis()
))
} catch (e: Exception) {
database.evidenceDao().update(evidence.copy(
uploadStatus = "ERROR",
retryCount = evidence.retryCount + 1
))
throw e
}
}
private fun calculateSHA256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}
Step 6: Sync Status & Feedback
Trigger: - Periodic background sync (every 15 minutes) - Manual refresh - App foreground transition
Purpose: Fetch updated submission states from server (validated, approved, rejected).
API Call:
Response:
{
"syncTimestamp": "2026-01-11T14:45:00Z",
"data": [
{
"submissionUuid": "abc-123",
"state": "VALIDATED",
"validationWarnings": [
{
"code": "ANOMALY_OUTLIER",
"message": "Value is 3x higher than last month",
"severity": "warning"
}
],
"reviewerFeedback": null
},
{
"submissionUuid": "def-456",
"state": "REJECTED",
"validationWarnings": [],
"reviewerFeedback": "Evidence photo is too blurry, please retake"
}
]
}
Mobile Implementation: (see SyncService.fetchServerUpdates() above)
Step 7: Resubmit After Rejection
Trigger: Collector fixes data based on reviewer feedback.
Purpose: Create new version of submission superseding the rejected one.
Mobile Implementation:
class SubmissionViewModel(
private val database: AppDatabase
) : ViewModel() {
fun resubmit(originalUuid: String) {
viewModelScope.launch {
val original = database.submissionDao().getByUuid(originalUuid)
if (original == null) {
_errorMessage.value = "Original submission not found"
return@launch
}
if (original.state != "REJECTED" && original.state != "VALIDATION_FAILED") {
_errorMessage.value = "Can only resubmit rejected submissions"
return@launch
}
// Create new submission (new UUID, incremented version)
val newSubmission = original.copy(
uuid = UUID.randomUUID().toString(),
state = "DRAFT",
version = original.version + 1,
supersedes = originalUuid,
queuedAt = null,
uploadedAt = null,
errorMessage = null,
errorDetails = null,
reviewerFeedback = null,
retryCount = 0,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis()
)
database.submissionDao().insert(newSubmission)
_navigationEvent.value = NavigateToEdit(newSubmission.uuid)
_snackbarMessage.value = "Ready to edit and resubmit"
}
}
}
Backend: Links versions via metadata.supersedes field for audit trail.
Queue Management
Queue Processing Order
- FIFO (First In, First Out)
- Process submissions in the order they were queued
-
Respect queuedAt timestamp
-
Stop on Network Error
- If upload fails with network error, stop processing queue
-
Remaining submissions stay queued for next sync cycle
-
Continue on API Error
- If upload fails with API error (validation, etc.), mark as ERROR
- Continue processing next queued submission
Queue Monitoring
class QueueMonitor(private val database: AppDatabase) {
fun getQueueStatus(): QueueStatus {
val queued = database.submissionDao().countByState("QUEUED")
val uploading = database.submissionDao().countByState("UPLOADING")
val errors = database.submissionDao().countByState("ERROR")
val oldestQueuedAt = database.submissionDao()
.getByState("QUEUED")
.minOfOrNull { it.queuedAt ?: Long.MAX_VALUE }
return QueueStatus(
queuedCount = queued,
uploadingCount = uploading,
errorCount = errors,
oldestQueuedAt = oldestQueuedAt
)
}
}
UI Indicators
- Status Bar Icon: Show sync icon when queue not empty
- Badge Count: Show number of queued submissions
- Last Sync Time: Display "Last synced 5 minutes ago"
- Sync Progress: Show progress bar during sync
Retry Logic
Exponential Backoff
class RetryPolicy {
fun shouldRetry(retryCount: Int, maxRetries: Int = 5): Boolean {
return retryCount < maxRetries
}
fun calculateDelay(retryCount: Int): Duration {
// Exponential backoff: 2s, 4s, 8s, 16s, 32s
val delaySeconds = (2.0.pow(retryCount.toDouble())).toLong()
return Duration.ofSeconds(delaySeconds)
}
}
class SyncScheduler(
private val syncService: SyncService,
private val retryPolicy: RetryPolicy,
private val database: AppDatabase
) {
suspend fun scheduleRetry() {
val failedSubmissions = database.submissionDao()
.getByState("ERROR")
.filter { retryPolicy.shouldRetry(it.retryCount) }
for (submission in failedSubmissions) {
val delay = retryPolicy.calculateDelay(submission.retryCount)
val nextRetryAt = System.currentTimeMillis() + delay.toMillis()
if (System.currentTimeMillis() >= (submission.lastRetryAt ?: 0) + delay.toMillis()) {
// Reset to QUEUED for retry
database.submissionDao().update(submission.copy(
state = "QUEUED",
lastRetryAt = System.currentTimeMillis()
))
}
}
}
}
Retry Triggers
- Automatic: Background sync service checks for retryable errors every sync cycle
- Manual: User taps "Retry Failed" button
- Network Restored: Connectivity monitor detects network restored, triggers sync
Max Retries
- Network Errors: Unlimited retries (temporary)
- API Errors 4xx: 5 retries with exponential backoff, then manual intervention required
- API Errors 5xx: 10 retries (server issue, may resolve)
Conflict Resolution
Conflict-Free Design
Core Principle: Server state always wins. No merge conflicts.
Scenario 1: Submission Modified on Server
Situation: Collector has local DRAFT, but server shows submission is APPROVED.
Resolution: 1. Sync endpoint returns updated state (APPROVED) 2. Mobile app updates local record to APPROVED 3. Lock UI to prevent further editing 4. Show message: "This submission has been approved and cannot be edited"
Scenario 2: Submission Deleted on Server
Situation: Submission deleted by admin, but exists locally.
Resolution:
1. Sync endpoint returns deleted: true flag
2. Mobile app soft-deletes local record
3. Hide from submission list
4. Preserve in local DB for audit trail
Scenario 3: Duplicate UUID
Situation: Mobile app attempts to upload UUID that already exists (should be prevented by idempotency).
Resolution: 1. Server returns existing submission (idempotency) 2. Mobile app checks if local version matches server version 3. If different, log warning and accept server version 4. This should never happen with proper idempotency implementation
Chain-of-Custody Tracking
Audit Trail Requirements
Every state transition must be logged with: - Timestamp (UTC) - Actor (user ID or system) - Old state → New state - Reason (for rejections) - IP address (for security)
Backend Implementation
@Entity
@Table(name = "submission_audit_log")
data class SubmissionAuditLog(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
val tenantId: Long,
@Column(nullable = false)
val submissionId: Long,
@Column(nullable = false)
val submissionUuid: String,
@Column(nullable = false)
val oldState: String,
@Column(nullable = false)
val newState: String,
@Column(nullable = false)
val actorUserId: Long?, // NULL for system actions
@Column(nullable = false)
val actorType: String, // USER, SYSTEM, API
@Column(columnDefinition = "TEXT")
val reason: String?,
@Column(nullable = false)
val ipAddress: String,
@Column(nullable = false)
val timestamp: Instant
)
@ApplicationScoped
class AuditLogService(
private val auditLogRepository: AuditLogRepository
) {
fun logStateTransition(
submission: MetricSubmission,
oldState: SubmissionState,
newState: SubmissionState,
actorUserId: Long?,
actorType: String,
reason: String?,
ipAddress: String
) {
val auditLog = SubmissionAuditLog(
tenantId = submission.tenantId,
submissionId = submission.id,
submissionUuid = submission.submissionUuid,
oldState = oldState.name,
newState = newState.name,
actorUserId = actorUserId,
actorType = actorType,
reason = reason,
ipAddress = ipAddress,
timestamp = Instant.now()
)
auditLogRepository.save(auditLog)
}
}
Mobile Audit Display
@Composable
fun AuditTrailScreen(submissionUuid: String) {
val auditLogs = viewModel.getAuditTrail(submissionUuid).observeAsState(emptyList())
LazyColumn {
items(auditLogs.value) { log ->
AuditLogItem(
timestamp = log.timestamp,
oldState = log.oldState,
newState = log.newState,
actor = log.actorName,
reason = log.reason
)
}
}
}
@Composable
fun AuditLogItem(
timestamp: Instant,
oldState: String,
newState: String,
actor: String,
reason: String?
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column {
Text(
text = "${timestamp.format()} - $actor",
style = MaterialTheme.typography.caption
)
Text(
text = "$oldState → $newState",
style = MaterialTheme.typography.body1,
fontWeight = FontWeight.Bold
)
if (reason != null) {
Text(
text = reason,
style = MaterialTheme.typography.body2,
color = MaterialTheme.colors.error
)
}
}
}
}
Error Handling
Network Errors
Types: - Connection timeout - DNS resolution failure - No internet connection - SSL certificate error
Handling:
catch (e: NetworkException) {
when (e) {
is TimeoutException -> {
// Retry with longer timeout
retryWithTimeout(timeoutMs = 60000)
}
is SSLException -> {
// Log security issue, show error to user
logSecurityError(e)
showError("Secure connection failed")
}
else -> {
// Generic network error, retry later
scheduleRetry()
}
}
}
API Errors
4xx Client Errors:
catch (e: ApiException) {
when (e.statusCode) {
401 -> {
// Token expired, refresh and retry
refreshAuthToken()
retryRequest()
}
403 -> {
// Permission denied, show error
showError("You don't have permission for this action")
}
422 -> {
// Validation error, show field errors
val errors = e.validationErrors
_validationErrors.value = errors
}
429 -> {
// Rate limited, respect Retry-After header
val retryAfter = e.retryAfter ?: 60
scheduleRetry(delaySeconds = retryAfter)
}
else -> {
// Generic client error
showError(e.message)
}
}
}
5xx Server Errors:
catch (e: ServerException) {
// Retry with exponential backoff (server may recover)
if (submission.retryCount < 10) {
scheduleRetry()
} else {
// Max retries exceeded, manual intervention required
showError("Server error persists. Please contact support.")
}
}
Validation Errors
Display: - Show inline errors below form fields - Highlight invalid fields in red - Show error summary at top of form - Disable submit button until errors resolved
Example:
@Composable
fun MetricValueField(
value: Double?,
onValueChange: (Double?) -> Unit,
error: String?
) {
OutlinedTextField(
value = value?.toString() ?: "",
onValueChange = { onValueChange(it.toDoubleOrNull()) },
label = { Text("Metric Value") },
isError = error != null,
supportingText = {
if (error != null) {
Text(
text = error,
color = MaterialTheme.colors.error
)
}
}
)
}
Background Jobs
Backend Async Processing
Validation Job
@ApplicationScoped
class ValidateSubmissionJob(
private val submissionRepository: SubmissionRepository,
private val validationService: ValidationService,
private val auditLogService: AuditLogService,
@Channel("processing-out")
private val processingEmitter: Emitter<ProcessSubmissionMessage>
) {
@Incoming("validation-queue")
@Blocking
@Transactional
fun process(message: ValidateSubmissionMessage) {
val submission = submissionRepository.findByIdOptional(message.submissionId)
.orElseThrow { SubmissionNotFoundException() }
try {
val result = validationService.validate(submission)
if (result.passed) {
// Validation passed
submission.state = SubmissionState.VALIDATED
submission.validationWarnings = result.warnings
submissionRepository.persist(submission)
auditLogService.logStateTransition(
submission = submission,
oldState = SubmissionState.RECEIVED,
newState = SubmissionState.VALIDATED,
actorUserId = null,
actorType = "SYSTEM",
reason = "Automated validation passed",
ipAddress = "internal"
)
// Queue processing job (SmallRye Reactive Messaging)
processingEmitter.send(ProcessSubmissionMessage(submission.id))
} else {
// Validation failed
submission.state = SubmissionState.VALIDATION_FAILED
submission.validationErrors = result.errors
submissionRepository.persist(submission)
auditLogService.logStateTransition(
submission = submission,
oldState = SubmissionState.RECEIVED,
newState = SubmissionState.VALIDATION_FAILED,
actorUserId = null,
actorType = "SYSTEM",
reason = "Validation failed: ${result.errors.joinToString(", ")}",
ipAddress = "internal"
)
}
} catch (e: Exception) {
// Log error and retry
logger.error("Validation job failed for submission ${submission.id}", e)
throw e // Retry via SmallRye Reactive Messaging DLQ
}
}
}
Processing Job
@ApplicationScoped
class ProcessSubmissionJob(
private val submissionRepository: SubmissionRepository,
private val calculationService: CalculationService,
private val auditLogService: AuditLogService
) {
@Incoming("processing-queue")
@Blocking
@Transactional
fun process(message: ProcessSubmissionMessage) {
val submission = submissionRepository.findByIdOptional(message.submissionId)
.orElseThrow { SubmissionNotFoundException() }
try {
// Perform calculations, aggregations, etc.
val calculations = calculationService.calculate(submission)
submission.state = SubmissionState.PROCESSED
submission.calculatedData = calculations
submissionRepository.persist(submission)
auditLogService.logStateTransition(
submission = submission,
oldState = SubmissionState.VALIDATED,
newState = SubmissionState.PROCESSED,
actorUserId = null,
actorType = "SYSTEM",
reason = "Automated processing completed",
ipAddress = "internal"
)
} catch (e: Exception) {
logger.error("Processing job failed for submission ${submission.id}", e)
throw e
}
}
}
Performance Considerations
Mobile App Optimization
-
Database Indexing
-
Pagination for Large Lists
-
Lazy Loading Evidence
- Don't load evidence files into memory until needed
- Use thumbnails for image preview
-
Stream large files during upload
-
Batch Operations
- Batch SQLite inserts/updates (transaction per batch, not per row)
- Batch API calls if backend supports (future enhancement)
Backend Optimization
-
Database Indexing
CREATE INDEX idx_submissions_tenant_uuid ON metric_submissions(tenant_id, submission_uuid); CREATE INDEX idx_submissions_tenant_state ON metric_submissions(tenant_id, state); CREATE INDEX idx_submissions_updated_at ON metric_submissions(updated_at); CREATE INDEX idx_audit_log_submission ON submission_audit_log(submission_id, timestamp); -
Query Optimization
- Use pagination for list endpoints (max 100 per page)
- Use database cursors for large result sets
-
Implement query result caching (Redis)
-
Async Processing
- All validation/processing must be async (don't block API response)
- Use message queue (RabbitMQ/SQS) for job distribution
-
Implement dead letter queue for failed jobs
-
S3 Upload Optimization
- Use multipart upload for files > 5MB
- Generate presigned URLs for direct client upload (reduce server load)
- Implement lifecycle policies for old evidence files
Security Requirements
Mobile App Security
- Token Storage
- Store JWT tokens in Android Keystore / iOS Keychain
- Never log tokens or sensitive data
-
Clear tokens on logout
-
Local Database Encryption
- Use SQLCipher for database encryption
- Generate encryption key from device keystore
-
Wipe database on app uninstall
-
Certificate Pinning
- Pin SSL certificates for API domain
- Prevent MITM attacks on enterprise networks
-
Update pinned certificates via app updates
-
Input Sanitization
- Sanitize all user inputs before saving
- Prevent SQL injection in local queries
- Validate file types before upload
Backend Security
- Tenant Isolation
- ALWAYS filter queries by tenant_id from JWT
- Use database row-level security policies
-
Audit cross-tenant access attempts
-
Idempotency Cache Security
- Store idempotency keys in Redis with tenant_id prefix
- Set 24-hour TTL on cached responses
-
Verify tenant_id matches before returning cached response
-
Evidence File Security
- Scan all uploaded files for viruses (ClamAV)
- Validate file types server-side (don't trust Content-Type)
- Encrypt files at rest in S3
-
Generate signed URLs for downloads (1-hour expiration)
-
Audit Logging
- Log all state transitions to immutable audit log
- Include IP address, user agent, timestamp
- Store audit logs in separate database (security)
- Implement log integrity checks (hash chain)
Testing Checklist
Mobile App Testing
Unit Tests: - [ ] LocalValidator correctly validates all metric types - [ ] SyncService correctly processes queue (FIFO order) - [ ] Retry logic implements exponential backoff - [ ] UUID generation is unique
Integration Tests: - [ ] Draft creation saves to SQLite - [ ] Queue submission triggers sync when online - [ ] Evidence upload queues files correctly - [ ] Sync endpoint updates local submission states - [ ] Resubmission creates new version
Offline Testing: - [ ] App works fully offline (draft creation, editing) - [ ] Queue builds up when offline - [ ] Queue processes when network restored - [ ] Evidence files queue for upload when offline
Performance Tests: - [ ] App handles 1000+ local submissions without lag - [ ] Sync completes in < 30 seconds for 100 submissions - [ ] Evidence upload handles 50MB files - [ ] Database queries complete in < 100ms
Backend Testing
Unit Tests: - [ ] Idempotency prevents duplicate submissions - [ ] Validation engine correctly validates all 6 types - [ ] State transitions enforce rules - [ ] Tenant isolation prevents cross-tenant access
Integration Tests: - [ ] End-to-end: Submit → Validate → Process → Approve - [ ] Evidence upload stores in S3 with encryption - [ ] Sync endpoint returns only updated submissions - [ ] Audit log records all state transitions
Load Tests: - [ ] 100 submissions/second throughput - [ ] 1000 concurrent sync requests - [ ] Evidence upload handles 100 concurrent uploads - [ ] Database queries scale to 1M submissions
Security Tests: - [ ] JWT validation rejects expired tokens - [ ] Tenant isolation prevents data leaks - [ ] File upload rejects malicious files - [ ] Rate limiting enforces limits
Collection Cadence for Human Capital Metrics
Quarterly Collection (GRI 405-1 Employee Demographics)
Metrics:
- GRI_405_1_EXECUTIVE_HEADCOUNT
- GRI_405_1_SALARIED_NON_NEC_HEADCOUNT
- GRI_405_1_WAGED_NEC_HEADCOUNT
Collection Schedule: - Q1: March 31 (due within 2 weeks of quarter end) - Q2: June 30 (due within 2 weeks of quarter end) - Q3: September 30 (due within 2 weeks of quarter end) - Q4: December 31 (due within 2 weeks of quarter end)
Dimensional Breakdown: - Total headcount by employment level - Disaggregated by gender (Male, Female) - Disaggregated by local community status
Evidence Required: - HR Register or Payroll Report (minimum 1 file) - Must show headcount snapshot as of quarter end date
Workflow:
1. HR collector fetches quarterly demographics template
2. Enters headcount data with gender and local community breakdown
3. Attaches HR register or payroll evidence
4. Submits to platform with activity_date = quarter end date
5. Platform validates totals (Male + Female = Total, Local Community ≤ Total)
6. Reviewer approves quarterly headcount data
Monthly Collection (GRI 401 Employment Type and Turnover)
Metrics:
- GRI_401_PERMANENT_EMPLOYEES_MONTHLY
- GRI_401_FIXED_TERM_EMPLOYEES_MONTHLY
- GRI_401_NEW_RECRUITS_PERMANENT_MALE_MONTHLY
- GRI_401_NEW_RECRUITS_PERMANENT_FEMALE_MONTHLY
- GRI_401_DEPARTURES_PERMANENT_MONTHLY
- GRI_401_CASUAL_WORKERS_MONTHLY
Collection Schedule: - Monthly, last day of each month (due within 1 week of month end) - January 31, February 28/29, March 31, ..., December 31
Simple Scalar Values: - Each metric submitted as a single integer value - No dimensional breakdown (except gender for recruitment metrics)
Evidence Required: - HR Register or Payroll Report (for headcount metrics) - Recruitment Report (for new recruits metrics) - Exit Report (for departures metric) - Time Sheet or Contract Register (for casual workers metric)
Workflow:
1. HR collector fetches monthly employment templates (6 metrics)
2. Enters monthly values for each metric (permanent, fixed-term, recruits male, recruits female, departures, casual)
3. Attaches relevant evidence for each metric type
4. Submits all 6 metrics with activity_date = month end date
5. Platform validates non-negative integers, reasonable ranges
6. Reviewer approves monthly employment data
Annual Aggregation: - Permanent/Fixed-Term: Average = SUM(monthly values) / count(months) - Recruits: Average = SUM(monthly values) / count(months) - Departures: Total = SUM(monthly values) - Casual Workers: Total = SUM(unique workers, deduplicated)
Monthly Collection (Employee Age Demographics)
Metrics: - Employee headcount by age group (Under 30, Aged 30-50, Over 50)
Collection Schedule: - Monthly, last day of each month (due within 1 week of month end)
Dimensional Breakdown: - Under 30 headcount - Aged 30-50 headcount - Over 50 headcount - Total headcount
Evidence Required: - HR Register or Payroll Report showing age distribution
Workflow:
1. HR collector fetches monthly age demographics template
2. Enters headcount by age group
3. Attaches HR register or payroll evidence
4. Submits with activity_date = month end date
5. Platform validates age group totals sum to total
6. Reviewer approves monthly age data
Annual Aggregation: - Total = SUM(unique employees across all months, deduplicated by employee ID)
Submission Best Practices for HR Data
1. Prepare Evidence First - Extract headcount data from HR system - Generate payroll or HR register report for the period - Ensure evidence shows aggregated counts only (no individual PII) - Redact sensitive information if necessary
2. Offline Data Entry - Mobile app supports offline draft creation for HR metrics - Enter all dimensional data (gender, age, local community) in offline mode - Platform validates totals locally before queuing for upload
3. Batch Monthly Submissions - Submit all 6 GRI 401 metrics together for the same month - Submit age demographics metric for the same month - Attach evidence once per metric type (HR register can be reused)
4. Quarterly vs Monthly Cadence - GRI 405-1 (Demographics): Quarterly snapshots sufficient for diversity reporting - GRI 401 (Employment/Turnover): Monthly data required for trend analysis - Age Demographics: Monthly data supports workforce planning
5. Evidence Attachments - HR Register: Primary evidence for all headcount metrics - Payroll Report: Alternative evidence (acceptable for all metrics) - Recruitment Report: Specific to new hires metrics - Exit Report: Specific to departures metric
6. PII Protection During Collection - Never include individual employee names, IDs, or contact info in submissions - Aggregate data at employment level, gender, age group level - Ensure minimum 5 employees per reported category (aggregation threshold) - Upload only anonymized/aggregated evidence files
Cross-References
- Collector API - REST API endpoints for mobile app
- Ingestion Pipeline - Backend processing pipeline
- Validation Engine - Details on 6 validation types
- Review and Approval Workflow - Reviewer task routing
- Evidence Management - File handling and S3 storage
- Error Handling - Standard error response format
- Collection Templates - Human capital and other collection templates
Change Log
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-03 | Senior Product Architect | Initial collector workflow specification |
| 2.0 | 2026-01-11 | Ralph Agent | Comprehensive expansion with state machine, retry logic, conflict resolution, Kotlin examples, performance and security sections |
| 2.1 | 2026-01-17 | Ralph Agent | Added collection cadence section for human capital metrics (GRI 405-1, GRI 401, Employee Age) with quarterly/monthly schedules, evidence requirements, and submission best practices |