specs/RFC-005-COMMERCE-MATRIX.md
2026-02-01 07:41:21 +00:00

20 KiB

RFC: Commerce Entity Matrix

Status: Implemented Created: 2026-01-15 Authors: Host UK Engineering


Abstract

The Commerce Entity Matrix is a hierarchical permission and content system for multi-channel commerce. It enables master companies (M1) to control product catalogues, storefronts (M2) to select and white-label products, and dropshippers (M3) to inherit complete stores with zero management overhead.

The core innovation is top-down immutable permissions: if a parent says "NO", every descendant is locked to "NO". Children can only restrict further, never expand. Combined with sparse content overrides and a self-learning training mode, the system provides complete audit trails and deterministic behaviour.

Like HLCRF for layouts and Compound SKU for product identity, the Matrix eliminates complexity through composable primitives rather than configuration sprawl.


Motivation

Traditional multi-tenant commerce systems copy data between entities, leading to synchronisation nightmares, inconsistent pricing, and broken audit trails. When Original Organics ran four websites, telephone orders, mail orders, and garden centre voucher schemes in 2008, they needed a system where:

  1. M1 owns truth — Products exist in one place; everything else references them
  2. M2 selects and customises — Storefronts choose products and can override presentation
  3. M3 inherits completely — Dropshippers get fully functional stores without management burden
  4. Permissions cascade down — A restriction at the top is immutable below
  5. Every action is gated — No default-allow; if it wasn't trained, it doesn't work

The Matrix addresses this through hierarchical entities, sparse overrides, and request-level permission enforcement.


Terminology

Entity Types

Code Type Role
M1 Master Company Source of truth. Owns the product catalogue, sets base pricing, controls what's possible.
M2 Facade/Storefront Selects from M1's catalogue. Can override content, adjust pricing within bounds, operate independent sales channels.
M3 Dropshipper Full inheritance with zero management. Sees everything, reports everything, manages nothing. Can create their own M2s.

Entity Hierarchy

M1 - Master Company (Source of Truth)
│
├── Master Product Catalogue
│   └── Products live here, nowhere else
│
├── M2 - Storefronts (Select from M1)
│   ├── waterbutts.com
│   ├── originalorganics.co.uk
│   ├── telephone-orders (internal)
│   └── garden-vouchers (B2B)
│
└── M3 - Dropshippers (Full Inheritance)
    ├── External company selling our products
    └── Can have their own M2s
        ├── dropshipper.com
        └── dropshipper-wholesale.com

Materialised Path

Each entity stores its position in the hierarchy as a path string:

Entity Path Depth
ORGORG (M1) ORGORG 0
WBUTS (M2) ORGORG/WBUTS 1
DRPSHP (M3) ORGORG/WBUTS/DRPSHP 2

The path enables ancestor lookups without recursive queries.


Permission Matrix

The Core Rules

If M1 says "NO" → Everything below is "NO"
If M1 says "YES" → M2 can say "NO" for itself
If M2 says "YES" → M3 can say "NO" for itself

Permissions cascade DOWN. Restrictions are IMMUTABLE from above.

Visual Model

                    M1 (Master)
                    ├── can_sell_alcohol: NO ──────────────┐
                    ├── can_discount: YES                  │
                    └── can_export: YES                    │
                         │                                 │
            ┌────────────┼────────────┐                    │
            ▼            ▼            ▼                    │
         M2-Web      M2-Phone     M2-Voucher              │
         ├── can_sell_alcohol: [LOCKED NO] ◄──────────────┘
         ├── can_discount: NO (restricted self)
         └── can_export: YES (inherited)
              │
              ▼
           M3-Dropshipper
           ├── can_sell_alcohol: [LOCKED NO] (from M1)
           ├── can_discount: [LOCKED NO] (from M2)
           └── can_export: YES (can restrict to NO)

The Three Dimensions

Dimension 1: Entity Hierarchy (M1 → M2 → M3)
Dimension 2: Permission Keys (can_sell, can_discount, can_view_cost...)
Dimension 3: Resource Scope (products, orders, customers, reports...)

Permission = Matrix[Entity][Key][Scope]

Permission Entry Schema

CREATE TABLE permission_matrix (
    id BIGINT PRIMARY KEY,
    entity_id BIGINT NOT NULL,

    -- What permission
    key VARCHAR(128),              -- product.create, order.refund
    scope VARCHAR(128),            -- Resource type or specific ID

    -- The value
    allowed BOOLEAN DEFAULT FALSE,
    locked BOOLEAN DEFAULT FALSE,  -- Set by parent, cannot override

    -- Audit
    source VARCHAR(32),            -- inherited, explicit, trained
    set_by_entity_id BIGINT,       -- Who locked it
    trained_at TIMESTAMP,          -- When it was learned
    trained_route VARCHAR(255),    -- Which route triggered training

    UNIQUE (entity_id, key, scope)
);

