Validation Engine
Status: Final Version: 3.3 Last Updated: 2026-01-18
Table of Contents
- Purpose
- Validation Architecture
- Validation Types
- Schema Validation
- Domain Validation
- Referential Validation
- Evidence Validation
- Business Rule Validation
- Anomaly Detection
- Validation Flow
- Error Format Specification
- Implementation Guide
- Performance Considerations
- Security Requirements
- Testing Strategy
- 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 RECEIVED → VALIDATED.
┌────────────────────────────────────────────────────────────────┐
│ 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/syncfor 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_resultstable - 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:
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) × 100GRI_405_1_EXECUTIVE_PERCENT_FEMALE=(female_count / total_headcount) × 100GRI_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:
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 OreSpecific Diesel = (Diesel Other + Diesel Mining + Diesel Generators) / Crushed OreSpecific 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
- Collector API - API endpoints for submission
- Admin API - Reviewer workflows for warnings
- Error Handling - Standard error response format
- Ingestion Pipeline - Validation job triggering
- Collector Workflow - Client-side validation
- Metric Catalog - Validation rules per metric
- Collection Templates - Template schemas and validation rules
- Data Model Complete - JSONB raw_data/processed_data fields
- Backend Domain Models - Human capital dimension enums
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 |