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

484 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |