17 KiB
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:
- Create separate config keys for each context (
twitter.style,instagram.style, etc.) - Store JSON blobs and parse them at runtime
- 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:
- Check instagram-specific value
- Check social (parent) value
- 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:
// 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
$config->set(
keyCode: 'social.posting.style',
value: 'casual',
profile: $workspaceProfile,
locked: false,
channel: 'instagram',
);
- Update
config_values(source of truth) - Clear affected entries from hash and
config_resolved - Re-prime the key for affected scope+channel
- Fire
ConfigChangedevent
Prime Operation
The prime operation pre-computes resolved values:
// 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
// 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
$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
// 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
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)
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)
-- 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
// 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
// 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
// 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
// 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
// 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:
- Read path unchanged — Most reads hit the hash (O(1))
- Batch loading — Resolution loads all channel values in one query
- Cached resolution —
config_resolvedstores pre-computed values per workspace+channel - Lazy priming — Only computes on first access, not on every request
Cycle Detection
Channel inheritance includes cycle detection to handle data corruption:
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:
// 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 modelapp/Core/Config/Models/ConfigValue.php— Value storage with channel supportapp/Core/Config/ConfigResolver.php— Resolution engineapp/Core/Config/ConfigService.php— Main APIapp/Core/Config/Migrations/2026_01_09_100001_add_config_channels.php— Schema
Version History
| Version | Date | Changes |
|---|---|---|
| 1.0 | 2026-01-15 | Initial RFC |