Skip to content

ESG Backend Domain Models (v1)

Quarkus Kotlin Architecture Specification

1. Design Principles

  • Audit-first: all ESG data is immutable once submitted
  • Evidence-driven: no metric exists without traceable evidence
  • GRI-aligned but extensible: GRI standards are first-class, custom KPIs supported
  • Offline-compatible ingestion: UUID-based, idempotent submissions
  • Separation of concerns:
  • Collection ≠ Review ≠ Approval ≠ Reporting

2. Entity Relationship Diagram

The following diagram shows the relationships between all domain entities:

erDiagram
    Organisation ||--o{ BusinessUnit : "has many"
    Organisation ||--o{ ReportingPeriod : "has many"
    Organisation ||--o{ MaterialTopic : "has many"
    Organisation ||--o{ MetricSubmission : "has many"
    Organisation ||--o{ StakeholderEngagement : "has many"
    Organisation ||--o{ Grievance : "has many"
    Organisation ||--o| Organisation : "parent of"

    BusinessUnit ||--o{ Site : "has many"
    BusinessUnit }o--|| Organisation : "belongs to"
    BusinessUnit ||--o{ MetricSubmission : "has many"

    Site }o--|| BusinessUnit : "belongs to"
    Site ||--o{ MetricSubmission : "has many"
    Site ||--o{ StakeholderEngagement : "has many"
    Site ||--o{ Grievance : "has many"

    GriStandard ||--o{ GriDisclosure : "has many"
    GriStandard ||--o{ MaterialTopic : "referenced by"

    GriDisclosure }o--|| GriStandard : "belongs to"
    GriDisclosure ||--o{ MetricDefinition : "has many"

    MetricDefinition }o--o| GriDisclosure : "optionally mapped to"
    MetricDefinition ||--o{ MetricSubmission : "has many"

    MetricSubmission }o--|| MetricDefinition : "belongs to"
    MetricSubmission }o--|| Organisation : "belongs to"
    MetricSubmission }o--o| BusinessUnit : "optionally belongs to"
    MetricSubmission }o--o| Site : "optionally belongs to"
    MetricSubmission }o--|| ReportingPeriod : "belongs to"
    MetricSubmission ||--o{ MetricEvidence : "has many"
    MetricSubmission ||--o{ ReviewAction : "has many"

    Evidence ||--o{ MetricEvidence : "has many"
    Evidence ||--o{ EngagementEvidence : "has many"
    Evidence ||--o{ GrievanceEvidence : "has many"

    MetricEvidence }o--|| MetricSubmission : "belongs to"
    MetricEvidence }o--|| Evidence : "belongs to"

    ReportingPeriod }o--|| Organisation : "belongs to"
    ReportingPeriod ||--o{ StakeholderEngagement : "has many"
    ReportingPeriod ||--o{ Grievance : "has many"

    MaterialTopic }o--|| Organisation : "belongs to"
    MaterialTopic }o--o| GriStandard : "optionally references"

    ReviewAction }o--|| MetricSubmission : "belongs to"

    StakeholderCategory }o--o{ StakeholderEngagement : "many-to-many"
    EngagementPlatform }o--o{ StakeholderEngagement : "many-to-many"

    StakeholderEngagement }o--|| Organisation : "belongs to"
    StakeholderEngagement }o--|| ReportingPeriod : "belongs to"
    StakeholderEngagement }o--o| Site : "optionally belongs to"
    StakeholderEngagement ||--o{ EngagementEvidence : "has many"

    Grievance }o--|| Organisation : "belongs to"
    Grievance }o--|| ReportingPeriod : "belongs to"
    Grievance }o--o| Site : "optionally belongs to"
    Grievance ||--o{ GrievanceEvidence : "has many"

    EngagementEvidence }o--|| StakeholderEngagement : "belongs to"
    EngagementEvidence }o--|| Evidence : "belongs to"

    GrievanceEvidence }o--|| Grievance : "belongs to"
    GrievanceEvidence }o--|| Evidence : "belongs to"

    AuditLog }o--o| Organisation : "tracks"
    AuditLog }o--o| MetricSubmission : "tracks"
    AuditLog }o--o| Evidence : "tracks"
    AuditLog }o--o| StakeholderEngagement : "tracks"
    AuditLog }o--o| Grievance : "tracks"

    Organisation {
        uuid id PK
        string name
        string registration_number
        string reporting_currency
        string country
        uuid parent_organisation_id FK
        timestamp created_at
        timestamp updated_at
    }

    BusinessUnit {
        uuid id PK
        uuid organisation_id FK
        string name
        enum type
        text description
        timestamp created_at
        timestamp updated_at
    }

    Site {
        uuid id PK
        uuid business_unit_id FK
        string name
        string location
        decimal latitude
        decimal longitude
        string operational_status
        timestamp created_at
        timestamp updated_at
    }

    GriStandard {
        uuid id PK
        string code
        string title
        enum category
        integer effective_year
    }

    GriDisclosure {
        uuid id PK
        uuid gri_standard_id FK
        string disclosure_code
        string title
        text description
        boolean quantitative
    }

    MetricDefinition {
        uuid id PK
        string code
        string name
        string unit
        enum data_type
        uuid gri_disclosure_id FK
        boolean custom
        text description
    }

    MetricSubmission {
        uuid id PK
        uuid metric_definition_id FK
        uuid organisation_id FK
        uuid business_unit_id FK
        uuid site_id FK
        uuid reporting_period_id FK
        string value
        uuid submitted_by
        timestamp submitted_at
        enum status
    }

    Evidence {
        uuid id PK
        string file_path
        string file_type
        string mime_type
        string checksum
        enum source_type
        uuid uploaded_by
        timestamp uploaded_at
    }

    MetricEvidence {
        uuid metric_submission_id FK
        uuid evidence_id FK
    }

    ReportingPeriod {
        uuid id PK
        uuid organisation_id FK
        string label
        date start_date
        date end_date
        boolean locked
    }

    MaterialTopic {
        uuid id PK
        uuid organisation_id FK
        string name
        text description
        uuid gri_standard_id FK
        text rationale
    }

    ReviewAction {
        uuid id PK
        uuid metric_submission_id FK
        uuid reviewer_id
        enum action
        text comments
        timestamp created_at
    }

    AuditLog {
        uuid id PK
        uuid actor_id
        string action
        string entity_type
        uuid entity_id
        json payload
        timestamp created_at
    }

    StakeholderCategory {
        uuid id PK
        uuid tenant_id FK
        string name
        text description
        boolean is_active
        timestamp created_at
        timestamp updated_at
    }

    EngagementPlatform {
        uuid id PK
        uuid tenant_id FK
        string name
        text description
        boolean is_active
        timestamp created_at
        timestamp updated_at
    }

    StakeholderEngagement {
        uuid id PK
        uuid tenant_id FK
        uuid reporting_period_id FK
        uuid organisation_id FK
        uuid site_id FK
        date initial_date
        string stakeholder_name
        text purpose
        text outcome
        string owner
        enum status
        date status_date
        timestamp created_at
        timestamp updated_at
    }

    Grievance {
        uuid id PK
        uuid tenant_id FK
        uuid reporting_period_id FK
        uuid organisation_id FK
        uuid site_id FK
        date date_reported
        boolean is_internal
        string stakeholder_group
        text nature
        text intervention
        enum resolution_status
        timestamp created_at
        timestamp updated_at
    }

    EngagementEvidence {
        uuid id PK
        uuid engagement_id FK
        uuid evidence_id FK
        timestamp created_at
    }

    GrievanceEvidence {
        uuid id PK
        uuid grievance_id FK
        uuid evidence_id FK
        timestamp created_at
    }

3. Core Aggregate Roots

3.1 Organisation

Represents the reporting entity (group or subsidiary).

import jakarta.persistence.*
import java.time.Instant
import java.util.UUID

@Entity
@Table(name = "organisations")
data class Organisation(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

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

    @Column(name = "registration_number")
    val registrationNumber: String,

    @Column(name = "reporting_currency", nullable = false)
    val reportingCurrency: String,

    @Column(nullable = false)
    val country: String,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "parent_organisation_id")
    val parentOrganisation: Organisation? = null,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: Instant = Instant.now()
) {
    @OneToMany(mappedBy = "organisation", cascade = [CascadeType.ALL])
    val businessUnits: MutableList<BusinessUnit> = mutableListOf()

    @OneToMany(mappedBy = "organisation", cascade = [CascadeType.ALL])
    val reportingPeriods: MutableList<ReportingPeriod> = mutableListOf()

    @OneToMany(mappedBy = "organisation", cascade = [CascadeType.ALL])
    val materialTopics: MutableList<MaterialTopic> = mutableListOf()
}

// Panache Repository
import io.quarkus.hibernate.orm.panache.kotlin.PanacheRepositoryBase
import jakarta.enterprise.context.ApplicationScoped

@ApplicationScoped
class OrganisationRepository : PanacheRepositoryBase<Organisation, UUID> {

    fun findByName(name: String): Organisation? =
        find("name", name).firstResult()

    fun findByCountry(country: String): List<Organisation> =
        list("country", country)

    fun findByParent(parentId: UUID): List<Organisation> =
        list("parentOrganisation.id", parentId)
}

3.2 BusinessUnit

Used to separate mining vs agribusiness operations.

enum class BusinessUnitType {
    MINING, AGRIBUSINESS, CORPORATE
}

