485 lines
17 KiB
Markdown
485 lines
17 KiB
Markdown
|
|
# RFC: Config Channels
|
|||
|
|
|
|||
|
|
**Status:** Implemented
|
|||
|
|
**Created:** 2026-01-15
|
|||
|
|
**Authors:** Host UK Engineering
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Abstract
|
|||
|
|
|
|||
|
|
Config Channels add a voice/context dimension to configuration resolution. Where scopes (workspace, org, system) determine *who* a setting applies to, channels determine *where* or *how* it applies.
|
|||
|
|
|
|||
|
|
A workspace might have one Twitter handle but different posting styles for different contexts. Channels let you define `social.posting.style = "casual"` for Instagram while keeping `social.posting.style = "professional"` for LinkedIn—same workspace, same key, different channel.
|
|||
|
|
|
|||
|
|
The system resolves values through a two-dimensional matrix: scope chain (workspace → org → system) crossed with channel chain (specific → parent → null). Most specific wins, unless a parent declares FINAL.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Motivation
|
|||
|
|
|
|||
|
|
Traditional configuration systems work on a single dimension: scope hierarchy. You set a value at system level, override it at workspace level. Simple.
|
|||
|
|
|
|||
|
|
But some configuration varies by context within a single workspace:
|
|||
|
|
|
|||
|
|
- **Technical channels:** web vs API vs mobile (different rate limits, caching, auth)
|
|||
|
|
- **Social channels:** Instagram vs Twitter vs TikTok (different post lengths, hashtags, tone)
|
|||
|
|
- **Voice channels:** formal vs casual vs support (different language, greeting styles)
|
|||
|
|
|
|||
|
|
Without channels, you either:
|
|||
|
|
1. Create separate config keys for each context (`twitter.style`, `instagram.style`, etc.)
|
|||
|
|
2. Store JSON blobs and parse them at runtime
|
|||
|
|
3. Build custom logic for each use case
|
|||
|
|
|
|||
|
|
Channels generalise this pattern. One key, multiple channel-specific values, clean resolution.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Core Concepts
|
|||
|
|
|
|||
|
|
### Channel
|
|||
|
|
|
|||
|
|
A named context for configuration. Channels have:
|
|||
|
|
|
|||
|
|
| Property | Purpose |
|
|||
|
|
|----------|---------|
|
|||
|
|
| `code` | Unique identifier (e.g., `instagram`, `api`, `support`) |
|
|||
|
|
| `name` | Human-readable label |
|
|||
|
|
| `parent_id` | Optional parent for inheritance |
|
|||
|
|
| `workspace_id` | Owner workspace (null = system channel) |
|
|||
|
|
| `metadata` | Arbitrary JSON for channel-specific data |
|
|||
|
|
|
|||
|
|
### Channel Inheritance
|
|||
|
|
|
|||
|
|
Channels form inheritance trees. A specific channel inherits from its parent:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────┐
|
|||
|
|
│ null │ ← All channels (fallback)
|
|||
|
|
└────┬────┘
|
|||
|
|
│
|
|||
|
|
┌────┴────┐
|
|||
|
|
│ social │ ← Social media defaults
|
|||
|
|
└────┬────┘
|
|||
|
|
┌─────────┼─────────┐
|
|||
|
|
│ │ │
|
|||
|
|
┌────┴────┐ ┌──┴───┐ ┌───┴───┐
|
|||
|
|
│instagram│ │twitter│ │tiktok │
|
|||
|
|
└─────────┘ └───────┘ └───────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
When resolving `social.posting.style` for the `instagram` channel:
|
|||
|
|
1. Check instagram-specific value
|
|||
|
|
2. Check social (parent) value
|
|||
|
|
3. Check null (all channels) value
|
|||
|
|
|
|||
|
|
### System vs Workspace Channels
|
|||
|
|
|
|||
|
|
**System channels** (`workspace_id = null`) are available to all workspaces. Platform-level contexts like `web`, `api`, `mobile`.
|
|||
|
|
|
|||
|
|
**Workspace channels** are private to a workspace. Custom contexts like `vip_support`, `internal_comms`, or workspace-specific social accounts.
|
|||
|
|
|
|||
|
|
When looking up a channel by code, workspace channels take precedence over system channels with the same code. This allows workspaces to override system channel behaviour.
|
|||
|
|
|
|||
|
|
### Resolution Matrix
|
|||
|
|
|
|||
|
|
Config resolution operates on a matrix of scope × channel:
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌──────────────────────────────────────────┐
|
|||
|
|
│ Channel Chain │
|
|||
|
|
│ instagram → social → null │
|
|||
|
|
└──────────────────────────────────────────┘
|
|||
|
|
┌───────────────────┐ ┌──────────┬──────────┬──────────┐
|
|||
|
|
│ │ │ │ │ │
|
|||
|
|
│ Scope Chain │ │ instagram│ social │ null │
|
|||
|
|
│ │ │ │ │ │
|
|||
|
|
├───────────────────┼───┼──────────┼──────────┼──────────┤
|
|||
|
|
│ workspace │ │ 1 │ 2 │ 3 │
|
|||
|
|
├───────────────────┼───┼──────────┼──────────┼──────────┤
|
|||
|
|
│ org │ │ 4 │ 5 │ 6 │
|
|||
|
|
├───────────────────┼───┼──────────┼──────────┼──────────┤
|
|||
|
|
│ system │ │ 7 │ 8 │ 9 │
|
|||
|
|
└───────────────────┴───┴──────────┴──────────┴──────────┘
|
|||
|
|
|
|||
|
|
Resolution order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9
|
|||
|
|
(Most specific scope + most specific channel first)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
The first non-null value wins—unless a less-specific combination has `locked = true` (FINAL), which blocks all more-specific values.
|
|||
|
|
|
|||
|
|
### FINAL (Locked Values)
|
|||
|
|
|
|||
|
|
A value marked as `locked` cannot be overridden by more specific scopes or channels. This implements the FINAL pattern from Java/OOP:
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// System admin sets rate limit and locks it
|
|||
|
|
$config->set('api.rate_limit', 1000, $systemProfile, locked: true, channel: 'api');
|
|||
|
|
|
|||
|
|
// Workspace cannot override - locked value always wins
|
|||
|
|
$config->set('api.rate_limit', 5000, $workspaceProfile, channel: 'api');
|
|||
|
|
// ↑ This value exists but is never returned
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Lock checks traverse from least specific (system + null channel) to most specific. Any lock encountered blocks all more-specific values.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## How It Works
|
|||
|
|
|
|||
|
|
### Read Path
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ $config->get('social.posting.style') │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
│
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 1. Hash lookup (O(1)) │
|
|||
|
|
│ ConfigResolver::$values['social.posting.style'] │
|
|||
|
|
│ → Found? Return immediately │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
│ Miss
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 2. Lazy load scope (1 query) │
|
|||
|
|
│ Load all resolved values for workspace+channel into hash │
|
|||
|
|
│ → Check hash again │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
│ Still miss
|
|||
|
|
▼
|
|||
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|||
|
|
│ 3. Lazy prime (N queries) │
|
|||
|
|
│ Build profile chain (workspace → org → system) │
|
|||
|
|
│ Build channel chain (specific → parent → null) │
|
|||
|
|
│ Batch load all values for key │
|
|||
|
|
│ Walk resolution matrix until value found │
|
|||
|
|
│ Store in hash + database │
|
|||
|
|
└─────────────────────────────────────────────────────────────────┘
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Most reads hit step 1 (hash lookup). The heavy resolution only runs once per key per scope+channel combination, then gets cached.
|
|||
|
|
|
|||
|
|
### Write Path
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
$config->set(
|
|||
|
|
keyCode: 'social.posting.style',
|
|||
|
|
value: 'casual',
|
|||
|
|
profile: $workspaceProfile,
|
|||
|
|
locked: false,
|
|||
|
|
channel: 'instagram',
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
1. Update `config_values` (source of truth)
|
|||
|
|
2. Clear affected entries from hash and `config_resolved`
|
|||
|
|
3. Re-prime the key for affected scope+channel
|
|||
|
|
4. Fire `ConfigChanged` event
|
|||
|
|
|
|||
|
|
### Prime Operation
|
|||
|
|
|
|||
|
|
The prime operation pre-computes resolved values:
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// Prime entire workspace
|
|||
|
|
$config->prime($workspace, channel: 'instagram');
|
|||
|
|
|
|||
|
|
// Prime all workspaces (scheduled job)
|
|||
|
|
$config->primeAll();
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This runs full matrix resolution for every key and stores results in `config_resolved`. Subsequent reads become single indexed lookups.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## API Reference
|
|||
|
|
|
|||
|
|
### Channel Model
|
|||
|
|
|
|||
|
|
**Namespace:** `Core\Config\Models\Channel`
|
|||
|
|
|
|||
|
|
#### Properties
|
|||
|
|
|
|||
|
|
| Property | Type | Description |
|
|||
|
|
|----------|------|-------------|
|
|||
|
|
| `code` | string | Unique identifier |
|
|||
|
|
| `name` | string | Human-readable label |
|
|||
|
|
| `parent_id` | int\|null | Parent channel for inheritance |
|
|||
|
|
| `workspace_id` | int\|null | Owner (null = system) |
|
|||
|
|
| `metadata` | array\|null | Arbitrary JSON data |
|
|||
|
|
|
|||
|
|
#### Methods
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// Find by code (prefers workspace-specific over system)
|
|||
|
|
Channel::byCode('instagram', $workspaceId): ?Channel
|
|||
|
|
|
|||
|
|
// Get inheritance chain (most specific first)
|
|||
|
|
$channel->inheritanceChain(): Collection
|
|||
|
|
|
|||
|
|
// Get all codes in chain
|
|||
|
|
$channel->inheritanceCodes(): array // ['instagram', 'social']
|
|||
|
|
|
|||
|
|
// Check inheritance
|
|||
|
|
$channel->inheritsFrom('social'): bool
|
|||
|
|
|
|||
|
|
// Is system channel?
|
|||
|
|
$channel->isSystem(): bool
|
|||
|
|
|
|||
|
|
// Get metadata value
|
|||
|
|
$channel->meta('platform_id'): mixed
|
|||
|
|
|
|||
|
|
// Ensure channel exists
|
|||
|
|
Channel::ensure(
|
|||
|
|
code: 'instagram',
|
|||
|
|
name: 'Instagram',
|
|||
|
|
parentCode: 'social',
|
|||
|
|
workspaceId: null,
|
|||
|
|
metadata: ['platform_id' => 'ig'],
|
|||
|
|
): Channel
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### ConfigService with Channels
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
$config = app(ConfigService::class);
|
|||
|
|
|
|||
|
|
// Set context (typically in middleware)
|
|||
|
|
$config->setContext($workspace, $channel);
|
|||
|
|
|
|||
|
|
// Get value using context
|
|||
|
|
$value = $config->get('social.posting.style');
|
|||
|
|
|
|||
|
|
// Explicit channel override
|
|||
|
|
$result = $config->resolve('social.posting.style', $workspace, 'instagram');
|
|||
|
|
|
|||
|
|
// Set channel-specific value
|
|||
|
|
$config->set(
|
|||
|
|
keyCode: 'social.posting.style',
|
|||
|
|
value: 'casual',
|
|||
|
|
profile: $profile,
|
|||
|
|
locked: false,
|
|||
|
|
channel: 'instagram',
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Lock a channel-specific value
|
|||
|
|
$config->lock('social.posting.style', $profile, 'instagram');
|
|||
|
|
|
|||
|
|
// Prime for specific channel
|
|||
|
|
$config->prime($workspace, 'instagram');
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### ConfigValue with Channels
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// Find value for profile + key + channel
|
|||
|
|
ConfigValue::findValue($profileId, $keyId, $channelId): ?ConfigValue
|
|||
|
|
|
|||
|
|
// Set value with channel
|
|||
|
|
ConfigValue::setValue(
|
|||
|
|
profileId: $profileId,
|
|||
|
|
keyId: $keyId,
|
|||
|
|
value: 'casual',
|
|||
|
|
locked: false,
|
|||
|
|
inheritedFrom: null,
|
|||
|
|
channelId: $channelId,
|
|||
|
|
): ConfigValue
|
|||
|
|
|
|||
|
|
// Get all values for key across profiles and channels
|
|||
|
|
ConfigValue::forKeyInProfiles($keyId, $profileIds, $channelIds): Collection
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Database Schema
|
|||
|
|
|
|||
|
|
### config_channels
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
CREATE TABLE config_channels (
|
|||
|
|
id BIGINT PRIMARY KEY,
|
|||
|
|
code VARCHAR(255),
|
|||
|
|
name VARCHAR(255),
|
|||
|
|
parent_id BIGINT REFERENCES config_channels(id),
|
|||
|
|
workspace_id BIGINT REFERENCES workspaces(id),
|
|||
|
|
metadata JSON,
|
|||
|
|
created_at TIMESTAMP,
|
|||
|
|
updated_at TIMESTAMP,
|
|||
|
|
|
|||
|
|
UNIQUE (code, workspace_id),
|
|||
|
|
INDEX (parent_id)
|
|||
|
|
);
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### config_values (extended)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
ALTER TABLE config_values ADD COLUMN
|
|||
|
|
channel_id BIGINT REFERENCES config_channels(id);
|
|||
|
|
|
|||
|
|
-- Updated unique constraint
|
|||
|
|
UNIQUE (profile_id, key_id, channel_id)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### config_resolved (extended)
|
|||
|
|
|
|||
|
|
```sql
|
|||
|
|
-- Channel dimension in resolved cache
|
|||
|
|
channel_id BIGINT,
|
|||
|
|
source_channel_id BIGINT,
|
|||
|
|
|
|||
|
|
-- Composite lookup
|
|||
|
|
INDEX (workspace_id, channel_id, key_code)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Examples
|
|||
|
|
|
|||
|
|
### Multi-platform social posting
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// System defaults (all channels)
|
|||
|
|
$config->set('social.posting.max_length', 280, $systemProfile);
|
|||
|
|
$config->set('social.posting.style', 'professional', $systemProfile);
|
|||
|
|
|
|||
|
|
// Channel-specific overrides
|
|||
|
|
$config->set('social.posting.max_length', 2200, $systemProfile, channel: 'instagram');
|
|||
|
|
$config->set('social.posting.max_length', 100000, $systemProfile, channel: 'linkedin');
|
|||
|
|
$config->set('social.posting.style', 'casual', $workspaceProfile, channel: 'tiktok');
|
|||
|
|
|
|||
|
|
// Resolution
|
|||
|
|
$config->resolve('social.posting.max_length', $workspace, 'twitter'); // 280 (default)
|
|||
|
|
$config->resolve('social.posting.max_length', $workspace, 'instagram'); // 2200
|
|||
|
|
$config->resolve('social.posting.style', $workspace, 'tiktok'); // 'casual'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### API rate limiting with FINAL
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// System admin sets hard limit for API channel
|
|||
|
|
$config->set('api.rate_limit.requests', 1000, $systemProfile, locked: true, channel: 'api');
|
|||
|
|
$config->set('api.rate_limit.window', 60, $systemProfile, locked: true, channel: 'api');
|
|||
|
|
|
|||
|
|
// Workspaces cannot exceed this
|
|||
|
|
$config->set('api.rate_limit.requests', 5000, $workspaceProfile, channel: 'api');
|
|||
|
|
// ↑ Stored but never returned - locked value wins
|
|||
|
|
|
|||
|
|
$config->resolve('api.rate_limit.requests', $workspace, 'api'); // Always 1000
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Voice/tone channels
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// Define voice channels
|
|||
|
|
Channel::ensure('support', 'Customer Support', parentCode: null);
|
|||
|
|
Channel::ensure('vi', 'Virtual Intelligence', parentCode: null);
|
|||
|
|
Channel::ensure('formal', 'Formal Communications', parentCode: null);
|
|||
|
|
|
|||
|
|
// Configure per voice
|
|||
|
|
$config->set('comms.greeting', 'Hello', $workspaceProfile, channel: null);
|
|||
|
|
$config->set('comms.greeting', 'Hey there!', $workspaceProfile, channel: 'support');
|
|||
|
|
$config->set('comms.greeting', 'Greetings', $workspaceProfile, channel: 'formal');
|
|||
|
|
$config->set('comms.greeting', 'Hi, I\'m your AI assistant', $workspaceProfile, channel: 'vi');
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Channel inheritance
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// Create hierarchy
|
|||
|
|
Channel::ensure('social', 'Social Media');
|
|||
|
|
Channel::ensure('instagram', 'Instagram', parentCode: 'social');
|
|||
|
|
Channel::ensure('instagram_stories', 'Instagram Stories', parentCode: 'instagram');
|
|||
|
|
|
|||
|
|
// Set at parent level
|
|||
|
|
$config->set('social.hashtags.enabled', true, $profile, channel: 'social');
|
|||
|
|
$config->set('social.hashtags.max', 30, $profile, channel: 'instagram');
|
|||
|
|
|
|||
|
|
// Child inherits from parent
|
|||
|
|
$config->resolve('social.hashtags.enabled', $workspace, 'instagram_stories');
|
|||
|
|
// → true (inherited from 'social')
|
|||
|
|
|
|||
|
|
$config->resolve('social.hashtags.max', $workspace, 'instagram_stories');
|
|||
|
|
// → 30 (inherited from 'instagram')
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### Workspace-specific channel override
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// System channel
|
|||
|
|
Channel::ensure('premium', 'Premium Features', workspaceId: null);
|
|||
|
|
|
|||
|
|
// Workspace overrides system channel
|
|||
|
|
Channel::ensure('premium', 'VIP Premium', workspaceId: $workspace->id, metadata: [
|
|||
|
|
'features' => ['priority_support', 'custom_branding'],
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Lookup prefers workspace channel
|
|||
|
|
$channel = Channel::byCode('premium', $workspace->id);
|
|||
|
|
// → Workspace's 'VIP Premium' channel, not system 'Premium Features'
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Implementation Notes
|
|||
|
|
|
|||
|
|
### Performance Considerations
|
|||
|
|
|
|||
|
|
The channel system adds a dimension to resolution, but performance impact is minimal:
|
|||
|
|
|
|||
|
|
1. **Read path unchanged** — Most reads hit the hash (O(1))
|
|||
|
|
2. **Batch loading** — Resolution loads all channel values in one query
|
|||
|
|
3. **Cached resolution** — `config_resolved` stores pre-computed values per workspace+channel
|
|||
|
|
4. **Lazy priming** — Only computes on first access, not on every request
|
|||
|
|
|
|||
|
|
### Cycle Detection
|
|||
|
|
|
|||
|
|
Channel inheritance includes cycle detection to handle data corruption:
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
public function inheritanceChain(): Collection
|
|||
|
|
{
|
|||
|
|
$seen = [$this->id => true];
|
|||
|
|
|
|||
|
|
while ($current->parent_id !== null) {
|
|||
|
|
if (isset($seen[$current->parent_id])) {
|
|||
|
|
Log::error('Circular reference in channel inheritance');
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
// ...
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### MariaDB NULL Handling
|
|||
|
|
|
|||
|
|
The `config_resolved` table uses `0` instead of `NULL` for system scope and all-channels:
|
|||
|
|
|
|||
|
|
```php
|
|||
|
|
// MariaDB composite unique constraints don't handle NULL well
|
|||
|
|
// workspace_id = 0 means system scope
|
|||
|
|
// channel_id = 0 means all channels
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
This is an implementation detail—the API accepts and returns `null` as expected.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Related Files
|
|||
|
|
|
|||
|
|
- `app/Core/Config/Models/Channel.php` — Channel model
|
|||
|
|
- `app/Core/Config/Models/ConfigValue.php` — Value storage with channel support
|
|||
|
|
- `app/Core/Config/ConfigResolver.php` — Resolution engine
|
|||
|
|
- `app/Core/Config/ConfigService.php` — Main API
|
|||
|
|
- `app/Core/Config/Migrations/2026_01_09_100001_add_config_channels.php` — Schema
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Version History
|
|||
|
|
|
|||
|
|
| Version | Date | Changes |
|
|||
|
|
|---------|------|---------|
|
|||
|
|
| 1.0 | 2026-01-15 | Initial RFC |
|