Metric Catalog
Status: Final Version: 1.0 Last Updated: 2026-01-03
Purpose
Define the comprehensive structure for ESG metrics, including metadata schema, validation rules, calculation methods, collection frequency, dimensionality, and sensitivity classification. This catalog serves as the single source of truth for all metric definitions and enables the validation engine, data collection templates, and reporting outputs.
Scope
In Scope: - Metric metadata schema (all required and optional fields) - Validation rule types and specifications - Calculation and aggregation methods - Collection frequency and reporting dimensions - Sensitivity classification and access controls - Custom KPI mapping to GRI disclosures - Example metric definitions for GRI 302 (Energy) and GRI 401 (Employment)
Out of Scope: - Complete metric catalog for all GRI disclosures (see GRI/ESG Scope v1) - Materiality assessment logic (v1 uses pre-configured topics) - Target-setting and performance tracking (vNext) - Real-time sensor/IoT metric collection (vNext)
Key Decisions & Assumptions
| Decision | Rationale | Alternatives Considered |
|---|---|---|
| JSONB for validation rules | Flexibility for complex rules without schema changes | Separate validation_rules table (more normalized but less flexible) |
| Site-level collection default | Aligns with operational reality; most granular level | Business unit or org-level only |
| Pre-defined metric catalog | Ensures GRI compliance; reduces config burden | Fully dynamic metric builder (too complex for v1) |
| Sensitivity classification | GDPR/PII requirements; access control integration | No classification (security risk) |
| Mandatory evidence types | Ensures auditability and assurance-readiness | Evidence optional (compliance risk) |
1. Metric Metadata Schema
Database Table: metric_definitions
CREATE TABLE metric_definitions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
-- Identification
metric_id VARCHAR(100) NOT NULL UNIQUE, -- e.g., "GRI_302_1_ELECTRICITY", "CUSTOM_WATER_RECYCLING"
name VARCHAR(255) NOT NULL, -- e.g., "Electricity Consumption"
description TEXT, -- Full description with context
-- GRI Mapping
framework_id BIGINT REFERENCES frameworks(id), -- e.g., GRI Standards 2021
standard_id BIGINT REFERENCES gri_standards(id), -- e.g., GRI 302: Energy
disclosure_id BIGINT REFERENCES gri_disclosures(id), -- e.g., GRI 302-1
is_custom_kpi BOOLEAN DEFAULT false, -- true if client-defined metric
-- Data Type & Unit
data_type VARCHAR(50) NOT NULL, -- ENUM: 'numeric', 'boolean', 'text', 'date', 'enum'
unit VARCHAR(50), -- e.g., 'MWh', 'tonnes', 'count', 'percentage', '%', 'hours'
allowed_values JSONB, -- For 'enum' type: ["option_a", "option_b"]
-- Collection & Dimensionality
collection_frequency VARCHAR(50), -- ENUM: 'monthly', 'quarterly', 'annually', 'ad_hoc'
dimensionality VARCHAR(50) NOT NULL, -- ENUM: 'site', 'business_unit', 'organisation', 'project'
is_mandatory BOOLEAN DEFAULT false, -- true if required for GRI compliance
-- Validation
validation_rules JSONB, -- See section 2 for schema
allowed_evidence_types JSONB, -- Array of evidence type codes
-- Calculation & Aggregation
calculation_method TEXT, -- Formula or description (e.g., "Sum of monthly electricity bills")
aggregation_method VARCHAR(50), -- ENUM: 'sum', 'weighted_average', 'count', 'calculated', 'none'
aggregation_formula JSONB, -- For 'calculated' type
-- Security & Sensitivity
sensitivity_classification VARCHAR(50), -- ENUM: 'public', 'internal', 'confidential', 'pii'
contains_pii BOOLEAN DEFAULT false,
-- Metadata
metadata JSONB, -- Extensible for GHG scope, tags, etc.
version INT DEFAULT 1, -- For metric definition versioning
deprecated_at TIMESTAMP,
replaced_by_metric_id VARCHAR(100), -- If deprecated, points to replacement
-- Audit
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by_user_id BIGINT REFERENCES users(id),
-- Indexes
INDEX idx_tenant_metric_id (tenant_id, metric_id),
INDEX idx_disclosure (disclosure_id),
INDEX idx_dimensionality (dimensionality),
INDEX idx_sensitivity (sensitivity_classification),
UNIQUE (tenant_id, metric_id)
);
Field Definitions
Identification Fields
| Field | Type | Required | Description |
|---|---|---|---|
metric_id |
VARCHAR(100) | ✅ | Globally unique identifier (e.g., GRI_302_1_ELECTRICITY) |
name |
VARCHAR(255) | ✅ | Human-readable name |
description |
TEXT | ❌ | Full description with context and guidance |
Naming Convention:
- GRI Metrics: GRI_{standard}_{disclosure}_{SPECIFIC_METRIC}
- Example: GRI_302_1_ELECTRICITY (GRI 302-1, electricity component)
- Custom KPIs: CUSTOM_{CATEGORY}_{METRIC_NAME}
- Example: CUSTOM_WATER_RECYCLING_RATE
Data Type & Unit
| Field | Type | Allowed Values | Description |
|---|---|---|---|
data_type |
ENUM | 'numeric', 'boolean', 'text', 'date', 'enum' | Data type for validation |
unit |
VARCHAR(50) | MWh, tonnes, count, %, hours, etc. | Unit of measurement |
allowed_values |
JSONB | Array for enum types | E.g., ["Yes", "No", "Partial"] |
Examples:
// Numeric metric
{
"data_type": "numeric",
"unit": "MWh",
"allowed_values": null
}
// Enum metric
{
"data_type": "enum",
"unit": null,
"allowed_values": ["Coal", "Natural Gas", "Diesel", "Renewable"]
}
// Boolean metric
{
"data_type": "boolean",
"unit": null,
"allowed_values": null
}
Collection & Dimensionality
| Field | Type | Allowed Values | Description |
|---|---|---|---|
collection_frequency |
ENUM | 'monthly', 'quarterly', 'annually', 'ad_hoc' | How often data collected |
dimensionality |
ENUM | 'site', 'business_unit', 'organisation', 'project' | Level of collection |
is_mandatory |
BOOLEAN | true/false | Required for GRI compliance |
Dimensionality Examples: - Site: Energy consumption (collected per factory/office) - Organisation: Board diversity (single value for entire org) - Project: Project-specific environmental impact (e.g., construction project)
Validation & Evidence
| Field | Type | Description |
|---|---|---|
validation_rules |
JSONB | Array of validation rule objects (see section 2) |
allowed_evidence_types |
JSONB | Array of evidence type codes (e.g., ["UTILITY_BILL", "METER_READING"]) |
Calculation & Aggregation
| Field | Type | Allowed Values | Description |
|---|---|---|---|
calculation_method |
TEXT | Free text | Description or formula |
aggregation_method |
ENUM | 'sum', 'weighted_average', 'count', 'calculated', 'none' | How to aggregate across sites |
aggregation_formula |
JSONB | Formula object | For 'calculated' type |
Security & Sensitivity
| Field | Type | Allowed Values | Description |
|---|---|---|---|
sensitivity_classification |
ENUM | 'public', 'internal', 'confidential', 'pii' | Access control level |
contains_pii |
BOOLEAN | true/false | Contains personally identifiable information |
Classification Levels: - Public: Can be published externally (e.g., total energy consumption) - Internal: Visible to all internal users (e.g., site-level energy) - Confidential: Restricted to Approver, Admin, Auditor roles (e.g., safety incidents) - PII: Contains personal data; strict access controls (e.g., employee diversity data)
2. Validation Rules
Rule Schema
Stored in metric_definitions.validation_rules as JSONB array:
{
"validation_rules": [
{
"type": "schema",
"rule": "required",
"error_message": "This field is required"
},
{
"type": "schema",
"rule": "numeric",
"error_message": "Value must be a number"
},
{
"type": "domain",
"rule": "min",
"value": 0,
"error_message": "Value cannot be negative"
},
{
"type": "domain",
"rule": "max",
"value": 1000000,
"error_message": "Value exceeds maximum (1,000,000)"
},
{
"type": "domain",
"rule": "regex",
"value": "^\\d+(\\.\\d{1,2})?$",
"error_message": "Value must have at most 2 decimal places"
},
{
"type": "business",
"rule": "custom",
"expression": "value <= reference_metric_value",
"reference_metric": "GRI_302_1_TOTAL_ENERGY",
"error_message": "Electricity consumption cannot exceed total energy consumption"
},
{
"type": "evidence",
"rule": "required_count",
"value": 1,
"error_message": "At least one evidence file is required"
},
{
"type": "anomaly",
"rule": "z_score",
"threshold": 3,
"severity": "warning",
"error_message": "Value is unusual compared to historical data (3+ standard deviations)"
}
]
}
Validation Rule Types
1. Schema Validation
Enforces data type and format constraints.
| Rule | Parameters | Example | Description |
|---|---|---|---|
required |
None | {"type": "schema", "rule": "required"} |
Field must not be null/empty |
numeric |
None | {"type": "schema", "rule": "numeric"} |
Must be a number |
integer |
None | {"type": "schema", "rule": "integer"} |
Must be whole number (no decimals) |
boolean |
None | {"type": "schema", "rule": "boolean"} |
Must be true/false |
date |
None | {"type": "schema", "rule": "date"} |
Must be valid ISO date (YYYY-MM-DD) |
enum |
None | {"type": "schema", "rule": "enum"} |
Must match allowed_values |
email |
None | {"type": "schema", "rule": "email"} |
Valid email format |
2. Domain Validation
Enforces business domain constraints (ranges, patterns).
| Rule | Parameters | Example | Description |
|---|---|---|---|
min |
value (number) |
{"type": "domain", "rule": "min", "value": 0} |
Minimum allowed value |
max |
value (number) |
{"type": "domain", "rule": "max", "value": 100} |
Maximum allowed value |
regex |
value (pattern) |
{"type": "domain", "rule": "regex", "value": "^[A-Z]{2}\\d{4}$"} |
Must match regex pattern |
length_min |
value (int) |
{"type": "domain", "rule": "length_min", "value": 3} |
Minimum string length |
length_max |
value (int) |
{"type": "domain", "rule": "length_max", "value": 500} |
Maximum string length |
precision |
value (int) |
{"type": "domain", "rule": "precision", "value": 2} |
Max decimal places |
3. Referential Validation
Cross-field consistency checks.
| Rule | Parameters | Example | Description |
|---|---|---|---|
sum_equals |
reference_metrics (array) |
See below | Sum of components must equal total |
less_than_or_equal |
reference_metric (string) |
See below | Must be ≤ reference metric |
greater_than |
reference_metric (string) |
See below | Must be > reference metric |
Example: Sum Equals
{
"type": "referential",
"rule": "sum_equals",
"reference_metrics": [
"GRI_302_1_ELECTRICITY",
"GRI_302_1_NATURAL_GAS",
"GRI_302_1_DIESEL"
],
"target_metric": "GRI_302_1_TOTAL_ENERGY",
"tolerance_percentage": 1,
"error_message": "Sum of energy components must equal total energy (±1% tolerance)"
}
4. Evidence Validation
Ensures required evidence is attached.
| Rule | Parameters | Example | Description |
|---|---|---|---|
required_count |
value (int) |
{"type": "evidence", "rule": "required_count", "value": 1} |
Minimum evidence files required |
required_types |
types (array) |
See below | Specific evidence types required |
Example: Required Evidence Types
{
"type": "evidence",
"rule": "required_types",
"types": ["UTILITY_BILL", "METER_READING"],
"error_message": "Must attach either a utility bill or meter reading"
}
5. Business Rule Validation
Custom logic specific to ESG domain.
| Rule | Parameters | Example | Description |
|---|---|---|---|
custom |
expression (string) |
See below | Custom boolean expression |
Example: Renewable Energy Percentage
{
"type": "business",
"rule": "custom",
"expression": "value >= 0 && value <= 100",
"error_message": "Renewable energy percentage must be between 0% and 100%"
}
Example: Cross-Metric Dependency
{
"type": "business",
"rule": "custom",
"expression": "if (metric['GRI_401_1_NEW_HIRES'] > 0) then metric['GRI_404_1_TRAINING_HOURS'] > 0",
"error_message": "If new hires reported, training hours must be > 0"
}
6. Anomaly Detection
Statistical outlier detection (warnings, not hard failures).
| Rule | Parameters | Example | Description |
|---|---|---|---|
z_score |
threshold (number), severity |
{"type": "anomaly", "rule": "z_score", "threshold": 3, "severity": "warning"} |
Value > N std devs from historical mean |
yoy_change |
max_percentage (number) |
{"type": "anomaly", "rule": "yoy_change", "max_percentage": 50, "severity": "warning"} |
Year-over-year change > threshold |
Note: Anomaly rules produce warnings (not errors). Submission can proceed but reviewer is flagged.
3. Calculation Methods
Simple Metrics (No Calculation)
Direct data entry, no formula needed.
Example: Electricity Consumption
{
"metric_id": "GRI_302_1_ELECTRICITY",
"calculation_method": "Sum of monthly electricity consumption from utility bills",
"aggregation_method": "sum"
}
Calculated Metrics (Derived from Other Metrics)
Example: Total Energy
{
"metric_id": "GRI_302_1_TOTAL_ENERGY",
"calculation_method": "Sum of all energy sources (electricity + natural gas + diesel + other)",
"aggregation_method": "calculated",
"aggregation_formula": {
"expression": "SUM(GRI_302_1_ELECTRICITY, GRI_302_1_NATURAL_GAS, GRI_302_1_DIESEL, GRI_302_1_OTHER)",
"components": [
"GRI_302_1_ELECTRICITY",
"GRI_302_1_NATURAL_GAS",
"GRI_302_1_DIESEL",
"GRI_302_1_OTHER"
]
}
}
Example: Energy Intensity
{
"metric_id": "GRI_302_3_ENERGY_INTENSITY",
"calculation_method": "Total energy consumption (MWh) / Total revenue (USD million)",
"aggregation_method": "calculated",
"aggregation_formula": {
"expression": "GRI_302_1_TOTAL_ENERGY / CUSTOM_REVENUE",
"numerator": "GRI_302_1_TOTAL_ENERGY",
"denominator": "CUSTOM_REVENUE",
"unit": "MWh per USD million"
}
}
Weighted Average Metrics
Example: Average Training Hours per Employee
{
"metric_id": "GRI_404_1_AVG_TRAINING_HOURS",
"calculation_method": "Total training hours / Total employees (FTE)",
"aggregation_method": "weighted_average",
"aggregation_formula": {
"value_metric": "GRI_404_1_TOTAL_TRAINING_HOURS",
"weight_metric": "GRI_401_1_TOTAL_EMPLOYEES"
}
}
Conversion Factors (Unit Normalization)
For metrics with multiple input units, store conversion factors:
Example: Energy from Natural Gas (therms, cubic meters, or GJ)
{
"metric_id": "GRI_302_1_NATURAL_GAS",
"unit": "MWh",
"metadata": {
"conversion_factors": {
"therms_to_MWh": 0.029307,
"cubic_meters_to_MWh": 0.0108,
"GJ_to_MWh": 0.277778
},
"accepted_input_units": ["therms", "cubic_meters", "GJ", "MWh"]
},
"calculation_method": "Convert input to MWh using conversion factors"
}
Laravel Implementation:
class MetricConversionService
{
public function convertToCanonicalUnit($value, $inputUnit, MetricDefinition $metric)
{
$canonicalUnit = $metric->unit;
$conversionFactors = $metric->metadata['conversion_factors'] ?? [];
if ($inputUnit === $canonicalUnit) {
return $value; // No conversion needed
}
$conversionKey = "{$inputUnit}_to_{$canonicalUnit}";
if (!isset($conversionFactors[$conversionKey])) {
throw new \Exception("No conversion factor for {$inputUnit} to {$canonicalUnit}");
}
return $value * $conversionFactors[$conversionKey];
}
}
4. Collection Frequency & Reporting Dimensions
Collection Frequency
| Frequency | Use Case | Example Metrics |
|---|---|---|
| Monthly | Utility consumption, production output | Energy, water, waste, production volume |
| Quarterly | Operational metrics, incident tracking | Safety incidents, supplier audits |
| Annually | Strategic metrics, workforce data | Board diversity, total employees, materiality assessment |
| Ad-hoc | Event-driven, project-based | Spill incidents, community investments, one-time assessments |
Platform Implementation: - Collection templates generated based on frequency - Monthly metrics: Collector receives 12 submission forms per year - Annual metrics: Single submission form per reporting period
Dimensionality
| Dimension | Description | Aggregation to Parent |
|---|---|---|
| Site | Collected per facility/location | Aggregate to Business Unit → Organisation |
| Business Unit | Collected per division/department | Aggregate to Organisation |
| Organisation | Single value for entire org | No aggregation (top-level) |
| Project | Project-specific (construction, R&D) | Aggregate to Organisation (if applicable) |
Laravel Implementation:
class MetricTemplateGenerator
{
public function generateTemplates(ReportingPeriod $period, MetricDefinition $metric)
{
switch ($metric->dimensionality) {
case 'site':
// Generate one template per site in scope
return Site::where('tenant_id', $period->tenant_id)
->where('included_in_reporting', true)
->get()
->map(fn($site) => $this->createTemplate($period, $metric, $site));
case 'business_unit':
// Generate one template per business unit
return BusinessUnit::where('tenant_id', $period->tenant_id)
->where('included_in_reporting', true)
->get()
->map(fn($bu) => $this->createTemplate($period, $metric, null, $bu));
case 'organisation':
// Single template for entire org
return [$this->createTemplate($period, $metric)];
case 'project':
// Generate one template per active project
return Project::where('tenant_id', $period->tenant_id)
->where('reporting_period_id', $period->id)
->get()
->map(fn($proj) => $this->createTemplate($period, $metric, null, null, $proj));
}
}
}
5. Sensitivity Classification & Access Control
Classification Levels
| Classification | Who Can View | Who Can Edit | Example Metrics |
|---|---|---|---|
| Public | All roles | Collector, Reviewer, Approver, Admin | Total energy consumption, total waste |
| Internal | All internal roles (not external auditors) | Collector, Reviewer, Approver, Admin | Site-level energy, supplier list |
| Confidential | Reviewer, Approver, Admin, Auditor | Approver, Admin | Safety incident details, compliance violations |
| PII | Admin, Auditor (with justification) | Admin only | Employee names, diversity by individual |
Access Control Integration
Laravel Policy:
class MetricSubmissionPolicy
{
public function view(User $user, MetricSubmission $submission)
{
$metric = $submission->metricDefinition;
// Tenant boundary check
if ($user->tenant_id !== $submission->tenant_id) {
return false;
}
// Sensitivity check
switch ($metric->sensitivity_classification) {
case 'public':
case 'internal':
return $user->hasAnyRole(['collector', 'reviewer', 'approver', 'admin', 'auditor']);
case 'confidential':
return $user->hasAnyRole(['reviewer', 'approver', 'admin', 'auditor']);
case 'pii':
// PII requires admin or auditor with audit log entry
if ($user->hasRole('admin') || $user->hasRole('auditor')) {
AuditLog::logPiiAccess($user, $submission);
return true;
}
return false;
default:
return false;
}
}
}
PII Handling
Metrics marked with contains_pii = true trigger additional controls:
- Encryption at Rest: PII fields encrypted in database (Laravel encryption)
- Access Logging: Every view/export logged to audit table
- Right to Erasure: GDPR compliance - anonymize on request
- Data Minimization: Collect only necessary PII (e.g., counts instead of names)
Example: Employee Diversity
// ❌ BAD: Collects individual-level PII
{
"metric_id": "CUSTOM_DIVERSITY_DETAILED",
"data_type": "json",
"contains_pii": true,
"example_value": [
{"name": "John Doe", "gender": "M", "ethnicity": "Asian"},
...
]
}
// ✅ GOOD: Aggregated counts (no individual PII)
{
"metric_id": "GRI_405_1_DIVERSITY_COUNTS",
"data_type": "json",
"contains_pii": false,
"example_value": {
"gender": {"male": 120, "female": 95, "non_binary": 5},
"age_group": {"under_30": 60, "30_50": 120, "over_50": 40}
}
}
6. Custom KPIs & GRI Mapping
Custom KPI Approach
Clients can define custom metrics not in GRI Standards but still map them to disclosures for traceability.
Database Fields:
- is_custom_kpi = true
- disclosure_id: Optional link to GRI disclosure (e.g., "This custom water recycling metric supports GRI 303-3")
Example: Water Recycling Rate
{
"metric_id": "CUSTOM_WATER_RECYCLING_RATE",
"name": "Water Recycling Rate",
"description": "Percentage of water recycled and reused on-site",
"is_custom_kpi": true,
"disclosure_id": 123, // Links to GRI 303-3 (Water Withdrawal)
"data_type": "numeric",
"unit": "%",
"validation_rules": [
{"type": "domain", "rule": "min", "value": 0},
{"type": "domain", "rule": "max", "value": 100}
],
"collection_frequency": "monthly",
"dimensionality": "site",
"sensitivity_classification": "internal"
}
Reporting: - Custom KPIs appear in separate section of reports - Clear labeling: "Custom KPI (not GRI-standard)" - Optional mapping to GRI disclosure noted for context
7. Example Metric Definitions
Environmental: GRI 302-1 (Energy Consumption)
Electricity Consumption
{
"metric_id": "GRI_302_1_ELECTRICITY",
"name": "Electricity Consumption",
"description": "Total electricity purchased from grid, including renewable and non-renewable sources",
"framework_id": 1,
"standard_id": 10,
"disclosure_id": 50,
"is_custom_kpi": false,
"data_type": "numeric",
"unit": "MWh",
"collection_frequency": "monthly",
"dimensionality": "site",
"is_mandatory": true,
"validation_rules": [
{"type": "schema", "rule": "required", "error_message": "Electricity consumption is required"},
{"type": "schema", "rule": "numeric", "error_message": "Must be a number"},
{"type": "domain", "rule": "min", "value": 0, "error_message": "Cannot be negative"},
{"type": "domain", "rule": "precision", "value": 2, "error_message": "Max 2 decimal places"},
{"type": "evidence", "rule": "required_types", "types": ["UTILITY_BILL", "METER_READING"], "error_message": "Attach utility bill or meter reading"},
{"type": "anomaly", "rule": "yoy_change", "max_percentage": 100, "severity": "warning", "error_message": "Consumption changed >100% year-over-year"}
],
"allowed_evidence_types": ["UTILITY_BILL", "METER_READING", "INVOICE"],
"calculation_method": "Sum of monthly electricity consumption from utility bills or meter readings",
"aggregation_method": "sum",
"sensitivity_classification": "public",
"contains_pii": false,
"metadata": {
"ghg_scope": null,
"gri_disclosure_code": "302-1-a-i",
"tags": ["energy", "environmental", "scope_2"]
}
}
Social: GRI 401-1 (New Employee Hires)
{
"metric_id": "GRI_401_1_NEW_HIRES_TOTAL",
"name": "Total New Employee Hires",
"description": "Total number of new employees hired during the reporting period",
"framework_id": 1,
"standard_id": 25,
"disclosure_id": 110,
"is_custom_kpi": false,
"data_type": "integer",
"unit": "count",
"collection_frequency": "quarterly",
"dimensionality": "site",
"is_mandatory": true,
"validation_rules": [
{"type": "schema", "rule": "required", "error_message": "New hires count is required"},
{"type": "schema", "rule": "integer", "error_message": "Must be a whole number"},
{"type": "domain", "rule": "min", "value": 0, "error_message": "Cannot be negative"},
{"type": "evidence", "rule": "required_types", "types": ["HR_REGISTER", "EMPLOYMENT_CONTRACT"], "error_message": "Attach HR register or contracts"},
{"type": "business", "rule": "custom", "expression": "value <= total_employees", "error_message": "New hires cannot exceed total employees"}
],
"allowed_evidence_types": ["HR_REGISTER", "EMPLOYMENT_CONTRACT", "ONBOARDING_RECORD"],
"calculation_method": "Count of employment contracts signed during reporting period",
"aggregation_method": "sum",
"sensitivity_classification": "internal",
"contains_pii": false,
"metadata": {
"gri_disclosure_code": "401-1-a",
"tags": ["social", "employment", "workforce"]
}
}
Governance: GRI 205-3 (Corruption Incidents)
{
"metric_id": "GRI_205_3_CORRUPTION_INCIDENTS",
"name": "Confirmed Corruption Incidents",
"description": "Total number of confirmed incidents of corruption during the reporting period",
"framework_id": 1,
"standard_id": 35,
"disclosure_id": 140,
"is_custom_kpi": false,
"data_type": "integer",
"unit": "count",
"collection_frequency": "quarterly",
"dimensionality": "organisation",
"is_mandatory": true,
"validation_rules": [
{"type": "schema", "rule": "required", "error_message": "Incident count is required (enter 0 if none)"},
{"type": "schema", "rule": "integer", "error_message": "Must be a whole number"},
{"type": "domain", "rule": "min", "value": 0, "error_message": "Cannot be negative"},
{"type": "business", "rule": "custom", "expression": "if (value > 0) then evidence_count > 0", "error_message": "If incidents reported, must attach investigation reports"}
],
"allowed_evidence_types": ["INVESTIGATION_REPORT", "AUDIT_REPORT", "BOARD_MINUTES"],
"calculation_method": "Count of substantiated corruption incidents from internal investigations or audits",
"aggregation_method": "sum",
"sensitivity_classification": "confidential",
"contains_pii": false,
"metadata": {
"gri_disclosure_code": "205-3-a",
"tags": ["governance", "ethics", "compliance"],
"regulatory_sensitivity": "high"
}
}
Data Model Notes
Entities
Primary:
- metric_definitions: Metric catalog (this document)
Related:
- frameworks: GRI Standards 2021, ISSB, etc.
- gri_standards: GRI 302 (Energy), GRI 401 (Employment), etc.
- gri_disclosures: GRI 302-1, GRI 401-1, etc.
- metric_submissions: Actual data submitted by collectors
- validation_results: Output of validation engine
Relationships
frameworks (1) → (many) gri_standards
gri_standards (1) → (many) gri_disclosures
gri_disclosures (1) → (many) metric_definitions
metric_definitions (1) → (many) metric_submissions
metric_submissions (1) → (many) validation_results
Laravel Implementation Notes
Models
MetricDefinition:
class MetricDefinition extends Model
{
protected $casts = [
'validation_rules' => 'array',
'allowed_evidence_types' => 'array',
'allowed_values' => 'array',
'aggregation_formula' => 'array',
'metadata' => 'array',
'is_mandatory' => 'boolean',
'is_custom_kpi' => 'boolean',
'contains_pii' => 'boolean',
];
// Relationships
public function framework() { return $this->belongsTo(Framework::class); }
public function standard() { return $this->belongsTo(GriStandard::class); }
public function disclosure() { return $this->belongsTo(GriDisclosure::class); }
public function submissions() { return $this->hasMany(MetricSubmission::class); }
// Scopes
public function scopeMandatory($query) { return $query->where('is_mandatory', true); }
public function scopeByDimensionality($query, $dim) { return $query->where('dimensionality', $dim); }
public function scopePublicOnly($query) { return $query->where('sensitivity_classification', 'public'); }
// Helpers
public function isNumeric() { return $this->data_type === 'numeric'; }
public function requiresEvidence() { return !empty($this->allowed_evidence_types); }
}
Migrations
Schema::create('metric_definitions', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained();
// Identification
$table->string('metric_id', 100)->unique();
$table->string('name', 255);
$table->text('description')->nullable();
// GRI Mapping
$table->foreignId('framework_id')->nullable()->constrained();
$table->foreignId('standard_id')->nullable()->constrained('gri_standards');
$table->foreignId('disclosure_id')->nullable()->constrained('gri_disclosures');
$table->boolean('is_custom_kpi')->default(false);
// Data Type & Unit
$table->enum('data_type', ['numeric', 'boolean', 'text', 'date', 'enum']);
$table->string('unit', 50)->nullable();
$table->jsonb('allowed_values')->nullable();
// Collection & Dimensionality
$table->enum('collection_frequency', ['monthly', 'quarterly', 'annually', 'ad_hoc'])->nullable();
$table->enum('dimensionality', ['site', 'business_unit', 'organisation', 'project']);
$table->boolean('is_mandatory')->default(false);
// Validation
$table->jsonb('validation_rules')->nullable();
$table->jsonb('allowed_evidence_types')->nullable();
// Calculation & Aggregation
$table->text('calculation_method')->nullable();
$table->enum('aggregation_method', ['sum', 'weighted_average', 'count', 'calculated', 'none']);
$table->jsonb('aggregation_formula')->nullable();
// Security & Sensitivity
$table->enum('sensitivity_classification', ['public', 'internal', 'confidential', 'pii'])->default('internal');
$table->boolean('contains_pii')->default(false);
// Metadata
$table->jsonb('metadata')->nullable();
$table->integer('version')->default(1);
$table->timestamp('deprecated_at')->nullable();
$table->string('replaced_by_metric_id', 100)->nullable();
// Audit
$table->timestamps();
$table->foreignId('created_by_user_id')->nullable()->constrained('users');
// Indexes
$table->index(['tenant_id', 'metric_id']);
$table->index('disclosure_id');
$table->index('dimensionality');
$table->index('sensitivity_classification');
});
Services
ValidationService: - Executes validation rules against submissions - See Validation Engine
MetricConversionService: - Converts input units to canonical units - Applies conversion factors from metadata
MetricAggregationService: - Aggregates site-level data to org-level - See Reporting Concepts
Seeders
MetricCatalogSeeder:
class MetricCatalogSeeder extends Seeder
{
public function run()
{
// GRI 302-1: Energy Consumption
MetricDefinition::create([
'tenant_id' => 1, // Default tenant
'metric_id' => 'GRI_302_1_ELECTRICITY',
'name' => 'Electricity Consumption',
// ... (see example above)
]);
// GRI 401-1: New Hires
MetricDefinition::create([
'tenant_id' => 1,
'metric_id' => 'GRI_401_1_NEW_HIRES_TOTAL',
'name' => 'Total New Employee Hires',
// ... (see example above)
]);
// Continue for all GRI Scope v1 metrics...
}
}
Acceptance Criteria
Done When:
- [ ] metric_definitions table created with all fields from schema
- [ ] All 6 validation rule types (schema, domain, referential, evidence, business, anomaly) supported by ValidationService
- [ ] All 5 aggregation methods (sum, weighted_average, count, calculated, none) implemented in MetricAggregationService
- [ ] Sensitivity classification enforced in MetricSubmissionPolicy
- [ ] PII metrics trigger access logging on every view
- [ ] Custom KPIs can be created with optional GRI mapping
- [ ] Unit conversion service handles multiple input units (e.g., therms → MWh)
- [ ] Collection templates generated based on dimensionality (site, business_unit, org, project)
- [ ] Metric catalog seeder includes all GRI Scope v1 metrics (Environmental, Social, Governance)
- [ ] API endpoints expose metric catalog to Collector app (see Collector API)
- [ ] Admin UI allows metric catalog management (CRUD, validation rule editing)
Not Done (vNext): - [ ] Dynamic metric builder (UI for non-technical users to create metrics) - [ ] ML-powered anomaly detection (v1 uses statistical Z-score only) - [ ] Multi-framework metrics (single metric mapped to GRI + SASB + TCFD) - [ ] Real-time IoT sensor metric collection
Cross-References
- Related: ESG Domain Glossary - Term definitions
- Related: GRI/ESG Scope v1 - Framework coverage
- Related: Standards & Framework Metadata - Framework hierarchy
- Related: Validation Engine - Validation rule execution
- Related: Reporting Concepts - Aggregation methods
- Related: Collector API - Template delivery endpoints
- Dependencies: Validation Engine depends on this catalog
Change Log
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-03 | Senior Product Architect | Initial metric catalog with validation rules, calculation methods, examples |