@Entity
@Table(name = "business_units")
data class BusinessUnit(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "organisation_id", nullable = false)
    val organisation: Organisation,

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

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    val type: BusinessUnitType,

    @Column(columnDefinition = "TEXT")
    val description: String? = null,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: Instant = Instant.now()
) {
    @OneToMany(mappedBy = "businessUnit", cascade = [CascadeType.ALL])
    val sites: MutableList<Site> = mutableListOf()

    @OneToMany(mappedBy = "businessUnit", cascade = [CascadeType.ALL])
    val metricSubmissions: MutableList<MetricSubmission> = mutableListOf()
}

3.3 Site

Physical reporting locations (mine, farm, processing facility).

@Entity
@Table(name = "sites")
data class Site(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "business_unit_id", nullable = false)
    val businessUnit: BusinessUnit,

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

    @Column(nullable = false)
    val location: String,

    @Column(precision = 10, scale = 8)
    val latitude: BigDecimal? = null,

    @Column(precision = 11, scale = 8)
    val longitude: BigDecimal? = null,

    @Column(name = "operational_status")
    val operationalStatus: String,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: Instant = Instant.now()
) {
    @OneToMany(mappedBy = "site", cascade = [CascadeType.ALL])
    val metricSubmissions: MutableList<MetricSubmission> = mutableListOf()
}

4. GRI Standards Model

4.1 GriStandard

enum class GriStandardCategory {
    UNIVERSAL, TOPIC
}

@Entity
@Table(name = "gri_standards")
data class GriStandard(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @Column(nullable = false, unique = true)
    val code: String, // e.g. "GRI 302"

    @Column(nullable = false)
    val title: String,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    val category: GriStandardCategory,

    @Column(name = "effective_year", nullable = false)
    val effectiveYear: Int
) {
    @OneToMany(mappedBy = "griStandard", cascade = [CascadeType.ALL])
    val disclosures: MutableList<GriDisclosure> = mutableListOf()
}

4.2 GriDisclosure

Individual disclosures within a standard.

@Entity
@Table(name = "gri_disclosures")
data class GriDisclosure(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "gri_standard_id", nullable = false)
    val griStandard: GriStandard,

    @Column(name = "disclosure_code", nullable = false)
    val disclosureCode: String, // e.g. "302-1"

    @Column(nullable = false)
    val title: String,

    @Column(columnDefinition = "TEXT")
    val description: String? = null,

    @Column(nullable = false)
    val quantitative: Boolean = false
) {
    @OneToMany(mappedBy = "griDisclosure", cascade = [CascadeType.ALL])
    val metricDefinitions: MutableList<MetricDefinition> = mutableListOf()
}

5. Metrics & KPIs

5.1 MetricDefinition

Defines what is being measured.

enum class MetricDataType {
    NUMBER, PERCENTAGE, TEXT, BOOLEAN
}

// Human Capital Dimensions (GRI 405-1, GRI 401)
enum class EmploymentLevel {
    EXECUTIVE,
    SALARIED_STAFF_NON_NEC,
    WAGED_STAFF_NEC
}

enum class Gender {
    MALE,
    FEMALE
}

enum class LocalCommunityStatus {
    YES,
    NO
}

enum class EmploymentType {
    PERMANENT,
    FIXED_TERM,
    CASUAL
}

enum class AgeGroup {
    UNDER_30,
    AGED_30_50,
    OVER_50
}

@Entity
@Table(name = "metric_definitions")
data class MetricDefinition(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @Column(nullable = false, unique = true)
    val code: String,

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

    @Column(nullable = false)
    val unit: String,

    @Enumerated(EnumType.STRING)
    @Column(name = "data_type", nullable = false)
    val dataType: MetricDataType,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "gri_disclosure_id")
    val griDisclosure: GriDisclosure? = null,

    @Column(nullable = false)
    val custom: Boolean = false,

    @Column(columnDefinition = "TEXT")
    val description: String? = null
) {
    @OneToMany(mappedBy = "metricDefinition", cascade = [CascadeType.ALL])
    val submissions: MutableList<MetricSubmission> = mutableListOf()
}

Examples: • Total energy consumed (GJ) • Lost Time Injury Frequency Rate • Total water withdrawn (m³)


5.2 MetricSubmission

Actual reported values (append-only).

enum class SubmissionStatus {
    SUBMITTED, REVIEWED, APPROVED, REJECTED
}

@Entity
@Table(name = "metric_submissions")
data class MetricSubmission(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "metric_definition_id", nullable = false)
    val metricDefinition: MetricDefinition,

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "organisation_id", nullable = false)
    val organisation: Organisation,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "business_unit_id")
    val businessUnit: BusinessUnit? = null,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "site_id")
    val site: Site? = null,

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "reporting_period_id", nullable = false)
    val reportingPeriod: ReportingPeriod,

    @Column(nullable = false)
    val value: String,

    @Type(JsonBinaryType::class)
    @Column(name = "raw_data", columnDefinition = "jsonb")
    val rawData: Map<String, Any>? = null,

    @Type(JsonBinaryType::class)
    @Column(name = "processed_data", columnDefinition = "jsonb")
    var processedData: Map<String, Any>? = null,

    @Column(name = "submitted_by", nullable = false)
    val submittedBy: UUID,

    @Column(name = "submitted_at", nullable = false, updatable = false)
    val submittedAt: Instant = Instant.now(),

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    var status: SubmissionStatus = SubmissionStatus.SUBMITTED
) {
    @OneToMany(mappedBy = "metricSubmission", cascade = [CascadeType.ALL])
    val evidenceLinks: MutableList<MetricEvidence> = mutableListOf()

    @OneToMany(mappedBy = "metricSubmission", cascade = [CascadeType.ALL])
    val reviewActions: MutableList<ReviewAction> = mutableListOf()
}