Source Types

Source Meaning
inherited Cascaded from parent entity's lock
explicit Manually set by administrator
trained Learned through training mode

Permission Cascade Algorithm

When checking if an entity can perform an action:

1. Build hierarchy path (root M1 → parent M2 → current entity)
2. For each ancestor, top-down:
   - Find permission for (entity, key, scope)
   - If locked AND denied → RETURN DENIED (immutable)
   - If denied (not locked) → RETURN DENIED
3. Check entity's own permission:
   - If exists → RETURN allowed/denied
4. Permission undefined → handle based on mode

Lock Cascade

When an entity locks a permission, all descendants receive an inherited lock:

public function lock(Entity $entity, string $key, bool $allowed): void
{
    // Set on this entity
    PermissionMatrix::updateOrCreate(
        ['entity_id' => $entity->id, 'key' => $key],
        ['allowed' => $allowed, 'locked' => true, 'source' => 'explicit']
    );

    // Cascade to all descendants
    $descendants = Entity::where('path', 'like', $entity->path . '/%')->get();

    foreach ($descendants as $descendant) {
        PermissionMatrix::updateOrCreate(
            ['entity_id' => $descendant->id, 'key' => $key],
            [
                'allowed' => $allowed,
                'locked' => true,
                'source' => 'inherited',
                'set_by_entity_id' => $entity->id,
            ]
        );
    }
}

Training Mode

The Problem

Building a complete permission matrix upfront is impractical. You don't know every action until you build the system.

The Solution

Training mode learns permissions by observing real usage:

1. Developer navigates to /admin/products
2. Clicks "Create Product"
3. System: "BLOCKED - No permission defined for:"
   - Entity: M1-Admin
   - Action: product.create
   - Route: POST /admin/products

4. Developer clicks [Allow for M1-Admin]
5. Permission recorded in matrix with source='trained'
6. Continue working

Result: Complete map of every action in the system

Configuration

// config/commerce.php
'matrix' => [
    // Training mode - undefined permissions prompt for approval
    'training_mode' => env('COMMERCE_MATRIX_TRAINING', false),

    // Production mode - undefined = denied
    'strict_mode' => env('COMMERCE_MATRIX_STRICT', true),

    // Log all permission checks (for audit)
    'log_all_checks' => env('COMMERCE_MATRIX_LOG_ALL', false),

    // Log denied requests
    'log_denials' => true,

    // Default action when permission undefined (only if strict=false)
    'default_allow' => false,
],

Permission Request Logging

CREATE TABLE permission_requests (
    id BIGINT PRIMARY KEY,
    entity_id BIGINT NOT NULL,

    -- Request details
    method VARCHAR(10),            -- GET, POST, PUT, DELETE
    route VARCHAR(255),            -- /admin/products
    action VARCHAR(128),           -- product.create
    scope VARCHAR(128),

    -- Context
    request_data JSON,             -- Sanitised request params
    user_agent VARCHAR(255),
    ip_address VARCHAR(45),

    -- Result
    status VARCHAR(32),            -- allowed, denied, pending
    was_trained BOOLEAN DEFAULT FALSE,
    trained_at TIMESTAMP,

    created_at TIMESTAMP
);

Production Mode

If permission not in matrix → 403 Forbidden
No exceptions. No fallbacks. No "default allow".

If it wasn't trained, it doesn't exist.

Product Assignment

How Products Flow Through the Hierarchy

M1 owns the master catalogue. M2/M3 entities don't copy products; they create assignments that reference the master and optionally override specific fields.

CREATE TABLE commerce_product_assignments (
    id BIGINT PRIMARY KEY,
    entity_id BIGINT NOT NULL,     -- M2 or M3
    product_id BIGINT NOT NULL,    -- Reference to master

    -- SKU customisation
    sku_suffix VARCHAR(64),        -- Custom suffix for this entity

    -- Price overrides (if allowed by matrix)
    price_override INT,            -- Override base price
    price_tier_overrides JSON,     -- Override tier pricing
    margin_percent DECIMAL(5,2),   -- Percentage margin
    fixed_margin INT,              -- Fixed margin amount

    -- Content overrides
    name_override VARCHAR(255),
    description_override TEXT,
    image_override VARCHAR(512),

    -- Control
    is_active BOOLEAN DEFAULT TRUE,
    is_featured BOOLEAN DEFAULT FALSE,
    sort_order INT DEFAULT 0,
    allocated_stock INT,           -- Entity-specific allocation
    can_discount BOOLEAN DEFAULT TRUE,
    min_price INT,                 -- Floor price
    max_price INT,                 -- Ceiling price

    UNIQUE (entity_id, product_id)
);

