Skip to content

Locking & Restatements Workflow

Status: Final Version: 2.0 Last Updated: 2026-01-11


Table of Contents

  1. Purpose
  2. Workflow Overview
  3. State Machine
  4. Period Locking Mechanism
  5. Versioning Strategy
  6. Restatement Workflow
  7. State Transition Rules
  8. Content Hash Generation
  9. API Endpoints
  10. Implementation Guide
  11. Performance Considerations
  12. Security Requirements
  13. Testing Strategy
  14. GRI 2-4 Disclosure Compliance
  15. Acceptance Criteria
  16. Cross-References

Purpose

The Locking and Restatements workflow ensures data immutability for regulatory compliance while providing a controlled mechanism for correcting locked data when necessary. This workflow is CRITICAL for:

  1. Regulatory Compliance: ESG reporting frameworks (GRI, SASB, TCFD) require immutable data snapshots for auditing
  2. Audit Trail: Complete chain-of-custody showing when data was finalized and any subsequent corrections
  3. Fraud Prevention: Locked periods prevent unauthorized data manipulation after approval
  4. GRI 2-4 Compliance: Automatic generation of restatement disclosures for material corrections
  5. Investor Trust: Demonstrates robust data governance and quality controls

Key Principles: - Immutability: Once locked, data cannot be modified without formal restatement process - Auditability: Complete audit trail for every lock, unlock, and restatement action - Cryptographic Integrity: SHA-256 content hash proves data has not been tampered with - Role-Based Control: Only administrators can unlock locked periods - Traceability: Versioning system tracks all changes to locked data


Workflow Overview

┌─────────────────────────────────────────────────────────────────────┐
│                     Locking & Restatements Workflow                   │
└─────────────────────────────────────────────────────────────────────┘

                            ┌──────────────┐
                            │  IN_REVIEW   │ (Period in progress)
                            └──────┬───────┘
                                   │ All submissions approved
                                   │ + Admin lock action
                            ┌──────▼───────┐
                            │    LOCKED    │ (Data immutable)
                            └──────┬───────┘
                    ┌──────────────┴──────────────┐
                    │                             │
         Error discovered             No changes needed
         + Admin unlock                           │
                    │                             │
             ┌──────▼───────┐            ┌───────▼────────┐
             │  IN_REVIEW   │            │     LOCKED     │
             │ (Restatement)│            │   (Forever)    │
             └──────┬───────┘            └────────────────┘
                    │ Corrections submitted
                    │ + Reapproval + Re-lock
             ┌──────▼───────┐
             │    LOCKED    │ (New version)
             │  (Version 2) │
             └──────────────┘

Workflow Stages

Stage Description Actor State Duration
1. Pre-Lock Review All submissions must be approved, no unreviewed data Reviewers/Approvers IN_REVIEW Days-Weeks
2. Period Locking Admin locks period, generates content hash Administrator IN_REVIEW → LOCKED Immediate
3. Locked State Data is immutable, reports can be generated N/A LOCKED Permanent
4. Error Discovery Issue identified requiring correction Anyone LOCKED Variable
5. Period Unlock Admin unlocks for restatement (audit logged) Administrator LOCKED → IN_REVIEW Immediate
6. Restatement Submission Corrected data submitted and re-approved Collectors/Reviewers IN_REVIEW Days
7. Re-Lock Period re-locked with new content hash Administrator IN_REVIEW → LOCKED Immediate

State Machine

Reporting Period States

┌───────────────────────────────────────────────────────────────────────┐
│                     Reporting Period State Machine                      │
└───────────────────────────────────────────────────────────────────────┘

    DRAFT ──────→ IN_REVIEW ──────→ LOCKED
                      ▲                │
                      │                │
                      └────────────────┘
                         (Unlock for
                        Restatement)

State Transition Table

Current State Action Next State Actor Prerequisites
DRAFT Open for submission IN_REVIEW System Period start date reached
IN_REVIEW Lock period LOCKED Administrator All submissions approved, no unreviewed data
LOCKED Unlock for restatement IN_REVIEW Administrator Restatement justification provided
IN_REVIEW Re-lock after restatement LOCKED Administrator All corrections approved, restatement record created

Allowed vs Forbidden Transitions

✅ ALLOWED: - IN_REVIEW → LOCKED (if all prerequisites met) - LOCKED → IN_REVIEW (if admin with justification) - IN_REVIEW → LOCKED (re-lock after restatement)

❌ FORBIDDEN: - DRAFT → LOCKED (must go through IN_REVIEW) - LOCKED → DRAFT (cannot revert to draft) - Any transition without proper role (only admins can lock/unlock)


Period Locking Mechanism

Prerequisites for Locking

Before a reporting period can be locked, ALL of the following conditions must be met:

  1. Submission Completeness:
  2. All mandatory metric submissions are in APPROVED state
  3. No submissions in DRAFT, QUEUED, UPLOADING, RECEIVED, VALIDATED, PROCESSED, UNDER_REVIEW, PENDING_APPROVAL states
  4. No submissions in REJECTED state (must be corrected and reapproved)

  5. Coverage Requirements:

  6. All mandatory metrics have at least one approved submission per site
  7. Minimum coverage threshold met (typically 95% for optional metrics)

  8. Evidence Requirements:

  9. All submissions requiring evidence have evidence files attached
  10. All evidence files have passed virus scanning and validation

  11. Period State:

  12. Period must be in IN_REVIEW state (not DRAFT or already LOCKED)

  13. Validation Status:

  14. No unresolved validation warnings (anomalies) marked as "requires investigation"
  15. All high-priority anomalies must be reviewed and acknowledged

Lock Process Implementation

Kotlin Service Implementation

package com.example.esg.services

import com.example.esg.entities.ReportingPeriod
import com.example.esg.entities.MetricSubmission
import com.example.esg.entities.AuditLog
import com.example.esg.entities.User
import com.example.esg.repositories.ReportingPeriodRepository
import com.example.esg.repositories.MetricSubmissionRepository
import com.example.esg.repositories.AuditLogRepository
import com.example.esg.exceptions.StatePrerequisiteException
import jakarta.enterprise.context.ApplicationScoped
import jakarta.transaction.Transactional
import java.security.MessageDigest
import java.time.LocalDateTime

@ApplicationScoped
class ReportingPeriodLockService(
    private val periodRepository: ReportingPeriodRepository,
    private val submissionRepository: MetricSubmissionRepository,
    private val auditLogRepository: AuditLogRepository
) {

    @Transactional
    fun lockPeriod(periodId: Long, adminUserId: Long, justification: String): ReportingPeriod {
        val period = periodRepository.findByIdOptional(periodId)
            .orElseThrow { IllegalArgumentException("Period not found: $periodId") }

        val admin = period.tenant.users.find { it.id == adminUserId }
            ?: throw IllegalArgumentException("Admin not found: $adminUserId")

        if (!admin.hasRole("ADMIN")) {
            throw SecurityException("Only administrators can lock periods")
        }

        // Validate prerequisites
        validateLockPrerequisites(period)

        // Generate cryptographic content hash
        val contentHash = generateContentHash(period)

        // Update period state
        period.state = "LOCKED"
        period.lockedAt = LocalDateTime.now()
        period.lockedByUserId = adminUserId
        period.lockJustification = justification
        period.contentHash = contentHash
        period.version = (period.version ?: 0) + 1

        // Persist with Panache
        periodRepository.persist(period)

        // Audit log
        auditLogRepository.persist(
            AuditLog(
                tenantId = period.tenantId,
                entityType = "reporting_period",
                entityId = period.id!!,
                action = "locked",
                actorUserId = adminUserId,
                ipAddress = getCurrentRequestIp(),
                userAgent = getCurrentRequestUserAgent(),
                before = mapOf("state" to "IN_REVIEW"),
                after = mapOf("state" to "LOCKED", "version" to period.version),
                reason = justification,
                timestamp = LocalDateTime.now()
            )
        )

        // Trigger CDI event for notifications
        periodLockedEvent.fireAsync(ReportingPeriodLockedEvent(period))

        return period
    }

    private fun validateLockPrerequisites(period: ReportingPeriod) {
        if (period.state != "IN_REVIEW") {
            throw StatePrerequisiteException(
                "Period must be in IN_REVIEW state to lock (current: ${period.state})"
            )
        }

        // Check for unreviewed submissions
        val unreviewedCount = submissionRepository.countByReportingPeriodIdAndStateNotIn(
            period.id!!,
            listOf("APPROVED", "REJECTED")
        )

        if (unreviewedCount > 0) {
            throw StatePrerequisiteException(
                "Cannot lock period with $unreviewedCount unreviewed submissions"
            )
        }

        // Check for rejected submissions (must be corrected first)
        val rejectedCount = submissionRepository.countByReportingPeriodIdAndState(
            period.id!!,
            "REJECTED"
        )

        if (rejectedCount > 0) {
            throw StatePrerequisiteException(
                "Cannot lock period with $rejectedCount rejected submissions. " +
                "All rejections must be corrected and reapproved."
            )
        }

        // Check mandatory metric coverage
        val mandatoryMetrics = period.getMandatoryMetrics()
        val sites = period.tenant.sites

        for (metric in mandatoryMetrics) {
            for (site in sites) {
                val approvedCount = submissionRepository.countByReportingPeriodIdAndMetricDefinitionIdAndSiteIdAndState(
                    period.id!!,
                    metric.id!!,
                    site.id!!,
                    "APPROVED"
                )

                if (approvedCount == 0) {
                    throw StatePrerequisiteException(
                        "Mandatory metric '${metric.name}' missing approved submission for site '${site.name}'"
                    )
                }
            }
        }

        // Check evidence requirements
        val submissionsRequiringEvidence = submissionRepository.findByReportingPeriodIdAndStateAndEvidenceRequired(
            period.id!!,
            "APPROVED",
            true
        )

        for (submission in submissionsRequiringEvidence) {
            if (submission.evidenceFiles.isEmpty()) {
                throw StatePrerequisiteException(
                    "Submission ${submission.submissionUuid} requires evidence but has none attached"
                )
            }
        }

        // Check Community Investment Completeness
        val unapprovedActivities = communityInvestmentRepository.count("reportingPeriodId = ?1 AND state != 'APPROVED'", period.id!!)
        if (unapprovedActivities > 0) {
            throw StatePrerequisiteException(
                "Cannot lock period with $unapprovedActivities unapproved Community Investment activities"
            )
        }
    }

    private fun generateContentHash(period: ReportingPeriod): String {
        // 1. Fetch all approved submissions in deterministic order (sorted by ID)
        val submissions = submissionRepository.findByReportingPeriodIdAndState(
            period.id!!,
            "APPROVED"
        ).sortedBy { it.id }

        // 2. Fetch all approved Community Investment activities
        val activities = communityInvestmentRepository.list("reportingPeriodId = ?1 AND state = 'APPROVED'", period.id!!)
            .sortedBy { it.id }

        // 3. Create JSON representation of submissions (deterministic field order)
        val submissionData = submissions.map { submission ->
            mapOf(
                "id" to submission.id,
                "uuid" to submission.submissionUuid,
                "metric_id" to submission.metricDefinitionId,
                "site_id" to submission.siteId,
                "processed_data" to submission.processedData,
                "approved_at" to submission.approvedAt.toString(),
                "approved_by" to submission.approvedByUserId
            )
        }

        // 4. Create JSON representation of activities
        val activityData = activities.map { activity ->
            mapOf(
                "id" to activity.id,
                "pillar" to activity.pillar,
                "description" to activity.description,
                "amount" to activity.actual,
                "currency" to activity.currency,
                "beneficiaries" to activity.beneficiaries,
                "approved_at" to activity.reviewedAt.toString()
            )
        }

        // Combine data
        val fullData = mapOf(
            "submissions" to submissionData,
            "community_investment" to activityData
        )

        val jsonString = objectMapper.writeValueAsString(fullData)

        // Generate SHA-256 hash
        val digest = MessageDigest.getInstance("SHA-256")
        val hashBytes = digest.digest(jsonString.toByteArray(Charsets.UTF_8))
        return hashBytes.joinToString("") { "%02x".format(it) }
    }

    private fun getCurrentRequestIp(): String {
        // Extract from RequestContextHolder or inject HttpServletRequest
        return "127.0.0.1" // Placeholder
    }

    private fun getCurrentRequestUserAgent(): String {
        // Extract from RequestContextHolder or inject HttpServletRequest
        return "Unknown" // Placeholder
    }

}

API Endpoint

POST /api/v1/admin/reporting-periods/{id}/lock

Authentication: Required (JWT) Authorization: admin.reporting_periods.lock Rate Limit: 10 requests/hour per tenant

Request:

{
  "justification": "Q4 2025 reporting complete. All submissions reviewed and approved. Ready for regulatory reporting.",
  "notifyStakeholders": true
}

Response (200 OK):

{
  "id": 123,
  "name": "Q4 2025",
  "state": "LOCKED",
  "version": 1,
  "lockedAt": "2026-01-15T10:30:00Z",
  "lockedByUserId": 42,
  "lockedBy": {
    "id": 42,
    "name": "Jane Admin",
    "email": "jane.admin@company.com"
  },
  "lockJustification": "Q4 2025 reporting complete. All submissions reviewed and approved. Ready for regulatory reporting.",
  "contentHash": "a7f3b2c1d9e8f7g6h5i4j3k2l1m0n9o8p7q6r5s4t3u2v1w0x9y8z7a6b5c4d3e2",
  "submissionCount": 245,
  "approvedSubmissionCount": 245,
  "coveragePercentage": 100.0
}

Error Responses:

403 Forbidden (Insufficient permissions):

{
  "code": "AUTH_INSUFFICIENT_PERMISSIONS",
  "message": "User does not have admin.reporting_periods.lock permission",
  "details": {
    "required_permission": "admin.reporting_periods.lock",
    "user_roles": ["APPROVER"]
  },
  "request_id": "req_8h3j9k2n1m",
  "timestamp": "2026-01-15T10:30:00Z"
}

422 Unprocessable Entity (Prerequisites not met):

{
  "code": "STATE_PREREQUISITE_NOT_MET",
  "message": "Cannot lock period: 5 submissions are not yet approved",
  "details": {
    "unreviewed_count": 5,
    "unreviewed_submissions": [
      {
        "id": 1001,
        "uuid": "550e8400-e29b-41d4-a716-446655440001",
        "state": "UNDER_REVIEW",
        "metric": "GRI 302-1 Energy Consumption"
      }
    ]
  },
  "request_id": "req_8h3j9k2n1m",
  "timestamp": "2026-01-15T10:30:00Z"
}


Versioning Strategy

The platform implements a comprehensive versioning system to track all changes to reporting period data, especially when restatements occur.

Version Numbering

  • Initial Lock: Version 1
  • First Restatement: Version 2
  • Subsequent Restatements: Version 3, 4, 5, ...

Format: Integer incremented on each re-lock after restatement

Version Tracking

Database Schema

-- Reporting Period Versioning
ALTER TABLE reporting_periods ADD COLUMN version INTEGER DEFAULT 1;
ALTER TABLE reporting_periods ADD COLUMN restatement_count INTEGER DEFAULT 0;
ALTER TABLE reporting_periods ADD COLUMN previous_content_hash VARCHAR(64);

-- Restatement History Table
CREATE TABLE restatements (
    id BIGSERIAL PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    reporting_period_id BIGINT NOT NULL,
    version_from INTEGER NOT NULL,
    version_to INTEGER NOT NULL,
    restatement_date TIMESTAMP NOT NULL,
    trigger VARCHAR(50) NOT NULL, -- 'error_correction', 'methodology_change', 'acquisition', 'audit_finding'
    description TEXT NOT NULL,
    before_content_hash VARCHAR(64) NOT NULL,
    after_content_hash VARCHAR(64) NOT NULL,
    before_values JSONB NOT NULL,
    after_values JSONB NOT NULL,
    impact_percentage DECIMAL(5, 2),
    approved_by_user_id BIGINT NOT NULL,
    approved_at TIMESTAMP NOT NULL,
    created_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT fk_restatements_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    CONSTRAINT fk_restatements_period FOREIGN KEY (reporting_period_id) REFERENCES reporting_periods(id),
    CONSTRAINT fk_restatements_approver FOREIGN KEY (approved_by_user_id) REFERENCES users(id)
);

CREATE INDEX idx_restatements_period ON restatements(reporting_period_id);
CREATE INDEX idx_restatements_tenant ON restatements(tenant_id);

-- Submission Versioning (track which submissions changed during restatement)
ALTER TABLE metric_submissions ADD COLUMN restatement_id BIGINT;
ALTER TABLE metric_submissions ADD COLUMN supersedes_submission_id BIGINT;
ALTER TABLE metric_submissions ADD COLUMN is_restatement BOOLEAN DEFAULT FALSE;

CREATE INDEX idx_submissions_restatement ON metric_submissions(restatement_id);
CREATE INDEX idx_submissions_supersedes ON metric_submissions(supersedes_submission_id);

Version History API

GET /api/v1/admin/reporting-periods/{id}/versions

Response:

{
  "periodId": 123,
  "name": "Q4 2025",
  "currentVersion": 2,
  "versions": [
    {
      "version": 1,
      "lockedAt": "2026-01-15T10:30:00Z",
      "lockedByUserId": 42,
      "lockedBy": "Jane Admin",
      "contentHash": "a7f3b2c1d9e8f7g6h5i4j3k2l1m0n9o8p7q6r5s4t3u2v1w0x9y8z7a6b5c4d3e2",
      "submissionCount": 245,
      "restatement": null
    },
    {
      "version": 2,
      "lockedAt": "2026-02-10T14:20:00Z",
      "lockedByUserId": 42,
      "lockedBy": "Jane Admin",
      "contentHash": "b8g4c3d2e1f0g9h8i7j6k5l4m3n2o1p0q9r8s7t6u5v4w3x2y1z0a9b8c7d6e5f4",
      "submissionCount": 246,
      "restatement": {
        "id": 5,
        "trigger": "error_correction",
        "description": "Corrected electricity consumption for Site A (meter reading error)",
        "impactPercentage": -2.4,
        "approvedAt": "2026-02-10T14:20:00Z"
      }
    }
  ]
}


Restatement Workflow

Restatement Triggers

Restatements are required when material errors or methodology changes affect locked data. Common triggers:

Trigger Description Materiality Threshold Approval Required
Error Correction Data entry error, calculation error, meter reading error > 5% impact on any metric Administrator
Methodology Change Updated calculation method, emissions factor change Any impact Administrator + Domain Expert
Acquisition/Divestment Baseline recalculation due to organization boundary change > 30% change Administrator + CFO
Audit Finding External auditor identifies discrepancy Any impact Administrator + Audit Committee

Restatement Process

Step 1: Unlock Period (Admin Only)

Only users with the ADMIN role can unlock locked reporting periods. Unlocking requires a justification that is permanently logged.

Kotlin Service Implementation:

@Transactional
fun unlockPeriod(periodId: Long, adminUserId: Long, reason: String, trigger: String): ReportingPeriod {
    val period = periodRepository.findByIdOptional(periodId)
        .orElseThrow { IllegalArgumentException("Period not found: $periodId") }

    val admin = period.tenant.users.find { it.id == adminUserId }
        ?: throw IllegalArgumentException("Admin not found: $adminUserId")

    if (!admin.hasRole("ADMIN")) {
        throw SecurityException("Only administrators can unlock periods")
    }

    if (period.state != "LOCKED") {
        throw StatePrerequisiteException("Period must be in LOCKED state to unlock (current: ${period.state})")
    }

    // Store previous content hash before unlocking
    period.previousContentHash = period.contentHash
    period.state = "IN_REVIEW"
    period.unlockedAt = LocalDateTime.now()
    period.unlockedByUserId = adminUserId
    period.unlockReason = reason

    // Persist with Panache
    periodRepository.persist(period)

    // Audit log
    auditLogRepository.persist(
        AuditLog(
            tenantId = period.tenantId,
            entityType = "reporting_period",
            entityId = period.id!!,
            action = "unlocked",
            actorUserId = adminUserId,
            ipAddress = getCurrentRequestIp(),
            userAgent = getCurrentRequestUserAgent(),
            before = mapOf("state" to "LOCKED", "version" to period.version),
            after = mapOf("state" to "IN_REVIEW"),
            reason = reason,
            timestamp = LocalDateTime.now()
        )
    )

    return period
}

API Endpoint:

POST /api/v1/admin/reporting-periods/{id}/unlock

Request:

{
  "reason": "Meter reading error discovered for Site A electricity consumption. Need to restate Q4 2025 data.",
  "trigger": "error_correction",
  "notifyStakeholders": true
}

Response (200 OK):

{
  "id": 123,
  "name": "Q4 2025",
  "state": "IN_REVIEW",
  "version": 1,
  "previousContentHash": "a7f3b2c1d9e8f7g6h5i4j3k2l1m0n9o8p7q6r5s4t3u2v1w0x9y8z7a6b5c4d3e2",
  "unlockedAt": "2026-02-05T09:15:00Z",
  "unlockedByUserId": 42,
  "unlockReason": "Meter reading error discovered for Site A electricity consumption. Need to restate Q4 2025 data."
}

Step 2: Create Restatement Record

Before submitting corrected data, a restatement record must be created to track the change.

Kotlin Implementation:

@Transactional
fun createRestatement(
    periodId: Long,
    trigger: String,
    description: String,
    adminUserId: Long
): Restatement {
    val period = periodRepository.findByIdOptional(periodId)
        .orElseThrow { IllegalArgumentException("Period not found: $periodId") }

    if (period.state != "IN_REVIEW") {
        throw StatePrerequisiteException("Period must be unlocked (IN_REVIEW) to create restatement")
    }

    // Capture snapshot of current approved data (before values)
    val beforeSnapshot = captureMetricSnapshot(period)

    val restatement = Restatement(
        tenantId = period.tenantId,
        reportingPeriodId = period.id!!,
        versionFrom = period.version!!,
        versionTo = period.version!! + 1,
        restatementDate = LocalDateTime.now(),
        trigger = trigger,
        description = description,
        beforeContentHash = period.previousContentHash!!,
        afterContentHash = null, // Will be updated after corrections
        beforeValues = beforeSnapshot,
        afterValues = null,
        impactPercentage = null,
        approvedByUserId = adminUserId,
        approvedAt = null // Will be set when re-locked
    )

    // Persist with Panache
    restatementRepository.persist(restatement)
    return restatement
}

    private fun captureMetricSnapshot(period: ReportingPeriod): Map<String, Any> {
        val submissions = submissionRepository.findByReportingPeriodIdAndState(
            period.id!!,
            "APPROVED"
        )

        val metricSnapshot = submissions.associate { submission ->
            val key = "${submission.metricDefinition.code}_${submission.site.code}"
            val value = mapOf(
                "metric" to submission.metricDefinition.name,
                "site" to submission.site.name,
                "value" to submission.processedData["value"],
                "unit" to submission.processedData["unit"],
                "approved_at" to submission.approvedAt.toString()
            )
            key to value
        }

        // Also capture Community Investment totals by Pillar
        val ciActivities = communityInvestmentRepository.list("reportingPeriodId = ?1 AND state = 'APPROVED'", period.id!!)
        val ciSnapshot = ciActivities.groupBy { it.pillar }.map { (pillar, acts) ->
            val total = acts.sumOf { it.actual }
            val key = "CI_PILLAR_${pillar.uppercase().replace(" ", "_")}"
            val value = mapOf(
                "metric" to "Community Investment - $pillar",
                "site" to "ALL",
                "value" to total,
                "unit" to acts.firstOrNull()?.currency ?: "USD",
                "count" to acts.size
            )
            key to value
        }.toMap()

        return metricSnapshot + ciSnapshot
    }```

**API Endpoint:**

**POST** `/api/v1/admin/reporting-periods/{id}/restatements`

**Request:**
```json
{
  "trigger": "error_correction",
  "description": "Corrected electricity consumption for Site A. Original meter reading was 10,500 MWh but should have been 10,250 MWh. Meter reading error discovered during internal audit.",
  "impactedMetrics": [
    {
      "metricCode": "GRI_302_1",
      "siteCode": "SITE_A"
    }
  ]
}

Response (201 Created):

{
  "id": 5,
  "periodId": 123,
  "versionFrom": 1,
  "versionTo": 2,
  "trigger": "error_correction",
  "description": "Corrected electricity consumption for Site A...",
  "beforeContentHash": "a7f3b2c1d9e8f7g6h5i4j3k2l1m0n9o8p7q6r5s4t3u2v1w0x9y8z7a6b5c4d3e2",
  "beforeValues": {
    "GRI_302_1_SITE_A": {
      "metric": "Energy Consumption",
      "site": "Site A",
      "value": 10500,
      "unit": "MWh",
      "approved_at": "2026-01-15T10:30:00Z"
    }
  },
  "afterValues": null,
  "impactPercentage": null,
  "approvedAt": null
}

Step 3: Submit Corrections

Collectors submit corrected data as new submissions. The system marks these submissions as restatements and links them to the restatement record.

Key Points: - Corrected submissions use new UUIDs (not the original submission UUID) - Corrected submissions have is_restatement = true flag - Corrected submissions reference supersedes_submission_id pointing to the original submission - Corrected submissions reference restatement_id pointing to the restatement record - Original submissions are NOT deleted (soft delete or archive)

Submission Creation:

@Transactional
fun submitCorrection(
    restatementId: Long,
    originalSubmissionId: Long,
    correctedData: Map<String, Any>,
    collectorUserId: Long
): MetricSubmission {
    val restatement = restatementRepository.findByIdOptional(restatementId)
        .orElseThrow { IllegalArgumentException("Restatement not found: $restatementId") }

    val originalSubmission = submissionRepository.findByIdOptional(originalSubmissionId)
        .orElseThrow { IllegalArgumentException("Original submission not found: $originalSubmissionId") }

    // Create new submission with corrected data
    val correctedSubmission = MetricSubmission(
        tenantId = originalSubmission.tenantId,
        submissionUuid = UUID.randomUUID(),
        reportingPeriodId = originalSubmission.reportingPeriodId,
        metricDefinitionId = originalSubmission.metricDefinitionId,
        siteId = originalSubmission.siteId,
        collectorUserId = collectorUserId,
        rawData = correctedData,
        state = "RECEIVED", // Will go through validation again
        isRestatement = true,
        restatementId = restatementId,
        supersedesSubmissionId = originalSubmissionId
    )

    // Persist with Panache
    submissionRepository.persist(correctedSubmission)
    return correctedSubmission
}

