Locking & Restatements Workflow
Status: Final Version: 2.0 Last Updated: 2026-01-11
Table of Contents
- Purpose
- Workflow Overview
- State Machine
- Period Locking Mechanism
- Versioning Strategy
- Restatement Workflow
- State Transition Rules
- Content Hash Generation
- API Endpoints
- Implementation Guide
- Performance Considerations
- Security Requirements
- Testing Strategy
- GRI 2-4 Disclosure Compliance
- Acceptance Criteria
- 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:
- Regulatory Compliance: ESG reporting frameworks (GRI, SASB, TCFD) require immutable data snapshots for auditing
- Audit Trail: Complete chain-of-custody showing when data was finalized and any subsequent corrections
- Fraud Prevention: Locked periods prevent unauthorized data manipulation after approval
- GRI 2-4 Compliance: Automatic generation of restatement disclosures for material corrections
- 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:
- Submission Completeness:
- All mandatory metric submissions are in APPROVED state
- No submissions in DRAFT, QUEUED, UPLOADING, RECEIVED, VALIDATED, PROCESSED, UNDER_REVIEW, PENDING_APPROVAL states
-
No submissions in REJECTED state (must be corrected and reapproved)
-
Coverage Requirements:
- All mandatory metrics have at least one approved submission per site
-
Minimum coverage threshold met (typically 95% for optional metrics)
-
Evidence Requirements:
- All submissions requiring evidence have evidence files attached
-
All evidence files have passed virus scanning and validation
-
Period State:
-
Period must be in IN_REVIEW state (not DRAFT or already LOCKED)
-
Validation Status:
- No unresolved validation warnings (anomalies) marked as "requires investigation"
- 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:
- Tamper Detection: Any change to locked data invalidates the hash
- Data Integrity: Proves data has not been modified since locking
- Audit Trail: Each version has unique hash, enabling version comparison
- 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
-
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) } -
Use Appropriate Isolation Level: Default (READ_COMMITTED) is usually sufficient
-
Avoid Nested REQUIRES_NEW: Creates multiple transactions, harder to reason about
-
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
- Hash Generation:
- Run hash generation as async job if > 500 submissions
- Cache intermediate submission data
-
Use database view for approved submissions query
-
Prerequisite Validation:
- Use database aggregation queries (COUNT, GROUP BY)
- Cache mandatory metric list (rarely changes)
-
Parallel validation checks (CompletableFuture)
-
Version History:
- Paginate restatement list (max 20 per page)
- 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
- Hash Algorithm: SHA-256 (industry standard)
- Hash Storage: Store in database, never transmitted except for verification
- Hash Verification: Periodic integrity checks (monthly scheduled job)
- 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
restatementstable - 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
- Admin API Documentation - API endpoints for period management
- Review and Approval Workflow - Prerequisite approval process
- Validation Engine - Validation requirements for restatements
- Reporting Concepts - Baseline and boundary concepts
- Audit Log - Audit trail requirements
- Security Compliance - Encryption and access control
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) |