Rules: • Never updated after submission • Corrections are new submissions with references

Dimensional Data Fields (Human Capital Metrics):

For metrics requiring dimensional disaggregation (e.g., GRI 405-1 demographics, GRI 401 employment type), two JSONB fields store structured dimensional data:

  1. raw_data (immutable):
  2. Stores the original submission payload as submitted by the data collector
  3. Contains nested dimensional breakdowns (e.g., gender, employment level, age group)
  4. Preserved for audit trail and re-processing
  5. Example structure for GRI 405-1 headcount:

    {
      "employment_level": "Executive",
      "dimensions": {
        "gender": { "Male": 7, "Female": 0 },
        "local_community": { "Yes": 0, "No": 7 }
      },
      "totals": { "total_headcount": 7, "from_local_community": 0 }
    }
    

  6. processed_data (mutable during validation):

  7. Stores normalized/flattened dimensional data after validation
  8. Optimized for reporting queries and aggregation
  9. Updated by ValidationService and MetricProcessingService
  10. Example structure for GRI 405-1 headcount:
    {
      "metric_id": "GRI_405_1_EXECUTIVE_HEADCOUNT",
      "quarter": "Q1",
      "fiscal_year": 2025,
      "employment_level": "Executive",
      "male_count": 7,
      "female_count": 0,
      "total_count": 7,
      "local_community_count": 0,
      "percent_male": 100.0,
      "percent_female": 0.0,
      "percent_local_community": 0.0,
      "validation_status": "PASSED"
    }
    

When to use dimensional fields: - Required for all GRI 405-1 (employee demographics) and GRI 401 (employment type/turnover) metrics - Required for metrics with disaggregation by gender, employment level, employment type, age group, or local community - Optional for simple scalar metrics (use value field only)

Querying dimensional data: - Use JSONB operators (->, ->>, @>) for filtering on dimensions - Extract and cast numeric values for aggregation: (processed_data->>'male_count')::INTEGER - Create GIN indexes on processed_data for performance: CREATE INDEX idx_metric_submissions_processed_data ON metric_submissions USING GIN (processed_data);

See Data Model Complete for detailed JSONB schemas and query examples.


6. Evidence Management

6.1 Evidence

Central to auditability.

enum class EvidenceSourceType {
    INVOICE, PHOTO, REPORT, LOG, POLICY
}

@Entity
@Table(name = "evidence")
data class Evidence(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @Column(name = "file_path", nullable = false)
    val filePath: String,

    @Column(name = "file_type", nullable = false)
    val fileType: String,

    @Column(name = "mime_type", nullable = false)
    val mimeType: String,

    @Column(nullable = false)
    val checksum: String, // SHA-256

    @Enumerated(EnumType.STRING)
    @Column(name = "source_type", nullable = false)
    val sourceType: EvidenceSourceType,

    @Column(name = "uploaded_by", nullable = false)
    val uploadedBy: UUID,

    @Column(name = "uploaded_at", nullable = false, updatable = false)
    val uploadedAt: Instant = Instant.now()
) {
    @OneToMany(mappedBy = "evidence", cascade = [CascadeType.ALL])
    val metricLinks: MutableList<MetricEvidence> = mutableListOf()
}

6.2 MetricEvidence (Pivot)

@Entity
@Table(name = "metric_evidence")
@IdClass(MetricEvidenceId::class)
data class MetricEvidence(
    @Id
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "metric_submission_id", nullable = false)
    val metricSubmission: MetricSubmission,

    @Id
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "evidence_id", nullable = false)
    val evidence: Evidence
)

// Composite key class
data class MetricEvidenceId(
    val metricSubmission: UUID = UUID.randomUUID(),
    val evidence: UUID = UUID.randomUUID()
) : Serializable

Rules: • At least one Evidence record per MetricSubmission • Evidence is immutable


7. Reporting Periods

@Entity
@Table(name = "reporting_periods")
data class ReportingPeriod(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "organisation_id", nullable = false)
    val organisation: Organisation,

    @Column(nullable = false)
    val label: String, // e.g. "FY2024"

    @Column(name = "start_date", nullable = false)
    val startDate: LocalDate,

    @Column(name = "end_date", nullable = false)
    val endDate: LocalDate,

    @Column(nullable = false)
    var locked: Boolean = false
) {
    @OneToMany(mappedBy = "reportingPeriod", cascade = [CascadeType.ALL])
    val submissions: MutableList<MetricSubmission> = mutableListOf()
}

