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:
raw_data(immutable):- Stores the original submission payload as submitted by the data collector
- Contains nested dimensional breakdowns (e.g., gender, employment level, age group)
- Preserved for audit trail and re-processing
-
Example structure for GRI 405-1 headcount:
-
processed_data(mutable during validation): - Stores normalized/flattened dimensional data after validation
- Optimized for reporting queries and aggregation
- Updated by ValidationService and MetricProcessingService
- 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) • Reviewer • Approver • Admin (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:
- JpaRepository → PanacheRepositoryBase<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
- Pageable → Page.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 |