Corrected submissions flow through the normal validation → review → approval workflow.

Step 4: Re-Lock Period

After all corrections are approved, the administrator re-locks the period with a new content hash.

Kotlin Implementation:

@Transactional
fun reLockPeriod(periodId: Long, restatementId: Long, adminUserId: Long): ReportingPeriod {
    val period = periodRepository.findByIdOptional(periodId)
        .orElseThrow { IllegalArgumentException("Period not found: $periodId") }

    val restatement = restatementRepository.findByIdOptional(restatementId)
        .orElseThrow { IllegalArgumentException("Restatement not found: $restatementId") }

    if (period.state != "IN_REVIEW") {
        throw StatePrerequisiteException("Period must be in IN_REVIEW state to re-lock")
    }

    // Validate all corrections are approved
    validateLockPrerequisites(period)

    // Generate new content hash
    val newContentHash = generateContentHash(period)

    // Capture snapshot of corrected data (after values)
    val afterSnapshot = captureMetricSnapshot(period)

    // Calculate impact percentage
    val impactPercentage = calculateImpactPercentage(restatement.beforeValues, afterSnapshot)

    // Update restatement record
    restatement.afterContentHash = newContentHash
    restatement.afterValues = afterSnapshot
    restatement.impactPercentage = impactPercentage
    restatement.approvedAt = LocalDateTime.now()
    restatementRepository.persist(restatement)

    // Re-lock period
    period.state = "LOCKED"
    period.lockedAt = LocalDateTime.now()
    period.lockedByUserId = adminUserId
    period.contentHash = newContentHash
    period.version = period.version!! + 1
    period.restatementCount = period.restatementCount!! + 1

    // Persist with Panache
    periodRepository.persist(period)

    // Audit log
    auditLogRepository.persist(
        AuditLog(
            tenantId = period.tenantId,
            entityType = "reporting_period",
            entityId = period.id!!,
            action = "restated",
            actorUserId = adminUserId,
            ipAddress = getCurrentRequestIp(),
            userAgent = getCurrentRequestUserAgent(),
            before = mapOf("state" to "IN_REVIEW", "version" to (period.version!! - 1)),
            after = mapOf("state" to "LOCKED", "version" to period.version),
            reason = "Restatement ${restatement.id} completed",
            timestamp = LocalDateTime.now()
        )
    )

    // Trigger CDI event
    periodRestatedEvent.fireAsync(ReportingPeriodRestatedEvent(period, restatement))

    return period
}

private fun calculateImpactPercentage(
    beforeValues: Map<String, Any>,
    afterValues: Map<String, Any>
): Double {
    // Calculate percentage change for primary metric
    // This is simplified - real implementation should aggregate multiple metrics
    val allKeys = beforeValues.keys + afterValues.keys

    var totalImpact = 0.0
    var metricCount = 0

    for (key in allKeys) {
        val beforeMetric = beforeValues[key] as? Map<String, Any>
        val afterMetric = afterValues[key] as? Map<String, Any>

        val beforeValue = (beforeMetric?.get("value") as? Number)?.toDouble() ?: 0.0
        val afterValue = (afterMetric?.get("value") as? Number)?.toDouble() ?: 0.0

        if (beforeValue != 0.0) {
            val impact = ((afterValue - beforeValue) / beforeValue) * 100
            totalImpact += impact
            metricCount++
        }
    }

    return if (metricCount > 0) totalImpact / metricCount else 0.0
}

API Endpoint:

POST /api/v1/admin/reporting-periods/{id}/relock

Request:

{
  "restatementId": 5,
  "justification": "All corrections reviewed and approved. Re-locking Q4 2025 with updated electricity data."
}

Response (200 OK):

{
  "id": 123,
  "name": "Q4 2025",
  "state": "LOCKED",
  "version": 2,
  "restatementCount": 1,
  "lockedAt": "2026-02-10T14:20:00Z",
  "contentHash": "b8g4c3d2e1f0g9h8i7j6k5l4m3n2o1p0q9r8s7t6u5v4w3x2y1z0a9b8c7d6e5f4",
  "restatement": {
    "id": 5,
    "trigger": "error_correction",
    "description": "Corrected electricity consumption for Site A...",
    "impactPercentage": -2.4,
    "beforeContentHash": "a7f3b2c1d9e8f7g6h5i4j3k2l1m0n9o8p7q6r5s4t3u2v1w0x9y8z7a6b5c4d3e2",
    "afterContentHash": "b8g4c3d2e1f0g9h8i7j6k5l4m3n2o1p0q9r8s7t6u5v4w3x2y1z0a9b8c7d6e5f4",
    "approvedAt": "2026-02-10T14:20:00Z"
  }
}


State Transition Rules

Period State Transitions

Transition Prerequisite Validator Actor Audit Required
IN_REVIEW → LOCKED All submissions approved validateLockPrerequisites() Administrator ✅ Yes
LOCKED → IN_REVIEW Restatement justification Role check (ADMIN) Administrator ✅ Yes
IN_REVIEW → LOCKED All corrections approved validateLockPrerequisites() Administrator ✅ Yes

Submission State Transitions (Restatement)

Transition Prerequisite Notes
New Correction Created Restatement record exists is_restatement = true, supersedes_submission_id set
RECEIVED → VALIDATED Passes all 6 validation types Same validation as original submissions
VALIDATED → PROCESSED Background job completes Calculations re-run
PROCESSED → UNDER_REVIEW Task assigned to reviewer May be assigned to same reviewer
UNDER_REVIEW → REVIEWED → APPROVED Review and approval workflow Must follow SoD rules

Content Hash Generation

Purpose

The SHA-256 content hash serves as a cryptographic fingerprint of all approved data in a locked reporting period. It enables:

  1. Tamper Detection: Any change to locked data invalidates the hash
  2. Data Integrity: Proves data has not been modified since locking
  3. Audit Trail: Each version has unique hash, enabling version comparison
  4. Compliance: Regulatory bodies can verify data integrity via hash validation

Hash Generation Algorithm

Requirements: - Deterministic: Same data must always produce same hash - Comprehensive: Include all approved submissions and metadata - Tamper-Proof: SHA-256 cryptographic hashing - Sortable: Data sorted by ID before hashing for consistency

Implementation Details:

fun generateContentHash(period: ReportingPeriod): String {
    // 1. Fetch all approved submissions in deterministic order (sorted by ID)
    val submissions = submissionRepository.findByReportingPeriodIdAndState(
        period.id!!,
        "APPROVED"
    ).sortedBy { it.id }

    // 2. Create deterministic JSON representation
    val submissionData = submissions.map { submission ->
        // Use LinkedHashMap to preserve field order
        linkedMapOf(
            "id" to submission.id,
            "uuid" to submission.submissionUuid.toString(),
            "metric_id" to submission.metricDefinitionId,
            "metric_code" to submission.metricDefinition.code,
            "site_id" to submission.siteId,
            "site_code" to submission.site.code,
            "raw_data" to submission.rawData,
            "processed_data" to submission.processedData,
            "approved_at" to submission.approvedAt.toString(),
            "approved_by_user_id" to submission.approvedByUserId,
            "evidence_file_ids" to submission.evidenceFiles.map { it.id }.sorted()
        )
    }

    // 3. Serialize to JSON (deterministic field order)
    val jsonString = objectMapper
        .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
        .writeValueAsString(submissionData)

    // 4. Generate SHA-256 hash
    val digest = MessageDigest.getInstance("SHA-256")
    val hashBytes = digest.digest(jsonString.toByteArray(Charsets.UTF_8))

    // 5. Convert to hexadecimal string
    return hashBytes.joinToString("") { "%02x".format(it) }
}

Hash Verification

Verify Period Integrity:

POST /api/v1/admin/reporting-periods/{id}/verify-integrity

Response:

{
  "periodId": 123,
  "version": 2,
  "storedHash": "b8g4c3d2e1f0g9h8i7j6k5l4m3n2o1p0q9r8s7t6u5v4w3x2y1z0a9b8c7d6e5f4",
  "calculatedHash": "b8g4c3d2e1f0g9h8i7j6k5l4m3n2o1p0q9r8s7t6u5v4w3x2y1z0a9b8c7d6e5f4",
  "isValid": true,
  "verifiedAt": "2026-03-01T12:00:00Z"
}

If hashes don't match (data tampering detected):

{
  "periodId": 123,
  "version": 2,
  "storedHash": "b8g4c3d2e1f0g9h8i7j6k5l4m3n2o1p0q9r8s7t6u5v4w3x2y1z0a9b8c7d6e5f4",
  "calculatedHash": "c9h5d4e3f2g1h0i9j8k7l6m5n4o3p2q1r0s9t8u7v6w5x4y3z2a1b0c9d8e7f6g5",
  "isValid": false,
  "verifiedAt": "2026-03-01T12:00:00Z",
  "warning": "DATA INTEGRITY VIOLATION: Hash mismatch indicates data tampering or corruption"
}


API Endpoints

Lock Period

POST /api/v1/admin/reporting-periods/{id}/lock

See Period Locking Mechanism section for details.

Unlock Period

POST /api/v1/admin/reporting-periods/{id}/unlock

See Restatement Workflow - Step 1 for details.

Create Restatement

POST /api/v1/admin/reporting-periods/{id}/restatements

See Restatement Workflow - Step 2 for details.

Re-Lock Period

POST /api/v1/admin/reporting-periods/{id}/relock

See Restatement Workflow - Step 4 for details.

Get Version History

GET /api/v1/admin/reporting-periods/{id}/versions

See Versioning Strategy for details.

Verify Integrity

POST /api/v1/admin/reporting-periods/{id}/verify-integrity

See Content Hash Generation for details.

List Restatements

GET /api/v1/admin/reporting-periods/{id}/restatements

Response:

{
  "periodId": 123,
  "name": "Q4 2025",
  "currentVersion": 2,
  "restatements": [
    {
      "id": 5,
      "versionFrom": 1,
      "versionTo": 2,
      "trigger": "error_correction",
      "description": "Corrected electricity consumption for Site A...",
      "impactPercentage": -2.4,
      "approvedAt": "2026-02-10T14:20:00Z",
      "approvedBy": {
        "id": 42,
        "name": "Jane Admin"
      },
      "changedMetrics": [
        {
          "metricCode": "GRI_302_1",
          "metricName": "Energy Consumption",
          "siteCode": "SITE_A",
          "siteName": "Site A",
          "beforeValue": 10500,
          "afterValue": 10250,
          "unit": "MWh",
          "change": -250,
          "changePercentage": -2.4
        }
      ]
    }
  ]
}


Implementation Guide

Database Schema

Complete schema for locking and restatements:

-- Reporting Periods Table (Add locking columns)
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS restatement_count INTEGER DEFAULT 0;
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS locked_at TIMESTAMP;
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS locked_by_user_id BIGINT;
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS lock_justification TEXT;
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS content_hash VARCHAR(64);
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS unlocked_at TIMESTAMP;
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS unlocked_by_user_id BIGINT;
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS unlock_reason TEXT;
ALTER TABLE reporting_periods ADD COLUMN IF NOT EXISTS previous_content_hash VARCHAR(64);

ALTER TABLE reporting_periods ADD CONSTRAINT fk_periods_locked_by
    FOREIGN KEY (locked_by_user_id) REFERENCES users(id);
ALTER TABLE reporting_periods ADD CONSTRAINT fk_periods_unlocked_by
    FOREIGN KEY (unlocked_by_user_id) REFERENCES users(id);

-- Restatements Table
CREATE TABLE IF NOT EXISTS restatements (
    id BIGSERIAL PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    reporting_period_id BIGINT NOT NULL,
    version_from INTEGER NOT NULL,
    version_to INTEGER NOT NULL,
    restatement_date TIMESTAMP NOT NULL,
    trigger VARCHAR(50) NOT NULL
        CHECK (trigger IN ('error_correction', 'methodology_change', 'acquisition', 'audit_finding')),
    description TEXT NOT NULL,
    before_content_hash VARCHAR(64) NOT NULL,
    after_content_hash VARCHAR(64),
    before_values JSONB NOT NULL,
    after_values JSONB,
    impact_percentage DECIMAL(5, 2),
    approved_by_user_id BIGINT NOT NULL,
    approved_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW(),

    CONSTRAINT fk_restatements_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id),
    CONSTRAINT fk_restatements_period FOREIGN KEY (reporting_period_id) REFERENCES reporting_periods(id),
    CONSTRAINT fk_restatements_approver FOREIGN KEY (approved_by_user_id) REFERENCES users(id)
);

CREATE INDEX idx_restatements_period ON restatements(reporting_period_id);
CREATE INDEX idx_restatements_tenant ON restatements(tenant_id);
CREATE INDEX idx_restatements_date ON restatements(restatement_date);

-- Submission Versioning
ALTER TABLE metric_submissions ADD COLUMN IF NOT EXISTS restatement_id BIGINT;
ALTER TABLE metric_submissions ADD COLUMN IF NOT EXISTS supersedes_submission_id BIGINT;
ALTER TABLE metric_submissions ADD COLUMN IF NOT EXISTS is_restatement BOOLEAN DEFAULT FALSE;

ALTER TABLE metric_submissions ADD CONSTRAINT fk_submissions_restatement
    FOREIGN KEY (restatement_id) REFERENCES restatements(id);
ALTER TABLE metric_submissions ADD CONSTRAINT fk_submissions_supersedes
    FOREIGN KEY (supersedes_submission_id) REFERENCES metric_submissions(id);

CREATE INDEX idx_submissions_restatement ON metric_submissions(restatement_id);
CREATE INDEX idx_submissions_supersedes ON metric_submissions(supersedes_submission_id);
CREATE INDEX idx_submissions_is_restatement ON metric_submissions(is_restatement) WHERE is_restatement = true;

JPA Entities

ReportingPeriod Entity (with locking fields):

@Entity
@Table(name = "reporting_periods")
data class ReportingPeriod(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(name = "tenant_id", nullable = false)
    val tenantId: Long,

    @Column(nullable = false)
    var name: String,

    @Column(nullable = false)
    var state: String, // DRAFT, IN_REVIEW, LOCKED

    @Column
    var version: Int? = 1,

    @Column(name = "restatement_count")
    var restatementCount: Int? = 0,

    @Column(name = "locked_at")
    var lockedAt: LocalDateTime? = null,

    @Column(name = "locked_by_user_id")
    var lockedByUserId: Long? = null,

    @Column(name = "lock_justification", columnDefinition = "TEXT")
    var lockJustification: String? = null,

    @Column(name = "content_hash", length = 64)
    var contentHash: String? = null,

    @Column(name = "unlocked_at")
    var unlockedAt: LocalDateTime? = null,

    @Column(name = "unlocked_by_user_id")
    var unlockedByUserId: Long? = null,

    @Column(name = "unlock_reason", columnDefinition = "TEXT")
    var unlockReason: String? = null,

    @Column(name = "previous_content_hash", length = 64)
    var previousContentHash: String? = null,

    @Column(name = "created_at")
    val createdAt: LocalDateTime = LocalDateTime.now()
)

Restatement Entity:

@Entity
@Table(name = "restatements")
data class Restatement(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Column(name = "tenant_id", nullable = false)
    val tenantId: Long,

    @Column(name = "reporting_period_id", nullable = false)
    val reportingPeriodId: Long,

    @Column(name = "version_from", nullable = false)
    val versionFrom: Int,

    @Column(name = "version_to", nullable = false)
    val versionTo: Int,

    @Column(name = "restatement_date", nullable = false)
    val restatementDate: LocalDateTime,

    @Column(nullable = false, length = 50)
    val trigger: String, // error_correction, methodology_change, acquisition, audit_finding

    @Column(nullable = false, columnDefinition = "TEXT")
    val description: String,

    @Column(name = "before_content_hash", nullable = false, length = 64)
    val beforeContentHash: String,

    @Column(name = "after_content_hash", length = 64)
    var afterContentHash: String? = null,

    @Column(name = "before_values", nullable = false, columnDefinition = "jsonb")
    val beforeValues: Map<String, Any>,

    @Column(name = "after_values", columnDefinition = "jsonb")
    var afterValues: Map<String, Any>? = null,

    @Column(name = "impact_percentage", precision = 5, scale = 2)
    var impactPercentage: Double? = null,

    @Column(name = "approved_by_user_id", nullable = false)
    val approvedByUserId: Long,

    @Column(name = "approved_at")
    var approvedAt: LocalDateTime? = null,

    @Column(name = "created_at")
    val createdAt: LocalDateTime = LocalDateTime.now()
)

Quarkus Configuration

application.properties:

# Prerequisites validation
esg.locking.require-all-mandatory-approved=true
esg.locking.allow-rejected-submissions=false
esg.locking.minimum-coverage-percentage=95.0

# Hash generation
esg.locking.hash-algorithm=SHA-256
esg.locking.include-evidence-in-hash=true

# Restatement rules
esg.locking.max-restatements-per-period=5
esg.locking.require-admin-approval=true
esg.locking.notify-stakeholders-on-restatement=true

# Materiality thresholds (> X% impact)
esg.locking.materiality-thresholds.error-correction=5.0
esg.locking.materiality-thresholds.methodology-change=0.0
esg.locking.materiality-thresholds.acquisition=30.0
esg.locking.materiality-thresholds.audit-finding=0.0

# Quarkus Transaction Configuration
quarkus.transaction-manager.default-transaction-timeout=300s
quarkus.transaction-manager.enable-recovery=true

# Quarkus Narayana Transaction Manager
quarkus.transaction-manager.node-name=esg-platform
quarkus.transaction-manager.enable-statistics=true

# Profile-specific overrides
%dev.esg.locking.require-all-mandatory-approved=false
%dev.esg.locking.minimum-coverage-percentage=80.0
%dev.quarkus.transaction-manager.default-transaction-timeout=600s
%test.esg.locking.require-admin-approval=false
%test.quarkus.transaction-manager.default-transaction-timeout=30s

