specs/RFC-003-CONFIG-CHANNELS.md
2026-02-01 07:41:21 +00:00

17 KiB
Raw Permalink Blame History

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:

// 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',
);
  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:

// 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:

  1. Read path unchanged — Most reads hit the hash (O(1))
  2. Batch loading — Resolution loads all channel values in one query
  3. Cached resolutionconfig_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:

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.


  • 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