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

705 lines
20 KiB
Markdown

# 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](./HLCRF-COMPOSITOR.md) for layouts and [Compound SKU](./COMPOUND-SKU.md) 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
```sql
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:
```php
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
```php
// 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
```sql
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.
```sql
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:
```php
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
```sql
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.
```php
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
```php
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
```php
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
```php
// 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
```php
// 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
```php
// 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 Matrix**`can_discount`, `max_discount_percent`, `can_sell_below_wholesale`
2. **Product Assignments**`price_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
---
## Related RFCs
- [HLCRF Compositor](./HLCRF-COMPOSITOR.md) — Same philosophy applied to layouts
- [Compound SKU](./COMPOUND-SKU.md) — Same philosophy applied to product identity
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-01-15 | Initial RFC |