Rules: • Locked periods are read-only • Locking happens after final approval


8. Material Topics

8.1 MaterialTopic

@Entity
@Table(name = "material_topics")
data class MaterialTopic(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "organisation_id", nullable = false)
    val organisation: Organisation,

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

    @Column(columnDefinition = "TEXT")
    val description: String? = null,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "gri_standard_id")
    val griStandard: GriStandard? = null,

    @Column(columnDefinition = "TEXT")
    val rationale: String? = null
)

Scope v1: • Materiality assumed (no scoring) • Rationale stored as narrative text


9. Workflow & Review

9.1 ReviewAction

enum class ReviewActionType {
    REVIEWED, APPROVED, REJECTED
}

@Entity
@Table(name = "review_actions")
data class ReviewAction(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "metric_submission_id", nullable = false)
    val metricSubmission: MetricSubmission,

    @Column(name = "reviewer_id", nullable = false)
    val reviewerId: UUID,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    val action: ReviewActionType,

    @Column(columnDefinition = "TEXT")
    val comments: String? = null,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now()
)

Rules: • Supports multi-step review • Immutable history


10. Stakeholder Engagement & Grievances

10.1 StakeholderCategory

Master list of stakeholder categories (e.g., "Community", "Suppliers", "Employees").

@Entity
@Table(name = "stakeholder_categories")
data class StakeholderCategory(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

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

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

    @Column(columnDefinition = "TEXT")
    val description: String? = null,

    @Column(name = "is_active", nullable = false)
    var isActive: Boolean = true,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: Instant = Instant.now()
)

10.2 EngagementPlatform

Master list of engagement platforms (e.g., "Community Meeting", "Written Correspondence").

@Entity
@Table(name = "engagement_platforms")
data class EngagementPlatform(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

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

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

    @Column(columnDefinition = "TEXT")
    val description: String? = null,

    @Column(name = "is_active", nullable = false)
    var isActive: Boolean = true,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: Instant = Instant.now()
)

10.3 StakeholderEngagement

Individual stakeholder engagement records for GRI 2-29 reporting.

enum class EngagementStatus {
    OPEN, WIP, CLOSED
}

@Entity
@Table(name = "stakeholder_engagements")
data class StakeholderEngagement(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "reporting_period_id", nullable = false)
    val reportingPeriod: ReportingPeriod,

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "organisation_id", nullable = false)
    val organisation: Organisation,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "site_id")
    val site: Site? = null,

    @Column(name = "initial_date", nullable = false)
    val initialDate: LocalDate,

    @Column(name = "stakeholder_name", nullable = false)
    val stakeholderName: String, // Group/organisation name, not individuals

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

    @Column(columnDefinition = "TEXT")
    val outcome: String? = null,

    @Column
    val owner: String? = null,

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    var status: EngagementStatus = EngagementStatus.OPEN,

    @Column(name = "status_date")
    var statusDate: LocalDate? = null,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: Instant = Instant.now()
) {
    @ManyToMany
    @JoinTable(
        name = "engagement_categories",
        joinColumns = [JoinColumn(name = "engagement_id")],
        inverseJoinColumns = [JoinColumn(name = "category_id")]
    )
    val categories: MutableSet<StakeholderCategory> = mutableSetOf()

    @ManyToMany
    @JoinTable(
        name = "engagement_platforms_used",
        joinColumns = [JoinColumn(name = "engagement_id")],
        inverseJoinColumns = [JoinColumn(name = "platform_id")]
    )
    val platforms: MutableSet<EngagementPlatform> = mutableSetOf()

    @OneToMany(mappedBy = "engagement", cascade = [CascadeType.ALL])
    val evidenceLinks: MutableList<EngagementEvidence> = mutableListOf()
}

Rules: - At least one stakeholder category must be selected - Status transitions: Open → WIP → Closed (no reverse) - stakeholderName should reference groups/organisations, not individuals (PII protection)


10.4 Grievance

Grievance mechanism records for GRI 2-29 disclosure.

enum class GrievanceStatus {
    OPEN, WIP, CLOSED
}

@Entity
@Table(name = "grievances")
data class Grievance(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

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

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "reporting_period_id", nullable = false)
    val reportingPeriod: ReportingPeriod,

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "organisation_id", nullable = false)
    val organisation: Organisation,

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "site_id")
    val site: Site? = null,

    @Column(name = "date_reported", nullable = false)
    val dateReported: LocalDate,

    @Column(name = "is_internal", nullable = false)
    val isInternal: Boolean = false,

    @Column(name = "stakeholder_group", nullable = false)
    val stakeholderGroup: String, // General group, NOT individual names (PII protection)

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

    @Column(columnDefinition = "TEXT")
    val intervention: String? = null,

    @Enumerated(EnumType.STRING)
    @Column(name = "resolution_status", nullable = false)
    var resolutionStatus: GrievanceStatus = GrievanceStatus.OPEN,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: Instant = Instant.now()
) {
    @OneToMany(mappedBy = "grievance", cascade = [CascadeType.ALL])
    val evidenceLinks: MutableList<GrievanceEvidence> = mutableListOf()
}

