Skip to content

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

  1. Workflow Overview
  2. State Machine
  3. Offline-First Architecture
  4. Step-by-Step Workflow
  5. Queue Management
  6. Retry Logic
  7. Conflict Resolution
  8. Chain-of-Custody Tracking
  9. Error Handling
  10. Background Jobs
  11. Performance Considerations
  12. Security Requirements
  13. Testing Checklist
  14. 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

  1. Local-First: All data entry happens in local SQLite database first
  2. Client-Generated UUIDs: Mobile app generates UUIDs for submissions and evidence
  3. Idempotency: Same UUID = same submission (prevent duplicates on retry)
  4. Incremental Sync: Only fetch changes since last sync timestamp
  5. Conflict-Free: Server state always wins (no merge conflicts)
  6. 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

  1. Data Entry Never Blocks on Network
  2. All form inputs save immediately to SQLite
  3. No "Loading..." spinners during data entry
  4. Validation happens client-side first (instant feedback)

  5. Background Synchronization

  6. Sync service runs every 5 minutes when app is active
  7. Background sync every 30 minutes when app is backgrounded
  8. Manual "Sync Now" button for immediate upload

  9. Network-Aware Queue Management

  10. Monitor network connectivity (WiFi, Cellular, Offline)
  11. Pause uploads when network unavailable
  12. Resume automatically when network restored
  13. Respect metered connections (prompt user for cellular uploads)

  14. 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:

GET /api/v1/collector/sync?since=2026-01-11T10:30:00Z
Authorization: Bearer {jwt_token}

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

  1. FIFO (First In, First Out)
  2. Process submissions in the order they were queued
  3. Respect queuedAt timestamp

  4. Stop on Network Error

  5. If upload fails with network error, stop processing queue
  6. Remaining submissions stay queued for next sync cycle

  7. Continue on API Error

  8. If upload fails with API error (validation, etc.), mark as ERROR
  9. 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

  1. Database Indexing

    @Entity(
        tableName = "submissions",
        indices = [
            Index(value = ["uuid"], unique = true),
            Index(value = ["state"]),
            Index(value = ["reporting_period_id", "site_id"]),
            Index(value = ["queued_at"])
        ]
    )
    

  2. Pagination for Large Lists

    @Query("SELECT * FROM submissions ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
    fun getSubmissionsPaged(limit: Int, offset: Int): List<SubmissionEntity>
    

  3. Lazy Loading Evidence

  4. Don't load evidence files into memory until needed
  5. Use thumbnails for image preview
  6. Stream large files during upload

  7. Batch Operations

  8. Batch SQLite inserts/updates (transaction per batch, not per row)
  9. Batch API calls if backend supports (future enhancement)

Backend Optimization

  1. 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);
    

  2. Query Optimization

  3. Use pagination for list endpoints (max 100 per page)
  4. Use database cursors for large result sets
  5. Implement query result caching (Redis)

  6. Async Processing

  7. All validation/processing must be async (don't block API response)
  8. Use message queue (RabbitMQ/SQS) for job distribution
  9. Implement dead letter queue for failed jobs

  10. S3 Upload Optimization

  11. Use multipart upload for files > 5MB
  12. Generate presigned URLs for direct client upload (reduce server load)
  13. Implement lifecycle policies for old evidence files

Security Requirements

Mobile App Security

  1. Token Storage
  2. Store JWT tokens in Android Keystore / iOS Keychain
  3. Never log tokens or sensitive data
  4. Clear tokens on logout

  5. Local Database Encryption

  6. Use SQLCipher for database encryption
  7. Generate encryption key from device keystore
  8. Wipe database on app uninstall

  9. Certificate Pinning

  10. Pin SSL certificates for API domain
  11. Prevent MITM attacks on enterprise networks
  12. Update pinned certificates via app updates

  13. Input Sanitization

  14. Sanitize all user inputs before saving
  15. Prevent SQL injection in local queries
  16. Validate file types before upload

Backend Security

  1. Tenant Isolation
  2. ALWAYS filter queries by tenant_id from JWT
  3. Use database row-level security policies
  4. Audit cross-tenant access attempts

  5. Idempotency Cache Security

  6. Store idempotency keys in Redis with tenant_id prefix
  7. Set 24-hour TTL on cached responses
  8. Verify tenant_id matches before returning cached response

  9. Evidence File Security

  10. Scan all uploaded files for viruses (ClamAV)
  11. Validate file types server-side (don't trust Content-Type)
  12. Encrypt files at rest in S3
  13. Generate signed URLs for downloads (1-hour expiration)

  14. Audit Logging

  15. Log all state transitions to immutable audit log
  16. Include IP address, user agent, timestamp
  17. Store audit logs in separate database (security)
  18. 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


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