Skip to content

Validation Engine

Status: Final Version: 3.3 Last Updated: 2026-01-18


Table of Contents

  1. Purpose
  2. Validation Architecture
  3. Validation Types
  4. Schema Validation
  5. Domain Validation
  6. Referential Validation
  7. Evidence Validation
  8. Business Rule Validation
  9. Anomaly Detection
  10. Validation Flow
  11. Error Format Specification
  12. Implementation Guide
  13. Performance Considerations
  14. Security Requirements
  15. Testing Strategy
  16. Acceptance Criteria

Purpose

The Validation Engine is the CRITICAL quality gate for all ESG data entering the system. It executes a comprehensive multi-stage validation pipeline that ensures data integrity, regulatory compliance, and business rule adherence before submissions are approved for reporting.

Why This Matters: - ESG data is used for regulatory compliance (GRI, SASB, TCFD) - Investor decisions depend on data accuracy - Incorrect data leads to compliance violations and reputational damage - The validation engine is the ONLY defense against bad data

Key Capabilities: - 6 validation types executed in strict order - Hard failures (schema, domain, referential, evidence, business rules) block submissions - Soft warnings (anomaly detection) flag outliers without blocking - Field-level error messages for precise feedback - Configurable validation rules per metric template - Performance-optimized for high-volume processing


Validation Architecture

The validation engine runs as a background job triggered when submissions transition from RECEIVEDVALIDATED.

┌────────────────────────────────────────────────────────────────┐
│                      VALIDATION PIPELINE                        │
└────────────────────────────────────────────────────────────────┘
                    ┌──────────────────┐
                    │  1. Schema       │ ◄── Data types, required fields
                    │     Validation   │     Format validation
                    └────────┬─────────┘
                             │ PASS
                    ┌──────────────────┐
                    │  2. Domain       │ ◄── Min/max, regex, precision
                    │     Validation   │     Enum values, ranges
                    └────────┬─────────┘
                             │ PASS
                    ┌──────────────────┐
                    │  3. Referential  │ ◄── Cross-field consistency
                    │     Validation   │     Sum checks, dependencies
                    └────────┬─────────┘
                             │ PASS
                    ┌──────────────────┐
                    │  4. Evidence     │ ◄── Required attachments
                    │     Validation   │     File type, size checks
                    └────────┬─────────┘
                             │ PASS
                    ┌──────────────────┐
                    │  5. Business     │ ◄── Custom logic
                    │     Rule Valid.  │     Conditional rules
                    └────────┬─────────┘
                             │ PASS
                    ┌──────────────────┐
                    │  6. Anomaly      │ ◄── Statistical outliers
                    │     Detection    │     YoY change warnings
                    └────────┬─────────┘
                             │ WARNINGS (always pass)
                    ┌──────────────────┐
                    │  VALIDATION      │ ◄── Result stored in DB
                    │  COMPLETE        │     State → VALIDATED or
                    └──────────────────┘     VALIDATION_FAILED

Execution Strategy: - Sequential: Each stage runs only if previous stage passes - Fail-Fast: First hard failure stops pipeline (except anomaly detection) - Atomic: All validation results stored in single transaction - Async: Runs as RabbitMQ background job (doesn't block HTTP response)


Validation Types

The validation engine executes 6 validation types in strict order:

Order Type Failure Impact Execution Time Examples
1 Schema Hard fail (VALIDATION_FAILED) < 10ms Data type, required fields, format
2 Domain Hard fail (VALIDATION_FAILED) < 50ms Min/max, regex, precision, enums
3 Referential Hard fail (VALIDATION_FAILED) < 100ms Cross-field consistency, sum checks
4 Evidence Hard fail (VALIDATION_FAILED) < 50ms Required evidence attached
5 Business Hard fail (VALIDATION_FAILED) < 200ms Custom logic, conditional rules
6 Anomaly Soft warning (pass with warnings) < 500ms Statistical outliers, YoY spikes

Total Validation Time Target: < 1 second (p95)

Hard Failure Behavior: - Submission state → VALIDATION_FAILED - Detailed error messages returned to collector - Submission appears in reviewer queue for manual review - Collector can resubmit after fixing errors

Soft Warning Behavior: - Submission state → VALIDATED (still passes) - Warnings attached to submission - Warnings visible to reviewers during approval - Anomalies require reviewer acknowledgment


Schema Validation

Purpose: Verify that all required fields are present and have correct data types and formats.

Execution Order: 1st (must pass before other validations run)

Validation Rules

Rule Description Error Example
required Field must be present and non-null "Field 'value' is required"
type:number Field must be numeric (integer or decimal) "Field 'value' must be a number"
type:integer Field must be integer (no decimals) "Field 'employee_count' must be an integer"
type:decimal Field must be decimal number "Field 'emissions' must be a decimal number"
type:boolean Field must be true/false "Field 'renewable_energy' must be boolean"
type:string Field must be text "Field 'notes' must be a string"
type:date Field must be ISO 8601 date (YYYY-MM-DD) "Field 'reporting_date' must be ISO 8601 date"
type:datetime Field must be ISO 8601 datetime "Field 'timestamp' must be ISO 8601 datetime"
type:uuid Field must be valid UUID v4 "Field 'submission_uuid' must be valid UUID"

Kotlin Implementation

package com.example.esg.validation

import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeParseException
import java.util.UUID

data class SchemaValidationRule(
    val field: String,
    val rule: String, // "required", "type:number", etc.
    val errorMessage: String
)

class SchemaValidator {

    fun validate(data: Map<String, Any?>, rules: List<SchemaValidationRule>): List<String> {
        val errors = mutableListOf<String>()

        for (rule in rules) {
            val value = data[rule.field]
            val isValid = when (rule.rule) {
                "required" -> validateRequired(value)
                "type:number" -> validateNumber(value)
                "type:integer" -> validateInteger(value)
                "type:decimal" -> validateDecimal(value)
                "type:boolean" -> validateBoolean(value)
                "type:string" -> validateString(value)
                "type:date" -> validateDate(value)
                "type:datetime" -> validateDateTime(value)
                "type:uuid" -> validateUuid(value)
                else -> true // Unknown rule, skip
            }

            if (!isValid) {
                errors.add(rule.errorMessage)
            }
        }

        return errors
    }

    private fun validateRequired(value: Any?): Boolean {
        return value != null && value.toString().isNotBlank()
    }

    private fun validateNumber(value: Any?): Boolean {
        if (value == null) return false
        return when (value) {
            is Number -> true
            is String -> value.toDoubleOrNull() != null
            else -> false
        }
    }

    private fun validateInteger(value: Any?): Boolean {
        if (value == null) return false
        return when (value) {
            is Int, is Long -> true
            is String -> value.toLongOrNull() != null
            is Double -> value % 1.0 == 0.0
            else -> false
        }
    }

    private fun validateDecimal(value: Any?): Boolean {
        return validateNumber(value)
    }

    private fun validateBoolean(value: Any?): Boolean {
        if (value == null) return false
        return when (value) {
            is Boolean -> true
            is String -> value.lowercase() in listOf("true", "false", "1", "0")
            is Number -> value.toInt() in listOf(0, 1)
            else -> false
        }
    }

    private fun validateString(value: Any?): Boolean {
        return value is String
    }

    private fun validateDate(value: Any?): Boolean {
        if (value == null || value !is String) return false
        return try {
            LocalDate.parse(value) // ISO 8601: YYYY-MM-DD
            true
        } catch (e: DateTimeParseException) {
            false
        }
    }

    private fun validateDateTime(value: Any?): Boolean {
        if (value == null || value !is String) return false
        return try {
            LocalDateTime.parse(value) // ISO 8601: YYYY-MM-DDTHH:mm:ss
            true
        } catch (e: DateTimeParseException) {
            false
        }
    }

    private fun validateUuid(value: Any?): Boolean {
        if (value == null || value !is String) return false
        return try {
            UUID.fromString(value)
            true
        } catch (e: IllegalArgumentException) {
            false
        }
    }
}

Example Schema Validation Rules (JSON)

{
  "metric_template_id": "emissions-scope-1-co2",
  "validation_rules": [
    {
      "type": "schema",
      "field": "value",
      "rule": "required",
      "error_message": "Emissions value is required"
    },
    {
      "type": "schema",
      "field": "value",
      "rule": "type:decimal",
      "error_message": "Emissions value must be a decimal number"
    },
    {
      "type": "schema",
      "field": "reporting_date",
      "rule": "required",
      "error_message": "Reporting date is required"
    },
    {
      "type": "schema",
      "field": "reporting_date",
      "rule": "type:date",
      "error_message": "Reporting date must be in ISO 8601 format (YYYY-MM-DD)"
    },
    {
      "type": "schema",
      "field": "unit",
      "rule": "required",
      "error_message": "Unit is required (e.g., 'tCO2e', 'kg', 'liters')"
    }
  ]
}

Domain Validation

Purpose: Verify that field values fall within acceptable ranges, match patterns, and conform to business constraints.

Execution Order: 2nd (runs after schema validation passes)

Validation Rules

Rule Description Error Example
min:X Numeric value must be >= X "Emissions value must be >= 0"
max:X Numeric value must be <= X "Employee count must be <= 1000000"
regex:PATTERN String must match regex pattern "Email must be valid format"
precision:X Decimal must have at most X decimal places "Emissions must have at most 2 decimal places"
enum:A,B,C Value must be one of allowed values "Unit must be one of: tCO2e, kg, liters"
length_min:X String must have at least X characters "Notes must have at least 10 characters"
length_max:X String must have at most X characters "Notes must not exceed 500 characters"

Kotlin Implementation

package com.example.esg.validation

import java.math.BigDecimal

data class DomainValidationRule(
    val field: String,
    val rule: String, // "min:0", "max:1000000", "regex:^[A-Z]+$", etc.
    val errorMessage: String
)

class DomainValidator {

    fun validate(data: Map<String, Any?>, rules: List<DomainValidationRule>): List<String> {
        val errors = mutableListOf<String>()

        for (rule in rules) {
            val value = data[rule.field]
            val isValid = when {
                rule.rule.startsWith("min:") -> validateMin(value, rule.rule.substringAfter("min:").toDouble())
                rule.rule.startsWith("max:") -> validateMax(value, rule.rule.substringAfter("max:").toDouble())
                rule.rule.startsWith("regex:") -> validateRegex(value, rule.rule.substringAfter("regex:"))
                rule.rule.startsWith("precision:") -> validatePrecision(value, rule.rule.substringAfter("precision:").toInt())
                rule.rule.startsWith("enum:") -> validateEnum(value, rule.rule.substringAfter("enum:").split(","))
                rule.rule.startsWith("length_min:") -> validateLengthMin(value, rule.rule.substringAfter("length_min:").toInt())
                rule.rule.startsWith("length_max:") -> validateLengthMax(value, rule.rule.substringAfter("length_max:").toInt())
                else -> true // Unknown rule, skip
            }

            if (!isValid) {
                errors.add(rule.errorMessage)
            }
        }

        return errors
    }

    private fun validateMin(value: Any?, min: Double): Boolean {
        if (value == null) return false
        val numValue = when (value) {
            is Number -> value.toDouble()
            is String -> value.toDoubleOrNull() ?: return false
            else -> return false
        }
        return numValue >= min
    }

    private fun validateMax(value: Any?, max: Double): Boolean {
        if (value == null) return false
        val numValue = when (value) {
            is Number -> value.toDouble()
            is String -> value.toDoubleOrNull() ?: return false
            else -> return false
        }
        return numValue <= max
    }

    private fun validateRegex(value: Any?, pattern: String): Boolean {
        if (value == null) return false
        val strValue = value.toString()
        return strValue.matches(Regex(pattern))
    }

    private fun validatePrecision(value: Any?, maxDecimalPlaces: Int): Boolean {
        if (value == null) return false
        val decimalValue = when (value) {
            is BigDecimal -> value
            is Number -> BigDecimal.valueOf(value.toDouble())
            is String -> value.toBigDecimalOrNull() ?: return false
            else -> return false
        }

        val scale = decimalValue.scale()
        return scale <= maxDecimalPlaces
    }

    private fun validateEnum(value: Any?, allowedValues: List<String>): Boolean {
        if (value == null) return false
        return value.toString() in allowedValues
    }

    private fun validateLengthMin(value: Any?, minLength: Int): Boolean {
        if (value == null) return false
        return value.toString().length >= minLength
    }

    private fun validateLengthMax(value: Any?, maxLength: Int): Boolean {
        if (value == null) return false
        return value.toString().length <= maxLength
    }
}

Example Domain Validation Rules (JSON)

{
  "metric_template_id": "emissions-scope-1-co2",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Emissions value cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "max:1000000000",
      "error_message": "Emissions value exceeds maximum (1 billion tCO2e)"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:2",
      "error_message": "Emissions value must have at most 2 decimal places"
    },
    {
      "type": "domain",
      "field": "unit",
      "rule": "enum:tCO2e,kgCO2e,lbsCO2e",
      "error_message": "Unit must be one of: tCO2e, kgCO2e, lbsCO2e"
    },
    {
      "type": "domain",
      "field": "notes",
      "rule": "length_max:500",
      "error_message": "Notes must not exceed 500 characters"
    }
  ]
}

Referential Validation

Purpose: Verify cross-field consistency, sum checks, and dependencies between multiple fields.

Execution Order: 3rd (runs after domain validation passes)

Validation Rules

Rule Description Error Example
sum_equals:field1,field2,field3 Sum of fields must equal target field "Total emissions must equal sum of Scope 1 + Scope 2 + Scope 3"
required_if:field=value Field required if another field has specific value "Evidence required if emissions > 10000"
mutually_exclusive:field1,field2 Only one of the fields can have a value "Cannot specify both 'renewable_energy' and 'fossil_fuel_energy'"
date_after:field Date must be after another date field "End date must be after start date"
date_before:field Date must be before another date field "Reporting date must be before current date"
percentage_of:field,min,max Value must be X% to Y% of another field "Renewable energy must be 0-100% of total energy"

Kotlin Implementation

package com.example.esg.validation

import java.math.BigDecimal
import java.time.LocalDate

data class ReferentialValidationRule(
    val rule: String, // "sum_equals:scope1,scope2,scope3,total"
    val errorMessage: String
)

class ReferentialValidator {

    fun validate(data: Map<String, Any?>, rules: List<ReferentialValidationRule>): List<String> {
        val errors = mutableListOf<String>()

        for (rule in rules) {
            val isValid = when {
                rule.rule.startsWith("sum_equals:") -> validateSumEquals(data, rule.rule.substringAfter("sum_equals:"))
                rule.rule.startsWith("required_if:") -> validateRequiredIf(data, rule.rule.substringAfter("required_if:"))
                rule.rule.startsWith("mutually_exclusive:") -> validateMutuallyExclusive(data, rule.rule.substringAfter("mutually_exclusive:"))
                rule.rule.startsWith("date_after:") -> validateDateAfter(data, rule.rule.substringAfter("date_after:"))
                rule.rule.startsWith("date_before:") -> validateDateBefore(data, rule.rule.substringAfter("date_before:"))
                rule.rule.startsWith("percentage_of:") -> validatePercentageOf(data, rule.rule.substringAfter("percentage_of:"))
                else -> true // Unknown rule, skip
            }

            if (!isValid) {
                errors.add(rule.errorMessage)
            }
        }

        return errors
    }

    private fun validateSumEquals(data: Map<String, Any?>, params: String): Boolean {
        // params format: "field1,field2,field3,targetField"
        val parts = params.split(",")
        if (parts.size < 3) return false

        val targetField = parts.last()
        val sumFields = parts.dropLast(1)

        val sum = sumFields.mapNotNull { field ->
            data[field]?.toString()?.toBigDecimalOrNull()
        }.fold(BigDecimal.ZERO) { acc, value -> acc + value }

        val target = data[targetField]?.toString()?.toBigDecimalOrNull() ?: return false

        return sum.compareTo(target) == 0
    }

    private fun validateRequiredIf(data: Map<String, Any?>, params: String): Boolean {
        // params format: "conditionField=conditionValue,requiredField"
        val parts = params.split(",")
        if (parts.size != 2) return false

        val (condition, requiredField) = parts
        val (conditionField, conditionValue) = condition.split("=")

        // If condition is met, required field must have value
        if (data[conditionField]?.toString() == conditionValue) {
            val value = data[requiredField]
            return value != null && value.toString().isNotBlank()
        }

        return true // Condition not met, validation passes
    }

    private fun validateMutuallyExclusive(data: Map<String, Any?>, params: String): Boolean {
        // params format: "field1,field2"
        val fields = params.split(",")

        val nonNullCount = fields.count { field ->
            val value = data[field]
            value != null && value.toString().isNotBlank()
        }

        return nonNullCount <= 1
    }

    private fun validateDateAfter(data: Map<String, Any?>, params: String): Boolean {
        // params format: "dateField,afterField"
        val (dateField, afterField) = params.split(",")

        val date1 = data[dateField]?.toString()?.let { LocalDate.parse(it) } ?: return false
        val date2 = data[afterField]?.toString()?.let { LocalDate.parse(it) } ?: return false

        return date1.isAfter(date2)
    }

    private fun validateDateBefore(data: Map<String, Any?>, params: String): Boolean {
        // params format: "dateField,beforeField"
        val (dateField, beforeField) = params.split(",")

        val date1 = data[dateField]?.toString()?.let { LocalDate.parse(it) } ?: return false
        val date2 = data[beforeField]?.toString()?.let { LocalDate.parse(it) } ?: return false

        return date1.isBefore(date2)
    }

    private fun validatePercentageOf(data: Map<String, Any?>, params: String): Boolean {
        // params format: "field,baseField,min,max"
        val parts = params.split(",")
        if (parts.size != 4) return false

        val (field, baseField, minStr, maxStr) = parts

        val value = data[field]?.toString()?.toDoubleOrNull() ?: return false
        val base = data[baseField]?.toString()?.toDoubleOrNull() ?: return false
        val min = minStr.toDoubleOrNull() ?: return false
        val max = maxStr.toDoubleOrNull() ?: return false

        if (base == 0.0) return true // Avoid division by zero

        val percentage = (value / base) * 100
        return percentage in min..max
    }
}

Example Referential Validation Rules (JSON)

{
  "metric_template_id": "emissions-total",
  "validation_rules": [
    {
      "type": "referential",
      "rule": "sum_equals:scope1_emissions,scope2_emissions,scope3_emissions,total_emissions",
      "error_message": "Total emissions must equal sum of Scope 1 + Scope 2 + Scope 3 emissions"
    },
    {
      "type": "referential",
      "rule": "required_if:total_emissions>10000,evidence_id",
      "error_message": "Evidence is required for emissions > 10,000 tCO2e"
    },
    {
      "type": "referential",
      "rule": "date_before:reporting_date,period_end_date",
      "error_message": "Reporting date must be before or equal to period end date"
    }
  ]
}

Evidence Validation

Purpose: Verify that required evidence files are attached and meet file requirements.

Execution Order: 4th (runs after referential validation passes)

Validation Rules

Rule Description Error Example
required At least one evidence file must be attached "Evidence is required for this metric"
min_files:X At least X evidence files required "At least 2 evidence files required"
max_files:X At most X evidence files allowed "Maximum 10 evidence files allowed"
allowed_types:pdf,xlsx,jpg Only specific file types allowed "Only PDF, XLSX, JPG files allowed"
max_size_mb:X Each file must be <= X MB "Evidence files must be <= 50 MB"

Kotlin Implementation

package com.example.esg.validation

data class EvidenceValidationRule(
    val rule: String, // "required", "min_files:2", "allowed_types:pdf,xlsx"
    val errorMessage: String
)

data class EvidenceFile(
    val id: String,
    val filename: String,
    val mimeType: String,
    val sizeBytes: Long
)

class EvidenceValidator {

    fun validate(evidenceFiles: List<EvidenceFile>, rules: List<EvidenceValidationRule>): List<String> {
        val errors = mutableListOf<String>()

        for (rule in rules) {
            val isValid = when {
                rule.rule == "required" -> validateRequired(evidenceFiles)
                rule.rule.startsWith("min_files:") -> validateMinFiles(evidenceFiles, rule.rule.substringAfter("min_files:").toInt())
                rule.rule.startsWith("max_files:") -> validateMaxFiles(evidenceFiles, rule.rule.substringAfter("max_files:").toInt())
                rule.rule.startsWith("allowed_types:") -> validateAllowedTypes(evidenceFiles, rule.rule.substringAfter("allowed_types:").split(","))
                rule.rule.startsWith("max_size_mb:") -> validateMaxSize(evidenceFiles, rule.rule.substringAfter("max_size_mb:").toDouble())
                else -> true // Unknown rule, skip
            }

            if (!isValid) {
                errors.add(rule.errorMessage)
            }
        }

        return errors
    }

    private fun validateRequired(files: List<EvidenceFile>): Boolean {
        return files.isNotEmpty()
    }

    private fun validateMinFiles(files: List<EvidenceFile>, minCount: Int): Boolean {
        return files.size >= minCount
    }

    private fun validateMaxFiles(files: List<EvidenceFile>, maxCount: Int): Boolean {
        return files.size <= maxCount
    }

    private fun validateAllowedTypes(files: List<EvidenceFile>, allowedTypes: List<String>): Boolean {
        val allowedExtensions = allowedTypes.map { it.lowercase() }

        return files.all { file ->
            val extension = file.filename.substringAfterLast('.', "").lowercase()
            extension in allowedExtensions
        }
    }

    private fun validateMaxSize(files: List<EvidenceFile>, maxSizeMB: Double): Boolean {
        val maxSizeBytes = (maxSizeMB * 1024 * 1024).toLong()

        return files.all { file ->
            file.sizeBytes <= maxSizeBytes
        }
    }
}

Example Evidence Validation Rules (JSON)

{
  "metric_template_id": "emissions-scope-1-co2",
  "validation_rules": [
    {
      "type": "evidence",
      "rule": "required",
      "error_message": "At least one evidence file is required for emissions reporting"
    },
    {
      "type": "evidence",
      "rule": "allowed_types:pdf,xlsx,jpg,png",
      "error_message": "Only PDF, XLSX, JPG, PNG files are allowed as evidence"
    },
    {
      "type": "evidence",
      "rule": "max_size_mb:50",
      "error_message": "Each evidence file must be 50 MB or smaller"
    },
    {
      "type": "evidence",
      "rule": "max_files:10",
      "error_message": "Maximum 10 evidence files allowed per submission"
    }
  ]
}

Business Rule Validation

Purpose: Execute custom validation logic that implements complex business requirements.

Execution Order: 5th (runs after evidence validation passes)

Common Business Rules

Rule Description Error Example
ghg_protocol_compliance Verify GHG Protocol calculation methodology "GHG Protocol calculation not followed"
renewable_energy_percentage Renewable energy must be 0-100% of total "Renewable energy cannot exceed total energy"
water_withdrawal_consistency Water withdrawal >= water consumption "Water consumption cannot exceed withdrawal"
waste_diversion_rate Diverted waste must be 0-100% of total "Waste diversion rate must be 0-100%"
employee_safety_metrics Lost time injury rate calculation "LTIR calculation incorrect"

Kotlin Implementation

package com.example.esg.validation

import java.math.BigDecimal

data class BusinessRuleValidationRule(
    val rule: String, // "ghg_protocol_compliance", "renewable_energy_percentage"
    val errorMessage: String,
    val params: Map<String, String> = emptyMap()
)

class BusinessRuleValidator {

    fun validate(data: Map<String, Any?>, rules: List<BusinessRuleValidationRule>): List<String> {
        val errors = mutableListOf<String>()

        for (rule in rules) {
            val isValid = when (rule.rule) {
                "ghg_protocol_compliance" -> validateGhgProtocolCompliance(data)
                "renewable_energy_percentage" -> validateRenewableEnergyPercentage(data)
                "water_withdrawal_consistency" -> validateWaterWithdrawalConsistency(data)
                "waste_diversion_rate" -> validateWasteDiversionRate(data)
                "employee_safety_metrics" -> validateEmployeeSafetyMetrics(data)
                else -> true // Unknown rule, skip (or implement custom validator)
            }

            if (!isValid) {
                errors.add(rule.errorMessage)
            }
        }

        return errors
    }

    private fun validateGhgProtocolCompliance(data: Map<String, Any?>): Boolean {
        // Example: Total emissions = Scope 1 + Scope 2 + Scope 3
        val scope1 = data["scope1_emissions"]?.toString()?.toBigDecimalOrNull() ?: BigDecimal.ZERO
        val scope2 = data["scope2_emissions"]?.toString()?.toBigDecimalOrNull() ?: BigDecimal.ZERO
        val scope3 = data["scope3_emissions"]?.toString()?.toBigDecimalOrNull() ?: BigDecimal.ZERO
        val total = data["total_emissions"]?.toString()?.toBigDecimalOrNull() ?: return false

        val calculatedTotal = scope1 + scope2 + scope3

        // Allow 0.01 tolerance for rounding
        return (calculatedTotal - total).abs() <= BigDecimal("0.01")
    }

    private fun validateRenewableEnergyPercentage(data: Map<String, Any?>): Boolean {
        val renewable = data["renewable_energy"]?.toString()?.toDoubleOrNull() ?: return false
        val total = data["total_energy"]?.toString()?.toDoubleOrNull() ?: return false

        if (total == 0.0) return true // No energy consumed, skip check

        val percentage = (renewable / total) * 100
        return percentage in 0.0..100.0
    }

    private fun validateWaterWithdrawalConsistency(data: Map<String, Any?>): Boolean {
        val withdrawal = data["water_withdrawal"]?.toString()?.toDoubleOrNull() ?: return false
        val consumption = data["water_consumption"]?.toString()?.toDoubleOrNull() ?: return false

        // Consumption cannot exceed withdrawal
        return consumption <= withdrawal
    }

    private fun validateWasteDiversionRate(data: Map<String, Any?>): Boolean {
        val diverted = data["waste_diverted"]?.toString()?.toDoubleOrNull() ?: return false
        val total = data["total_waste"]?.toString()?.toDoubleOrNull() ?: return false

        if (total == 0.0) return true // No waste, skip check

        val diversionRate = (diverted / total) * 100
        return diversionRate in 0.0..100.0
    }

    private fun validateEmployeeSafetyMetrics(data: Map<String, Any?>): Boolean {
        // LTIR = (Lost Time Injuries × 200,000) / Total Hours Worked
        val lostTimeInjuries = data["lost_time_injuries"]?.toString()?.toDoubleOrNull() ?: return false
        val totalHoursWorked = data["total_hours_worked"]?.toString()?.toDoubleOrNull() ?: return false
        val reportedLtir = data["ltir"]?.toString()?.toDoubleOrNull() ?: return false

        if (totalHoursWorked == 0.0) return true // No hours worked, skip check

        val calculatedLtir = (lostTimeInjuries * 200_000) / totalHoursWorked

        // Allow 0.01 tolerance for rounding
        return kotlin.math.abs(calculatedLtir - reportedLtir) <= 0.01
    }
}

Example Business Rule Validation Rules (JSON)

{
  "metric_template_id": "emissions-total",
  "validation_rules": [
    {
      "type": "business_rule",
      "rule": "ghg_protocol_compliance",
      "error_message": "Total emissions must equal sum of Scope 1 + Scope 2 + Scope 3 per GHG Protocol"
    }
  ]
}

Anomaly Detection

Purpose: Identify statistical outliers and unusual patterns that may indicate data quality issues WITHOUT blocking submission.

Execution Order: 6th (always runs last, produces warnings only)

Key Difference: Anomaly detection NEVER causes hard failure. Submissions pass validation but carry warning flags that reviewers must acknowledge.

Detection Methods

Method Description Warning Example
z_score Statistical outlier detection (> 3 std dev) "Value is 4.2 standard deviations above average"
yoy_change Year-over-year change > threshold "Value increased 150% compared to last year"
qoq_change Quarter-over-quarter change > threshold "Value decreased 80% compared to last quarter"
missing_pattern Previously reported, now missing "This metric was reported last period but is missing now"
spike_detection Sudden spike compared to historical average "Value is 300% of historical average"

Kotlin Implementation

package com.example.esg.validation

import kotlin.math.abs
import kotlin.math.sqrt

data class AnomalyDetectionRule(
    val rule: String, // "z_score", "yoy_change:50"
    val threshold: Double,
    val warningMessage: String
)

data class HistoricalDataPoint(
    val value: Double,
    val periodDate: String
)

class AnomalyDetector {

    fun detect(
        currentValue: Double,
        historicalData: List<HistoricalDataPoint>,
        rules: List<AnomalyDetectionRule>
    ): List<String> {
        val warnings = mutableListOf<String>()

        for (rule in rules) {
            val hasAnomaly = when {
                rule.rule == "z_score" -> detectZScore(currentValue, historicalData, rule.threshold)
                rule.rule.startsWith("yoy_change") -> detectYoyChange(currentValue, historicalData, rule.threshold)
                rule.rule.startsWith("qoq_change") -> detectQoqChange(currentValue, historicalData, rule.threshold)
                rule.rule == "spike_detection" -> detectSpike(currentValue, historicalData, rule.threshold)
                else -> false
            }

            if (hasAnomaly) {
                warnings.add(rule.warningMessage)
            }
        }

        return warnings
    }

    private fun detectZScore(currentValue: Double, historicalData: List<HistoricalDataPoint>, threshold: Double): Boolean {
        if (historicalData.size < 3) return false // Need at least 3 data points

        val values = historicalData.map { it.value }
        val mean = values.average()
        val variance = values.map { (it - mean) * (it - mean) }.average()
        val stdDev = sqrt(variance)

        if (stdDev == 0.0) return false // No variance

        val zScore = abs((currentValue - mean) / stdDev)
        return zScore > threshold
    }

    private fun detectYoyChange(currentValue: Double, historicalData: List<HistoricalDataPoint>, threshold: Double): Boolean {
        // Find value from same period last year (12 months ago)
        val lastYearValue = historicalData
            .sortedByDescending { it.periodDate }
            .firstOrNull()
            ?.value
            ?: return false

        if (lastYearValue == 0.0) return false // Avoid division by zero

        val changePercentage = abs((currentValue - lastYearValue) / lastYearValue * 100)
        return changePercentage > threshold
    }

    private fun detectQoqChange(currentValue: Double, historicalData: List<HistoricalDataPoint>, threshold: Double): Boolean {
        // Find value from last quarter (3 months ago)
        val lastQuarterValue = historicalData
            .sortedByDescending { it.periodDate }
            .firstOrNull()
            ?.value
            ?: return false

        if (lastQuarterValue == 0.0) return false // Avoid division by zero

        val changePercentage = abs((currentValue - lastQuarterValue) / lastQuarterValue * 100)
        return changePercentage > threshold
    }

    private fun detectSpike(currentValue: Double, historicalData: List<HistoricalDataPoint>, threshold: Double): Boolean {
        if (historicalData.isEmpty()) return false

        val historicalAverage = historicalData.map { it.value }.average()

        if (historicalAverage == 0.0) return false // Avoid division by zero

        val percentageOfAverage = (currentValue / historicalAverage) * 100
        return percentageOfAverage > threshold
    }
}

Example Anomaly Detection Rules (JSON)

{
  "metric_template_id": "emissions-scope-1-co2",
  "validation_rules": [
    {
      "type": "anomaly",
      "rule": "z_score",
      "threshold": 3.0,
      "warning_message": "WARNING: Emissions value is more than 3 standard deviations from historical average. Please verify accuracy."
    },
    {
      "type": "anomaly",
      "rule": "yoy_change",
      "threshold": 50.0,
      "warning_message": "WARNING: Emissions changed by more than 50% compared to last year. Please provide explanation in notes."
    },
    {
      "type": "anomaly",
      "rule": "spike_detection",
      "threshold": 200.0,
      "warning_message": "WARNING: Emissions are more than 2x the historical average. Please verify data accuracy."
    }
  ]
}

Standard Hibernate Validator Annotations

In addition to the custom validation engine, Quarkus supports standard Hibernate Validator annotations for declarative validation on DTOs and entity classes.

Common Validation Annotations

Annotation Description Example
@NotNull Field must not be null @NotNull val value: Double?
@NotBlank String must not be null, empty, or whitespace @NotBlank val name: String
@NotEmpty Collection/String must not be null or empty @NotEmpty val items: List<String>
@Size(min, max) Collection/String size must be in range @Size(min=1, max=500) val notes: String
@Min(value) Number must be >= value @Min(0) val emissions: Double
@Max(value) Number must be <= value @Max(1000000) val employeeCount: Int
@DecimalMin(value) Decimal must be >= value @DecimalMin("0.0") val value: BigDecimal
@DecimalMax(value) Decimal must be <= value @DecimalMax("1000000.0") val value: BigDecimal
@Positive Number must be > 0 @Positive val amount: Double
@PositiveOrZero Number must be >= 0 @PositiveOrZero val emissions: Double
@Negative Number must be < 0 @Negative val adjustment: Double
@NegativeOrZero Number must be <= 0 @NegativeOrZero val offset: Double
@Email String must be valid email format @Email val email: String
@Pattern(regexp) String must match regex pattern @Pattern(regexp="[A-Z]{3}") val code: String
@Past Date must be in the past @Past val reportingDate: LocalDate
@PastOrPresent Date must be in the past or present @PastOrPresent val submittedDate: LocalDate
@Future Date must be in the future @Future val dueDate: LocalDate
@FutureOrPresent Date must be in the future or present @FutureOrPresent val nextReviewDate: LocalDate
@Digits(integer, fraction) Number must have specific digit constraints @Digits(integer=10, fraction=2) val value: BigDecimal
@Valid Cascade validation to nested objects @Valid val address: Address

Example: DTO with Hibernate Validator Annotations

package com.example.esg.dto

import jakarta.validation.constraints.*
import java.time.LocalDate
import java.math.BigDecimal

data class MetricSubmissionRequest(

    @field:NotNull(message = "Metric template ID is required")
    val metricTemplateId: Long,

    @field:NotNull(message = "Site ID is required")
    val siteId: Long,

    @field:NotNull(message = "Reporting period ID is required")
    val reportingPeriodId: Long,

    @field:NotNull(message = "Value is required")
    @field:DecimalMin(value = "0.0", message = "Value cannot be negative")
    @field:Digits(integer = 10, fraction = 2, message = "Value must have at most 2 decimal places")
    val value: BigDecimal,

    @field:NotBlank(message = "Unit is required")
    @field:Pattern(regexp = "^(tCO2e|kgCO2e|lbsCO2e|MWh|GJ)$", message = "Invalid unit")
    val unit: String,

    @field:NotNull(message = "Reporting date is required")
    @field:PastOrPresent(message = "Reporting date cannot be in the future")
    val reportingDate: LocalDate,

    @field:Size(max = 500, message = "Notes must not exceed 500 characters")
    val notes: String? = null,

    @field:Valid
    val evidence: List<@Valid EvidenceAttachment>? = null
)

data class EvidenceAttachment(
    @field:NotBlank(message = "Filename is required")
    val filename: String,

    @field:NotBlank(message = "Content type is required")
    @field:Pattern(regexp = "^(application/pdf|application/vnd\\.ms-excel|image/jpeg|image/png)$", 
                   message = "Only PDF, Excel, JPEG, PNG files allowed")
    val contentType: String,

    @field:Positive(message = "File size must be positive")
    @field:Max(value = 52428800, message = "File size must not exceed 50 MB")
    val sizeBytes: Long
)

Validation Groups

Use validation groups to apply different validation rules based on context:

package com.example.esg.validation

// Validation group interfaces
interface CreateValidation
interface UpdateValidation
interface ApprovalValidation

data class MetricSubmissionRequest(
    @field:Null(groups = [CreateValidation::class], message = "ID must be null for new submissions")
    @field:NotNull(groups = [UpdateValidation::class], message = "ID is required for updates")
    val id: Long?,

    @field:NotNull(groups = [CreateValidation::class, UpdateValidation::class])
    @field:DecimalMin(value = "0.0")
    val value: BigDecimal,

    @field:NotBlank(groups = [ApprovalValidation::class], message = "Approval notes required")
    val approvalNotes: String? = null
)

Usage with Validation Groups:

// Validate for creation
val violations = validator.validate(request, CreateValidation::class.java)

// Validate for update
val violations = validator.validate(request, UpdateValidation::class.java)

// Validate for approval
val violations = validator.validate(request, ApprovalValidation::class.java)

Custom Constraints

Create custom validation constraints for domain-specific rules:

package com.example.esg.validation

import jakarta.validation.Constraint
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import jakarta.validation.Payload
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [EmissionsUnitValidator::class])
annotation class ValidEmissionsUnit(
    val message: String = "Invalid emissions unit",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

class EmissionsUnitValidator : ConstraintValidator<ValidEmissionsUnit, String> {

    private val validUnits = setOf("tCO2e", "kgCO2e", "lbsCO2e")

    override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean {
        if (value == null) return true // @NotNull handles null check
        return value in validUnits
    }
}

Usage:

data class EmissionsSubmission(
    @field:NotNull
    @field:ValidEmissionsUnit(message = "Unit must be one of: tCO2e, kgCO2e, lbsCO2e")
    val unit: String
)

JAX-RS Validation Integration

Quarkus automatically integrates Hibernate Validator with JAX-RS REST endpoints. Simply add @Valid annotation to request parameters.

Automatic Validation on REST Endpoints

package com.example.esg.resource

import jakarta.validation.Valid
import jakarta.validation.constraints.NotNull
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import jakarta.ws.rs.core.Response
import jakarta.enterprise.context.ApplicationScoped

@Path("/api/v1/collector/submissions")
@ApplicationScoped
class SubmissionResource {

    @POST
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.APPLICATION_JSON)
    fun createSubmission(@Valid request: MetricSubmissionRequest): Response {
        // If validation fails, Quarkus automatically returns 400 Bad Request
        // with detailed constraint violations

        // Process valid submission
        val submission = submissionService.create(request)

        return Response.status(Response.Status.CREATED)
            .entity(submission)
            .build()
    }

    @GET
    @Path("/{id}")
    @Produces(MediaType.APPLICATION_JSON)
    fun getSubmission(
        @PathParam("id") 
        @NotNull(message = "Submission ID is required")
        @Positive(message = "Submission ID must be positive")
        id: Long
    ): Response {
        // Path parameter validation happens automatically
        val submission = submissionService.findById(id)

        return Response.ok(submission).build()
    }
}

Exception Handling for Validation Errors

When validation fails, Quarkus throws ConstraintViolationException. Create a custom exception mapper:

package com.example.esg.exception

import jakarta.validation.ConstraintViolationException
import jakarta.ws.rs.core.Response
import jakarta.ws.rs.ext.ExceptionMapper
import jakarta.ws.rs.ext.Provider
import java.time.Instant

@Provider
class ValidationExceptionMapper : ExceptionMapper<ConstraintViolationException> {

    override fun toResponse(exception: ConstraintViolationException): Response {
        val errors = exception.constraintViolations.map { violation ->
            ValidationError(
                field = violation.propertyPath.toString(),
                message = violation.message,
                invalidValue = violation.invalidValue?.toString()
            )
        }

        val errorResponse = ValidationErrorResponse(
            code = "VALIDATION_FAILED",
            message = "Request validation failed",
            timestamp = Instant.now().toString(),
            errors = errors
        )

        return Response.status(Response.Status.BAD_REQUEST)
            .entity(errorResponse)
            .build()
    }
}

data class ValidationErrorResponse(
    val code: String,
    val message: String,
    val timestamp: String,
    val errors: List<ValidationError>
)

data class ValidationError(
    val field: String,
    val message: String,
    val invalidValue: String? = null
)

Example Validation Error Response

Request:

{
  "metricTemplateId": null,
  "siteId": -5,
  "value": -100.5,
  "unit": "INVALID_UNIT",
  "reportingDate": "2030-01-01",
  "notes": "Lorem ipsum dolor sit amet..." // > 500 characters
}

Response (400 Bad Request):

{
  "code": "VALIDATION_FAILED",
  "message": "Request validation failed",
  "timestamp": "2026-01-13T10:30:00Z",
  "errors": [
    {
      "field": "metricTemplateId",
      "message": "Metric template ID is required",
      "invalidValue": null
    },
    {
      "field": "siteId",
      "message": "Site ID must be positive",
      "invalidValue": "-5"
    },
    {
      "field": "value",
      "message": "Value cannot be negative",
      "invalidValue": "-100.5"
    },
    {
      "field": "unit",
      "message": "Invalid unit",
      "invalidValue": "INVALID_UNIT"
    },
    {
      "field": "reportingDate",
      "message": "Reporting date cannot be in the future",
      "invalidValue": "2030-01-01"
    },
    {
      "field": "notes",
      "message": "Notes must not exceed 500 characters",
      "invalidValue": "Lorem ipsum dolor sit amet..."
    }
  ]
}

Method-Level Validation

Enable method-level validation for service classes:

package com.example.esg.service

import jakarta.enterprise.context.ApplicationScoped
import jakarta.validation.Valid
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Positive

@ApplicationScoped
class SubmissionService {

    fun create(@Valid request: MetricSubmissionRequest): MetricSubmission {
        // Method parameter validation happens automatically
        // ConstraintViolationException thrown if invalid

        // Process submission
        return metricSubmission
    }

    fun findById(
        @NotNull @Positive id: Long
    ): MetricSubmission {
        // Parameter validation
        return repository.findByIdOptional(id)
            .orElseThrow { NotFoundException("Submission not found: $id") }
    }
}

Configuration

Enable method validation in application.properties:

# Hibernate Validator Configuration
quarkus.hibernate-validator.method-validation.allow-overriding-parameter-constraints=true
quarkus.hibernate-validator.method-validation.allow-multiple-cascaded-validation-on-result=true
quarkus.hibernate-validator.fail-fast=false

# Return all validation errors (not just first one)
quarkus.hibernate-validator.method-validation.allow-parameter-constraint-violation=true

Validation Flow

Complete Validation Pipeline

package com.example.esg.validation

import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional

data class ValidationResult(
    val passed: Boolean,
    val errors: List<String> = emptyList(),
    val warnings: List<String> = emptyList()
)

@ApplicationScoped
class ValidationService @Inject constructor(
    private val schemaValidator: SchemaValidator,
    private val domainValidator: DomainValidator,
    private val referentialValidator: ReferentialValidator,
    private val evidenceValidator: EvidenceValidator,
    private val businessRuleValidator: BusinessRuleValidator,
    private val anomalyDetector: AnomalyDetector,
    private val validationResultRepository: ValidationResultRepository,
    private val historicalDataService: HistoricalDataService
) {

    @Transactional
    fun validate(submission: MetricSubmission): ValidationResult {
        val metricTemplate = submission.metricTemplate
        val data = submission.rawData
        val evidenceFiles = submission.evidenceFiles

        val errors = mutableListOf<String>()
        val warnings = mutableListOf<String>()

        // 1. Schema Validation (MUST PASS)
        val schemaRules = metricTemplate.validationRules.filter { it.type == "schema" }
        val schemaErrors = schemaValidator.validate(data, schemaRules)
        if (schemaErrors.isNotEmpty()) {
            errors.addAll(schemaErrors)
            return saveAndReturn(submission, ValidationResult(false, errors, warnings))
        }

        // 2. Domain Validation (MUST PASS)
        val domainRules = metricTemplate.validationRules.filter { it.type == "domain" }
        val domainErrors = domainValidator.validate(data, domainRules)
        if (domainErrors.isNotEmpty()) {
            errors.addAll(domainErrors)
            return saveAndReturn(submission, ValidationResult(false, errors, warnings))
        }

        // 3. Referential Validation (MUST PASS)
        val referentialRules = metricTemplate.validationRules.filter { it.type == "referential" }
        val referentialErrors = referentialValidator.validate(data, referentialRules)
        if (referentialErrors.isNotEmpty()) {
            errors.addAll(referentialErrors)
            return saveAndReturn(submission, ValidationResult(false, errors, warnings))
        }

        // 4. Evidence Validation (MUST PASS)
        val evidenceRules = metricTemplate.validationRules.filter { it.type == "evidence" }
        val evidenceErrors = evidenceValidator.validate(evidenceFiles, evidenceRules)
        if (evidenceErrors.isNotEmpty()) {
            errors.addAll(evidenceErrors)
            return saveAndReturn(submission, ValidationResult(false, errors, warnings))
        }

        // 5. Business Rule Validation (MUST PASS)
        val businessRuleRules = metricTemplate.validationRules.filter { it.type == "business_rule" }
        val businessRuleErrors = businessRuleValidator.validate(data, businessRuleRules)
        if (businessRuleErrors.isNotEmpty()) {
            errors.addAll(businessRuleErrors)
            return saveAndReturn(submission, ValidationResult(false, errors, warnings))
        }

        // 6. Anomaly Detection (WARNINGS ONLY - NEVER FAILS)
        val anomalyRules = metricTemplate.validationRules.filter { it.type == "anomaly" }
        if (anomalyRules.isNotEmpty()) {
            val currentValue = data["value"]?.toString()?.toDoubleOrNull() ?: 0.0
            val historicalData = historicalDataService.getHistoricalData(
                tenantId = submission.tenantId,
                metricTemplateId = metricTemplate.id,
                siteId = submission.siteId
            )
            val anomalyWarnings = anomalyDetector.detect(currentValue, historicalData, anomalyRules)
            warnings.addAll(anomalyWarnings)
        }

        // Validation passed (possibly with warnings)
        return saveAndReturn(submission, ValidationResult(true, emptyList(), warnings))
    }

    private fun saveAndReturn(submission: MetricSubmission, result: ValidationResult): ValidationResult {
        // Save validation result to database
        val validationResultEntity = ValidationResultEntity(
            metricSubmissionId = submission.id,
            passed = result.passed,
            errors = result.errors,
            warnings = result.warnings,
            validatedAt = java.time.Instant.now()
        )
        validationResultRepository.persist(validationResultEntity)

        // Update submission state
        submission.state = if (result.passed) "VALIDATED" else "VALIDATION_FAILED"
        submission.validationErrors = result.errors
        submission.validationWarnings = result.warnings

        return result
    }
}

Quarkus Messaging Listener

package com.example.esg.jobs

import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.reactive.messaging.Incoming
import io.smallrye.reactive.messaging.annotations.Blocking

@ApplicationScoped
class ValidationJobListener @Inject constructor(
    private val validationService: ValidationService,
    private val submissionRepository: SubmissionRepository,
    private val auditLogService: AuditLogService
) {

    @Incoming("validation-queue")
    @Blocking
    fun processValidation(message: ValidationJobMessage) {
        val submission = submissionRepository.findByIdOptional(message.submissionId)
            .orElseThrow { IllegalArgumentException("Submission not found: ${message.submissionId}") }

        try {
            // Execute validation
            val result = validationService.validate(submission)

            // Log result
            auditLogService.log(
                entityType = "metric_submission",
                entityId = submission.id,
                action = if (result.passed) "VALIDATION_PASSED" else "VALIDATION_FAILED",
                actor = "system",
                details = mapOf(
                    "errors" to result.errors,
                    "warnings" to result.warnings
                )
            )

            println("Validation completed for submission ${submission.id}: passed=${result.passed}")

        } catch (e: Exception) {
            println("Validation failed with exception: ${e.message}")

            // Update submission to ERROR state
            submission.state = "ERROR"
            submission.validationErrors = listOf("Internal validation error: ${e.message}")
            submissionRepository.persist(submission)

            throw e // Re-throw to trigger message retry
        }
    }
}

data class ValidationJobMessage(
    val submissionId: Long
)

Error Format Specification

Validation Error Response

When validation fails, the API returns a structured error response with field-level details:

{
  "code": "VALIDATION_FAILED",
  "message": "Submission failed validation",
  "request_id": "req-abc123",
  "timestamp": "2026-01-11T10:30:00Z",
  "details": {
    "submission_id": "550e8400-e29b-41d4-a716-446655440000",
    "errors": [
      {
        "field": "value",
        "type": "schema",
        "message": "Emissions value is required"
      },
      {
        "field": "reporting_date",
        "type": "schema",
        "message": "Reporting date must be in ISO 8601 format (YYYY-MM-DD)"
      },
      {
        "field": "total_emissions",
        "type": "referential",
        "message": "Total emissions must equal sum of Scope 1 + Scope 2 + Scope 3 emissions"
      }
    ],
    "warnings": []
  }
}

Validation Warning Response

When validation passes with warnings (anomaly detection):

{
  "code": "OK",
  "message": "Submission validated successfully with warnings",
  "submission_id": "550e8400-e29b-41d4-a716-446655440000",
  "state": "VALIDATED",
  "warnings": [
    {
      "type": "anomaly",
      "message": "WARNING: Emissions value is more than 3 standard deviations from historical average. Please verify accuracy.",
      "severity": "warning"
    },
    {
      "type": "anomaly",
      "message": "WARNING: Emissions changed by more than 50% compared to last year. Please provide explanation in notes.",
      "severity": "warning"
    }
  ]
}

Implementation Guide

Database Schema