Rules: - stakeholderGroup should be a general category, not individual names (PII protection) - Status transitions: Open → WIP → Closed (no reverse)


10.5 EngagementEvidence (Pivot)

Links evidence to stakeholder engagements.

@Entity
@Table(name = "engagement_evidence")
data class EngagementEvidence(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "engagement_id", nullable = false)
    val engagement: StakeholderEngagement,

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "evidence_id", nullable = false)
    val evidence: Evidence,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now()
)

10.6 GrievanceEvidence (Pivot)

Links evidence to grievances.

@Entity
@Table(name = "grievance_evidence")
data class GrievanceEvidence(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "grievance_id", nullable = false)
    val grievance: Grievance,

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "evidence_id", nullable = false)
    val evidence: Evidence,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now()
)

10.7 Repository Examples

import io.quarkus.hibernate.orm.panache.kotlin.PanacheRepositoryBase
import jakarta.enterprise.context.ApplicationScoped

@ApplicationScoped
class StakeholderCategoryRepository : PanacheRepositoryBase<StakeholderCategory, UUID> {
    fun findByTenantAndActive(tenantId: UUID, isActive: Boolean): List<StakeholderCategory> =
        list("tenantId = ?1 and isActive = ?2", tenantId, isActive)

    fun findByTenantId(tenantId: UUID): List<StakeholderCategory> =
        list("tenantId", tenantId)
}

@ApplicationScoped
class EngagementPlatformRepository : PanacheRepositoryBase<EngagementPlatform, UUID> {
    fun findByTenantAndActive(tenantId: UUID, isActive: Boolean): List<EngagementPlatform> =
        list("tenantId = ?1 and isActive = ?2", tenantId, isActive)

    fun findByTenantId(tenantId: UUID): List<EngagementPlatform> =
        list("tenantId", tenantId)
}

@ApplicationScoped
class StakeholderEngagementRepository : PanacheRepositoryBase<StakeholderEngagement, UUID> {
    fun findByReportingPeriod(periodId: UUID): List<StakeholderEngagement> =
        list("reportingPeriod.id", periodId)

    fun findByOrganisationAndPeriod(
        organisationId: UUID,
        periodId: UUID
    ): List<StakeholderEngagement> =
        list(
            "organisation.id = ?1 and reportingPeriod.id = ?2",
            organisationId,
            periodId
        )

    fun findByStatus(status: EngagementStatus): List<StakeholderEngagement> =
        list("status", status)

    fun findByOrganisationAndStatus(
        organisationId: UUID,
        status: EngagementStatus
    ): List<StakeholderEngagement> =
        list(
            "organisation.id = ?1 and status = ?2",
            organisationId,
            status
        )
}

@ApplicationScoped
class GrievanceRepository : PanacheRepositoryBase<Grievance, UUID> {
    fun findByReportingPeriod(periodId: UUID): List<Grievance> =
        list("reportingPeriod.id", periodId)

    fun findByOrganisationAndPeriod(
        organisationId: UUID,
        periodId: UUID
    ): List<Grievance> =
        list(
            "organisation.id = ?1 and reportingPeriod.id = ?2",
            organisationId,
            periodId
        )

    fun findByResolutionStatus(status: GrievanceStatus): List<Grievance> =
        list("resolutionStatus", status)

    fun findByInternalExternal(isInternal: Boolean): List<Grievance> =
        list("isInternal", isInternal)

    fun countByStatus(status: GrievanceStatus): Long =
        count("resolutionStatus", status)
}

11. Audit Log (Mandatory)

@Entity
@Table(name = "audit_logs")
data class AuditLog(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @Column(name = "actor_id", nullable = false)
    val actorId: UUID,

    @Column(nullable = false)
    val action: String,

    @Column(name = "entity_type", nullable = false)
    val entityType: String,

    @Column(name = "entity_id", nullable = false)
    val entityId: UUID,

    @Type(JsonBinaryType::class)
    @Column(columnDefinition = "jsonb")
    val payload: Map<String, Any>? = null,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now()
)

Tracked events: • Submission • Evidence upload • Review decision • Period lock


11. Access Control (RBAC)

See: Tenancy & Role Model for comprehensive RBAC specification including permission matrices, workflow states, and API-level access control patterns.

Implementation Resources: - RBAC Matrix (YAML) - Machine-readable policy-as-code specification - Quarkus Security RBAC Implementation Guide - Complete Quarkus Security implementation with authorization, filters, and code examples