Configuration Class:

@ApplicationScoped
class LockingConfig(
    @ConfigProperty(name = "esg.locking.require-all-mandatory-approved")
    val requireAllMandatoryApproved: Boolean,

    @ConfigProperty(name = "esg.locking.allow-rejected-submissions")
    val allowRejectedSubmissions: Boolean,

    @ConfigProperty(name = "esg.locking.minimum-coverage-percentage")
    val minimumCoveragePercentage: Double,

    @ConfigProperty(name = "esg.locking.hash-algorithm")
    val hashAlgorithm: String,

    @ConfigProperty(name = "esg.locking.include-evidence-in-hash")
    val includeEvidenceInHash: Boolean,

    @ConfigProperty(name = "esg.locking.max-restatements-per-period")
    val maxRestatementsPerPeriod: Int,

    @ConfigProperty(name = "esg.locking.require-admin-approval")
    val requireAdminApproval: Boolean,

    @ConfigProperty(name = "esg.locking.notify-stakeholders-on-restatement")
    val notifyStakeholdersOnRestatement: Boolean
)

Quarkus Transaction Management

Transaction Basics

Quarkus uses Narayana as the JTA transaction manager, providing robust transaction support for the locking and restatement workflows.

@Transactional Annotation

import jakarta.transaction.Transactional
import jakarta.transaction.Transactional.TxType

@ApplicationScoped
class ReportingPeriodLockService {

    // Default: REQUIRED (joins existing transaction or creates new one)
    @Transactional
    fun lockPeriod(periodId: Long, adminUserId: Long, justification: String): ReportingPeriod {
        // Transaction automatically committed on success
        // Automatically rolled back on exception
    }

    // REQUIRES_NEW: Always creates new transaction (suspends existing)
    @Transactional(Transactional.TxType.REQUIRES_NEW)
    fun auditLockAction(periodId: Long, action: String) {
        // This audit log persists even if parent transaction fails
    }

    // MANDATORY: Must have existing transaction (throws exception if none)
    @Transactional(Transactional.TxType.MANDATORY)
    fun validateLockPrerequisites(period: ReportingPeriod) {
        // Must be called within existing transaction
    }

    // NEVER: Must not have transaction (throws exception if exists)
    @Transactional(Transactional.TxType.NEVER)
    fun generateContentHash(period: ReportingPeriod): String {
        // Read-only operation, no transaction needed
    }
}

Transaction Propagation Examples

Scenario 1: Lock Period with Nested Transactions

@ApplicationScoped
class ReportingPeriodLockService(
    private val periodRepository: ReportingPeriodRepository,
    private val auditLogService: AuditLogService,
    private val notificationService: NotificationService
) {

    @Transactional
    fun lockPeriod(periodId: Long, adminUserId: Long, justification: String): ReportingPeriod {
        // Transaction TX1 starts
        val period = periodRepository.findByIdOptional(periodId).orElseThrow()

        validateLockPrerequisites(period) // Joins TX1 (REQUIRED)

        period.state = "LOCKED"
        period.contentHash = generateContentHash(period) // No transaction (NEVER)
        periodRepository.persist(period)

        // Audit in separate transaction (REQUIRES_NEW)
        auditLogService.logLockAction(period, adminUserId, justification)

        // Notification is async, runs outside transaction
        notificationService.sendLockNotificationAsync(period)

        return period
        // TX1 commits here
    }

    @Transactional(TxType.REQUIRED)
    private fun validateLockPrerequisites(period: ReportingPeriod) {
        // Joins parent transaction TX1
        val unreviewedCount = submissionRepository.countUnreviewed(period.id!!)
        if (unreviewedCount > 0) {
            throw StatePrerequisiteException("Cannot lock with unreviewed submissions")
            // Exception causes TX1 rollback
        }
    }
}

@ApplicationScoped
class AuditLogService(
    private val auditLogRepository: AuditLogRepository
) {

    @Transactional(TxType.REQUIRES_NEW)
    fun logLockAction(period: ReportingPeriod, adminUserId: Long, justification: String) {
        // Transaction TX2 starts (separate from TX1)
        val auditLog = AuditLog(
            tenantId = period.tenantId,
            entityType = "reporting_period",
            entityId = period.id!!,
            action = "locked",
            actorUserId = adminUserId,
            reason = justification,
            timestamp = LocalDateTime.now()
        )
        auditLogRepository.persist(auditLog)
        // TX2 commits independently
        // If TX1 fails later, audit log still persists
    }
}

Transaction Timeout Configuration

@ApplicationScoped
class LongRunningLockService {

    // Override default timeout for long-running operations
    @Transactional(timeout = 600) // 600 seconds = 10 minutes
    fun lockLargePeriod(periodId: Long): ReportingPeriod {
        // Complex hash generation for 10,000+ submissions
        val period = periodRepository.findByIdOptional(periodId).orElseThrow()

        // This may take several minutes
        val contentHash = generateComplexHash(period)

        period.contentHash = contentHash
        period.state = "LOCKED"
        periodRepository.persist(period)

        return period
    }
}

Programmatic Transaction Management

For complex scenarios, use UserTransaction directly:

import jakarta.transaction.UserTransaction
import jakarta.inject.Inject

@ApplicationScoped
class RestatementService {

    @Inject
    lateinit var userTransaction: UserTransaction

    fun complexRestatementWorkflow(periodId: Long): Restatement {
        var restatement: Restatement? = null

        try {
            // Manually start transaction
            userTransaction.begin()

            val period = periodRepository.findByIdOptional(periodId).orElseThrow()

            // Create restatement record
            restatement = Restatement(
                tenantId = period.tenantId,
                reportingPeriodId = period.id!!,
                versionFrom = period.version!!,
                versionTo = period.version!! + 1,
                trigger = "manual_correction",
                description = "Complex restatement",
                beforeContentHash = period.contentHash!!,
                beforeValues = captureSnapshot(period),
                approvedByUserId = getCurrentUserId()
            )
            restatementRepository.persist(restatement)

            // Manually commit
            userTransaction.commit()

        } catch (e: Exception) {
            // Manually rollback on error
            try {
                userTransaction.rollback()
            } catch (rbEx: Exception) {
                logger.error("Rollback failed", rbEx)
            }
            throw e
        }

        return restatement!!
    }
}

Pessimistic Locking

For concurrent access control during restatements:

import jakarta.persistence.LockModeType
import io.quarkus.hibernate.orm.panache.kotlin.PanacheRepository

interface ReportingPeriodRepository : PanacheRepository<ReportingPeriod> {

    // Pessimistic write lock - blocks other transactions from reading or writing
    fun findByIdWithLock(id: Long): ReportingPeriod? {
        return find("id", id).withLock(LockModeType.PESSIMISTIC_WRITE).firstResult()
    }

    // Pessimistic read lock - allows other reads but blocks writes
    fun findByIdWithReadLock(id: Long): ReportingPeriod? {
        return find("id", id).withLock(LockModeType.PESSIMISTIC_READ).firstResult()
    }
}

@ApplicationScoped
class ConcurrentLockService(
    private val periodRepository: ReportingPeriodRepository
) {

    @Transactional
    fun lockPeriodSafely(periodId: Long, adminUserId: Long): ReportingPeriod {
        // Acquire pessimistic lock - blocks concurrent lock attempts
        val period = periodRepository.findByIdWithLock(periodId)
            ?: throw NotFoundException("Period not found")

        if (period.state == "LOCKED") {
            throw StateException("Period already locked")
        }

        period.state = "LOCKED"
        period.lockedAt = LocalDateTime.now()
        period.lockedByUserId = adminUserId
        periodRepository.persist(period)

        return period
        // Lock released on transaction commit
    }
}

Optimistic Locking

Using @Version for optimistic concurrency control:

import jakarta.persistence.Version

@Entity
@Table(name = "reporting_periods")
data class ReportingPeriod(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    val id: Long? = null,

    @Version // Optimistic locking version
    var version: Int? = 1,

    var state: String,
    var contentHash: String? = null,
    // ... other fields
)

@ApplicationScoped
class OptimisticLockService(
    private val periodRepository: ReportingPeriodRepository
) {

    @Transactional
    fun lockPeriodOptimistically(periodId: Long, expectedVersion: Int): ReportingPeriod {
        val period = periodRepository.findByIdOptional(periodId).orElseThrow()

        // Check version matches (optimistic check)
        if (period.version != expectedVersion) {
            throw OptimisticLockException(
                "Period was modified by another user. Expected version $expectedVersion but found ${period.version}"
            )
        }

        period.state = "LOCKED"
        period.contentHash = generateContentHash(period)
        // Panache automatically increments version on persist
        periodRepository.persist(period)

        return period
        // If another transaction modified the period, OptimisticLockException thrown at commit
    }
}

Transaction Rollback Rules

@ApplicationScoped
class RollbackExampleService {