-- Validation results table
CREATE TABLE validation_results (
    id BIGSERIAL PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    metric_submission_id BIGINT NOT NULL REFERENCES metric_submissions(id),
    passed BOOLEAN NOT NULL,
    errors JSONB DEFAULT '[]', -- Array of error objects
    warnings JSONB DEFAULT '[]', -- Array of warning objects
    validated_at TIMESTAMP DEFAULT NOW(),

    INDEX idx_validation_submission (metric_submission_id),
    INDEX idx_validation_tenant_passed (tenant_id, passed)
);

-- Metric templates with validation rules
CREATE TABLE metric_templates (
    id BIGSERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    category VARCHAR(100),
    validation_rules JSONB DEFAULT '[]', -- Array of validation rule objects
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);

-- Example validation rules JSONB structure:
-- [
--   {"type": "schema", "field": "value", "rule": "required", "error_message": "Value is required"},
--   {"type": "domain", "field": "value", "rule": "min:0", "error_message": "Value must be >= 0"},
--   {"type": "anomaly", "rule": "z_score", "threshold": 3.0, "warning_message": "Value is statistical outlier"}
-- ]

Quarkus Messaging Configuration

# application.properties

# RabbitMQ Connection
quarkus.rabbitmq.host=localhost
quarkus.rabbitmq.port=5672
quarkus.rabbitmq.username=guest
quarkus.rabbitmq.password=guest
quarkus.rabbitmq.virtual-host=/

# Incoming validation queue
mp.messaging.incoming.validation-queue.connector=smallrye-rabbitmq
mp.messaging.incoming.validation-queue.queue.name=validation-queue
mp.messaging.incoming.validation-queue.queue.durable=true
mp.messaging.incoming.validation-queue.queue.auto-delete=false
mp.messaging.incoming.validation-queue.queue.declare=true
mp.messaging.incoming.validation-queue.queue.x-dead-letter-exchange=validation-dlx
mp.messaging.incoming.validation-queue.queue.x-dead-letter-routing-key=validation-failed
mp.messaging.incoming.validation-queue.failure-strategy=reject

# Outgoing validation submission
mp.messaging.outgoing.validation-submit.connector=smallrye-rabbitmq
mp.messaging.outgoing.validation-submit.exchange.name=validation-exchange
mp.messaging.outgoing.validation-submit.exchange.type=topic
mp.messaging.outgoing.validation-submit.exchange.durable=true
mp.messaging.outgoing.validation-submit.routing-key=validation.submitted

# Dead letter queue
mp.messaging.incoming.validation-dlq.connector=smallrye-rabbitmq
mp.messaging.incoming.validation-dlq.queue.name=validation-dlq
mp.messaging.incoming.validation-dlq.queue.durable=true
mp.messaging.incoming.validation-dlq.queue.x-queue-mode=lazy

Sending Messages to Validation Queue:

package com.example.esg.service

import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.reactive.messaging.Channel
import org.eclipse.microprofile.reactive.messaging.Emitter

@ApplicationScoped
class ValidationPublisher @Inject constructor(
    @Channel("validation-submit")
    private val validationEmitter: Emitter<ValidationJobMessage>
) {

    fun submitForValidation(submissionId: Long) {
        validationEmitter.send(ValidationJobMessage(submissionId))
    }
}

Complete ValidationService

The complete ValidationService implementation is shown in the Validation Flow section above.


Performance Considerations

Database Optimization

-- Index on validation results for fast lookups
CREATE INDEX idx_validation_submission ON validation_results (metric_submission_id);
CREATE INDEX idx_validation_tenant_passed ON validation_results (tenant_id, passed);

-- Index on metric templates for validation rule lookups
CREATE INDEX idx_metric_template_category ON metric_templates (category);

-- GIN index on JSONB validation_rules for rule filtering
CREATE INDEX idx_metric_template_rules ON metric_templates USING GIN (validation_rules);

Caching Strategy

package com.example.esg.service

import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import io.quarkus.cache.CacheResult
import io.quarkus.cache.CacheKey

@ApplicationScoped
class MetricTemplateService @Inject constructor(
    private val metricTemplateRepository: MetricTemplateRepository
) {

    // Cache metric templates for 24 hours (templates rarely change)
    @CacheResult(cacheName = "metric-templates")
    fun getMetricTemplate(@CacheKey id: Long): MetricTemplate {
        return metricTemplateRepository.findByIdOptional(id)
            .orElseThrow { IllegalArgumentException("Metric template not found: $id") }
    }
}

Cache Configuration (application.properties):

# Quarkus Cache configuration
quarkus.cache.caffeine."metric-templates".initial-capacity=100
quarkus.cache.caffeine."metric-templates".maximum-size=1000
quarkus.cache.caffeine."metric-templates".expire-after-write=24H

Async Processing

  • Validation runs as background job (RabbitMQ listener)
  • HTTP response returns immediately after saving submission (< 200ms)
  • Validation executes asynchronously (1-5 seconds)
  • Client polls /api/v1/collector/sync for updated submission state

Performance Targets

Metric Target Notes
Schema validation < 10ms In-memory checks only
Domain validation < 50ms In-memory checks only
Referential validation < 100ms May require DB lookups
Evidence validation < 50ms Metadata checks only (no file reading)
Business rule validation < 200ms May require calculations
Anomaly detection < 500ms Requires historical data query
Total validation time < 1 second (p95) End-to-end validation pipeline

Security Requirements

Tenant Isolation

CRITICAL: All validation operations MUST enforce tenant isolation.

package com.example.esg.filter

import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.persistence.EntityManager
import jakarta.ws.rs.container.ContainerRequestContext
import jakarta.ws.rs.container.ContainerRequestFilter
import jakarta.ws.rs.container.ContainerResponseContext
import jakarta.ws.rs.container.ContainerResponseFilter
import jakarta.ws.rs.ext.Provider
import org.hibernate.Filter
import org.hibernate.Session
import org.eclipse.microprofile.jwt.JsonWebToken

@Provider
@ApplicationScoped
class TenantIsolationFilter @Inject constructor(
    private val entityManager: EntityManager,
    private val jwt: JsonWebToken
) : ContainerRequestFilter, ContainerResponseFilter {

    override fun filter(requestContext: ContainerRequestContext) {
        // Extract tenant_id from JWT token
        val tenantId = extractTenantIdFromJwt()

        // Activate Hibernate filter for tenant isolation
        val session = entityManager.unwrap(Session::class.java)
        val filter: Filter = session.enableFilter("tenantFilter")
        filter.setParameter("tenantId", tenantId)
    }

    override fun filter(
        requestContext: ContainerRequestContext,
        responseContext: ContainerResponseContext
    ) {
        // Disable filter after request completes
        val session = entityManager.unwrap(Session::class.java)
        session.disableFilter("tenantFilter")
    }

    private fun extractTenantIdFromJwt(): Long {
        // JsonWebToken is automatically injected by Quarkus SmallRye JWT
        return jwt.getClaim<Long>("tenant_id")
            ?: throw SecurityException("Missing tenant_id in JWT token")
    }
}

Historical Data Access

When fetching historical data for anomaly detection, ALWAYS filter by tenant:

fun getHistoricalData(tenantId: Long, metricTemplateId: Long, siteId: Long): List<HistoricalDataPoint> {
    return jdbcTemplate.query(
        """
        SELECT value, reporting_date
        FROM metric_submissions
        WHERE tenant_id = ?
          AND metric_template_id = ?
          AND site_id = ?
          AND state = 'APPROVED'
          AND deleted_at IS NULL
        ORDER BY reporting_date DESC
        LIMIT 12
        """,
        arrayOf(tenantId, metricTemplateId, siteId)
    ) { rs, _ ->
        HistoricalDataPoint(
            value = rs.getDouble("value"),
            periodDate = rs.getString("reporting_date")
        )
    }
}

Audit Logging

Log all validation events:

auditLogService.log(
    entityType = "metric_submission",
    entityId = submission.id,
    action = if (result.passed) "VALIDATION_PASSED" else "VALIDATION_FAILED",
    actor = "system",
    ipAddress = null,
    userAgent = null,
    details = mapOf(
        "errors" to result.errors,
        "warnings" to result.warnings,
        "validation_duration_ms" to validationDurationMs
    )
)

Testing Strategy

Unit Tests

package com.example.esg.validation

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import io.quarkus.test.junit.QuarkusTest

class SchemaValidatorTest {

    private val validator = SchemaValidator()

    @Test
    fun `validates required field - should fail when missing`() {
        val data = mapOf<String, Any?>()
        val rules = listOf(
            SchemaValidationRule("value", "required", "Value is required")
        )

        val errors = validator.validate(data, rules)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).isEqualTo("Value is required")
    }

    @Test
    fun `validates number type - should pass for numeric string`() {
        val data = mapOf("value" to "123.45")
        val rules = listOf(
            SchemaValidationRule("value", "type:number", "Value must be number")
        )

        val errors = validator.validate(data, rules)

        assertThat(errors).isEmpty()
    }

    @Test
    fun `validates date format - should fail for invalid date`() {
        val data = mapOf("reporting_date" to "2026-13-45") // Invalid month and day
        val rules = listOf(
            SchemaValidationRule("reporting_date", "type:date", "Invalid date format")
        )

        val errors = validator.validate(data, rules)

        assertThat(errors).hasSize(1)
    }
}

Integration Tests

package com.example.esg.validation

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import io.quarkus.test.junit.QuarkusTest

@QuarkusTest
@Transactional
class ValidationServiceIntegrationTest {

    @Inject
    private lateinit var validationService: ValidationService

    @Inject
    private lateinit var submissionRepository: SubmissionRepository

    @Test
    fun `end-to-end validation - should pass valid submission`() {
        // Create submission with valid data
        val submission = createTestSubmission(
            data = mapOf(
                "value" to 1234.56,
                "unit" to "tCO2e",
                "reporting_date" to "2026-01-11"
            ),
            evidenceFiles = listOf(
                createTestEvidence("invoice.pdf", "application/pdf", 1024L)
            )
        )

        // Execute validation
        val result = validationService.validate(submission)

        // Assertions
        assertThat(result.passed).isTrue()
        assertThat(result.errors).isEmpty()
        assertThat(submission.state).isEqualTo("VALIDATED")
    }

    @Test
    fun `end-to-end validation - should fail when required field missing`() {
        // Create submission with missing required field
        val submission = createTestSubmission(
            data = mapOf(
                "unit" to "tCO2e",
                "reporting_date" to "2026-01-11"
                // "value" is missing
            )
        )

        // Execute validation
        val result = validationService.validate(submission)

        // Assertions
        assertThat(result.passed).isFalse()
        assertThat(result.errors).isNotEmpty()
        assertThat(submission.state).isEqualTo("VALIDATION_FAILED")
    }

    @Test
    fun `anomaly detection - should pass with warnings`() {
        // Create historical data (average ~1000)
        createHistoricalSubmissions(values = listOf(900.0, 1000.0, 1100.0))

        // Create submission with outlier value (5000 - 5x average)
        val submission = createTestSubmission(
            data = mapOf(
                "value" to 5000.0,
                "unit" to "tCO2e",
                "reporting_date" to "2026-01-11"
            )
        )

        // Execute validation
        val result = validationService.validate(submission)

        // Assertions
        assertThat(result.passed).isTrue() // Anomaly detection doesn't fail
        assertThat(result.warnings).isNotEmpty()
        assertThat(result.warnings[0]).contains("standard deviations")
        assertThat(submission.state).isEqualTo("VALIDATED")
    }
}

Load Tests

package com.example.esg.validation

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import jakarta.inject.Inject
import io.quarkus.test.junit.QuarkusTest
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import kotlin.system.measureTimeMillis

@QuarkusTest
class ValidationPerformanceTest {

    @Inject
    private lateinit var validationService: ValidationService

    @Test
    fun `validate 1000 submissions concurrently - should complete in under 10 seconds`() {
        val submissionCount = 1000
        val executor = Executors.newFixedThreadPool(50)
        val latch = CountDownLatch(submissionCount)

        val duration = measureTimeMillis {
            repeat(submissionCount) {
                executor.submit {
                    try {
                        val submission = createTestSubmission()
                        validationService.validate(submission)
                    } finally {
                        latch.countDown()
                    }
                }
            }
            latch.await()
        }

        executor.shutdown()

        println("Validated $submissionCount submissions in ${duration}ms")
        assertThat(duration).isLessThan(10_000) // < 10 seconds
    }
}

Security Tests

@Test
fun `tenant isolation - user cannot validate other tenant submissions`() {
    // Create submission for tenant 1
    val tenant1Submission = createTestSubmission(tenantId = 1L)

    // Try to validate as tenant 2 user
    val tenant2Token = generateJwtToken(tenantId = 2L)

    // Should throw permission error or return empty
    assertThrows<SecurityException> {
        validationService.validate(tenant1Submission)
    }
}

Acceptance Criteria

  • All 6 validation types implemented (schema, domain, referential, evidence, business rule, anomaly)
  • Sequential execution: each stage runs only if previous passes
  • Schema validation stops pipeline if failed (fail-fast)
  • Anomaly checks produce warnings only (not hard failures)
  • Validation results stored in dedicated validation_results table
  • Field-level error messages returned to collector via API
  • RabbitMQ async job processing for validation
  • Tenant isolation enforced for all validation operations
  • Performance target: < 1 second (p95) for end-to-end validation
  • Comprehensive unit, integration, load, and security tests
  • Kotlin/Quarkus implementation examples provided
  • Audit logging for all validation events

Human Capital Metrics Validation Rules

Purpose

This section documents validation rules specific to human capital metrics (GRI 405-1 Employee Demographics, GRI 401 Employment Type and Turnover, and Employee Age Demographics). These rules ensure data quality for HR reporting and compliance with PII protection requirements.

Key Challenges: - Dimensional data validation (gender breakdowns, age groups, employment levels) - Cross-field consistency (male + female = total, age group sums = total) - Quarterly vs monthly collection frequency - PII aggregation thresholds (minimum 5 employees per category) - Site-level vs organisation-level dimensionality - Percentage derivation from headcount metrics


GRI 405-1 Employee Demographics Validation

Collection Frequency: Quarterly (Q1: Mar 31, Q2: Jun 30, Q3: Sep 30, Q4: Dec 31)

Metrics: - GRI_405_1_EXECUTIVE_HEADCOUNT (organisation-level only) - GRI_405_1_SALARIED_NON_NEC_HEADCOUNT (site-level) - GRI_405_1_WAGED_NEC_HEADCOUNT (site-level)

Data Structure: JSONB raw_data field with:

{
  "total_headcount": 433,
  "male_count": 346,
  "female_count": 87,
  "local_community_count": 231
}

Schema Validation Rules

{
  "metric_template_id": "GRI_405_1_EMPLOYEE_DEMOGRAPHICS_V1",
  "validation_rules": [
    {
      "type": "schema",
      "field": "raw_data.total_headcount",
      "rule": "required",
      "error_message": "Total headcount is required"
    },
    {
      "type": "schema",
      "field": "raw_data.total_headcount",
      "rule": "type:integer",
      "error_message": "Total headcount must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.male_count",
      "rule": "required",
      "error_message": "Male count is required"
    },
    {
      "type": "schema",
      "field": "raw_data.male_count",
      "rule": "type:integer",
      "error_message": "Male count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.female_count",
      "rule": "required",
      "error_message": "Female count is required"
    },
    {
      "type": "schema",
      "field": "raw_data.female_count",
      "rule": "type:integer",
      "error_message": "Female count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.local_community_count",
      "rule": "required",
      "error_message": "Local community count is required"
    },
    {
      "type": "schema",
      "field": "raw_data.local_community_count",
      "rule": "type:integer",
      "error_message": "Local community count must be an integer"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "required",
      "error_message": "Activity date is required"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "type:date",
      "error_message": "Activity date must be in ISO 8601 format (YYYY-MM-DD)"
    }
  ]
}

Domain Validation Rules

{
  "metric_template_id": "GRI_405_1_EMPLOYEE_DEMOGRAPHICS_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "raw_data.total_headcount",
      "rule": "min:0",
      "error_message": "Total headcount cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.male_count",
      "rule": "min:0",
      "error_message": "Male count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.female_count",
      "rule": "min:0",
      "error_message": "Female count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.local_community_count",
      "rule": "min:0",
      "error_message": "Local community count cannot be negative"
    }
  ]
}

Referential Validation Rules

{
  "metric_template_id": "GRI_405_1_EMPLOYEE_DEMOGRAPHICS_V1",
  "validation_rules": [
    {
      "type": "referential",
      "rule": "sum_equals:raw_data.male_count,raw_data.female_count,raw_data.total_headcount",
      "error_message": "Male and female counts must sum to total headcount"
    },
    {
      "type": "referential",
      "rule": "field_lte:raw_data.local_community_count,raw_data.total_headcount",
      "error_message": "Local community count cannot exceed total headcount"
    }
  ]
}

Business Rule Validation

package com.example.esg.validation.hr

import com.example.esg.validation.BusinessRuleValidationRule
import java.math.BigDecimal
import java.time.LocalDate

class HRDemographicsValidator {

    fun validateGri4051Demographics(data: Map<String, Any?>): List<String> {
        val errors = mutableListOf<String>()

        // Extract raw_data JSONB
        val rawData = data["raw_data"] as? Map<String, Any?> ?: return listOf("raw_data field is missing")

        val totalHeadcount = rawData["total_headcount"]?.toString()?.toIntOrNull() ?: 0
        val maleCount = rawData["male_count"]?.toString()?.toIntOrNull() ?: 0
        val femaleCount = rawData["female_count"]?.toString()?.toIntOrNull() ?: 0
        val localCommunityCount = rawData["local_community_count"]?.toString()?.toIntOrNull() ?: 0

        // Business Rule 1: Gender totals must sum to total
        if (maleCount + femaleCount != totalHeadcount) {
            errors.add("Gender totals validation failed: Male ($maleCount) + Female ($femaleCount) ≠ Total ($totalHeadcount)")
        }

        // Business Rule 2: Local community cannot exceed total
        if (localCommunityCount > totalHeadcount) {
            errors.add("Local community validation failed: Local community count ($localCommunityCount) > Total headcount ($totalHeadcount)")
        }

        // Business Rule 3: Quarter-end date validation
        val activityDate = data["activity_date"]?.toString()?.let { LocalDate.parse(it) }
        if (activityDate != null && !isQuarterEndDate(activityDate)) {
            errors.add("Activity date must be the last day of a quarter (Mar 31, Jun 30, Sep 30, or Dec 31)")
        }

        // Business Rule 4: Site dimensionality validation
        val metricId = data["metric_id"]?.toString()
        val siteId = data["site_id"]

        if (metricId == "GRI_405_1_EXECUTIVE_HEADCOUNT" && siteId != null) {
            errors.add("Executive headcount must be organisation-level only (site_id must be null)")
        }

        if ((metricId == "GRI_405_1_SALARIED_NON_NEC_HEADCOUNT" || metricId == "GRI_405_1_WAGED_NEC_HEADCOUNT") && siteId == null) {
            errors.add("Salaried and Waged headcount metrics require site_id")
        }

        // Business Rule 5: PII aggregation threshold (warning only, not hard failure)
        // Implemented in anomaly detection section

        return errors
    }

    private fun isQuarterEndDate(date: LocalDate): Boolean {
        val validQuarterEnds = listOf(
            "03-31", // Q1 end
            "06-30", // Q2 end
            "09-30", // Q3 end
            "12-31"  // Q4 end
        )

        val monthDay = String.format("%02d-%02d", date.monthValue, date.dayOfMonth)
        return monthDay in validQuarterEnds
    }
}

Integration with ValidationService:

// In ValidationService, add business rule for GRI 405-1
if (metricTemplate.id == "GRI_405_1_EMPLOYEE_DEMOGRAPHICS_V1") {
    val hrDemographicsValidator = HRDemographicsValidator()
    val hrErrors = hrDemographicsValidator.validateGri4051Demographics(data)
    if (hrErrors.isNotEmpty()) {
        errors.addAll(hrErrors)
        return saveAndReturn(submission, ValidationResult(false, errors, warnings))
    }
}

Percentage Derivation Rules

Derived Metrics (NOT collected, calculated at report generation time):

  • GRI_405_1_EXECUTIVE_PERCENT_MALE = (male_count / total_headcount) × 100
  • GRI_405_1_EXECUTIVE_PERCENT_FEMALE = (female_count / total_headcount) × 100
  • GRI_405_1_EXECUTIVE_PERCENT_LOCAL_COMMUNITY = (local_community_count / total_headcount) × 100

Calculation Validation Rules:

fun calculateAndValidatePercentages(rawData: Map<String, Any?>): Map<String, Double> {
    val totalHeadcount = rawData["total_headcount"]?.toString()?.toDoubleOrNull() ?: 0.0
    val maleCount = rawData["male_count"]?.toString()?.toDoubleOrNull() ?: 0.0
    val femaleCount = rawData["female_count"]?.toString()?.toDoubleOrNull() ?: 0.0
    val localCommunityCount = rawData["local_community_count"]?.toString()?.toDoubleOrNull() ?: 0.0

    if (totalHeadcount == 0.0) {
        // No employees, return 0% for all metrics
        return mapOf(
            "percent_male" to 0.0,
            "percent_female" to 0.0,
            "percent_local_community" to 0.0
        )
    }

    val percentMale = (maleCount / totalHeadcount) * 100
    val percentFemale = (femaleCount / totalHeadcount) * 100
    val percentLocalCommunity = (localCommunityCount / totalHeadcount) * 100

    // Validate percentages are in 0-100 range
    require(percentMale in 0.0..100.0) { "Percent male must be 0-100%" }
    require(percentFemale in 0.0..100.0) { "Percent female must be 0-100%" }
    require(percentLocalCommunity in 0.0..100.0) { "Percent local community must be 0-100%" }

    // Round to 2 decimal places
    return mapOf(
        "percent_male" to String.format("%.2f", percentMale).toDouble(),
        "percent_female" to String.format("%.2f", percentFemale).toDouble(),
        "percent_local_community" to String.format("%.2f", percentLocalCommunity).toDouble()
    )
}

Storage: Percentages stored in processed_data JSONB field after validation passes:

{
  "raw_data": {
    "total_headcount": 433,
    "male_count": 346,
    "female_count": 87,
    "local_community_count": 231
  },
  "processed_data": {
    "total_headcount": 433,
    "male_count": 346,
    "female_count": 87,
    "local_community_count": 231,
    "percent_male": 79.91,
    "percent_female": 20.09,
    "percent_local_community": 53.35
  }
}

GRI 401 Employment Type and Turnover Validation

Collection Frequency: Monthly (last day of each month)

Metrics: - GRI_401_PERMANENT_EMPLOYEES_MONTHLY (organisation-level) - GRI_401_FIXED_TERM_EMPLOYEES_MONTHLY (organisation-level) - GRI_401_NEW_RECRUITS_PERMANENT_MALE_MONTHLY (organisation-level) - GRI_401_NEW_RECRUITS_PERMANENT_FEMALE_MONTHLY (organisation-level) - GRI_401_DEPARTURES_PERMANENT_MONTHLY (organisation-level) - GRI_401_CASUAL_WORKERS_MONTHLY (organisation-level)

Data Structure: Simple scalar value field (integer)

Schema Validation Rules

{
  "metric_template_id": "GRI_401_EMPLOYMENT_TYPE_TURNOVER_V1",
  "validation_rules": [
    {
      "type": "schema",
      "field": "value",
      "rule": "required",
      "error_message": "Value is required"
    },
    {
      "type": "schema",
      "field": "value",
      "rule": "type:integer",
      "error_message": "Value must be an integer (whole number)"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "required",
      "error_message": "Activity date is required"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "type:date",
      "error_message": "Activity date must be in ISO 8601 format (YYYY-MM-DD)"
    }
  ]
}

Domain Validation Rules

{
  "metric_template_id": "GRI_401_EMPLOYMENT_TYPE_TURNOVER_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Value cannot be negative"
    }
  ]
}

Business Rule Validation

package com.example.esg.validation.hr

import java.time.LocalDate
import java.time.YearMonth

class GRI401Validator {

    fun validateGri401EmploymentMetrics(data: Map<String, Any?>): List<String> {
        val errors = mutableListOf<String>()

        // Extract value and date
        val value = data["value"]?.toString()?.toIntOrNull() ?: 0
        val activityDate = data["activity_date"]?.toString()?.let { LocalDate.parse(it) }

        // Business Rule 1: Month-end date validation
        if (activityDate != null && !isMonthEndDate(activityDate)) {
            errors.add("Activity date must be the last day of the month")
        }

        // Business Rule 2: Site validation (organisation-level only)
        val siteId = data["site_id"]
        if (siteId != null) {
            errors.add("GRI 401 metrics must be organisation-level only (site_id must be null)")
        }

        // Business Rule 3: Reasonable range check for departures
        val metricId = data["metric_id"]?.toString()
        if (metricId == "GRI_401_DEPARTURES_PERMANENT_MONTHLY") {
            // Fetch permanent headcount for same period (would be done via repository in real implementation)
            // For now, we'll just flag if departures > 50% of typical workforce
            // This is implemented as anomaly detection (soft warning)
        }

        return errors
    }