11.1 Role Summary

Recommended roles: • Collector (mobile / field) • ReviewerApproverAdmin (ESG system owner) • Auditor (read-only)

11.2 Enforcement Points

Access control enforced at: • MetricSubmission (CRUD operations) • Evidence access (upload, read, delete) • Period locking (state transitions) • ReviewAction (workflow state changes)

11.3 Implementation Notes

User-Role Assignment

enum class UserRoleType {
    COLLECTOR, REVIEWER, APPROVER, ADMIN, AUDITOR
}

enum class RoleScopeType {
    SITE, PROJECT, BUSINESS_UNIT
}

@Entity
@Table(name = "user_roles")
data class UserRole(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @Column(name = "user_id", nullable = false)
    val userId: UUID,

    @Column(name = "tenant_id", nullable = false)
    val tenantId: UUID, // organisation_id

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    val role: UserRoleType,

    @Enumerated(EnumType.STRING)
    @Column(name = "scope_type")
    val scopeType: RoleScopeType? = null,

    @Column(name = "scope_id")
    val scopeId: UUID? = null,

    @Column(name = "expires_at")
    val expiresAt: Instant? = null,

    @Column(name = "created_at", nullable = false, updatable = false)
    val createdAt: Instant = Instant.now(),

    @Column(name = "updated_at", nullable = false)
    var updatedAt: Instant = Instant.now()
)

Permission Checks - All MetricSubmission operations must verify tenant boundary - Collectors can only CRUD their own submissions (until submitted) - Reviewers can transition submissions: submitted → reviewed - Approvers can transition submissions: reviewed → approved - No self-approval: submitted_by ≠ approver_id

Reporting Period State Gates - locked = false: Collectors can create/update submissions - locked = true: All write operations blocked except Admin with justification

See Section 5: RBAC Permission Matrix for detailed permission mappings.


12. API Boundary Notes

Collector API

• Create MetricSubmission • Upload Evidence • Offline-safe UUIDs

Admin API

• Define Metrics • Manage GRI mappings • Review & approve submissions • Generate reports


13. Explicit Non-Goals (v1)

• No deletions • No inline edits • No computed carbon factors • No real-time sensors


14. Forward-Compatible Extensions

Planned additions: • Targets & baselines • Materiality scoring • Assurance workflows • ISSB / IFRS S2 mapping • Sector standards


15. Panache Repository Patterns

15.1 Repository Pattern vs Active Record

Quarkus Panache supports two patterns: 1. Active Record Pattern: Entity extends PanacheEntity, methods on entity itself 2. Repository Pattern: Plain JPA entities + PanacheRepository

This project uses Repository Pattern for clear separation between domain model and data access.

15.2 MetricSubmission Repository Example

import io.quarkus.hibernate.orm.panache.kotlin.PanacheRepositoryBase
import io.quarkus.panache.common.Sort
import jakarta.enterprise.context.ApplicationScoped
import java.time.Instant
import java.util.UUID

@ApplicationScoped
class MetricSubmissionRepository : PanacheRepositoryBase<MetricSubmission, UUID> {

    // Simple queries using Panache query methods
    fun findByOrganisation(organisationId: UUID): List<MetricSubmission> =
        list("organisation.id", organisationId)

    fun findByStatus(status: SubmissionStatus): List<MetricSubmission> =
        list("status", status)

    // Compound queries with sorting
    fun findByOrganisationAndPeriod(
        organisationId: UUID,
        periodId: UUID
    ): List<MetricSubmission> =
        list(
            "organisation.id = ?1 and reportingPeriod.id = ?2",
            Sort.descending("submittedAt"),
            organisationId,
            periodId
        )

    // Queries with named parameters (more readable)
    fun findBySubmitter(userId: UUID): List<MetricSubmission> =
        list("submittedBy = :userId", mapOf("userId" to userId))

    // Pagination with Panache
    fun findByOrganisationPaged(
        organisationId: UUID,
        page: Int,
        pageSize: Int
    ): io.quarkus.panache.common.Page<MetricSubmission> {
        return find("organisation.id", organisationId)
            .page(io.quarkus.panache.common.Page.of(page, pageSize))
            .list()
            .let { results ->
                io.quarkus.panache.common.Page(
                    results,
                    count("organisation.id", organisationId),
                    page,
                    pageSize
                )
            }
    }

    // Count queries
    fun countByStatus(status: SubmissionStatus): Long =
        count("status", status)

    // Native queries for complex operations
    fun findSubmissionsRequiringReview(tenantId: UUID): List<MetricSubmission> =
        find(
            """
            status = :status
            and organisation.id = :tenantId
            and submittedAt < :cutoff
            """.trimIndent(),
            mapOf(
                "status" to SubmissionStatus.SUBMITTED,
                "tenantId" to tenantId,
                "cutoff" to Instant.now().minusSeconds(3600)
            )
        ).list()