    // Default: Rolls back on RuntimeException and Error
    @Transactional
    fun lockWithDefaultRollback(periodId: Long): ReportingPeriod {
        val period = periodRepository.findByIdOptional(periodId).orElseThrow()
        period.state = "LOCKED"
        periodRepository.persist(period)

        if (someCondition) {
            throw RuntimeException("Validation failed") // Triggers rollback
        }

        return period
    }

    // Rollback on specific exceptions
    @Transactional(rollbackOn = [ValidationException::class, StatePrerequisiteException::class])
    fun lockWithCustomRollback(periodId: Long): ReportingPeriod {
        val period = periodRepository.findByIdOptional(periodId).orElseThrow()
        period.state = "LOCKED"
        periodRepository.persist(period)

        if (!validate(period)) {
            throw ValidationException("Validation failed") // Triggers rollback
        }

        return period
    }

    // Don't rollback on specific exceptions
    @Transactional(dontRollbackOn = [WarningException::class])
    fun lockWithWarnings(periodId: Long): ReportingPeriod {
        val period = periodRepository.findByIdOptional(periodId).orElseThrow()
        period.state = "LOCKED"
        periodRepository.persist(period)

        if (hasWarnings(period)) {
            throw WarningException("Has warnings but proceeding") // Does NOT rollback
        }

        return period
        // Transaction commits despite WarningException
    }
}

Quarkus Narayana Configuration

application.properties:

# Transaction Manager
quarkus.transaction-manager.node-name=esg-platform-node1
quarkus.transaction-manager.default-transaction-timeout=300s
quarkus.transaction-manager.enable-recovery=true
quarkus.transaction-manager.object-store-directory=target/tx-object-store

# Enable transaction statistics
quarkus.transaction-manager.enable-statistics=true

# XA datasource (for distributed transactions)
quarkus.datasource.jdbc.transactions=xa
quarkus.datasource.jdbc.enable-metrics=true

# Transaction timeout warnings
quarkus.transaction-manager.warn-about-unsafe-multiple-last-resources=true

# Profile-specific settings
%dev.quarkus.transaction-manager.default-transaction-timeout=600s
%test.quarkus.transaction-manager.default-transaction-timeout=30s
%prod.quarkus.transaction-manager.enable-recovery=true

Transaction Best Practices

  1. Keep Transactions Short: Long transactions increase lock contention

    // BAD: Long-running operation in transaction
    @Transactional
    fun processLargePeriod() {
        val data = fetchData() // Fast
        val result = complexCalculation(data) // Slow - holds transaction!
        save(result) // Fast
    }
    
    // GOOD: Only persist in transaction
    @Transactional(TxType.NEVER)
    fun processLargePeriod() {
        val data = fetchData()
        val result = complexCalculation(data) // Slow but no transaction held
        saveResult(result) // Separate fast transaction
    }
    
    @Transactional
    private fun saveResult(result: ProcessedData) {
        repository.persist(result)
    }
    

  2. Use Appropriate Isolation Level: Default (READ_COMMITTED) is usually sufficient

    // For critical operations requiring SERIALIZABLE isolation
    @Transactional
    fun lockPeriodSerializable(periodId: Long): ReportingPeriod {
        // Set isolation level via entity manager if needed
        val period = periodRepository.findByIdWithLock(periodId)
        // ... lock logic
    }
    

  3. Avoid Nested REQUIRES_NEW: Creates multiple transactions, harder to reason about

    // AVOID: Too many nested REQUIRES_NEW
    @Transactional
    fun operation1() {
        operation2() // REQUIRES_NEW
    }
    
    @Transactional(TxType.REQUIRES_NEW)
    fun operation2() {
        operation3() // REQUIRES_NEW
    }
    
    // BETTER: Single transaction or clearly separated transactions
    

  4. Handle Optimistic Lock Exceptions: Retry or inform user

    fun lockPeriodWithRetry(periodId: Long, maxRetries: Int = 3): ReportingPeriod {
        var attempts = 0
        while (attempts < maxRetries) {
            try {
                return lockPeriodOptimistically(periodId)
            } catch (e: OptimisticLockException) {
                attempts++
                if (attempts >= maxRetries) throw e
                Thread.sleep(100 * attempts) // Exponential backoff
            }
        }
        throw IllegalStateException("Should not reach here")
    }
    


Performance Considerations

Database Optimization

Critical Indexes:

-- Reporting Periods
CREATE INDEX idx_periods_state ON reporting_periods(tenant_id, state);
CREATE INDEX idx_periods_locked ON reporting_periods(tenant_id, locked_at) WHERE state = 'LOCKED';
CREATE INDEX idx_periods_version ON reporting_periods(tenant_id, version);

-- Restatements
CREATE INDEX idx_restatements_period ON restatements(reporting_period_id);
CREATE INDEX idx_restatements_tenant_date ON restatements(tenant_id, restatement_date DESC);

-- Submissions (for hash generation)
CREATE INDEX idx_submissions_period_approved ON metric_submissions(reporting_period_id, state)
    WHERE state = 'APPROVED';
CREATE INDEX idx_submissions_restatement ON metric_submissions(restatement_id)
    WHERE is_restatement = true;

Caching Strategy

Cache Period Lock Status:

@Cacheable(value = ["period-lock-status"], key = "#periodId")
fun getPeriodLockStatus(periodId: Long): PeriodLockStatus {
    // Returns state, version, lockedAt, contentHash
}

Cache TTL: 15 minutes (must be short to prevent stale lock status)

Performance Targets

Operation Target Notes
Lock period < 2 seconds Hash generation can be slow for large periods
Unlock period < 500ms Simple state change
Re-lock period < 3 seconds Hash regeneration + impact calculation
Verify integrity < 1 second Hash recalculation
List restatements < 200ms Paginated query

Optimization Strategies

  1. Hash Generation:
  2. Run hash generation as async job if > 500 submissions
  3. Cache intermediate submission data
  4. Use database view for approved submissions query

  5. Prerequisite Validation:

  6. Use database aggregation queries (COUNT, GROUP BY)
  7. Cache mandatory metric list (rarely changes)
  8. Parallel validation checks (CompletableFuture)

  9. Version History:

  10. Paginate restatement list (max 20 per page)
  11. Cache version history for locked periods

Security Requirements

Tenant Isolation

CRITICAL: All locking/unlocking/restatement operations MUST enforce tenant isolation.

@Provider
@Priority(Priorities.AUTHENTICATION)
class TenantIsolationFilter : ContainerRequestFilter {
    override fun filter(requestContext: ContainerRequestContext) {
        // Extract tenant ID from JWT SecurityIdentity
        val securityIdentity = requestContext.securityContext?.userPrincipal as? SecurityIdentity
        val tenantId = securityIdentity?.getAttribute<Long>("tenantId")
            ?: throw UnauthorizedException("Tenant ID not found in token")

        // Store tenant ID in request-scoped context
        TenantContext.setCurrentTenant(tenantId)
    }
}

Role-Based Access Control

Required Permissions:

Action Permission Roles
Lock period admin.reporting_periods.lock Administrator
Unlock period admin.reporting_periods.unlock Administrator
Create restatement admin.restatements.create Administrator
Re-lock period admin.reporting_periods.relock Administrator
Verify integrity admin.reporting_periods.verify Administrator, Auditor
View restatements admin.restatements.view Administrator, Auditor, Reviewer

Audit Logging

ALL locking, unlocking, and restatement actions MUST be logged to the audit log table:

auditLogRepository.save(
    AuditLog(
        tenantId = period.tenantId,
        entityType = "reporting_period",
        entityId = period.id!!,
        action = "locked", // or "unlocked", "restated"
        actorUserId = adminUserId,
        ipAddress = getCurrentRequestIp(),
        userAgent = getCurrentRequestUserAgent(),
        before = mapOf("state" to "IN_REVIEW", "version" to 1),
        after = mapOf("state" to "LOCKED", "version" to 2),
        reason = justification,
        timestamp = LocalDateTime.now()
    )
)

Audit Log Retention: 7 years (regulatory compliance requirement)

Content Hash Security

  1. Hash Algorithm: SHA-256 (industry standard)
  2. Hash Storage: Store in database, never transmitted except for verification
  3. Hash Verification: Periodic integrity checks (monthly scheduled job)
  4. Hash Tampering Detection: Alert security team immediately if hash mismatch detected

Testing Strategy

Unit Tests

Test Lock Prerequisites Validation:

@Test
fun `should throw exception when locking period with unreviewed submissions`() {
    // Given
    val period = createPeriod(state = "IN_REVIEW")
    val unreviewedSubmission = createSubmission(state = "UNDER_REVIEW")

    // When / Then
    assertThatThrownBy {
        lockService.lockPeriod(period.id!!, adminUserId, "Test lock")
    }
        .isInstanceOf(StatePrerequisiteException::class.java)
        .hasMessageContaining("unreviewed submissions")
}

Test Content Hash Determinism:

@Test
fun `should generate identical hash for same data`() {
    // Given
    val period = createPeriodWithSubmissions()

    // When
    val hash1 = lockService.generateContentHash(period)
    val hash2 = lockService.generateContentHash(period)

    // Then
    assertThat(hash1).isEqualTo(hash2)
}

Test Restatement Impact Calculation:

@Test
fun `should calculate negative impact percentage for decreased values`() {
    // Given
    val beforeValues = mapOf("metric1" to mapOf("value" to 10000))
    val afterValues = mapOf("metric1" to mapOf("value" to 9500))

    // When
    val impactPercentage = lockService.calculateImpactPercentage(beforeValues, afterValues)

    // Then
    assertThat(impactPercentage).isCloseTo(-5.0, within(0.01))
}

Integration Tests

Test Full Lock-Unlock-Relock Workflow:

@QuarkusTest
@TestTransaction
class LockingWorkflowTest {

    @Inject
    lateinit var lockService: ReportingPeriodLockService

    @Test
    fun `should successfully complete lock-unlock-restatement-relock workflow`() {
        // Given - Create period with approved submissions
        val period = createPeriodWithApprovedSubmissions()

        // When - Lock period
        val lockedPeriod = lockService.lockPeriod(period.id!!, adminUserId, "Q4 complete")

        // Then - Verify locked state
        assertThat(lockedPeriod.state).isEqualTo("LOCKED")
        assertThat(lockedPeriod.version).isEqualTo(1)
        assertThat(lockedPeriod.contentHash).isNotNull()
        val originalHash = lockedPeriod.contentHash

        // When - Unlock for restatement
        val unlockedPeriod = lockService.unlockPeriod(
            period.id!!,
            adminUserId,
            "Error correction needed",
            "error_correction"
        )

        // Then - Verify unlocked state
        assertThat(unlockedPeriod.state).isEqualTo("IN_REVIEW")
        assertThat(unlockedPeriod.previousContentHash).isEqualTo(originalHash)

        // When - Create restatement
        val restatement = lockService.createRestatement(
            period.id!!,
            "error_correction",
            "Corrected meter reading",
            adminUserId
        )

        // Then - Verify restatement created
        assertThat(restatement.versionFrom).isEqualTo(1)
        assertThat(restatement.versionTo).isEqualTo(2)

        // When - Submit corrected data and re-lock
        submitCorrectedData(period.id!!, restatement.id!!)
        val reLockedPeriod = lockService.reLockPeriod(period.id!!, restatement.id!!, adminUserId)

        // Then - Verify re-locked state
        assertThat(reLockedPeriod.state).isEqualTo("LOCKED")
        assertThat(reLockedPeriod.version).isEqualTo(2)
        assertThat(reLockedPeriod.restatementCount).isEqualTo(1)
        assertThat(reLockedPeriod.contentHash).isNotEqualTo(originalHash) // Hash changed

        // Verify audit trail
        val auditLogs = auditLogRepository.findByEntityTypeAndEntityId("reporting_period", period.id!!)
        assertThat(auditLogs).hasSize(3) // locked, unlocked, restated
    }
}

Security Tests

Test Tenant Isolation:

@Test
fun `should prevent cross-tenant period locking`() {
    // Given
    val tenant1Period = createPeriod(tenantId = 1L)
    val tenant2Admin = createAdmin(tenantId = 2L)

    // When / Then
    assertThatThrownBy {
        lockService.lockPeriod(tenant1Period.id!!, tenant2Admin.id!!, "Test")
    }
        .isInstanceOf(SecurityException::class.java)
        .hasMessageContaining("tenant")
}

Test Role Authorization:

@Test
fun `should prevent non-admin from unlocking period`() {
    // Given
    val period = createPeriod(state = "LOCKED")
    val reviewerUser = createReviewer()

    // When / Then
    assertThatThrownBy {
        lockService.unlockPeriod(period.id!!, reviewerUser.id!!, "Test", "error_correction")
    }
        .isInstanceOf(SecurityException::class.java)
        .hasMessageContaining("Only administrators")
}

Load Tests

Test Large Period Locking Performance:

@Test
fun `should lock period with 10000 submissions in under 3 seconds`() {
    // Given
    val period = createPeriodWith10000ApprovedSubmissions()

    // When
    val startTime = System.currentTimeMillis()
    val lockedPeriod = lockService.lockPeriod(period.id!!, adminUserId, "Load test")
    val duration = System.currentTimeMillis() - startTime

    // Then
    assertThat(lockedPeriod.state).isEqualTo("LOCKED")
    assertThat(duration).isLessThan(3000) // < 3 seconds
}

GRI 2-4 Disclosure Compliance

GRI 2-4: Restatements of Information

GRI 2-4 requires organizations to disclose restatements of information provided in previous reports and explain the reasons for such restatements.

The platform automatically generates GRI 2-4 compliant restatement tables for regulatory reports.

Auto-Generated Restatement Table

API Endpoint:

GET /api/v1/admin/reporting-periods/{id}/gri-2-4-disclosure

Response:

{
  "periodId": 123,
  "periodName": "Q4 2025",
  "restatements": [
    {
      "metricCode": "GRI_302_1",
      "metricName": "Energy Consumption (Electricity)",
      "site": "Site A",
      "originalValue": 10500,
      "restatedValue": 10250,
      "unit": "MWh",
      "change": -250,
      "changePercentage": -2.4,
      "reason": "Meter reading correction. Original reading included estimated values; actual meter data showed lower consumption.",
      "restatementDate": "2026-02-10",
      "approver": "Jane Admin"
    },
    {
      "metricCode": "GRI_305_1",
      "metricName": "GHG Emissions (Scope 1)",
      "site": "Site A",
      "originalValue": 5250,
      "restatedValue": 5125,
      "unit": "tCO2e",
      "change": -125,
      "changePercentage": -2.4,
      "reason": "Emissions recalculated using corrected electricity consumption data (linked to GRI 302-1 restatement).",
      "restatementDate": "2026-02-10",
      "approver": "Jane Admin"
    }
  ]
}

Report Integration

The restatement disclosure is automatically included in PDF and XLSX reports:

PDF Report Section:

┌─────────────────────────────────────────────────────────────────────┐
│                     GRI 2-4: Restatements of Information              │
└─────────────────────────────────────────────────────────────────────┘

The following metrics have been restated from previously reported values:

╔═══════════════════╦══════════╦══════════╦═══════╦════════╦═══════════════╗
║ Metric            ║ Original ║ Restated ║ Change║ %      ║ Reason        ║
╠═══════════════════╬══════════╬══════════╬═══════╬════════╬═══════════════╣
║ GRI 302-1         ║ 10,500   ║ 10,250   ║ -250  ║ -2.4%  ║ Meter reading ║
║ Electricity (MWh) ║          ║          ║       ║        ║ correction    ║
╠═══════════════════╬══════════╬══════════╬═══════╬════════╬═══════════════╣
║ GRI 305-1         ║ 5,250    ║ 5,125    ║ -125  ║ -2.4%  ║ Emissions     ║
║ Scope 1 (tCO2e)   ║          ║          ║       ║        ║ recalculation ║
╚═══════════════════╩══════════╩══════════╩═══════╩════════╩═══════════════╝

Restatement Date: February 10, 2026
Approved By: Jane Admin (Administrator)

XLSX Report Sheet: - Dedicated "Restatements" worksheet tab - Columns: Metric, Site, Original Value, Restated Value, Change, %, Reason, Date, Approver - Conditional formatting: Red for increases, Green for decreases


Acceptance Criteria

  • Periods can only be locked if all submissions approved
  • Periods cannot be locked with rejected submissions
  • Mandatory metric coverage validated before locking
  • Evidence requirements validated before locking
  • Content hash generated and stored at lock time using SHA-256
  • Only admins can unlock locked periods
  • Unlock requires justification (audit logged)
  • All restatements tracked in dedicated restatements table
  • Restatement count incremented on each re-lock
  • Version number incremented on each re-lock
  • Previous content hash stored when unlocking
  • New content hash generated when re-locking
  • Before/after values captured in restatement record
  • Impact percentage calculated and stored
  • Corrected submissions marked with is_restatement = true
  • Corrected submissions link to original via supersedes_submission_id
  • Corrected submissions link to restatement via restatement_id
  • GRI 2-4 restatement disclosure auto-generated
  • API endpoint for version history
  • API endpoint for restatement list
  • API endpoint for integrity verification
  • Complete audit trail for all actions (lock, unlock, restatement)
  • Tenant isolation enforced on all operations
  • Role-based access control (ADMIN only)
  • Performance: Lock < 2s, Unlock < 500ms, Re-lock < 3s
  • Content hash determinism (same data = same hash)
  • Test coverage: unit, integration, security, load tests

Cross-References


Change Log

Version Date Author Changes
1.0 2026-01-03 Senior Product Architect Initial locking/restatement specification
2.0 2026-01-11 Claude Agent (Ralph) Comprehensive expansion: state machine, versioning, Kotlin implementation, API endpoints, security, testing, GRI 2-4 compliance (8.5x expansion)