    private fun isMonthEndDate(date: LocalDate): Boolean {
        val yearMonth = YearMonth.of(date.year, date.month)
        val lastDayOfMonth = yearMonth.atEndOfMonth()
        return date == lastDayOfMonth
    }
}

Monthly Completeness Checks

Purpose: Ensure all 6 GRI 401 metrics are submitted for each month in the fiscal year.

package com.example.esg.validation.hr

import java.time.LocalDate

data class MonthlyCompletenessResult(
    val isComplete: Boolean,
    val missingMetrics: List<String>,
    val message: String
)

class MonthlyCompletenessValidator {

    private val gri401MetricIds = listOf(
        "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"
    )

    fun validateMonthlyCompleteness(
        tenantId: Long,
        organisationId: Long,
        month: LocalDate,
        submittedMetricIds: List<String>
    ): MonthlyCompletenessResult {

        val missingMetrics = gri401MetricIds.filter { it !in submittedMetricIds }

        if (missingMetrics.isEmpty()) {
            return MonthlyCompletenessResult(
                isComplete = true,
                missingMetrics = emptyList(),
                message = "All GRI 401 metrics submitted for ${month.month} ${month.year}"
            )
        }

        return MonthlyCompletenessResult(
            isComplete = false,
            missingMetrics = missingMetrics,
            message = "Missing ${missingMetrics.size} GRI 401 metric(s) for ${month.month} ${month.year}: ${missingMetrics.joinToString(", ")}"
        )
    }
}

Integration with Validation Pipeline:

Monthly completeness check runs AFTER all individual metric validations pass, as a final validation step before marking the reporting period complete.

// In ValidationService, after validating all submissions for a month
fun validateMonthlyCompletenessForGri401(
    tenantId: Long,
    organisationId: Long,
    month: LocalDate
): MonthlyCompletenessResult {

    // Fetch all validated submissions for this month
    val submissions = submissionRepository.findByTenantIdAndOrganisationIdAndMonth(
        tenantId = tenantId,
        organisationId = organisationId,
        month = month
    )

    val submittedMetricIds = submissions.map { it.metricTemplate.metricId }

    val validator = MonthlyCompletenessValidator()
    return validator.validateMonthlyCompleteness(tenantId, organisationId, month, submittedMetricIds)
}

Employee Age Demographics Validation

Collection Frequency: Monthly (last day of each month)

Metrics: - EMPLOYEE_AGE_UNDER_30_MONTHLY (organisation-level) - EMPLOYEE_AGE_30_50_MONTHLY (organisation-level) - EMPLOYEE_AGE_OVER_50_MONTHLY (organisation-level)

Data Structure: JSONB raw_data field with:

{
  "under_30": 115,
  "aged_30_50": 243,
  "over_50": 75,
  "total": 433
}

Schema Validation Rules

{
  "metric_template_id": "EMPLOYEE_AGE_DEMOGRAPHICS_V1",
  "validation_rules": [
    {
      "type": "schema",
      "field": "raw_data.under_30",
      "rule": "required",
      "error_message": "Under 30 count is required"
    },
    {
      "type": "schema",
      "field": "raw_data.under_30",
      "rule": "type:integer",
      "error_message": "Under 30 count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.aged_30_50",
      "rule": "required",
      "error_message": "Aged 30-50 count is required"
    },
    {
      "type": "schema",
      "field": "raw_data.aged_30_50",
      "rule": "type:integer",
      "error_message": "Aged 30-50 count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.over_50",
      "rule": "required",
      "error_message": "Over 50 count is required"
    },
    {
      "type": "schema",
      "field": "raw_data.over_50",
      "rule": "type:integer",
      "error_message": "Over 50 count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.total",
      "rule": "required",
      "error_message": "Total headcount is required"
    },
    {
      "type": "schema",
      "field": "raw_data.total",
      "rule": "type:integer",
      "error_message": "Total headcount must be an integer"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "required",
      "error_message": "Activity date is required"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "type:date",
      "error_message": "Activity date must be in ISO 8601 format (YYYY-MM-DD)"
    }
  ]
}

Domain Validation Rules

{
  "metric_template_id": "EMPLOYEE_AGE_DEMOGRAPHICS_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "raw_data.under_30",
      "rule": "min:0",
      "error_message": "Under 30 count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.aged_30_50",
      "rule": "min:0",
      "error_message": "Aged 30-50 count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.over_50",
      "rule": "min:0",
      "error_message": "Over 50 count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.total",
      "rule": "min:0",
      "error_message": "Total headcount cannot be negative"
    }
  ]
}

Referential Validation Rules

{
  "metric_template_id": "EMPLOYEE_AGE_DEMOGRAPHICS_V1",
  "validation_rules": [
    {
      "type": "referential",
      "rule": "sum_equals:raw_data.under_30,raw_data.aged_30_50,raw_data.over_50,raw_data.total",
      "error_message": "Age group counts must sum to total headcount"
    }
  ]
}

Business Rule Validation

package com.example.esg.validation.hr

import java.time.LocalDate

class AgeDemographicsValidator {

    fun validateAgeDemographics(data: Map<String, Any?>): List<String> {
        val errors = mutableListOf<String>()

        // Extract raw_data JSONB
        val rawData = data["raw_data"] as? Map<String, Any?> ?: return listOf("raw_data field is missing")

        val under30 = rawData["under_30"]?.toString()?.toIntOrNull() ?: 0
        val aged30to50 = rawData["aged_30_50"]?.toString()?.toIntOrNull() ?: 0
        val over50 = rawData["over_50"]?.toString()?.toIntOrNull() ?: 0
        val total = rawData["total"]?.toString()?.toIntOrNull() ?: 0

        // Business Rule 1: Age group totals must sum to total
        if (under30 + aged30to50 + over50 != total) {
            errors.add("Age group totals validation failed: Under 30 ($under30) + Aged 30-50 ($aged30to50) + Over 50 ($over50) ≠ Total ($total)")
        }

        // Business Rule 2: Month-end date validation
        val activityDate = data["activity_date"]?.toString()?.let { LocalDate.parse(it) }
        if (activityDate != null && !isMonthEndDate(activityDate)) {
            errors.add("Activity date must be the last day of the month")
        }

        // Business Rule 3: Site validation (organisation-level only)
        val siteId = data["site_id"]
        if (siteId != null) {
            errors.add("Employee age demographics must be organisation-level only (site_id must be null)")
        }

        return errors
    }

    private fun isMonthEndDate(date: LocalDate): Boolean {
        val yearMonth = java.time.YearMonth.of(date.year, date.month)
        val lastDayOfMonth = yearMonth.atEndOfMonth()
        return date == lastDayOfMonth
    }
}

PII Protection Validation

Purpose: Ensure HR data submissions meet minimum aggregation thresholds to prevent individual identification.

Aggregation Rule: Minimum 5 employees per category (implemented as anomaly detection warning, not hard failure)

package com.example.esg.validation.hr

data class PIIAggregationWarning(
    val field: String,
    val count: Int,
    val threshold: Int,
    val message: String
)

class PIIAggregationValidator {

    private val MIN_AGGREGATION_THRESHOLD = 5

    fun checkPIIAggregationThreshold(rawData: Map<String, Any?>): List<PIIAggregationWarning> {
        val warnings = mutableListOf<PIIAggregationWarning>()

        // Check all numeric fields in raw_data
        for ((field, value) in rawData) {
            val count = value?.toString()?.toIntOrNull() ?: continue

            if (count in 1 until MIN_AGGREGATION_THRESHOLD) {
                warnings.add(
                    PIIAggregationWarning(
                        field = field,
                        count = count,
                        threshold = MIN_AGGREGATION_THRESHOLD,
                        message = "WARNING: $field has only $count employees (below PII threshold of $MIN_AGGREGATION_THRESHOLD). Consider aggregating to higher level to protect individual privacy."
                    )
                )
            }
        }

        return warnings
    }
}

Integration with Anomaly Detection:

// In ValidationService, during anomaly detection phase
if (metricTemplate.category == "HUMAN_CAPITAL") {
    val rawData = data["raw_data"] as? Map<String, Any?> ?: emptyMap()
    val piiValidator = PIIAggregationValidator()
    val piiWarnings = piiValidator.checkPIIAggregationThreshold(rawData)

    warnings.addAll(piiWarnings.map { it.message })
}

Note: PII aggregation warnings do NOT block submission. They serve as alerts for reviewers to consider: 1. Aggregating site-level data to organisation-level 2. Combining categories (e.g., age groups, employment levels) 3. Suppressing specific breakdowns in public reports


Annual Aggregation Validation

Purpose: Validate annual aggregate values calculated from monthly/quarterly submissions.

Aggregation Methods: - AVERAGE: Permanent/Fixed-Term Headcount, Recruitment (Male/Female) - TOTAL: Departures, Casual Workers

package com.example.esg.validation.hr

import java.math.BigDecimal
import java.math.RoundingMode

data class AnnualAggregationResult(
    val aggregateValue: Double,
    val formula: String,
    val monthlyValues: List<Double>,
    val isValid: Boolean,
    val errorMessage: String?
)

class AnnualAggregationValidator {

    fun validateAnnualAverage(
        monthlyValues: List<Double>,
        reportedAnnualAverage: Double,
        tolerance: Double = 0.5 // Allow rounding difference
    ): AnnualAggregationResult {

        if (monthlyValues.isEmpty()) {
            return AnnualAggregationResult(
                aggregateValue = 0.0,
                formula = "No monthly data available",
                monthlyValues = emptyList(),
                isValid = false,
                errorMessage = "Cannot calculate annual average: no monthly submissions found"
            )
        }

        val calculatedAverage = monthlyValues.average()
        val roundedAverage = BigDecimal(calculatedAverage).setScale(0, RoundingMode.HALF_UP).toDouble()

        val isValid = Math.abs(roundedAverage - reportedAnnualAverage) <= tolerance

        return AnnualAggregationResult(
            aggregateValue = roundedAverage,
            formula = "AVERAGE = SUM(${monthlyValues.joinToString(" + ")}) / ${monthlyValues.size}",
            monthlyValues = monthlyValues,
            isValid = isValid,
            errorMessage = if (!isValid) {
                "Annual average validation failed: Reported ($reportedAnnualAverage) ≠ Calculated ($roundedAverage)"
            } else null
        )
    }

    fun validateAnnualTotal(
        monthlyValues: List<Double>,
        reportedAnnualTotal: Double,
        tolerance: Double = 0.0 // Totals must match exactly
    ): AnnualAggregationResult {

        if (monthlyValues.isEmpty()) {
            return AnnualAggregationResult(
                aggregateValue = 0.0,
                formula = "No monthly data available",
                monthlyValues = emptyList(),
                isValid = false,
                errorMessage = "Cannot calculate annual total: no monthly submissions found"
            )
        }

        val calculatedTotal = monthlyValues.sum()

        val isValid = Math.abs(calculatedTotal - reportedAnnualTotal) <= tolerance

        return AnnualAggregationResult(
            aggregateValue = calculatedTotal,
            formula = "TOTAL = SUM(${monthlyValues.joinToString(" + ")})",
            monthlyValues = monthlyValues,
            isValid = isValid,
            errorMessage = if (!isValid) {
                "Annual total validation failed: Reported ($reportedAnnualTotal) ≠ Calculated ($calculatedTotal)"
            } else null
        )
    }
}

Usage Example:

// Validate permanent employee annual average
val monthlyPermanentCounts = listOf(417.0, 417.0, 412.0, 412.0, 401.0, 401.0, 412.0, 433.0, 433.0)
val reportedAnnualAverage = 319.0 // From submission

val validator = AnnualAggregationValidator()
val result = validator.validateAnnualAverage(monthlyPermanentCounts, reportedAnnualAverage)

if (!result.isValid) {
    errors.add(result.errorMessage!!)
}

HR Metrics Validation Summary

Validation Type GRI 405-1 Demographics GRI 401 Employment Age Demographics
Schema JSONB fields: total_headcount, male_count, female_count, local_community_count value (integer) JSONB fields: under_30, aged_30_50, over_50, total
Domain All counts >= 0 value >= 0 All counts >= 0
Referential male + female = total, local_community <= total N/A under_30 + aged_30_50 + over_50 = total
Evidence HR_REGISTER, PAYROLL_REPORT, BOARD_REPORT HR_REGISTER, RECRUITMENT_REPORT, EXIT_REPORT HR_REGISTER, PAYROLL_REPORT
Business Rules Quarter-end date, Site dimensionality (Executive vs Salaried/Waged) Month-end date, Organisation-level only Month-end date, Organisation-level only
Anomaly PII aggregation threshold (>= 5 employees) Departures > 50% headcount PII aggregation threshold
Monthly Completeness N/A (quarterly) All 6 metrics required per month All 3 metrics required per month
Annual Aggregation N/A (quarterly snapshots) Average for headcount/recruitment, Total for departures/casual N/A (monthly snapshots)
Percentage Derivation % Male, % Female, % Local Community (calculated, not collected) N/A N/A

Testing HR Validation Rules

Unit Tests

package com.example.esg.validation.hr

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class HRDemographicsValidatorTest {

    private val validator = HRDemographicsValidator()

    @Test
    fun `GRI 405-1 validation - should pass with valid gender totals`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "total_headcount" to 433,
                "male_count" to 346,
                "female_count" to 87,
                "local_community_count" to 231
            ),
            "activity_date" to "2025-09-30", // Q3 end
            "metric_id" to "GRI_405_1_SALARIED_NON_NEC_HEADCOUNT",
            "site_id" to 123L
        )

        val errors = validator.validateGri4051Demographics(data)

        assertThat(errors).isEmpty()
    }

    @Test
    fun `GRI 405-1 validation - should fail when gender totals do not sum`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "total_headcount" to 433,
                "male_count" to 300,
                "female_count" to 100, // 300 + 100 = 400 ≠ 433
                "local_community_count" to 231
            ),
            "activity_date" to "2025-09-30",
            "metric_id" to "GRI_405_1_SALARIED_NON_NEC_HEADCOUNT",
            "site_id" to 123L
        )

        val errors = validator.validateGri4051Demographics(data)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("Gender totals validation failed")
    }

    @Test
    fun `GRI 405-1 validation - should fail when local community exceeds total`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "total_headcount" to 433,
                "male_count" to 346,
                "female_count" to 87,
                "local_community_count" to 500 // > 433
            ),
            "activity_date" to "2025-09-30",
            "metric_id" to "GRI_405_1_SALARIED_NON_NEC_HEADCOUNT",
            "site_id" to 123L
        )

        val errors = validator.validateGri4051Demographics(data)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("Local community validation failed")
    }

    @Test
    fun `GRI 405-1 validation - should fail when date is not quarter end`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "total_headcount" to 433,
                "male_count" to 346,
                "female_count" to 87,
                "local_community_count" to 231
            ),
            "activity_date" to "2025-09-15", // Not quarter end
            "metric_id" to "GRI_405_1_SALARIED_NON_NEC_HEADCOUNT",
            "site_id" to 123L
        )

        val errors = validator.validateGri4051Demographics(data)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("Activity date must be the last day of a quarter")
    }

    @Test
    fun `GRI 405-1 validation - should fail when Executive metric has site_id`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "total_headcount" to 20,
                "male_count" to 15,
                "female_count" to 5,
                "local_community_count" to 10
            ),
            "activity_date" to "2025-09-30",
            "metric_id" to "GRI_405_1_EXECUTIVE_HEADCOUNT",
            "site_id" to 123L // Should be null
        )

        val errors = validator.validateGri4051Demographics(data)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("Executive headcount must be organisation-level only")
    }
}

Integration Tests

package com.example.esg.validation.hr

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import io.quarkus.test.junit.QuarkusTest

@QuarkusTest
@Transactional
class HRValidationIntegrationTest {

    @Inject
    private lateinit var validationService: ValidationService

    @Test
    fun `end-to-end GRI 405-1 validation - should pass with valid data`() {
        val submission = createTestSubmission(
            metricTemplateId = "GRI_405_1_EMPLOYEE_DEMOGRAPHICS_V1",
            data = mapOf(
                "raw_data" to mapOf(
                    "total_headcount" to 433,
                    "male_count" to 346,
                    "female_count" to 87,
                    "local_community_count" to 231
                ),
                "activity_date" to "2025-09-30",
                "metric_id" to "GRI_405_1_SALARIED_NON_NEC_HEADCOUNT",
                "site_id" to 123L
            ),
            evidenceFiles = listOf(
                createTestEvidence("hr_register.xlsx", "application/vnd.ms-excel", 2048L)
            )
        )

        val result = validationService.validate(submission)

        assertThat(result.passed).isTrue()
        assertThat(result.errors).isEmpty()
        assertThat(submission.state).isEqualTo("VALIDATED")

        // Check processed_data has calculated percentages
        val processedData = submission.processedData as Map<String, Any?>
        assertThat(processedData["percent_male"]).isEqualTo(79.91)
        assertThat(processedData["percent_female"]).isEqualTo(20.09)
    }

    @Test
    fun `monthly completeness check - should flag missing GRI 401 metrics`() {
        // Submit only 4 out of 6 GRI 401 metrics for January 2025
        createAndValidateSubmission("GRI_401_PERMANENT_EMPLOYEES_MONTHLY", 417, "2025-01-31")
        createAndValidateSubmission("GRI_401_FIXED_TERM_EMPLOYEES_MONTHLY", 33, "2025-01-31")
        createAndValidateSubmission("GRI_401_NEW_RECRUITS_PERMANENT_MALE_MONTHLY", 0, "2025-01-31")
        createAndValidateSubmission("GRI_401_NEW_RECRUITS_PERMANENT_FEMALE_MONTHLY", 0, "2025-01-31")
        // Missing: DEPARTURES and CASUAL_WORKERS

        val completenessResult = validationService.validateMonthlyCompletenessForGri401(
            tenantId = 1L,
            organisationId = 1L,
            month = LocalDate.parse("2025-01-31")
        )

        assertThat(completenessResult.isComplete).isFalse()
        assertThat(completenessResult.missingMetrics).hasSize(2)
        assertThat(completenessResult.missingMetrics).contains(
            "GRI_401_DEPARTURES_PERMANENT_MONTHLY",
            "GRI_401_CASUAL_WORKERS_MONTHLY"
        )
    }
}

Occupational Health and Safety (OHS) Metrics Validation Rules

Purpose

This section documents validation rules specific to Occupational Health and Safety (OHS) metrics (GRI 403-9 Work-related Injuries and GRI 403-10 Work-related Ill Health). These rules ensure data quality for safety reporting, regulatory compliance, and confidentiality protection of incident data.

Key Challenges: - Dimensional data validation (10 incident types × 2 workforce types × 4 quarters) - Cross-field consistency (Mine + Contractors totals, Q1+Q2+Q3+Q4 = Total) - LTIFR calculation validation (LTI count × 1,000,000 / hours worked) - Cross-validation between F.1 incident counts and F.2 LTIFR calculations - Quarterly collection frequency with quarter-end date validation - Evidence requirements tiered by incident severity (MANDATORY, RECOMMENDED, OPTIONAL) - Zero-value consistency (if LTI count = 0, then LTI days must = 0) - Site-level collection with organisation-level aggregation


F.1 Quarterly Incident Statistics Validation

Collection Frequency: Quarterly (Q1: Mar 31, Q2: Jun 30, Q3: Sep 30, Q4: Dec 31)

Template ID: OHS_QUARTERLY_INCIDENT_STATS_V1

Metrics: - OHS_INCIDENT_NEAR_MISS (site-level) - OHS_INCIDENT_FIRST_AID (site-level) - OHS_INCIDENT_RESTRICTED_WORK (site-level) - OHS_INCIDENT_MEDICAL_TREATMENT (site-level) - OHS_INCIDENT_LTI (site-level) - OHS_INCIDENT_FATALITY (site-level) - OHS_INCIDENT_HIGH_POTENTIAL (site-level) - OHS_INCIDENT_PROPERTY_DAMAGE (site-level) - OHS_INCIDENT_SILICOSIS (site-level) - OHS_INCIDENT_OTHER_CLINIC (site-level)

Data Structure: JSONB raw_data field with nested workforce type breakdown:

{
  "near_miss": {
    "mine": 12,
    "contractors": 8
  },
  "first_aid": {
    "mine": 5,
    "contractors": 3
  },
  "restricted_work": {
    "mine": 2,
    "contractors": 1
  },
  "medical_treatment": {
    "mine": 3,
    "contractors": 2
  },
  "lti": {
    "mine": 1,
    "contractors": 0
  },
  "fatality": {
    "mine": 0,
    "contractors": 0
  },
  "high_potential": {
    "mine": 2,
    "contractors": 1
  },
  "property_damage": {
    "mine": 1,
    "contractors": 0
  },
  "silicosis_pneumoconiosis": {
    "mine": 0,
    "contractors": 0
  },
  "other_clinic_visits": {
    "mine": 8,
    "contractors": 5
  }
}

Schema Validation Rules

{
  "metric_template_id": "OHS_QUARTERLY_INCIDENT_STATS_V1",
  "validation_rules": [
    {
      "type": "schema",
      "field": "raw_data.near_miss",
      "rule": "required",
      "error_message": "Near miss incident data is required"
    },
    {
      "type": "schema",
      "field": "raw_data.near_miss.mine",
      "rule": "type:integer",
      "error_message": "Mine near miss count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.near_miss.contractors",
      "rule": "type:integer",
      "error_message": "Contractors near miss count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.first_aid.mine",
      "rule": "type:integer",
      "error_message": "Mine first aid count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.first_aid.contractors",
      "rule": "type:integer",
      "error_message": "Contractors first aid count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.lti.mine",
      "rule": "type:integer",
      "error_message": "Mine LTI count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.lti.contractors",
      "rule": "type:integer",
      "error_message": "Contractors LTI count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.fatality.mine",
      "rule": "type:integer",
      "error_message": "Mine fatality count must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.fatality.contractors",
      "rule": "type:integer",
      "error_message": "Contractors fatality count must be an integer"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "required",
      "error_message": "Activity date is required"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "type:date",
      "error_message": "Activity date must be in ISO 8601 format (YYYY-MM-DD)"
    }
  ]
}

Note: Schema validation rules apply to all 10 incident types (near_miss, first_aid, restricted_work, medical_treatment, lti, fatality, high_potential, property_damage, silicosis_pneumoconiosis, other_clinic_visits) with mine/contractors breakdown. Above shows representative examples.

Domain Validation Rules

{
  "metric_template_id": "OHS_QUARTERLY_INCIDENT_STATS_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "raw_data.near_miss.mine",
      "rule": "min:0",
      "error_message": "Mine near miss count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.near_miss.contractors",
      "rule": "min:0",
      "error_message": "Contractors near miss count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.lti.mine",
      "rule": "min:0",
      "error_message": "Mine LTI count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.lti.contractors",
      "rule": "min:0",
      "error_message": "Contractors LTI count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.fatality.mine",
      "rule": "min:0",
      "error_message": "Mine fatality count cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.fatality.contractors",
      "rule": "min:0",
      "error_message": "Contractors fatality count cannot be negative"
    }
  ]
}

Note: Domain validation rules (min:0) apply to all 10 incident types with mine/contractors breakdown. Above shows representative examples.

Referential Validation Rules

F.1 uses dimensional data with workforce type breakdown. Totals are calculated at report generation time, not validated during collection. Cross-validation with F.2 LTI counts occurs during LTIFR validation (see F.2 section below).

Evidence Validation Rules

Evidence requirements for F.1 are tiered by incident severity:

{
  "metric_template_id": "OHS_QUARTERLY_INCIDENT_STATS_V1",
  "evidence_requirements": [
    {
      "condition": "raw_data.fatality.mine > 0 OR raw_data.fatality.contractors > 0",
      "required_evidence_types": ["FATALITY_REPORT", "INVESTIGATION_REPORT"],
      "min_attachments": 2,
      "severity": "MANDATORY",
      "error_message": "Fatality incidents require both FATALITY_REPORT and INVESTIGATION_REPORT (minimum 2 attachments)"
    },
    {
      "condition": "raw_data.lti.mine > 0 OR raw_data.lti.contractors > 0",
      "required_evidence_types": ["LTI_REGISTER", "ACCIDENT_REPORT"],
      "min_attachments": 1,
      "severity": "MANDATORY",
      "error_message": "LTI incidents require at least one of: LTI_REGISTER, ACCIDENT_REPORT"
    },
    {
      "condition": "raw_data.high_potential.mine > 0 OR raw_data.high_potential.contractors > 0",
      "required_evidence_types": ["INVESTIGATION_REPORT", "HSE_REPORT"],
      "min_attachments": 1,
      "severity": "MANDATORY",
      "error_message": "High potential incidents require INVESTIGATION_REPORT or HSE_REPORT"
    },
    {
      "condition": "raw_data.silicosis_pneumoconiosis.mine > 0 OR raw_data.silicosis_pneumoconiosis.contractors > 0",
      "required_evidence_types": ["OCCUPATIONAL_DISEASE_REGISTER", "MEDICAL_TREATMENT_RECORD"],
      "min_attachments": 1,
      "severity": "MANDATORY",
      "error_message": "Silicosis/pneumoconiosis cases require OCCUPATIONAL_DISEASE_REGISTER or MEDICAL_TREATMENT_RECORD"
    },
    {
      "condition": "raw_data.medical_treatment.mine > 0 OR raw_data.medical_treatment.contractors > 0",
      "required_evidence_types": ["MEDICAL_TREATMENT_RECORD", "CLINIC_REGISTER"],
      "min_attachments": 1,
      "severity": "RECOMMENDED",
      "warning_message": "Medical treatment incidents should include MEDICAL_TREATMENT_RECORD or CLINIC_REGISTER"
    },
    {
      "condition": "raw_data.restricted_work.mine > 0 OR raw_data.restricted_work.contractors > 0",
      "required_evidence_types": ["ACCIDENT_REPORT", "INCIDENT_REGISTER"],
      "min_attachments": 1,
      "severity": "RECOMMENDED",
      "warning_message": "Restricted work incidents should include ACCIDENT_REPORT or INCIDENT_REGISTER"
    },
    {
      "condition": "raw_data.property_damage.mine > 0 OR raw_data.property_damage.contractors > 0",
      "required_evidence_types": ["PROPERTY_DAMAGE_REPORT"],
      "min_attachments": 1,
      "severity": "RECOMMENDED",
      "warning_message": "Property damage incidents should include PROPERTY_DAMAGE_REPORT"
    }
  ]
}

Evidence Severity Tiers: - MANDATORY: Hard validation failure, submission blocked (fatality, LTI, high potential, occupational disease) - RECOMMENDED: Soft warning, submission allowed (medical treatment, restricted work, property damage) - OPTIONAL: No validation check (first aid, near miss, other clinic visits)

Business Rule Validation

package com.example.esg.validation.ohs

import com.example.esg.validation.BusinessRuleValidationRule
import java.time.LocalDate

class OHSIncidentValidator {

    fun validateQuarterlyIncidentStats(data: Map<String, Any?>): List<String> {
        val errors = mutableListOf<String>()

        // Extract raw_data JSONB
        val rawData = data["raw_data"] as? Map<String, Any?>
            ?: return listOf("raw_data field is missing")

        // Business Rule 1: Quarter-end date validation
        val activityDate = data["activity_date"]?.toString()?.let { LocalDate.parse(it) }
        if (activityDate != null && !isQuarterEndDate(activityDate)) {
            errors.add("Activity date must be the last day of a quarter (Mar 31, Jun 30, Sep 30, or Dec 31)")
        }

        // Business Rule 2: Site dimensionality validation (all OHS incident metrics require site_id)
        val siteId = data["site_id"]
        if (siteId == null) {
            errors.add("OHS incident metrics require site_id (site-level collection)")
        }

        // Business Rule 3: Zero-value completeness (all incident types required, zero allowed)
        val requiredIncidentTypes = listOf(
            "near_miss", "first_aid", "restricted_work", "medical_treatment",
            "lti", "fatality", "high_potential", "property_damage",
            "silicosis_pneumoconiosis", "other_clinic_visits"
        )

        requiredIncidentTypes.forEach { incidentType ->
            val incidentData = rawData[incidentType] as? Map<String, Any?>
            if (incidentData == null) {
                errors.add("Incident type '$incidentType' is required (zero values allowed)")
            } else {
                // Validate mine and contractors fields exist
                if (!incidentData.containsKey("mine")) {
                    errors.add("Incident type '$incidentType' missing 'mine' workforce breakdown")
                }
                if (!incidentData.containsKey("contractors")) {
                    errors.add("Incident type '$incidentType' missing 'contractors' workforce breakdown")
                }
            }
        }

        // Business Rule 4: Evidence requirement enforcement (MANDATORY severity only)
        val fatalityMine = getIncidentCount(rawData, "fatality", "mine")
        val fatalityContractors = getIncidentCount(rawData, "fatality", "contractors")
        if ((fatalityMine + fatalityContractors) > 0) {
            val evidenceAttachments = data["evidence_attachments"] as? List<Map<String, Any?>> ?: emptyList()
            val evidenceTypes = evidenceAttachments.mapNotNull { it["evidence_type"]?.toString() }

            val hasFatalityReport = evidenceTypes.contains("FATALITY_REPORT")
            val hasInvestigationReport = evidenceTypes.contains("INVESTIGATION_REPORT")

            if (!hasFatalityReport || !hasInvestigationReport) {
                errors.add("Fatality incidents require both FATALITY_REPORT and INVESTIGATION_REPORT (found: ${evidenceTypes.joinToString(", ")})")
            }

            if (evidenceAttachments.size < 2) {
                errors.add("Fatality incidents require minimum 2 evidence attachments (found: ${evidenceAttachments.size})")
            }
        }

        val ltiMine = getIncidentCount(rawData, "lti", "mine")
        val ltiContractors = getIncidentCount(rawData, "lti", "contractors")
        if ((ltiMine + ltiContractors) > 0) {
            val evidenceAttachments = data["evidence_attachments"] as? List<Map<String, Any?>> ?: emptyList()
            val evidenceTypes = evidenceAttachments.mapNotNull { it["evidence_type"]?.toString() }

            val hasLtiEvidence = evidenceTypes.any { it in listOf("LTI_REGISTER", "ACCIDENT_REPORT") }
            if (!hasLtiEvidence) {
                errors.add("LTI incidents require at least one of: LTI_REGISTER, ACCIDENT_REPORT (found: ${evidenceTypes.joinToString(", ")})")
            }
        }

        val highPotentialMine = getIncidentCount(rawData, "high_potential", "mine")
        val highPotentialContractors = getIncidentCount(rawData, "high_potential", "contractors")
        if ((highPotentialMine + highPotentialContractors) > 0) {
            val evidenceAttachments = data["evidence_attachments"] as? List<Map<String, Any?>> ?: emptyList()
            val evidenceTypes = evidenceAttachments.mapNotNull { it["evidence_type"]?.toString() }

            val hasHighPotentialEvidence = evidenceTypes.any { it in listOf("INVESTIGATION_REPORT", "HSE_REPORT") }
            if (!hasHighPotentialEvidence) {
                errors.add("High potential incidents require INVESTIGATION_REPORT or HSE_REPORT (found: ${evidenceTypes.joinToString(", ")})")
            }
        }

        val silicosisM = getIncidentCount(rawData, "silicosis_pneumoconiosis", "mine")
        val silicosisContractors = getIncidentCount(rawData, "silicosis_pneumoconiosis", "contractors")
        if ((silicosisM + silicosisContractors) > 0) {
            val evidenceAttachments = data["evidence_attachments"] as? List<Map<String, Any?>> ?: emptyList()
            val evidenceTypes = evidenceAttachments.mapNotNull { it["evidence_type"]?.toString() }

            val hasSilicosisEvidence = evidenceTypes.any {
                it in listOf("OCCUPATIONAL_DISEASE_REGISTER", "MEDICAL_TREATMENT_RECORD")
            }
            if (!hasSilicosisEvidence) {
                errors.add("Silicosis/pneumoconiosis cases require OCCUPATIONAL_DISEASE_REGISTER or MEDICAL_TREATMENT_RECORD (found: ${evidenceTypes.joinToString(", ")})")
            }
        }

        return errors
    }

    private fun isQuarterEndDate(date: LocalDate): Boolean {
        val validQuarterEnds = listOf(
            "03-31", // Q1 end
            "06-30", // Q2 end
            "09-30", // Q3 end
            "12-31"  // Q4 end
        )

        val monthDay = String.format("%02d-%02d", date.monthValue, date.dayOfMonth)
        return monthDay in validQuarterEnds
    }

    private fun getIncidentCount(rawData: Map<String, Any?>, incidentType: String, workforceType: String): Int {
        val incidentData = rawData[incidentType] as? Map<String, Any?> ?: return 0
        return incidentData[workforceType]?.toString()?.toIntOrNull() ?: 0
    }
}

Integration with ValidationService:

// In ValidationService, add business rule for OHS Incident Stats
if (metricTemplate.id == "OHS_QUARTERLY_INCIDENT_STATS_V1") {
    val ohsIncidentValidator = OHSIncidentValidator()
    val ohsErrors = ohsIncidentValidator.validateQuarterlyIncidentStats(data)
    if (ohsErrors.isNotEmpty()) {
        errors.addAll(ohsErrors)
        return saveAndReturn(submission, ValidationResult(false, errors, warnings))
    }
}

Anomaly Detection

class OHSAnomalyDetector {

    fun detectIncidentSpikes(
        currentQuarter: Map<String, Any?>,
        previousQuarter: Map<String, Any?>?
    ): List<String> {
        val warnings = mutableListOf<String>()

        if (previousQuarter == null) {
            // No baseline for comparison
            return warnings
        }

        val currentRawData = currentQuarter["raw_data"] as? Map<String, Any?> ?: return warnings
        val previousRawData = previousQuarter["raw_data"] as? Map<String, Any?> ?: return warnings

        // Anomaly Detection 1: Fatality spike warning
        val currentFatalities = getTotalIncidentCount(currentRawData, "fatality")
        val previousFatalities = getTotalIncidentCount(previousRawData, "fatality")

        if (currentFatalities > previousFatalities && currentFatalities > 0) {
            warnings.add("WARNING: Fatality count increased from $previousFatalities (previous quarter) to $currentFatalities (current quarter). Verify all investigation reports are attached.")
        }

        // Anomaly Detection 2: LTI spike (>50% increase)
        val currentLTI = getTotalIncidentCount(currentRawData, "lti")
        val previousLTI = getTotalIncidentCount(previousRawData, "lti")

        if (previousLTI > 0) {
            val percentChange = ((currentLTI - previousLTI).toDouble() / previousLTI) * 100
            if (percentChange > 50.0) {
                warnings.add("WARNING: LTI count increased by ${String.format("%.1f", percentChange)}% from $previousLTI (previous quarter) to $currentLTI (current quarter). Review safety controls.")
            }
        }

        // Anomaly Detection 3: High potential incident spike
        val currentHighPotential = getTotalIncidentCount(currentRawData, "high_potential")
        val previousHighPotential = getTotalIncidentCount(previousRawData, "high_potential")

        if (previousHighPotential > 0) {
            val percentChange = ((currentHighPotential - previousHighPotential).toDouble() / previousHighPotential) * 100
            if (percentChange > 50.0) {
                warnings.add("WARNING: High potential incident count increased by ${String.format("%.1f", percentChange)}% from $previousHighPotential (previous quarter) to $currentHighPotential (current quarter). Review controls.")
            }
        }

        // Anomaly Detection 4: Contractor incident proportion (>70% of total incidents)
        val totalIncidents = getTotalAllIncidents(currentRawData)
        val contractorIncidents = getTotalContractorIncidents(currentRawData)

        if (totalIncidents > 0) {
            val contractorProportion = (contractorIncidents.toDouble() / totalIncidents) * 100
            if (contractorProportion > 70.0) {
                warnings.add("WARNING: Contractor incidents represent ${String.format("%.1f", contractorProportion)}% of total incidents. Review contractor safety management.")
            }
        }

        return warnings
    }

    private fun getTotalIncidentCount(rawData: Map<String, Any?>, incidentType: String): Int {
        val incidentData = rawData[incidentType] as? Map<String, Any?> ?: return 0
        val mine = incidentData["mine"]?.toString()?.toIntOrNull() ?: 0
        val contractors = incidentData["contractors"]?.toString()?.toIntOrNull() ?: 0
        return mine + contractors
    }

    private fun getTotalAllIncidents(rawData: Map<String, Any?>): Int {
        val incidentTypes = listOf(
            "near_miss", "first_aid", "restricted_work", "medical_treatment",
            "lti", "fatality", "high_potential", "property_damage",
            "silicosis_pneumoconiosis", "other_clinic_visits"
        )
        return incidentTypes.sumOf { getTotalIncidentCount(rawData, it) }
    }

    private fun getTotalContractorIncidents(rawData: Map<String, Any?>): Int {
        val incidentTypes = listOf(
            "near_miss", "first_aid", "restricted_work", "medical_treatment",
            "lti", "fatality", "high_potential", "property_damage",
            "silicosis_pneumoconiosis", "other_clinic_visits"
        )
        return incidentTypes.sumOf { incidentType ->
            val incidentData = rawData[incidentType] as? Map<String, Any?> ?: return@sumOf 0
            incidentData["contractors"]?.toString()?.toIntOrNull() ?: 0
        }
    }
}

F.2 LTI Days and LTIFR Validation

Collection Frequency: Quarterly (Q1: Mar 31, Q2: Jun 30, Q3: Sep 30, Q4: Dec 31)

Template ID: OHS_LTI_PERFORMANCE_V1

Metrics: - OHS_LTI_DAYS (site-level) - OHS_LTIFR (site-level, calculated metric) - OHS_HOURS_WORKED (site-level, required for LTIFR calculation)

Data Structure: JSONB raw_data field with workforce type breakdown:

{
  "lti_days": {
    "mine": 45,
    "contractors": 0
  },
  "hours_worked": {
    "mine": 123456,
    "contractors": 45678
  },
  "ltifr": {
    "mine": 8.11,
    "contractors": 0.00
  }
}

Schema Validation Rules

{
  "metric_template_id": "OHS_LTI_PERFORMANCE_V1",
  "validation_rules": [
    {
      "type": "schema",
      "field": "raw_data.lti_days",
      "rule": "required",
      "error_message": "LTI days data is required"
    },
    {
      "type": "schema",
      "field": "raw_data.lti_days.mine",
      "rule": "type:integer",
      "error_message": "Mine LTI days must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.lti_days.contractors",
      "rule": "type:integer",
      "error_message": "Contractors LTI days must be an integer"
    },
    {
      "type": "schema",
      "field": "raw_data.hours_worked",
      "rule": "required",
      "error_message": "Hours worked data is required"
    },
    {
      "type": "schema",
      "field": "raw_data.hours_worked.mine",
      "rule": "type:number",
      "error_message": "Mine hours worked must be a number"
    },
    {
      "type": "schema",
      "field": "raw_data.hours_worked.contractors",
      "rule": "type:number",
      "error_message": "Contractors hours worked must be a number"
    },
    {
      "type": "schema",
      "field": "raw_data.ltifr",
      "rule": "required",
      "error_message": "LTIFR data is required"
    },
    {
      "type": "schema",
      "field": "raw_data.ltifr.mine",
      "rule": "type:number",
      "error_message": "Mine LTIFR must be a number"
    },
    {
      "type": "schema",
      "field": "raw_data.ltifr.contractors",
      "rule": "type:number",
      "error_message": "Contractors LTIFR must be a number"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "required",
      "error_message": "Activity date is required"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "type:date",
      "error_message": "Activity date must be in ISO 8601 format (YYYY-MM-DD)"
    }
  ]
}

Domain Validation Rules

{
  "metric_template_id": "OHS_LTI_PERFORMANCE_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "raw_data.lti_days.mine",
      "rule": "min:0",
      "error_message": "Mine LTI days cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.lti_days.contractors",
      "rule": "min:0",
      "error_message": "Contractors LTI days cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.hours_worked.mine",
      "rule": "min:0",
      "error_message": "Mine hours worked cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.hours_worked.contractors",
      "rule": "min:0",
      "error_message": "Contractors hours worked cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.ltifr.mine",
      "rule": "min:0",
      "error_message": "Mine LTIFR cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.ltifr.contractors",
      "rule": "min:0",
      "error_message": "Contractors LTIFR cannot be negative"
    },
    {
      "type": "domain",
      "field": "raw_data.ltifr.mine",
      "rule": "precision:2",
      "error_message": "Mine LTIFR must have maximum 2 decimal places"
    },
    {
      "type": "domain",
      "field": "raw_data.ltifr.contractors",
      "rule": "precision:2",
      "error_message": "Contractors LTIFR must have maximum 2 decimal places"
    }
  ]
}

Referential Validation Rules

F.2 requires cross-validation between F.1 LTI counts and F.2 LTIFR calculation inputs. This ensures the LTI count used in LTIFR formula matches the LTI count reported in F.1 for the same quarter, site, and workforce type.

Implementation: Cross-validation occurs in Business Rule Validation (see below).

Evidence Validation Rules

{
  "metric_template_id": "OHS_LTI_PERFORMANCE_V1",
  "evidence_requirements": [
    {
      "condition": "ALWAYS",
      "required_evidence_types": ["LTI_REGISTER", "TIME_SHEET", "PAYROLL_REPORT", "HR_REGISTER"],
      "min_attachments": 1,
      "severity": "MANDATORY",
      "error_message": "LTI performance metrics require at least one of: LTI_REGISTER, TIME_SHEET, PAYROLL_REPORT, HR_REGISTER"
    }
  ]
}

Business Rule Validation

package com.example.esg.validation.ohs

import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDate

class OHSLTIPerformanceValidator {

    companion object {
        const val LTIFR_MULTIPLIER = 1_000_000.0 // Per million hours worked
        const val LTIFR_TOLERANCE = 0.01 // Allow 0.01 difference due to rounding
    }

    fun validateLTIPerformance(
        data: Map<String, Any?>,
        f1IncidentData: Map<String, Any?>? = null // F.1 data for cross-validation
    ): List<String> {
        val errors = mutableListOf<String>()

        // Extract raw_data JSONB
        val rawData = data["raw_data"] as? Map<String, Any?>
            ?: return listOf("raw_data field is missing")

        // Extract LTI days, hours worked, and LTIFR
        val ltiDaysData = rawData["lti_days"] as? Map<String, Any?>
            ?: return listOf("lti_days field is missing")
        val hoursWorkedData = rawData["hours_worked"] as? Map<String, Any?>
            ?: return listOf("hours_worked field is missing")
        val ltifrData = rawData["ltifr"] as? Map<String, Any?>
            ?: return listOf("ltifr field is missing")

        val ltiDaysMine = ltiDaysData["mine"]?.toString()?.toIntOrNull() ?: 0
        val ltiDaysContractors = ltiDaysData["contractors"]?.toString()?.toIntOrNull() ?: 0
        val hoursWorkedMine = hoursWorkedData["mine"]?.toString()?.toDoubleOrNull() ?: 0.0
        val hoursWorkedContractors = hoursWorkedData["contractors"]?.toString()?.toDoubleOrNull() ?: 0.0
        val ltifrMine = ltifrData["mine"]?.toString()?.toDoubleOrNull() ?: 0.0
        val ltifrContractors = ltifrData["contractors"]?.toString()?.toDoubleOrNull() ?: 0.0

        // Business Rule 1: Quarter-end date validation
        val activityDate = data["activity_date"]?.toString()?.let { LocalDate.parse(it) }
        if (activityDate != null && !isQuarterEndDate(activityDate)) {
            errors.add("Activity date must be the last day of a quarter (Mar 31, Jun 30, Sep 30, or Dec 31)")
        }

        // Business Rule 2: Site dimensionality validation
        val siteId = data["site_id"]
        if (siteId == null) {
            errors.add("OHS LTI performance metrics require site_id (site-level collection)")
        }

        // Business Rule 3: Zero-consistency checks
        // If LTI count = 0, then LTI days must = 0
        // If LTI days > 0, then LTIFR must > 0 (assuming hours worked > 0)

        // Get LTI count from F.1 for cross-validation
        var ltiCountMine = 0
        var ltiCountContractors = 0

        if (f1IncidentData != null) {
            val f1RawData = f1IncidentData["raw_data"] as? Map<String, Any?>
            val ltiData = f1RawData?.get("lti") as? Map<String, Any?>
            ltiCountMine = ltiData?.get("mine")?.toString()?.toIntOrNull() ?: 0
            ltiCountContractors = ltiData?.get("contractors")?.toString()?.toIntOrNull() ?: 0
        }

        // Zero-consistency for Mine workforce
        if (ltiCountMine == 0 && ltiDaysMine > 0) {
            errors.add("Zero-consistency failed for Mine: LTI count = 0 but LTI days = $ltiDaysMine (must be 0)")
        }
        if (ltiCountMine == 0 && ltifrMine > 0.0) {
            errors.add("Zero-consistency failed for Mine: LTI count = 0 but LTIFR = $ltifrMine (must be 0.00)")
        }

        // Zero-consistency for Contractors workforce
        if (ltiCountContractors == 0 && ltiDaysContractors > 0) {
            errors.add("Zero-consistency failed for Contractors: LTI count = 0 but LTI days = $ltiDaysContractors (must be 0)")
        }
        if (ltiCountContractors == 0 && ltifrContractors > 0.0) {
            errors.add("Zero-consistency failed for Contractors: LTI count = 0 but LTIFR = $ltifrContractors (must be 0.00)")
        }

        // Positive LTI consistency
        if (ltiCountMine > 0 && ltiDaysMine == 0) {
            errors.add("Positive LTI consistency failed for Mine: LTI count = $ltiCountMine but LTI days = 0 (LTI days must be > 0)")
        }
        if (ltiCountContractors > 0 && ltiDaysContractors == 0) {
            errors.add("Positive LTI consistency failed for Contractors: LTI count = $ltiCountContractors but LTI days = 0 (LTI days must be > 0)")
        }

        // Business Rule 4: LTIFR calculation validation
        // LTIFR = (LTI count × 1,000,000) / hours worked

        if (hoursWorkedMine > 0) {
            val expectedLTIFRMine = calculateLTIFR(ltiCountMine, hoursWorkedMine)
            if (Math.abs(ltifrMine - expectedLTIFRMine) > LTIFR_TOLERANCE) {
                errors.add("LTIFR calculation failed for Mine: Expected ${String.format("%.2f", expectedLTIFRMine)} (LTI count: $ltiCountMine × 1,000,000 / hours: ${hoursWorkedMine.toInt()}) but got $ltifrMine")
            }
        } else if (ltifrMine != 0.0) {
            errors.add("LTIFR calculation failed for Mine: Hours worked = 0, so LTIFR must be 0.00 (got $ltifrMine)")
        }

        if (hoursWorkedContractors > 0) {
            val expectedLTIFRContractors = calculateLTIFR(ltiCountContractors, hoursWorkedContractors)
            if (Math.abs(ltifrContractors - expectedLTIFRContractors) > LTIFR_TOLERANCE) {
                errors.add("LTIFR calculation failed for Contractors: Expected ${String.format("%.2f", expectedLTIFRContractors)} (LTI count: $ltiCountContractors × 1,000,000 / hours: ${hoursWorkedContractors.toInt()}) but got $ltifrContractors")
            }
        } else if (ltifrContractors != 0.0) {
            errors.add("LTIFR calculation failed for Contractors: Hours worked = 0, so LTIFR must be 0.00 (got $ltifrContractors)")
        }

        // Business Rule 5: Cross-validation with F.1 LTI counts
        if (f1IncidentData != null) {
            // Already extracted ltiCountMine and ltiCountContractors above
            // Cross-validation ensures F.1 LTI count matches F.2 LTIFR calculation inputs
            // This is implicitly validated in Business Rule 4 (LTIFR calculation)
            // No additional validation needed here
        }

        // Business Rule 6: Hours worked reasonableness check
        // Warn if hours worked exceeds maximum possible (2080 hours/year × number of employees)
        // This is implemented in anomaly detection

        return errors
    }

    private fun calculateLTIFR(ltiCount: Int, hoursWorked: Double): Double {
        if (hoursWorked == 0.0) return 0.0
        val ltifr = (ltiCount.toDouble() * LTIFR_MULTIPLIER) / hoursWorked
        return BigDecimal(ltifr).setScale(2, RoundingMode.HALF_UP).toDouble()
    }

    private fun isQuarterEndDate(date: LocalDate): Boolean {
        val validQuarterEnds = listOf(
            "03-31", // Q1 end
            "06-30", // Q2 end
            "09-30", // Q3 end
            "12-31"  // Q4 end
        )

        val monthDay = String.format("%02d-%02d", date.monthValue, date.dayOfMonth)
        return monthDay in validQuarterEnds
    }
}

Integration with ValidationService:

