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:
- M1 owns truth — Products exist in one place; everything else references them
- M2 selects and customises — Storefronts choose products and can override presentation
- M3 inherits completely — Dropshippers get fully functional stores without management burden
- Permissions cascade down — A restriction at the top is immutable below
- 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:
- Permission Matrix —
can_discount,max_discount_percent,can_sell_below_wholesale - Product Assignments —
price_override,min_price,max_price,margin_percent - Content Overrides — Sparse price adjustments per entity
- 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 hierarchyapp/Mod/Commerce/Models/PermissionMatrix.php— Permission entriesapp/Mod/Commerce/Models/PermissionRequest.php— Request loggingapp/Mod/Commerce/Models/ContentOverride.php— Sparse overridesapp/Mod/Commerce/Models/ProductAssignment.php— M2/M3 product links
Services
app/Mod/Commerce/Services/PermissionMatrixService.php— Permission logicapp/Mod/Commerce/Services/ContentOverrideService.php— Override resolution
Configuration
app/Mod/Commerce/config.php— Matrix configuration
Related RFCs
- HLCRF Compositor — Same philosophy applied to layouts
- Compound SKU — Same philosophy applied to product identity
Version History
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2026-01-15 | Initial RFC |