    // Update queries
    fun approveSubmissions(submissionIds: List<UUID>, approverId: UUID): Int =
        update(
            "status = :status where id in :ids",
            mapOf(
                "status" to SubmissionStatus.APPROVED,
                "ids" to submissionIds
            )
        )

    // Delete queries (soft delete pattern)
    fun archiveOldSubmissions(beforeDate: Instant): Int =
        update(
            "status = :status where submittedAt < :date and status = :oldStatus",
            mapOf(
                "status" to SubmissionStatus.ARCHIVED,
                "date" to beforeDate,
                "oldStatus" to SubmissionStatus.REJECTED
            )
        )

    // Streaming for large result sets
    fun streamByPeriod(periodId: UUID): Stream<MetricSubmission> =
        stream("reportingPeriod.id", periodId)

    // Pessimistic locking for concurrent updates
    fun findAndLockForUpdate(id: UUID): MetricSubmission? =
        find("id", id)
            .withLock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE)
            .firstResult()
}

15.3 Common Panache Patterns

Basic CRUD Operations:

// Create/Update
val submission = MetricSubmission(/* ... */)
submissionRepository.persist(submission)

// Read
val submission = submissionRepository.findByIdOptional(id)
    .orElseThrow { NotFoundException("Submission not found") }

// Delete
submissionRepository.delete(submission)
// Or by ID
submissionRepository.deleteById(id)

// Bulk operations
submissionRepository.persist(listOf(submission1, submission2))

Query Shortcuts:

// Find all
val all = submissionRepository.listAll()

// Find with sorting
val sorted = submissionRepository.listAll(Sort.by("submittedAt").descending())

// Count all
val total = submissionRepository.count()

// Delete all (use with caution)
submissionRepository.deleteAll()

Dynamic Queries:

// Build queries dynamically
fun findByFilters(
    status: SubmissionStatus?,
    organisationId: UUID?,
    startDate: Instant?
): List<MetricSubmission> {
    val conditions = mutableListOf<String>()
    val params = mutableMapOf<String, Any>()

    status?.let {
        conditions.add("status = :status")
        params["status"] = it
    }

    organisationId?.let {
        conditions.add("organisation.id = :orgId")
        params["orgId"] = it
    }

    startDate?.let {
        conditions.add("submittedAt >= :startDate")
        params["startDate"] = it
    }

    val query = conditions.joinToString(" and ")
    return if (query.isEmpty()) {
        listAll()
    } else {
        list(query, params)
    }
}

Transactions:

import jakarta.transaction.Transactional

@ApplicationScoped
class SubmissionService @Inject constructor(
    private val submissionRepository: MetricSubmissionRepository
) {

    @Transactional
    fun createSubmission(request: CreateSubmissionRequest): MetricSubmission {
        val submission = MetricSubmission(/* ... */)
        submissionRepository.persist(submission)
        return submission
    }

    @Transactional
    fun updateStatus(id: UUID, newStatus: SubmissionStatus) {
        val submission = submissionRepository.findByIdOptional(id)
            .orElseThrow { NotFoundException("Not found") }

        submission.status = newStatus
        submissionRepository.persist(submission)
    }
}

15.4 Migration from Spring Data JPA

Spring Data JPA:

interface SubmissionRepository : JpaRepository<MetricSubmission, UUID> {
    fun findByOrganisationId(orgId: UUID): List<MetricSubmission>

    @Query("SELECT s FROM MetricSubmission s WHERE s.status = :status")
    fun findByStatus(@Param("status") status: SubmissionStatus): List<MetricSubmission>
}

Quarkus Panache:

@ApplicationScoped
class SubmissionRepository : PanacheRepositoryBase<MetricSubmission, UUID> {
    fun findByOrganisationId(orgId: UUID): List<MetricSubmission> =
        list("organisation.id", orgId)

    fun findByStatus(status: SubmissionStatus): List<MetricSubmission> =
        list("status", status)
}

Key Differences: - JpaRepositoryPanacheRepositoryBase<Entity, ID> - .save().persist() - .findById().findByIdOptional() - .findAll().listAll() - .deleteById().deleteById() (same) - Method query derivation → Panache query methods with find(), list(), stream() - @Query → Panache find() with HQL/JPQL string - PageablePage.of(pageIndex, pageSize)


Status: Ready for Quarkus Kotlin implementation with Panache Alignment: GRI / ESG Scope v1 & Client Annual Report


Change Log

Version Date Author Changes
1.2 2026-01-17 Ralph Agent (TASK-004) Added raw_data and processed_data JSONB fields to MetricSubmission entity with documentation on dimensional data storage and querying patterns
1.1 2026-01-17 Ralph Agent (TASK-002) Added human capital dimension enums: EmploymentLevel, Gender, LocalCommunityStatus, EmploymentType, AgeGroup
1.0 2026-01-03 Senior Product Architect Initial backend domain models for Quarkus Kotlin