// In ValidationService, add business rule for OHS LTI Performance
if (metricTemplate.id == "OHS_LTI_PERFORMANCE_V1") {
    // Fetch F.1 incident data for the same quarter, site, and organisation for cross-validation
    val f1IncidentData = submissionRepository.findByTemplateAndQuarter(
        organisationId = data["organisation_id"] as Long,
        siteId = data["site_id"] as? Long,
        reportingPeriodId = data["reporting_period_id"] as Long,
        templateId = "OHS_QUARTERLY_INCIDENT_STATS_V1"
    )

    val ohsLTIValidator = OHSLTIPerformanceValidator()
    val ohsErrors = ohsLTIValidator.validateLTIPerformance(data, f1IncidentData)
    if (ohsErrors.isNotEmpty()) {
        errors.addAll(ohsErrors)
        return saveAndReturn(submission, ValidationResult(false, errors, warnings))
    }
}

YTD Calculation and Validation

YTD Metrics (calculated at report generation time, not collected):

  • YTD LTI Days: Cumulative sum of quarterly LTI days (Q1 + Q2 + Q3 + Q4)
  • YTD LTIFR: Weighted calculation using cumulative LTI count over cumulative hours worked

YTD LTIFR Calculation:

fun calculateYTDLTIFR(quarters: List<Map<String, Any?>>, workforceType: String): Double {
    var cumulativeLTICount = 0
    var cumulativeHoursWorked = 0.0

    quarters.forEach { quarter ->
        val rawData = quarter["raw_data"] as? Map<String, Any?> ?: return@forEach

        // Get LTI count from F.1 incident data (cross-referenced)
        val ltiData = rawData["lti"] as? Map<String, Any?>
        val ltiCount = ltiData?.get(workforceType)?.toString()?.toIntOrNull() ?: 0

        // Get hours worked from F.2 performance data
        val hoursWorkedData = rawData["hours_worked"] as? Map<String, Any?>
        val hoursWorked = hoursWorkedData?.get(workforceType)?.toString()?.toDoubleOrNull() ?: 0.0

        cumulativeLTICount += ltiCount
        cumulativeHoursWorked += hoursWorked
    }

    if (cumulativeHoursWorked == 0.0) return 0.0

    val ytdLTIFR = (cumulativeLTICount.toDouble() * OHSLTIPerformanceValidator.LTIFR_MULTIPLIER) / cumulativeHoursWorked
    return BigDecimal(ytdLTIFR).setScale(2, RoundingMode.HALF_UP).toDouble()
}

Important: YTD LTIFR is NOT calculated by averaging quarterly LTIFR values. It uses cumulative LTI count over cumulative hours worked to maintain statistical accuracy.

Example: - Q1: 1 LTI, 100,000 hours → LTIFR = 10.00 - Q2: 0 LTI, 120,000 hours → LTIFR = 0.00 - Q3: 2 LTI, 110,000 hours → LTIFR = 18.18

Incorrect YTD LTIFR (average): (10.00 + 0.00 + 18.18) / 3 = 9.39

Correct YTD LTIFR (weighted): (1+0+2) × 1,000,000 / (100,000+120,000+110,000) = 3 × 1,000,000 / 330,000 = 9.09


OHS Metrics Validation Summary

Validation Type F.1 Quarterly Incident Stats F.2 LTI Performance
Schema JSONB fields: 10 incident types × 2 workforce types (mine, contractors) JSONB fields: lti_days, hours_worked, ltifr (mine, contractors)
Domain All counts >= 0 (integer) lti_days >= 0 (integer), hours_worked >= 0 (number), ltifr >= 0.00 (2 decimals)
Referential Cross-validation with F.2 LTI count for LTIFR calculation Cross-validation with F.1 LTI count (must match LTIFR calculation input)
Evidence MANDATORY: Fatality (2+ attachments), LTI, High Potential, Silicosis
RECOMMENDED: Medical Treatment, Restricted Work, Property Damage
OPTIONAL: First Aid, Near Miss, Clinic Visits
MANDATORY: LTI_REGISTER, TIME_SHEET, PAYROLL_REPORT, or HR_REGISTER (1+ attachment)
Business Rules Quarter-end date, Site dimensionality, Zero-value completeness (all 10 incident types required), Evidence enforcement (MANDATORY severity) Quarter-end date, Site dimensionality, Zero-consistency (LTI count = 0 → LTI days/LTIFR = 0), Positive LTI consistency (LTI count > 0 → LTI days > 0), LTIFR calculation validation (formula, multiplier, precision)
Anomaly Fatality spike, LTI spike (>50%), High potential spike (>50%), Contractor incident proportion (>70%) Hours worked reasonableness (>2080 hrs/employee/year warning), LTIFR outlier detection (>100.00 warning)
Quarterly Completeness All 10 incident types required per quarter (zero values allowed) lti_days, hours_worked, ltifr required per quarter
YTD Aggregation N/A (dimensional totals calculated at report generation) YTD LTI Days: Cumulative sum (Q1+Q2+Q3+Q4)
YTD LTIFR: Weighted calculation (Σ LTI count × 1,000,000 / Σ hours worked)
Cross-Validation F.1 LTI count must match F.2 LTIFR calculation input F.2 LTIFR must be calculated from F.1 LTI count

Testing OHS Validation Rules

Unit Tests

package com.example.esg.validation.ohs

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

class OHSIncidentValidatorTest {

    private val validator = OHSIncidentValidator()

    @Test
    fun `F1 validation - should pass with valid incident data`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "near_miss" to mapOf("mine" to 12, "contractors" to 8),
                "first_aid" to mapOf("mine" to 5, "contractors" to 3),
                "restricted_work" to mapOf("mine" to 2, "contractors" to 1),
                "medical_treatment" to mapOf("mine" to 3, "contractors" to 2),
                "lti" to mapOf("mine" to 1, "contractors" to 0),
                "fatality" to mapOf("mine" to 0, "contractors" to 0),
                "high_potential" to mapOf("mine" to 2, "contractors" to 1),
                "property_damage" to mapOf("mine" to 1, "contractors" to 0),
                "silicosis_pneumoconiosis" to mapOf("mine" to 0, "contractors" to 0),
                "other_clinic_visits" to mapOf("mine" to 8, "contractors" to 5)
            ),
            "activity_date" to "2025-09-30", // Q3 end
            "site_id" to 123L,
            "evidence_attachments" to listOf(
                mapOf("evidence_type" to "LTI_REGISTER")
            )
        )

        val errors = validator.validateQuarterlyIncidentStats(data)

        assertThat(errors).isEmpty()
    }

    @Test
    fun `F1 validation - should fail when quarter-end date is invalid`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "near_miss" to mapOf("mine" to 12, "contractors" to 8),
                "first_aid" to mapOf("mine" to 5, "contractors" to 3),
                "restricted_work" to mapOf("mine" to 2, "contractors" to 1),
                "medical_treatment" to mapOf("mine" to 3, "contractors" to 2),
                "lti" to mapOf("mine" to 0, "contractors" to 0),
                "fatality" to mapOf("mine" to 0, "contractors" to 0),
                "high_potential" to mapOf("mine" to 2, "contractors" to 1),
                "property_damage" to mapOf("mine" to 1, "contractors" to 0),
                "silicosis_pneumoconiosis" to mapOf("mine" to 0, "contractors" to 0),
                "other_clinic_visits" to mapOf("mine" to 8, "contractors" to 5)
            ),
            "activity_date" to "2025-09-15", // Not quarter end
            "site_id" to 123L
        )

        val errors = validator.validateQuarterlyIncidentStats(data)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("must be the last day of a quarter")
    }

    @Test
    fun `F1 validation - should fail when fatality has no evidence`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "near_miss" to mapOf("mine" to 12, "contractors" to 8),
                "first_aid" to mapOf("mine" to 5, "contractors" to 3),
                "restricted_work" to mapOf("mine" to 2, "contractors" to 1),
                "medical_treatment" to mapOf("mine" to 3, "contractors" to 2),
                "lti" to mapOf("mine" to 0, "contractors" to 0),
                "fatality" to mapOf("mine" to 1, "contractors" to 0), // Fatality present
                "high_potential" to mapOf("mine" to 2, "contractors" to 1),
                "property_damage" to mapOf("mine" to 1, "contractors" to 0),
                "silicosis_pneumoconiosis" to mapOf("mine" to 0, "contractors" to 0),
                "other_clinic_visits" to mapOf("mine" to 8, "contractors" to 5)
            ),
            "activity_date" to "2025-09-30",
            "site_id" to 123L,
            "evidence_attachments" to emptyList<Map<String, Any?>>() // No evidence
        )

        val errors = validator.validateQuarterlyIncidentStats(data)

        assertThat(errors).isNotEmpty()
        assertThat(errors).anyMatch { it.contains("FATALITY_REPORT and INVESTIGATION_REPORT") }
    }

    @Test
    fun `F1 validation - should fail when site_id is missing`() {
        val data = mapOf(
            "raw_data" to mapOf(
                "near_miss" to mapOf("mine" to 12, "contractors" to 8),
                "first_aid" to mapOf("mine" to 5, "contractors" to 3),
                "restricted_work" to mapOf("mine" to 2, "contractors" to 1),
                "medical_treatment" to mapOf("mine" to 3, "contractors" to 2),
                "lti" to mapOf("mine" to 0, "contractors" to 0),
                "fatality" to mapOf("mine" to 0, "contractors" to 0),
                "high_potential" to mapOf("mine" to 2, "contractors" to 1),
                "property_damage" to mapOf("mine" to 1, "contractors" to 0),
                "silicosis_pneumoconiosis" to mapOf("mine" to 0, "contractors" to 0),
                "other_clinic_visits" to mapOf("mine" to 8, "contractors" to 5)
            ),
            "activity_date" to "2025-09-30",
            "site_id" to null // Missing site_id
        )

        val errors = validator.validateQuarterlyIncidentStats(data)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("require site_id")
    }
}

class OHSLTIPerformanceValidatorTest {

    private val validator = OHSLTIPerformanceValidator()

    @Test
    fun `F2 validation - should pass with valid LTIFR calculation`() {
        val f1Data = mapOf(
            "raw_data" to mapOf(
                "lti" to mapOf("mine" to 1, "contractors" to 0)
            )
        )

        val f2Data = mapOf(
            "raw_data" to mapOf(
                "lti_days" to mapOf("mine" to 45, "contractors" to 0),
                "hours_worked" to mapOf("mine" to 123456.0, "contractors" to 45678.0),
                "ltifr" to mapOf("mine" to 8.10, "contractors" to 0.00)
            ),
            "activity_date" to "2025-09-30",
            "site_id" to 123L
        )

        val errors = validator.validateLTIPerformance(f2Data, f1Data)

        assertThat(errors).isEmpty()
    }

    @Test
    fun `F2 validation - should fail when LTIFR calculation is incorrect`() {
        val f1Data = mapOf(
            "raw_data" to mapOf(
                "lti" to mapOf("mine" to 2, "contractors" to 0)
            )
        )

        val f2Data = mapOf(
            "raw_data" to mapOf(
                "lti_days" to mapOf("mine" to 45, "contractors" to 0),
                "hours_worked" to mapOf("mine" to 123456.0, "contractors" to 45678.0),
                "ltifr" to mapOf("mine" to 10.00, "contractors" to 0.00) // Incorrect, should be 16.20
            ),
            "activity_date" to "2025-09-30",
            "site_id" to 123L
        )

        val errors = validator.validateLTIPerformance(f2Data, f1Data)

        assertThat(errors).isNotEmpty()
        assertThat(errors).anyMatch { it.contains("LTIFR calculation failed") }
    }

    @Test
    fun `F2 validation - should fail when LTI count is 0 but LTI days is positive`() {
        val f1Data = mapOf(
            "raw_data" to mapOf(
                "lti" to mapOf("mine" to 0, "contractors" to 0) // Zero LTIs
            )
        )

        val f2Data = mapOf(
            "raw_data" to mapOf(
                "lti_days" to mapOf("mine" to 45, "contractors" to 0), // But 45 LTI days???
                "hours_worked" to mapOf("mine" to 123456.0, "contractors" to 45678.0),
                "ltifr" to mapOf("mine" to 0.00, "contractors" to 0.00)
            ),
            "activity_date" to "2025-09-30",
            "site_id" to 123L
        )

        val errors = validator.validateLTIPerformance(f2Data, f1Data)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("Zero-consistency failed")
        assertThat(errors[0]).contains("LTI count = 0 but LTI days = 45")
    }

    @Test
    fun `F2 validation - should fail when LTI count is positive but LTI days is 0`() {
        val f1Data = mapOf(
            "raw_data" to mapOf(
                "lti" to mapOf("mine" to 2, "contractors" to 0) // 2 LTIs
            )
        )

        val f2Data = mapOf(
            "raw_data" to mapOf(
                "lti_days" to mapOf("mine" to 0, "contractors" to 0), // But 0 LTI days???
                "hours_worked" to mapOf("mine" to 123456.0, "contractors" to 45678.0),
                "ltifr" to mapOf("mine" to 16.20, "contractors" to 0.00)
            ),
            "activity_date" to "2025-09-30",
            "site_id" to 123L
        )

        val errors = validator.validateLTIPerformance(f2Data, f1Data)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("Positive LTI consistency failed")
        assertThat(errors[0]).contains("LTI count = 2 but LTI days = 0")
    }
}

class OHSAnomalyDetectorTest {

    private val detector = OHSAnomalyDetector()

    @Test
    fun `anomaly detection - should warn when LTI count increases by more than 50 percent`() {
        val previousQuarter = mapOf(
            "raw_data" to mapOf(
                "lti" to mapOf("mine" to 2, "contractors" to 0)
            )
        )

        val currentQuarter = mapOf(
            "raw_data" to mapOf(
                "lti" to mapOf("mine" to 4, "contractors" to 0) // 100% increase
            )
        )

        val warnings = detector.detectIncidentSpikes(currentQuarter, previousQuarter)

        assertThat(warnings).isNotEmpty()
        assertThat(warnings).anyMatch { it.contains("LTI count increased by") && it.contains("100.0%") }
    }

    @Test
    fun `anomaly detection - should warn when fatality occurs in current quarter`() {
        val previousQuarter = mapOf(
            "raw_data" to mapOf(
                "fatality" to mapOf("mine" to 0, "contractors" to 0)
            )
        )

        val currentQuarter = mapOf(
            "raw_data" to mapOf(
                "fatality" to mapOf("mine" to 1, "contractors" to 0)
            )
        )

        val warnings = detector.detectIncidentSpikes(currentQuarter, previousQuarter)

        assertThat(warnings).isNotEmpty()
        assertThat(warnings).anyMatch { it.contains("Fatality count increased") }
    }

    @Test
    fun `anomaly detection - should warn when contractor incidents exceed 70 percent`() {
        val currentQuarter = mapOf(
            "raw_data" to mapOf(
                "near_miss" to mapOf("mine" to 2, "contractors" to 18), // 90% contractors
                "first_aid" to mapOf("mine" to 0, "contractors" to 5),
                "restricted_work" to mapOf("mine" to 0, "contractors" to 0),
                "medical_treatment" to mapOf("mine" to 0, "contractors" to 0),
                "lti" to mapOf("mine" to 0, "contractors" to 2),
                "fatality" to mapOf("mine" to 0, "contractors" to 0),
                "high_potential" to mapOf("mine" to 0, "contractors" to 0),
                "property_damage" to mapOf("mine" to 0, "contractors" to 0),
                "silicosis_pneumoconiosis" to mapOf("mine" to 0, "contractors" to 0),
                "other_clinic_visits" to mapOf("mine" to 0, "contractors" to 0)
            )
        )

        val warnings = detector.detectIncidentSpikes(currentQuarter, null)

        assertThat(warnings).isNotEmpty()
        assertThat(warnings).anyMatch { it.contains("Contractor incidents represent") && it.contains("contractor safety management") }
    }
}

Integration Tests

package com.example.esg.validation.ohs

import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test

@QuarkusTest
class OHSValidationIntegrationTest {

    @Inject
    lateinit var validationService: ValidationService

    @Test
    fun `end-to-end F1 validation - should pass through all 6 validation stages`() {
        val submission = createF1Submission(
            nearMissMine = 12,
            ltiMine = 1,
            fatalityMine = 0,
            evidenceTypes = listOf("LTI_REGISTER", "ACCIDENT_REPORT")
        )

        val result = validationService.validate(submission)

        assertThat(result.isValid).isTrue()
        assertThat(result.errors).isEmpty()
        assertThat(result.warnings).isEmpty()
    }

    @Test
    fun `end-to-end F2 validation - should detect LTIFR calculation error`() {
        val f1Submission = createF1Submission(ltiMine = 2, ltiContractors = 0)
        val f2Submission = createF2Submission(
            ltiDaysMine = 45,
            hoursWorkedMine = 123456.0,
            ltifrMine = 10.00, // Incorrect, should be 16.20
            f1SubmissionId = f1Submission.id
        )

        val result = validationService.validate(f2Submission)

        assertThat(result.isValid).isFalse()
        assertThat(result.errors).anyMatch { it.message.contains("LTIFR calculation failed") }
    }

    @Test
    fun `quarterly completeness check - should flag missing OHS incident types`() {
        // Submit only 5 out of 10 incident types
        val submission = createF1Submission(
            includeAllIncidentTypes = false
        )

        val result = validationService.validate(submission)

        assertThat(result.isValid).isFalse()
        assertThat(result.errors).anyMatch { it.message.contains("Incident type") && it.message.contains("is required") }
    }
}

Environmental Metrics Validation Rules

Purpose

This section documents validation rules specific to Environmental metrics (GRI 301 Materials, GRI 302 Energy, GRI 303 Water, GRI 305 Emissions, GRI 306 Waste, GRI 307 Environmental Compliance). These rules ensure data quality for environmental reporting, regulatory compliance, and confidentiality protection of environmental incident data.

Key Challenges: - Cross-metric validation (production denominators for intensity calculations across G.2, G.3, G.4, G.6) - Water balance validation (abstracted + recycled ≈ consumed + losses) - Generator efficiency validation (diesel fuel vs electricity output with typical ranges) - Tailings vs milled ore cross-validation (G.7 tailings ≈ G.1 milled ore) - Quality band calculation for water quality (G.4.4) and air emissions (G.5) - TSF critical parameter thresholds (freeboard ≥1.5 m regulatory minimum) - Hazardous materials evidence requirements (cyanide, hazardous waste) - Environmental incidents confidentiality and severity-based evidence - Dimensional data validation (sampling points for water quality, monitoring areas for air emissions) - Monthly vs quarterly collection frequencies with date validation


G.1 Production Metrics Validation

Collection Frequency: Monthly (last day of month)

Template ID: ENV_PRODUCTION_MONTHLY_V1

Metrics: - ENV_PRODUCTION_CRUSHED_ORE (site-level, tonnes) - ENV_PRODUCTION_MILLED_ORE (site-level, tonnes) - ENV_PRODUCTION_GOLD (site-level, troy ounces)

Data Structure: Simple metric submission (single value per metric)

Schema Validation Rules

{
  "metric_template_id": "ENV_PRODUCTION_MONTHLY_V1",
  "validation_rules": [
    {
      "type": "schema",
      "field": "value",
      "rule": "required",
      "error_message": "Production value is required"
    },
    {
      "type": "schema",
      "field": "value",
      "rule": "type:decimal",
      "error_message": "Production value must be a decimal number"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "required",
      "error_message": "Activity date is required"
    },
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "type:date",
      "error_message": "Activity date must be ISO 8601 date"
    }
  ]
}

Domain Validation Rules

{
  "metric_template_id": "ENV_PRODUCTION_MONTHLY_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Production value cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:2",
      "error_message": "Production value must have at most 2 decimal places"
    }
  ]
}

Business Rule Validation

Rule 1: Milled ore ≤ Crushed ore × 1.1

Milled ore should not significantly exceed crushed ore production (typical process allows up to 10% variance due to timing differences and moisture content).

Rule 2: Month-end date validation

Activity date must be the last day of the month for monthly production metrics.

Rule 3: Site dimensionality

All production metrics require site_id (site-level collection).

Kotlin Implementation

package com.example.esg.validation.environmental

import java.math.BigDecimal
import java.time.LocalDate
import java.time.YearMonth

class ProductionValidator {

    companion object {
        const val MILLED_ORE_CRUSHED_ORE_MAX_RATIO = 1.1
    }

    fun validateMilledOreCrushedOreRatio(
        milledOre: BigDecimal,
        crushedOre: BigDecimal,
        reportingPeriod: YearMonth
    ): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 1: Milled ore ≤ Crushed ore × 1.1
        if (crushedOre > BigDecimal.ZERO) {
            val ratio = milledOre.divide(crushedOre, 4, java.math.RoundingMode.HALF_UP)
            if (ratio > BigDecimal.valueOf(MILLED_ORE_CRUSHED_ORE_MAX_RATIO)) {
                errors.add(
                    "Milled ore (${milledOre.toPlainString()} t) exceeds crushed ore " +
                    "(${crushedOre.toPlainString()} t) by more than 10%. Ratio: ${ratio.toPlainString()}. " +
                    "Check for data entry errors or timing differences."
                )
            }
        }

        return errors
    }

    fun validateMonthEndDate(activityDate: LocalDate, reportingPeriod: YearMonth): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 2: Month-end date validation
        val lastDayOfMonth = reportingPeriod.atEndOfMonth()
        if (activityDate != lastDayOfMonth) {
            errors.add(
                "Activity date must be last day of month (${lastDayOfMonth}), got ${activityDate}"
            )
        }

        return errors
    }

    fun validateSiteDimensionality(siteId: Long?): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 3: Site dimensionality validation
        if (siteId == null) {
            errors.add("Production metrics require site_id (site-level collection)")
        }

        return errors
    }
}

G.2 Materials Consumption Validation

Collection Frequency: Monthly (last day of month)

Template ID: ENV_MATERIALS_MONTHLY_V1

Metrics: - ENV_MATERIAL_ACTIVATED_CARBON (kg) - ENV_MATERIAL_CYANIDE (kg, MANDATORY evidence) - ENV_MATERIAL_HYDROGEN_PEROXIDE (L) - ENV_MATERIAL_CAUSTIC_SODA (kg) - ENV_MATERIAL_BLASTING_EMULSION (kg) - ENV_MATERIAL_MILL_BALLS (kg)

Intensity Metrics (Calculated): - ENV_MATERIAL_INTENSITY_* (kg/tonne or L/tonne, calculated from Total Material / Total Crushed Ore)

Domain Validation Rules

{
  "metric_template_id": "ENV_MATERIALS_MONTHLY_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Materials consumption cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:2",
      "error_message": "Materials consumption must have at most 2 decimal places"
    }
  ]
}

Evidence Validation Rules

{
  "metric_id": "ENV_MATERIAL_CYANIDE",
  "validation_rules": [
    {
      "type": "evidence",
      "rule": "required_types",
      "required_types": ["INVOICE", "STOCK_CARD", "MATERIALS_REGISTER", "HAZMAT_REGISTER"],
      "error_message": "Cyanide consumption requires MANDATORY evidence (INVOICE, STOCK_CARD, MATERIALS_REGISTER, or HAZMAT_REGISTER)"
    }
  ]
}

Business Rule Validation

Rule 1: Intensity metrics require non-zero crushed ore

Intensity metrics (materials per tonne crushed ore) cannot be calculated if crushed ore production is zero.

Rule 2: Calculation formula validation

Intensity = Total Material Consumption / Total Crushed Ore

Kotlin Implementation

package com.example.esg.validation.environmental

import java.math.BigDecimal
import java.math.RoundingMode

class MaterialsIntensityValidator {

    companion object {
        const val INTENSITY_PRECISION = 3 // 3 decimal places for intensity metrics
    }

    fun calculateIntensity(
        totalMaterial: BigDecimal,
        totalCrushedOre: BigDecimal,
        materialName: String
    ): ValidationResult<BigDecimal> {
        val errors = mutableListOf<String>()

        // Business Rule 1: Require non-zero crushed ore
        if (totalCrushedOre <= BigDecimal.ZERO) {
            errors.add(
                "Cannot calculate ${materialName} intensity: crushed ore production is zero or negative " +
                "(${totalCrushedOre.toPlainString()} t)"
            )
            return ValidationResult(isValid = false, errors = errors, value = null)
        }

        // Business Rule 2: Calculate intensity
        val intensity = totalMaterial.divide(totalCrushedOre, INTENSITY_PRECISION, RoundingMode.HALF_UP)

        return ValidationResult(isValid = true, errors = emptyList(), value = intensity)
    }
}

data class ValidationResult<T>(
    val isValid: Boolean,
    val errors: List<String>,
    val value: T?
)

G.3 Energy and Fuel Metrics Validation

Collection Frequency: Monthly (last day of month)

Template ID: ENV_ENERGY_MONTHLY_V1

Electricity Metrics: - ENV_ENERGY_GRID_ELECTRICITY (kWh) - ENV_ENERGY_GENERATOR_ELECTRICITY (kWh)

Fuel Metrics: - ENV_FUEL_DIESEL_OTHER (L) - ENV_FUEL_DIESEL_MINING_DRILLING (L) - ENV_FUEL_DIESEL_GENERATORS (L) - ENV_FUEL_PETROL_OTHER (L)

Calculated Metrics: - ENV_ENERGY_GENERATOR_CONSUMPTION_RATE (L/kWh, typical range 0.2-0.5 L/kWh) - ENV_ENERGY_SPECIFIC_* (kWh/tonne or L/tonne)

Domain Validation Rules

{
  "metric_template_id": "ENV_ENERGY_MONTHLY_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Energy/fuel consumption cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:2",
      "error_message": "Energy/fuel consumption must have at most 2 decimal places"
    }
  ]
}

Business Rule Validation

Rule 1: Generator electricity and diesel consistency

If generator electricity > 0, then diesel for generators must > 0 (and vice versa).

Rule 2: Generator consumption rate typical range

Generator consumption rate should be 0.2-0.5 L/kWh (typical diesel generators). Values outside this range flag measurement errors.

Rule 3: Calculation formulas

  • Generator Consumption Rate = Diesel for Generators (L) / Generator Electricity (kWh)
  • Specific Electricity = (Grid Electricity + Generator Electricity) / Crushed Ore
  • Specific Diesel = (Diesel Other + Diesel Mining + Diesel Generators) / Crushed Ore
  • Specific Petrol = Petrol Other / Crushed Ore

Kotlin Implementation

package com.example.esg.validation.environmental

import java.math.BigDecimal
import java.math.RoundingMode

class EnergyValidator {

    companion object {
        const val GENERATOR_CONSUMPTION_RATE_MIN = 0.2 // L/kWh
        const val GENERATOR_CONSUMPTION_RATE_MAX = 0.5 // L/kWh
        const val CALCULATION_PRECISION = 3 // 3 decimal places for calculated metrics
    }

    fun validateGeneratorConsistency(
        generatorElectricity: BigDecimal,
        dieselForGenerators: BigDecimal
    ): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 1: Generator electricity and diesel consistency
        if (generatorElectricity > BigDecimal.ZERO && dieselForGenerators <= BigDecimal.ZERO) {
            errors.add(
                "Generator electricity is ${generatorElectricity.toPlainString()} kWh, " +
                "but diesel for generators is ${dieselForGenerators.toPlainString()} L. " +
                "Both must be greater than zero."
            )
        }

        if (dieselForGenerators > BigDecimal.ZERO && generatorElectricity <= BigDecimal.ZERO) {
            errors.add(
                "Diesel for generators is ${dieselForGenerators.toPlainString()} L, " +
                "but generator electricity is ${generatorElectricity.toPlainString()} kWh. " +
                "Both must be greater than zero."
            )
        }

        return errors
    }

    fun calculateGeneratorConsumptionRate(
        dieselForGenerators: BigDecimal,
        generatorElectricity: BigDecimal
    ): ValidationResult<BigDecimal> {
        val errors = mutableListOf<String>()
        val warnings = mutableListOf<String>()

        // Check both values are positive
        if (dieselForGenerators <= BigDecimal.ZERO || generatorElectricity <= BigDecimal.ZERO) {
            errors.add(
                "Cannot calculate generator consumption rate: diesel (${dieselForGenerators.toPlainString()} L) " +
                "and electricity (${generatorElectricity.toPlainString()} kWh) must both be greater than zero"
            )
            return ValidationResult(isValid = false, errors = errors, value = null, warnings = emptyList())
        }

        // Business Rule 3: Calculate consumption rate
        val rate = dieselForGenerators.divide(generatorElectricity, CALCULATION_PRECISION, RoundingMode.HALF_UP)

        // Business Rule 2: Validate typical range
        val rateDouble = rate.toDouble()
        if (rateDouble < GENERATOR_CONSUMPTION_RATE_MIN || rateDouble > GENERATOR_CONSUMPTION_RATE_MAX) {
            warnings.add(
                "Generator consumption rate (${rate.toPlainString()} L/kWh) is outside typical range " +
                "(${GENERATOR_CONSUMPTION_RATE_MIN}-${GENERATOR_CONSUMPTION_RATE_MAX} L/kWh). " +
                "Check for measurement errors or inefficient generators."
            )
        }

        return ValidationResult(
            isValid = true,
            errors = emptyList(),
            value = rate,
            warnings = warnings
        )
    }

    fun calculateSpecificElectricity(
        gridElectricity: BigDecimal,
        generatorElectricity: BigDecimal,
        crushedOre: BigDecimal
    ): ValidationResult<BigDecimal> {
        val errors = mutableListOf<String>()

        if (crushedOre <= BigDecimal.ZERO) {
            errors.add(
                "Cannot calculate specific electricity: crushed ore production is zero or negative " +
                "(${crushedOre.toPlainString()} t)"
            )
            return ValidationResult(isValid = false, errors = errors, value = null, warnings = emptyList())
        }

        val totalElectricity = gridElectricity + generatorElectricity
        val specificElectricity = totalElectricity.divide(crushedOre, CALCULATION_PRECISION, RoundingMode.HALF_UP)

        return ValidationResult(
            isValid = true,
            errors = emptyList(),
            value = specificElectricity,
            warnings = emptyList()
        )
    }
}

data class ValidationResult<T>(
    val isValid: Boolean,
    val errors: List<String>,
    val value: T?,
    val warnings: List<String> = emptyList()
)

G.4 Water Consumption and Quality Validation

Collection Frequency: - Monthly (water volumes, last day of month) - Quarterly (water quality monitoring, quarter-end dates)

Template ID (Volumes): ENV_WATER_MONTHLY_V1 Template ID (Quality): ENV_WATER_QUALITY_QUARTERLY_V1

Water Volume Metrics: - ENV_WATER_ABSTRACTION_FRESH_GROUNDWATER (m³) - ENV_WATER_ABSTRACTION_FRESH_SURFACE_WATER (m³) - ENV_WATER_ABSTRACTION_LOW_QUALITY_GROUNDWATER (m³) - ENV_WATER_RECYCLED_TSF_RETURN (m³) - ENV_WATER_RECYCLED_OTHER_STREAMS (m³) - ENV_WATER_CONSUMED_PROCESSING_PLANT (m³) - ENV_WATER_CONSUMED_MINING_OPERATIONS (m³) - ENV_WATER_CONSUMED_POTABLE (m³) - ENV_WATER_SPECIFIC_CONSUMPTION (m³/tonne, calculated)

Water Quality Metrics (Dimensional, by sampling point): - ENV_WATER_QUALITY_PH (0-14 scale) - ENV_WATER_QUALITY_TURBIDITY (NTU) - ENV_WATER_QUALITY_SUSPENDED_SOLIDS (mg/L) - ENV_WATER_QUALITY_CYANIDE (mg/L, MANDATORY evidence) - ENV_WATER_QUALITY_HEAVY_METALS (mg/L, MANDATORY evidence) - ENV_WATER_QUALITY_BAND (Green/Amber/Red, calculated) - ENV_WATER_QUALITY_NON_COMPLIANCE (text, list of non-compliant parameters)

Domain Validation Rules

{
  "metric_template_id": "ENV_WATER_MONTHLY_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Water volume cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:2",
      "error_message": "Water volume must have at most 2 decimal places"
    }
  ]
}
{
  "metric_id": "ENV_WATER_QUALITY_PH",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "pH must be between 0 and 14"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "max:14",
      "error_message": "pH must be between 0 and 14"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:1",
      "error_message": "pH must have at most 1 decimal place"
    }
  ]
}
{
  "metric_id": "ENV_WATER_QUALITY_CYANIDE",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Cyanide concentration cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:3",
      "error_message": "Cyanide concentration must have at most 3 decimal places"
    }
  ]
}
{
  "metric_id": "ENV_WATER_QUALITY_BAND",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "enum:Green,Amber,Red",
      "error_message": "Quality band must be Green, Amber, or Red"
    }
  ]
}

Evidence Validation Rules

{
  "metric_id": "ENV_WATER_QUALITY_CYANIDE",
  "validation_rules": [
    {
      "type": "evidence",
      "rule": "required_types",
      "required_types": ["LAB_TEST_REPORT"],
      "error_message": "Cyanide measurement requires MANDATORY evidence (LAB_TEST_REPORT from accredited laboratory)"
    }
  ]
}
{
  "metric_id": "ENV_WATER_QUALITY_HEAVY_METALS",
  "validation_rules": [
    {
      "type": "evidence",
      "rule": "required_types",
      "required_types": ["LAB_TEST_REPORT"],
      "error_message": "Heavy metals measurement requires MANDATORY evidence (LAB_TEST_REPORT from accredited laboratory)"
    }
  ]
}

Business Rule Validation

Rule 1: Water balance check

Abstracted + Recycled ≈ Consumed + Losses

Where: - Abstracted = Fresh Groundwater + Fresh Surface Water + Low Quality Groundwater - Recycled = TSF Return Water + Other Recycle Streams - Consumed = Processing Plant + Mining Operations + Potable Water - Losses = Evaporation, seepage, tailings moisture (not directly measured)

Allow up to 20% variance to account for measurement errors and unaccounted losses.

Rule 2: Quality band calculation

Green (Compliant):
- All parameters within environmental limits
- No exceedances

Amber (Minor Exceedance):
- 1-2 parameters exceed limits by ≤20%
- No critical exceedances

Red (Major Exceedance):
- ≥3 parameters exceed limits, OR
- Any parameter exceeds limits by >20%
- Critical environmental concern

Rule 3: Quarter-end date validation

Water quality monitoring activity dates must be quarter-end dates (Mar 31, Jun 30, Sep 30, Dec 31).

Kotlin Implementation

package com.example.esg.validation.environmental

import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDate

class WaterValidator {

    companion object {
        const val WATER_BALANCE_VARIANCE_THRESHOLD = 0.20 // 20% variance allowed
    }

    fun validateWaterBalance(
        freshGroundwater: BigDecimal,
        freshSurfaceWater: BigDecimal,
        lowQualityGroundwater: BigDecimal,
        tsfReturnWater: BigDecimal,
        otherRecycleStreams: BigDecimal,
        processingPlantConsumption: BigDecimal,
        miningOperationsConsumption: BigDecimal,
        potableWaterConsumption: BigDecimal
    ): List<String> {
        val errors = mutableListOf<String>()

        // Calculate totals
        val totalAbstracted = freshGroundwater + freshSurfaceWater + lowQualityGroundwater
        val totalRecycled = tsfReturnWater + otherRecycleStreams
        val totalConsumed = processingPlantConsumption + miningOperationsConsumption + potableWaterConsumption

        val totalInput = totalAbstracted + totalRecycled
        val totalOutput = totalConsumed

        // Business Rule 1: Water balance check (allow 20% variance)
        if (totalInput > BigDecimal.ZERO) {
            val variance = (totalInput - totalOutput).divide(totalInput, 4, RoundingMode.HALF_UP).abs()
            if (variance > BigDecimal.valueOf(WATER_BALANCE_VARIANCE_THRESHOLD)) {
                errors.add(
                    "Water balance variance exceeds 20%. " +
                    "Total input (abstracted + recycled) = ${totalInput.toPlainString()} m³, " +
                    "Total consumed = ${totalOutput.toPlainString()} m³, " +
                    "Variance = ${variance.multiply(BigDecimal(100)).toPlainString()}%. " +
                    "Check for measurement errors or unaccounted losses."
                )
            }
        }

        return errors
    }

    fun calculateQualityBand(
        parameters: Map<String, WaterQualityParameter>,
        limits: Map<String, BigDecimal>
    ): QualityBandResult {
        val exceedances = mutableListOf<String>()
        var exceedanceCount = 0
        var majorExceedance = false

        for ((paramName, param) in parameters) {
            val limit = limits[paramName]
            if (limit != null && param.value > limit) {
                val exceedancePercent = ((param.value - limit).divide(limit, 4, RoundingMode.HALF_UP))
                    .multiply(BigDecimal(100))

                exceedances.add(paramName)
                exceedanceCount++

                if (exceedancePercent > BigDecimal.valueOf(20.0)) {
                    majorExceedance = true
                }
            }
        }

        // Business Rule 2: Quality band logic
        val band = when {
            exceedanceCount == 0 -> QualityBand.GREEN
            exceedanceCount <= 2 && !majorExceedance -> QualityBand.AMBER
            else -> QualityBand.RED
        }

        return QualityBandResult(
            band = band,
            nonCompliantParameters = exceedances.joinToString(", ")
        )
    }

    fun validateQuarterEndDate(activityDate: LocalDate): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 3: Quarter-end date validation
        val validQuarterEndDates = listOf(
            activityDate.year to listOf(3 to 31, 6 to 30, 9 to 30, 12 to 31)
        ).flatMap { (year, quarters) ->
            quarters.map { (month, day) -> LocalDate.of(year, month, day) }
        }

        if (activityDate !in validQuarterEndDates) {
            errors.add(
                "Water quality monitoring activity date must be a quarter-end date " +
                "(Mar 31, Jun 30, Sep 30, or Dec 31), got ${activityDate}"
            )
        }

        return errors
    }
}

data class WaterQualityParameter(
    val value: BigDecimal,
    val unit: String
)

enum class QualityBand {
    GREEN, AMBER, RED
}

data class QualityBandResult(
    val band: QualityBand,
    val nonCompliantParameters: String
)

G.5 Air Emissions Validation

Collection Frequency: Quarterly (quarter-end dates)

Template ID: ENV_AIR_EMISSIONS_QUARTERLY_V1

Metrics (Dimensional, by monitoring area): - ENV_AIR_PM10 (μg/m³) - ENV_AIR_SO2 (μg/m³) - ENV_AIR_NO2 (μg/m³) - ENV_AIR_CO (μg/m³) - ENV_AIR_QUALITY_BAND (Green/Amber/Red, calculated)

Domain Validation Rules

{
  "metric_template_id": "ENV_AIR_EMISSIONS_QUARTERLY_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Air pollutant concentration cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:1",
      "error_message": "Air pollutant concentration must have at most 1 decimal place"
    }
  ]
}
{
  "metric_id": "ENV_AIR_QUALITY_BAND",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "enum:Green,Amber,Red",
      "error_message": "Air quality band must be Green, Amber, or Red"
    }
  ]
}

Business Rule Validation

Rule 1: Air quality band calculation

Green (Compliant):
- All pollutants within environmental limits
- No exceedances

Amber (Minor Exceedance):
- 1 pollutant exceeds limits by ≤20%
- Minor environmental concern

Red (Major Exceedance):
- ≥2 pollutants exceed limits, OR
- Any pollutant exceeds limits by >20%
- Major environmental concern

Rule 2: Quarter-end date validation

Air emissions monitoring activity dates must be quarter-end dates (Mar 31, Jun 30, Sep 30, Dec 31).

Kotlin Implementation

package com.example.esg.validation.environmental

import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDate

class AirEmissionsValidator {

    fun calculateAirQualityBand(
        pollutants: Map<String, BigDecimal>,
        limits: Map<String, BigDecimal>
    ): AirQualityBandResult {
        var exceedanceCount = 0
        var majorExceedance = false

        for ((pollutantName, concentration) in pollutants) {
            val limit = limits[pollutantName]
            if (limit != null && concentration > limit) {
                val exceedancePercent = ((concentration - limit).divide(limit, 4, RoundingMode.HALF_UP))
                    .multiply(BigDecimal(100))

                exceedanceCount++

                if (exceedancePercent > BigDecimal.valueOf(20.0)) {
                    majorExceedance = true
                }
            }
        }

        // Business Rule 1: Air quality band logic
        val band = when {
            exceedanceCount == 0 -> AirQualityBand.GREEN
            exceedanceCount == 1 && !majorExceedance -> AirQualityBand.AMBER
            else -> AirQualityBand.RED
        }

        return AirQualityBandResult(band = band)
    }

    fun validateQuarterEndDate(activityDate: LocalDate): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 2: Quarter-end date validation
        val month = activityDate.monthValue
        val dayOfMonth = activityDate.dayOfMonth

        val isQuarterEnd = when (month) {
            3 -> dayOfMonth == 31
            6 -> dayOfMonth == 30
            9 -> dayOfMonth == 30
            12 -> dayOfMonth == 31
            else -> false
        }

        if (!isQuarterEnd) {
            errors.add(
                "Air emissions monitoring activity date must be a quarter-end date " +
                "(Mar 31, Jun 30, Sep 30, or Dec 31), got ${activityDate}"
            )
        }

        return errors
    }
}

enum class AirQualityBand {
    GREEN, AMBER, RED
}

data class AirQualityBandResult(
    val band: AirQualityBand
)

G.6-G.7 Waste Generation Validation

Collection Frequency: Monthly (last day of month)

Template ID: ENV_WASTE_MONTHLY_V1

Non-Mineralised Waste Metrics: - ENV_WASTE_NON_MIN_GENERAL (tonnes) - ENV_WASTE_NON_MIN_RECYCLABLE_METAL (tonnes) - ENV_WASTE_NON_MIN_RECYCLABLE_PLASTIC (tonnes) - ENV_WASTE_NON_MIN_HAZARDOUS (tonnes, MANDATORY evidence) - ENV_WASTE_NON_MIN_CONTAMINATED_SOIL (tonnes) - ENV_WASTE_NON_MIN_SPECIFIC (kg/tonne, calculated)

Mineralised Waste Metrics: - ENV_WASTE_MIN_WASTE_ROCK (tonnes) - ENV_WASTE_MIN_TAILINGS (tonnes)

Domain Validation Rules

{
  "metric_template_id": "ENV_WASTE_MONTHLY_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Waste generation cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:2",
      "error_message": "Waste generation must have at most 2 decimal places"
    }
  ]
}

Evidence Validation Rules

{
  "metric_id": "ENV_WASTE_NON_MIN_HAZARDOUS",
  "validation_rules": [
    {
      "type": "evidence",
      "rule": "required_types",
      "required_types": ["WASTE_MANIFEST", "HAZMAT_DISPOSAL_CERTIFICATE"],
      "error_message": "Hazardous waste requires MANDATORY evidence (WASTE_MANIFEST or HAZMAT_DISPOSAL_CERTIFICATE)"
    }
  ]
}

Business Rule Validation

Rule 1: Tailings ≤ Milled ore × 1.1

Tailings generation should approximate milled ore production (accounting for water content, up to 10% variance allowed).

Rule 2: Specific waste calculation

Specific Waste = Total Non-Mineralised Waste / Total Crushed Ore

Requires non-zero crushed ore production.

Kotlin Implementation

package com.example.esg.validation.environmental

import java.math.BigDecimal
import java.math.RoundingMode

class WasteValidator {

    companion object {
        const val TAILINGS_MILLED_ORE_MAX_RATIO = 1.1
        const val SPECIFIC_WASTE_PRECISION = 3
    }

    fun validateTailingsMilledOreRatio(
        tailings: BigDecimal,
        milledOre: BigDecimal
    ): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 1: Tailings ≤ Milled ore × 1.1
        if (milledOre > BigDecimal.ZERO) {
            val ratio = tailings.divide(milledOre, 4, RoundingMode.HALF_UP)
            if (ratio > BigDecimal.valueOf(TAILINGS_MILLED_ORE_MAX_RATIO)) {
                errors.add(
                    "Tailings (${tailings.toPlainString()} t) exceeds milled ore " +
                    "(${milledOre.toPlainString()} t) by more than 10%. Ratio: ${ratio.toPlainString()}. " +
                    "Check for data entry errors or process water content."
                )
            }
        }

        return errors
    }

    fun calculateSpecificWaste(
        totalNonMineralisedWaste: BigDecimal,
        crushedOre: BigDecimal
    ): ValidationResult<BigDecimal> {
        val errors = mutableListOf<String>()

        // Business Rule 2: Require non-zero crushed ore
        if (crushedOre <= BigDecimal.ZERO) {
            errors.add(
                "Cannot calculate specific waste: crushed ore production is zero or negative " +
                "(${crushedOre.toPlainString()} t)"
            )
            return ValidationResult(isValid = false, errors = errors, value = null)
        }

        // Convert tonnes to kg and divide by crushed ore
        val wasteInKg = totalNonMineralisedWaste.multiply(BigDecimal(1000))
        val specificWaste = wasteInKg.divide(crushedOre, SPECIFIC_WASTE_PRECISION, RoundingMode.HALF_UP)

        return ValidationResult(isValid = true, errors = emptyList(), value = specificWaste)
    }
}

G.8 TSF Management Validation

Collection Frequency: Monthly (last day of month)

Template ID: ENV_TSF_MONTHLY_V1

Metrics: - ENV_TSF_SLURRY_DENSITY (g/cm³, typical range 1.0-2.0) - ENV_TSF_FREEBOARD (m, CRITICAL: ≥1.5 m regulatory minimum) - ENV_TSF_SURFACE_AREA (ha) - ENV_TSF_RATE_OF_RISE (m/month, typical range 0.2-0.5)

Domain Validation Rules

{
  "metric_id": "ENV_TSF_SLURRY_DENSITY",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:1.0",
      "error_message": "Slurry density cannot be less than 1.0 g/cm³"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "max:2.0",
      "error_message": "Slurry density cannot exceed 2.0 g/cm³"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:2",
      "error_message": "Slurry density must have at most 2 decimal places"
    }
  ]
}
{
  "metric_id": "ENV_TSF_FREEBOARD",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "min:0",
      "error_message": "Freeboard cannot be negative"
    },
    {
      "type": "domain",
      "field": "value",
      "rule": "precision:2",
      "error_message": "Freeboard must have at most 2 decimal places"
    }
  ]
}

Evidence Validation Rules

{
  "metric_id": "ENV_TSF_FREEBOARD",
  "validation_rules": [
    {
      "type": "evidence",
      "rule": "conditional_required",
      "condition": "value < 1.5",
      "required_types": ["TSF_INSPECTION_REPORT", "SURVEY_REPORT"],
      "error_message": "Freeboard below 1.5 m requires MANDATORY evidence (TSF_INSPECTION_REPORT or SURVEY_REPORT)"
    }
  ]
}

Business Rule Validation

Rule 1: Freeboard critical threshold

Freeboard < 1.5 m triggers CRITICAL alert (regulatory minimum) and requires MANDATORY evidence.

Rule 2: Slurry density low water content warning

Slurry density < 1.5 g/cm³ flags high water content (typical acceptable range 1.3-1.8 g/cm³).

Rule 3: Rate of rise excessive construction warning

Rate of rise > 1.0 m/month flags excessive embankment construction rate (typical 0.2-0.5 m/month).

Kotlin Implementation

package com.example.esg.validation.environmental

import java.math.BigDecimal

class TSFValidator {

    companion object {
        const val FREEBOARD_CRITICAL_THRESHOLD = 1.5 // metres, regulatory minimum
        const val SLURRY_DENSITY_LOW_THRESHOLD = 1.5 // g/cm³, high water content
        const val RATE_OF_RISE_EXCESSIVE_THRESHOLD = 1.0 // m/month, excessive construction
    }

    fun validateFreeboard(freeboard: BigDecimal): ValidationResult<Unit> {
        val errors = mutableListOf<String>()
        val warnings = mutableListOf<String>()

        // Business Rule 1: Freeboard critical threshold
        if (freeboard < BigDecimal.valueOf(FREEBOARD_CRITICAL_THRESHOLD)) {
            errors.add(
                "CRITICAL: Freeboard (${freeboard.toPlainString()} m) is below regulatory minimum " +
                "(${FREEBOARD_CRITICAL_THRESHOLD} m). MANDATORY evidence required (TSF_INSPECTION_REPORT or SURVEY_REPORT). " +
                "Immediate action required to ensure TSF safety."
            )
        }

        return ValidationResult(
            isValid = freeboard >= BigDecimal.valueOf(FREEBOARD_CRITICAL_THRESHOLD),
            errors = errors,
            value = Unit,
            warnings = warnings
        )
    }

    fun validateSlurryDensity(slurryDensity: BigDecimal): ValidationResult<Unit> {
        val warnings = mutableListOf<String>()

        // Business Rule 2: Slurry density low water content warning
        if (slurryDensity < BigDecimal.valueOf(SLURRY_DENSITY_LOW_THRESHOLD)) {
            warnings.add(
                "WARNING: Slurry density (${slurryDensity.toPlainString()} g/cm³) is below typical range " +
                "(1.3-1.8 g/cm³), indicating high water content. Consider increasing solids content."
            )
        }

        return ValidationResult(
            isValid = true,
            errors = emptyList(),
            value = Unit,
            warnings = warnings
        )
    }

    fun validateRateOfRise(rateOfRise: BigDecimal): ValidationResult<Unit> {
        val warnings = mutableListOf<String>()

        // Business Rule 3: Rate of rise excessive construction warning
        if (rateOfRise > BigDecimal.valueOf(RATE_OF_RISE_EXCESSIVE_THRESHOLD)) {
            warnings.add(
                "WARNING: Rate of rise (${rateOfRise.toPlainString()} m/month) exceeds typical range " +
                "(0.2-0.5 m/month), indicating rapid embankment construction. Verify construction schedule and stability."
            )
        }

        return ValidationResult(
            isValid = true,
            errors = emptyList(),
            value = Unit,
            warnings = warnings
        )
    }
}

G.9-G.10 Rehabilitation and Incidents Validation

Collection Frequency: Quarterly (quarter-end dates)

Template ID (Rehabilitation): ENV_REHAB_QUARTERLY_V1 Template ID (Incidents): ENV_INCIDENT_QUARTERLY_V1

Rehabilitation Metrics: - ENV_REHAB_DATE_STARTED (date) - ENV_REHAB_ACTIVITY_DESCRIPTION (text) - ENV_REHAB_COST (currency) - ENV_REHAB_STATUS (Planned/WIP/Completed/On Hold) - ENV_REHAB_STATUS_DATE (date, MANDATORY evidence when status = Completed)

Environmental Incident Metrics (CONFIDENTIAL): - ENV_INCIDENT_NUMBER (text, unique identifier) - ENV_INCIDENT_DATE (date) - ENV_INCIDENT_DESCRIPTION (text) - ENV_INCIDENT_SEVERITY (Low/Medium/High/Critical) - ENV_INCIDENT_ACTIONS_TAKEN (text) - ENV_INCIDENT_STATUS (Open/WIP/Closed) - ENV_INCIDENT_CLOSURE_DATE (date, MANDATORY evidence for High/Critical when closed)

Domain Validation Rules

{
  "metric_id": "ENV_REHAB_STATUS",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "enum:Planned,WIP,Completed,On Hold",
      "error_message": "Rehabilitation status must be Planned, WIP, Completed, or On Hold"
    }
  ]
}
{
  "metric_id": "ENV_INCIDENT_SEVERITY",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "enum:Low,Medium,High,Critical",
      "error_message": "Incident severity must be Low, Medium, High, or Critical"
    }
  ]
}
{
  "metric_id": "ENV_INCIDENT_STATUS",
  "validation_rules": [
    {
      "type": "domain",
      "field": "value",
      "rule": "enum:Open,WIP,Closed",
      "error_message": "Incident status must be Open, WIP, or Closed"
    }
  ]
}

Evidence Validation Rules

{
  "metric_id": "ENV_REHAB_STATUS_DATE",
  "validation_rules": [
    {
      "type": "evidence",
      "rule": "conditional_required",
      "condition": "status == 'Completed'",
      "required_types": ["REHAB_COMPLETION_REPORT", "PHOTOGRAPHIC_EVIDENCE"],
      "error_message": "Completed rehabilitation requires MANDATORY evidence (REHAB_COMPLETION_REPORT or PHOTOGRAPHIC_EVIDENCE)"
    }
  ]
}
{
  "metric_id": "ENV_INCIDENT_CLOSURE_DATE",
  "validation_rules": [
    {
      "type": "evidence",
      "rule": "conditional_required",
      "condition": "status == 'Closed' && (severity == 'High' || severity == 'Critical')",
      "required_types": ["INCIDENT_REPORT", "INVESTIGATION_REPORT", "CLOSURE_REPORT"],
      "error_message": "Closed High/Critical incidents require MANDATORY evidence (INCIDENT_REPORT, INVESTIGATION_REPORT, or CLOSURE_REPORT)"
    }
  ]
}

Business Rule Validation

Rule 1: Rehabilitation status date ≥ Date started

Status date cannot be before the activity start date.

Rule 2: Incident closure date ≥ Incident date

Closure date cannot be before the incident occurred.

Rule 3: Incident closure date required when status = Closed

If incident status is Closed, closure date must be provided.

Rule 4: Confidentiality classification

All environmental incident data (G.10) is classified as CONFIDENTIAL (regulatory and reputational sensitivity).

Kotlin Implementation

package com.example.esg.validation.environmental

import java.time.LocalDate

class RehabilitationIncidentValidator {

    fun validateRehabStatusDate(
        dateStarted: LocalDate,
        statusDate: LocalDate,
        status: String
    ): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 1: Status date >= Date started
        if (statusDate < dateStarted) {
            errors.add(
                "Rehabilitation status date (${statusDate}) cannot be before date started (${dateStarted})"
            )
        }

        return errors
    }

    fun validateIncidentClosureDate(
        incidentDate: LocalDate,
        closureDate: LocalDate?,
        status: String
    ): List<String> {
        val errors = mutableListOf<String>()

        // Business Rule 3: Closure date required when status = Closed
        if (status == "Closed" && closureDate == null) {
            errors.add("Incident closure date is required when status is Closed")
        }

        // Business Rule 2: Closure date >= Incident date
        if (closureDate != null && closureDate < incidentDate) {
            errors.add(
                "Incident closure date (${closureDate}) cannot be before incident date (${incidentDate})"
            )
        }

        return errors
    }

    fun validateIncidentSeverityEvidence(
        severity: String,
        status: String,
        hasEvidence: Boolean
    ): List<String> {
        val errors = mutableListOf<String>()

        // Evidence requirement for High/Critical incidents when closed
        if ((severity == "High" || severity == "Critical") && status == "Closed" && !hasEvidence) {
            errors.add(
                "Closed ${severity} incidents require MANDATORY evidence " +
                "(INCIDENT_REPORT, INVESTIGATION_REPORT, or CLOSURE_REPORT)"
            )
        }

        return errors
    }
}

Environmental Metrics Validation Summary

The environmental validation rules ensure data quality across all G.1-G.10 sections:

Section Metric Category Key Validation Rules Evidence Requirements
G.1 Production Milled ore ≤ Crushed ore × 1.1, Month-end dates OPTIONAL
G.2 Materials Consumption Non-negative, Precision 2dp, Intensity calculations MANDATORY (cyanide)
G.3 Energy & Fuel Generator consistency, Consumption rate 0.2-0.5 L/kWh RECOMMENDED
G.4 Water Volumes Water balance ±20%, Month-end dates RECOMMENDED
G.4.4 Water Quality Quality band logic, Quarter-end dates, pH 0-14 MANDATORY (cyanide, heavy metals)
G.5 Air Emissions Air quality band logic, Quarter-end dates RECOMMENDED
G.6-G.7 Waste Tailings ≤ Milled ore × 1.1, Specific waste calculation MANDATORY (hazardous waste)
G.8 TSF Management Freeboard ≥1.5 m (CRITICAL), Slurry density 1.0-2.0 MANDATORY (freeboard <1.5 m)
G.9 Rehabilitation Status date ≥ Date started, Quarter-end dates MANDATORY (status = Completed)
G.10 Environmental Incidents (CONFIDENTIAL) Closure date ≥ Incident date, Severity-based evidence MANDATORY (High/Critical closed)

Cross-Validation Rules: - Crushed ore (G.1) → Intensity denominators (G.2, G.3, G.4, G.6) - Milled ore (G.1) → Tailings (G.7) cross-check - Generator diesel (G.3) ↔ Generator electricity (G.3) consistency - Water balance: Abstracted + Recycled (G.4) ≈ Consumed (G.4) + Losses

Quality Band Calculations: - Water quality (G.4.4): Green (compliant), Amber (1-2 params ≤20% exceedance), Red (≥3 params or >20% exceedance) - Air emissions (G.5): Green (compliant), Amber (1 pollutant ≤20% exceedance), Red (≥2 pollutants or >20% exceedance)

Critical Thresholds: - Freeboard < 1.5 m: CRITICAL alert, MANDATORY evidence, regulatory minimum - Slurry density < 1.5 g/cm³: WARNING, high water content - Rate of rise > 1.0 m/month: WARNING, excessive construction rate - Generator consumption rate outside 0.2-0.5 L/kWh: WARNING, measurement error or inefficiency

Confidentiality: - INTERNAL: G.1-G.9 (general environmental data, may be disclosed in sustainability reports) - CONFIDENTIAL: G.10 (environmental incidents, regulatory and reputational sensitivity)


Testing Environmental Validation Rules

Unit Tests

package com.example.esg.validation.environmental

import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.LocalDate
import java.time.YearMonth

class ProductionValidatorTest {

    private val validator = ProductionValidator()

    @Test
    fun `milled ore crushed ore ratio - should pass when ratio is within 10 percent`() {
        val milledOre = BigDecimal("100.00")
        val crushedOre = BigDecimal("100.00")
        val reportingPeriod = YearMonth.of(2026, 1)

        val errors = validator.validateMilledOreCrushedOreRatio(milledOre, crushedOre, reportingPeriod)

        assertThat(errors).isEmpty()
    }

    @Test
    fun `milled ore crushed ore ratio - should fail when milled ore exceeds crushed ore by more than 10 percent`() {
        val milledOre = BigDecimal("120.00")
        val crushedOre = BigDecimal("100.00")
        val reportingPeriod = YearMonth.of(2026, 1)

        val errors = validator.validateMilledOreCrushedOreRatio(milledOre, crushedOre, reportingPeriod)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("exceeds crushed ore")
    }

    @Test
    fun `month end date validation - should pass for last day of month`() {
        val activityDate = LocalDate.of(2026, 1, 31)
        val reportingPeriod = YearMonth.of(2026, 1)

        val errors = validator.validateMonthEndDate(activityDate, reportingPeriod)

        assertThat(errors).isEmpty()
    }

    @Test
    fun `month end date validation - should fail for non-last day of month`() {
        val activityDate = LocalDate.of(2026, 1, 15)
        val reportingPeriod = YearMonth.of(2026, 1)

        val errors = validator.validateMonthEndDate(activityDate, reportingPeriod)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("must be last day of month")
    }
}

class EnergyValidatorTest {

    private val validator = EnergyValidator()

    @Test
    fun `generator consumption rate - should pass for typical range`() {
        val dieselForGenerators = BigDecimal("300.00") // L
        val generatorElectricity = BigDecimal("1000.00") // kWh

        val result = validator.calculateGeneratorConsumptionRate(dieselForGenerators, generatorElectricity)

        assertThat(result.isValid).isTrue()
        assertThat(result.value).isEqualTo(BigDecimal("0.300")) // 0.3 L/kWh
        assertThat(result.warnings).isEmpty()
    }

    @Test
    fun `generator consumption rate - should warn for rate outside typical range`() {
        val dieselForGenerators = BigDecimal("600.00") // L
        val generatorElectricity = BigDecimal("1000.00") // kWh

        val result = validator.calculateGeneratorConsumptionRate(dieselForGenerators, generatorElectricity)

        assertThat(result.isValid).isTrue()
        assertThat(result.value).isEqualTo(BigDecimal("0.600")) // 0.6 L/kWh (too high)
        assertThat(result.warnings).hasSize(1)
        assertThat(result.warnings[0]).contains("outside typical range")
    }

    @Test
    fun `generator consistency - should fail when electricity is positive but diesel is zero`() {
        val generatorElectricity = BigDecimal("1000.00")
        val dieselForGenerators = BigDecimal.ZERO

        val errors = validator.validateGeneratorConsistency(generatorElectricity, dieselForGenerators)

        assertThat(errors).hasSize(1)
        assertThat(errors[0]).contains("Both must be greater than zero")
    }
}

class WaterValidatorTest {

    private val validator = WaterValidator()

    @Test
    fun `water balance - should pass when variance is within 20 percent`() {
        val freshGroundwater = BigDecimal("1000.00")
        val freshSurfaceWater = BigDecimal("500.00")
        val lowQualityGroundwater = BigDecimal.ZERO
        val tsfReturnWater = BigDecimal("200.00")
        val otherRecycleStreams = BigDecimal("100.00")
        val processingPlantConsumption = BigDecimal("1500.00")
        val miningOperationsConsumption = BigDecimal("250.00")
        val potableWaterConsumption = BigDecimal("50.00")

        val errors = validator.validateWaterBalance(
            freshGroundwater, freshSurfaceWater, lowQualityGroundwater,
            tsfReturnWater, otherRecycleStreams,
            processingPlantConsumption, miningOperationsConsumption, potableWaterConsumption
        )

        // Total input = 1800 m³, Total consumed = 1800 m³, variance = 0%
        assertThat(errors).isEmpty()
    }

    @Test
    fun `quality band calculation - should return Green when all parameters within limits`() {
        val parameters = mapOf(
            "pH" to WaterQualityParameter(BigDecimal("7.5"), "pH"),
            "turbidity" to WaterQualityParameter(BigDecimal("50.0"), "NTU"),
            "cyanide" to WaterQualityParameter(BigDecimal("0.03"), "mg/L")
        )
        val limits = mapOf(
            "pH" to BigDecimal("9.0"),
            "turbidity" to BigDecimal("100.0"),
            "cyanide" to BigDecimal("0.05")
        )

        val result = validator.calculateQualityBand(parameters, limits)

        assertThat(result.band).isEqualTo(QualityBand.GREEN)
        assertThat(result.nonCompliantParameters).isEmpty()
    }

    @Test
    fun `quality band calculation - should return Amber for 1-2 parameters with minor exceedance`() {
        val parameters = mapOf(
            "pH" to WaterQualityParameter(BigDecimal("9.5"), "pH"), // Exceeds 9.0 by 5.6%
            "turbidity" to WaterQualityParameter(BigDecimal("110.0"), "NTU"), // Exceeds 100 by 10%
            "cyanide" to WaterQualityParameter(BigDecimal("0.03"), "mg/L")
        )
        val limits = mapOf(
            "pH" to BigDecimal("9.0"),
            "turbidity" to BigDecimal("100.0"),
            "cyanide" to BigDecimal("0.05")
        )

        val result = validator.calculateQualityBand(parameters, limits)

        assertThat(result.band).isEqualTo(QualityBand.AMBER)
        assertThat(result.nonCompliantParameters).contains("pH", "turbidity")
    }

    @Test
    fun `quality band calculation - should return Red for major exceedance`() {
        val parameters = mapOf(
            "pH" to WaterQualityParameter(BigDecimal("11.0"), "pH"), // Exceeds 9.0 by 22%
            "cyanide" to WaterQualityParameter(BigDecimal("0.03"), "mg/L")
        )
        val limits = mapOf(
            "pH" to BigDecimal("9.0"),
            "cyanide" to BigDecimal("0.05")
        )

        val result = validator.calculateQualityBand(parameters, limits)

        assertThat(result.band).isEqualTo(QualityBand.RED)
        assertThat(result.nonCompliantParameters).contains("pH")
    }
}

class TSFValidatorTest {

    private val validator = TSFValidator()

    @Test
    fun `freeboard validation - should fail for freeboard below regulatory minimum`() {
        val freeboard = BigDecimal("1.2") // Below 1.5 m

        val result = validator.validateFreeboard(freeboard)

        assertThat(result.isValid).isFalse()
        assertThat(result.errors).hasSize(1)
        assertThat(result.errors[0]).contains("CRITICAL")
        assertThat(result.errors[0]).contains("below regulatory minimum")
    }

    @Test
    fun `freeboard validation - should pass for freeboard above regulatory minimum`() {
        val freeboard = BigDecimal("2.0")

        val result = validator.validateFreeboard(freeboard)

        assertThat(result.isValid).isTrue()
        assertThat(result.errors).isEmpty()
    }

    @Test
    fun `slurry density validation - should warn for low density`() {
        val slurryDensity = BigDecimal("1.3") // Below 1.5 g/cm³

        val result = validator.validateSlurryDensity(slurryDensity)

        assertThat(result.isValid).isTrue()
        assertThat(result.warnings).hasSize(1)
        assertThat(result.warnings[0]).contains("high water content")
    }

    @Test
    fun `rate of rise validation - should warn for excessive rate`() {
        val rateOfRise = BigDecimal("1.5") // Exceeds 1.0 m/month

        val result = validator.validateRateOfRise(rateOfRise)

        assertThat(result.isValid).isTrue()
        assertThat(result.warnings).hasSize(1)
        assertThat(result.warnings[0]).contains("rapid embankment construction")
    }
}

Community Investment Validation

Purpose

This section documents validation rules specific to Community Investment logs (H.1 Quarterly CSR/CSIR Activities). Unlike scalar metrics, these are transaction-based logs where each row represents a distinct activity. Validation ensures data quality, financial consistency, and reporting accuracy.

Key Challenges: - Log-based data structure (multiple rows per period) - Date range handling (single date vs start/end range) - Financial variance logic (Actual - Budget) and currency consistency - Evidence requirements for financial vs non-financial support - Completeness checks for quarterly reporting

H.1 Quarterly CSR/CSIR Activities Validation

Collection Frequency: Quarterly (activity logs submitted throughout the quarter)

Template ID: COMMUNITY_INVESTMENT_LOG_V1

Data Structure: List of activity objects.

Schema Validation Rules

{
  "metric_template_id": "COMMUNITY_INVESTMENT_LOG_V1",
  "validation_rules": [
    {
      "type": "schema",
      "field": "activity_date",
      "rule": "required",
      "error_message": "Activity date is required"
    },
    {
      "type": "schema",
      "field": "description",
      "rule": "required",
      "error_message": "Activity description is required"
    },
    {
      "type": "schema",
      "field": "pillar",
      "rule": "required",
      "error_message": "Pillar is required"
    },
    {
      "type": "schema",
      "field": "currency_code",
      "rule": "required",
      "error_message": "Currency code is required (e.g., USD, ZWG)"
    },
    {
      "type": "schema",
      "field": "actual_investment_amount",
      "rule": "required",
      "error_message": "Actual investment amount is required"
    },
    {
      "type": "schema",
      "field": "actual_investment_amount",
      "rule": "type:number",
      "error_message": "Actual investment amount must be a number"
    }
  ]
}

Domain Validation Rules

{
  "metric_template_id": "COMMUNITY_INVESTMENT_LOG_V1",
  "validation_rules": [
    {
      "type": "domain",
      "field": "pillar",
      "rule": "enum:Education & Sports,Social Empowerment,Community Development,Donations & Sponsorship,Other",
      "error_message": "Pillar must be one of: Education & Sports, Social Empowerment, Community Development, Donations & Sponsorship, Other"
    },
    {
      "type": "domain",
      "field": "actual_investment_amount",
      "rule": "min:0",
      "error_message": "Actual investment amount cannot be negative"
    },
    {
      "type": "domain",
      "field": "budget_amount",
      "rule": "min:0",
      "error_message": "Budget amount cannot be negative"
    },
    {
      "type": "domain",
      "field": "currency_code",
      "rule": "regex:^[A-Z]{3}$",
      "error_message": "Currency code must be a 3-letter ISO code"
    }
  ]
}

Business Rule Validation

Rule 1: Date Range Logic - If end_date is provided, it must be greater than or equal to activity_date (start date). - Both dates must fall within the reporting period (quarter) being submitted.

Rule 2: Variance Calculation - variance = actual_investment_amount - budget_amount (if budget is present). - If budget_amount is missing (null/0), variance is equal to actual_investment_amount (marked as unbudgeted). - If variance is submitted explicitly, it must match the calculated value (Actual - Budget).

Rule 3: Completeness - For a "Completed" quarterly submission, there must be at least one activity row OR an explicit "No Activity" flag (handled via metadata).

Rule 4: Evidence Requirements - Financial Support (> Threshold): If actual_investment_amount > [Threshold, e.g., 1000 USD], evidence (Receipt/Invoice) is MANDATORY. - In-Kind/Non-Financial: Evidence (Photo/Acknowledgement) is RECOMMENDED.

Kotlin Implementation

package com.example.esg.validation.community

import java.math.BigDecimal
import java.time.LocalDate

class CommunityInvestmentValidator {

    fun validateDateRange(
        activityDate: LocalDate,
        endDate: LocalDate?,
        periodStartDate: LocalDate,
        periodEndDate: LocalDate
    ): List<String> {
        val errors = mutableListOf<String>()

        // Rule 1: End date >= Start date
        if (endDate != null && endDate.isBefore(activityDate)) {
            errors.add("End date ($endDate) cannot be before activity start date ($activityDate)")
        }

        // Rule 1b: Within Period
        if (activityDate.isBefore(periodStartDate) || activityDate.isAfter(periodEndDate)) {
            errors.add("Activity date ($activityDate) must be within the reporting period ($periodStartDate - $periodEndDate)")
        }
        if (endDate != null && (endDate.isBefore(periodStartDate) || endDate.isAfter(periodEndDate))) {
            errors.add("End date ($endDate) must be within the reporting period ($periodStartDate - $periodEndDate)")
        }

        return errors
    }

    fun validateVariance(
        actual: BigDecimal,
        budget: BigDecimal?,
        submittedVariance: BigDecimal?
    ): List<String> {
        val errors = mutableListOf<String>()
        val safeBudget = budget ?: BigDecimal.ZERO
        val calculatedVariance = actual.subtract(safeBudget)

        // Rule 2: Variance consistency
        if (submittedVariance != null) {
            // Allow small rounding difference
            if (submittedVariance.subtract(calculatedVariance).abs() > BigDecimal("0.01")) {
                errors.add("Submitted variance ($submittedVariance) does not match calculated variance ($calculatedVariance = $actual - $safeBudget)")
            }
        }

        return errors
    }
}

Cross-References


Change Log

Version Date Author Changes
1.0 2026-01-03 Senior Product Architect Initial validation engine specification
2.0 2026-01-11 Ralph Agent Comprehensive expansion with all 6 validation types, Kotlin examples, implementation guide, performance and security sections
3.0 2026-01-13 Ralph Agent Converted from Spring Boot to Quarkus: CDI dependency injection, Jakarta @Transactional, Quarkus Reactive Messaging, JAX-RS ContainerRequestFilter, Quarkus Cache, Quarkus Test patterns. Added sections on Hibernate Validator annotations and JAX-RS validation integration
3.1 2026-01-17 Ralph Agent Added Human Capital Metrics Validation Rules section: GRI 405-1 demographics validation (gender totals, local community limits, quarter-end dates, site dimensionality), GRI 401 employment type validation (month-end dates, monthly completeness checks), Employee Age Demographics validation (age group totals, month-end dates), PII aggregation threshold validation, percentage derivation rules, annual aggregation validation (averages vs totals), comprehensive Kotlin implementation examples, unit and integration tests
3.2 2026-01-18 Ralph Agent Added Occupational Health and Safety (OHS) Metrics Validation Rules section: F.1 Quarterly Incident Statistics validation (10 incident types, workforce type breakdown, quarter-end dates, site dimensionality, tiered evidence requirements by severity), F.2 LTI Days and LTIFR validation (LTIFR calculation formula with 1,000,000 multiplier, zero-consistency checks, positive LTI consistency, cross-validation with F.1 LTI counts), anomaly detection (fatality spike, LTI spike >50%, high potential spike >50%, contractor incident proportion >70%), YTD LTIFR weighted calculation, comprehensive Kotlin implementation with unit and integration tests
3.3 2026-01-18 Ralph Agent Added Environmental Metrics Validation Rules section: G.1 Production validation (milled ore ≤ crushed ore × 1.1, month-end dates), G.2 Materials validation (cyanide MANDATORY evidence, intensity calculations), G.3 Energy validation (generator consistency, consumption rate 0.2-0.5 L/kWh typical range), G.4 Water validation (water balance ±20% variance, quality band logic Green/Amber/Red for 1-2 params ≤20% or ≥3 params/>20%, quarter-end dates, cyanide/heavy metals MANDATORY evidence), G.5 Air Emissions validation (air quality band logic Green/Amber/Red for 1 pollutant ≤20% or ≥2 pollutants/>20%), G.6-G.7 Waste validation (tailings ≤ milled ore × 1.1, hazardous waste MANDATORY evidence, specific waste calculation), G.8 TSF validation (freeboard ≥1.5 m CRITICAL threshold with MANDATORY evidence, slurry density 1.0-2.0 g/cm³, rate of rise warnings), G.9 Rehabilitation validation (status date ≥ date started, Completed status MANDATORY evidence), G.10 Environmental Incidents CONFIDENTIAL validation (closure date ≥ incident date, High/Critical closed MANDATORY evidence), comprehensive Kotlin implementation with cross-validation rules and unit tests
3.4 2026-01-21 Ralph Agent Added Community Investment Validation Rules section: H.1 Quarterly CSR/CSIR Activities validation (log-based structure, date range logic, variance calculation, currency consistency, evidence thresholds), comprehensive Kotlin implementation examples