Skip to content

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:

  1. Encryption at Rest: PII fields encrypted in database (Laravel encryption)
  2. Access Logging: Every view/export logged to audit table
  3. Right to Erasure: GDPR compliance - anonymize on request
  4. 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


Change Log

Version Date Author Changes
1.0 2026-01-03 Senior Product Architect Initial metric catalog with validation rules, calculation methods, examples