Effective Values

The assignment provides effective value getters that fall back to the master product:

public function getEffectivePrice(): int
{
    return $this->price_override ?? $this->product->price;
}

public function getEffectiveName(): string
{
    return $this->name_override ?? $this->product->name;
}

SKU Lineage

Full SKUs encode the entity path:

ORGORG-WBUTS-WB500L    # Original Organics → Waterbutts → 500L Water Butt
ORGORG-PHONE-WB500L    # Same product, telephone channel
DRPSHP-THEIR1-WB500L   # Dropshipper's storefront selling our product

This tracks:

  • Where the sale originated
  • Which facade/channel
  • Back to master SKU

Content Overrides

The Core Insight

Don't copy data. Create sparse overrides. Resolve at runtime.

M1 (Master) has content
    │
    │ (M2 sees M1's content by default)
    ▼
M2 customises product name
    │
    │ Override entry: (M2, product:123, name, "Custom Name")
    │ Everything else still inherits from M1
    ▼
M3 (Dropshipper) inherits M2's view
    │
    │ (Sees M2's custom name, M1's everything else)
    ▼
M3 customises description
    │
    │ Override entry: (M3, product:123, description, "Their description")
    │ Still has M2's name, M1's other fields
    ▼
Resolution: M3 sees merged content from all levels

Override Table Schema

CREATE TABLE commerce_content_overrides (
    id BIGINT PRIMARY KEY,
    entity_id BIGINT NOT NULL,

    -- What's being overridden (polymorphic)
    overrideable_type VARCHAR(128),  -- Product, Category, Page, etc.
    overrideable_id BIGINT,
    field VARCHAR(64),               -- name, description, image, price

    -- The override value
    value TEXT,
    value_type VARCHAR(32),          -- string, json, html, decimal, boolean

    -- Audit
    created_by BIGINT,
    updated_by BIGINT,
    created_at TIMESTAMP,
    updated_at TIMESTAMP,

    UNIQUE (entity_id, overrideable_type, overrideable_id, field)
);

Value Types

Type Storage Use Case
string Raw text Names, short descriptions
json JSON-encoded Structured data, arrays
html Raw HTML Rich content
integer String → int Counts, quantities
decimal String → float Prices, percentages
boolean 1/0 Flags, toggles

Resolution Algorithm

Query: "What is product 123's name for M3-ACME?"

Step 1: Check M3-ACME overrides
        → NULL (no override)

Step 2: Check M2-WATERBUTTS overrides (parent)
        → "Premium 500L Water Butt" ✓

Step 3: Return "Premium 500L Water Butt"
        (M3-ACME sees M2's override, not M1's original)

If M3-ACME later customises the name, their override takes precedence for themselves and their descendants.


API Reference

PermissionMatrixService

The service handles all permission checks and training.

use Mod\Commerce\Services\PermissionMatrixService;

$matrix = app(PermissionMatrixService::class);

// Check permission
$result = $matrix->can($entity, 'product.create', $scope);

if ($result->isAllowed()) {
    // Proceed
} elseif ($result->isDenied()) {
    // Handle denial: $result->reason
} elseif ($result->isUndefined()) {
    // No permission defined
}

// Gate a request (handles training mode)
$result = $matrix->gateRequest($request, $entity, 'order.refund');

// Set permission explicitly
$matrix->setPermission($entity, 'product.create', true);

// Lock permission (cascades to descendants)
$matrix->lock($entity, 'product.view_cost', false);

// Unlock (removes inherited locks)
$matrix->unlock($entity, 'product.view_cost');

// Train permission (dev mode)
$matrix->train($entity, 'product.create', $scope, true, $route);

PermissionResult

use Mod\Commerce\Services\PermissionResult;

// Factory methods
PermissionResult::allowed();
PermissionResult::denied(reason: 'Locked by M1', lockedBy: $entity);
PermissionResult::undefined(key: 'action', scope: 'resource');
PermissionResult::pending(key: 'action', trainingUrl: '/train/...');

// Status checks
$result->isAllowed();
$result->isDenied();
$result->isUndefined();
$result->isPending();

Entity Model

use Mod\Commerce\Models\Entity;

// Create master
$m1 = Entity::createMaster('ORGORG', 'Original Organics');

// Create facade under master
$m2 = $m1->createFacade('WBUTS', 'Waterbutts.com', [
    'domain' => 'waterbutts.com',
    'currency' => 'GBP',
]);

// Create dropshipper under facade
$m3 = $m2->createDropshipper('ACME', 'ACME Supplies');

// Hierarchy helpers
$m3->getAncestors();    // [M1, M2]
$m3->getHierarchy();    // [M1, M2, M3]
$m3->getRoot();         // M1
$m3->getDescendants();  // Children, grandchildren, etc.

// Type checks
$entity->isMaster();      // or isM1()
$entity->isFacade();      // or isM2()
$entity->isDropshipper(); // or isM3()

// SKU building
$entity->buildSku('WB500L'); // "ORGORG-WBUTS-WB500L"

Standard Permission Keys

// Product permissions
'product.list'              // View product list
'product.view'              // View product detail
'product.view_cost'         // See cost price (M1 only usually)
'product.create'            // Create new product (M1 only)
'product.update'            // Update product
'product.delete'            // Delete product
'product.price_override'    // Override price on facade

// Order permissions
'order.list'                // View orders
'order.view'                // View order detail
'order.create'              // Create order
'order.update'              // Update order
'order.cancel'              // Cancel order
'order.refund'              // Process refund
'order.export'              // Export order data

// Customer permissions
'customer.list'
'customer.view'
'customer.view_email'       // See customer email
'customer.view_phone'       // See customer phone
'customer.export'           // Export customer data (GDPR)

// Report permissions
'report.sales'              // Sales reports
'report.revenue'            // Revenue (might hide from M3)
'report.cost'               // Cost reports (M1 only)
'report.margin'             // Margin reports (M1 only)

// System permissions
'settings.view'
'settings.update'
'entity.create'             // Create child entities
'entity.manage'             // Manage entity settings

Middleware Integration

CommerceMatrixGate

// app/Http/Middleware/CommerceMatrixGate.php

public function handle(Request $request, Closure $next)
{
    $entity = $this->resolveEntity($request);
    $action = $this->resolveAction($request);

    if (!$entity || !$action) {
        return $next($request); // Not a commerce route
    }

    $result = $this->matrix->gateRequest($request, $entity, $action);

    if ($result->isDenied()) {
        return response()->json([
            'error' => 'permission_denied',
            'message' => $result->reason,
        ], 403);
    }

    if ($result->isPending()) {
        // Training mode - show prompt
        return response()->view('commerce.matrix.train-prompt', [
            'result' => $result,
            'entity' => $entity,
        ], 428); // Precondition Required
    }

    return $next($request);
}

Route Definition

// Explicit action mapping
Route::post('/products', [ProductController::class, 'store'])
    ->matrixAction('product.create');

Route::post('/orders/{order}/refund', [OrderController::class, 'refund'])
    ->matrixAction('order.refund');

Order Flow Through the Matrix

Customer places order on waterbutts.com (M2)
    │
    ▼
┌─────────────────────────────────────────┐
│ Order Created                            │
│ - entity_id: M2-WBUTS                   │
│ - sku: ORGORG-WBUTS-WB500L              │
│ - customer sees: M2 branding            │
└────────────────┬────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────┐
│ M1 Fulfillment Queue                     │
│ - M1 sees all orders from all M2s       │
│ - Can filter by facade                  │
│ - Ships with M2 branding (or neutral)   │
└────────────────┬────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────┐
│ Reporting                                │
│ - M1: Sees all, costs, margins          │
│ - M2: Sees own orders, no cost data     │
│ - M3: Sees own orders, wholesale price  │
└─────────────────────────────────────────┘

Pricing

Pricing is not a separate system. It emerges from:

  1. Permission Matrixcan_discount, max_discount_percent, can_sell_below_wholesale
  2. Product Assignmentsprice_override, min_price, max_price, margin_percent
  3. Content Overrides — Sparse price adjustments per entity
  4. SKU System — Bundle hashes, option modifiers, volume rules

No separate pricing engine needed. Primitives compose.


Implementation Files

Models

  • app/Mod/Commerce/Models/Entity.php — Entity hierarchy
  • app/Mod/Commerce/Models/PermissionMatrix.php — Permission entries
  • app/Mod/Commerce/Models/PermissionRequest.php — Request logging
  • app/Mod/Commerce/Models/ContentOverride.php — Sparse overrides
  • app/Mod/Commerce/Models/ProductAssignment.php — M2/M3 product links

Services

  • app/Mod/Commerce/Services/PermissionMatrixService.php — Permission logic
  • app/Mod/Commerce/Services/ContentOverrideService.php — Override resolution

Configuration

  • app/Mod/Commerce/config.php — Matrix configuration


Version History

Version Date Changes
1.0 2026-01-15 Initial RFC