Config Versioning Pattern
Immutable versioning for Configs. Audit trail, reproducibility, time-travel queries.
Prerequisites: PRINCIPLES.md (Principle 6), PRIMITIVES.md, ledger-pattern.md, durable-objects.md
Overview
Section titled “Overview”Configs govern behavior and determine values. Because Facts reference Configs at the time of recording, Config versioning is essential for:
| Purpose | Why It Matters |
|---|---|
| Audit trail | Know exactly what rules applied when |
| Reproducibility | Recalculate any historical Fact with original Config |
| Time-travel queries | Answer “what was the pricing on date X?” |
| Compliance | Prove what terms were in effect at any point |
Core invariant: Configs are never modified in place. Changes create new versions.
Version Semantics
Section titled “Version Semantics”How Versioning Works
Section titled “How Versioning Works”Version is an integer counter per config_id. Each Config change creates a new row with incremented version; the previous row receives a superseded_at timestamp.
// Initial creationConfig { id: "cfg_123", version: 1, effective_at: T1, superseded_at: null, settings: {...} }
// After update at T2Config { id: "cfg_123", version: 1, effective_at: T1, superseded_at: T2, settings: {...} } // OLDConfig { id: "cfg_123", version: 2, effective_at: T2, superseded_at: null, settings: {...} } // CURRENTStorage in DO SQLite
Section titled “Storage in DO SQLite”Configs are stored in the entity’s Durable Object SQLite:
CREATE TABLE configs ( id TEXT NOT NULL, version INTEGER NOT NULL, type TEXT NOT NULL, category TEXT NOT NULL, -- policy | logic name TEXT, applies_to TEXT NOT NULL, -- Entity ID this Config governs scope TEXT NOT NULL, -- account, campaign, asset tenant_id TEXT, settings TEXT NOT NULL, -- JSON blob effective_at INTEGER NOT NULL, superseded_at INTEGER, -- NULL if current PRIMARY KEY (id, version));
CREATE INDEX idx_configs_active ON configs(id) WHERE superseded_at IS NULL;CREATE INDEX idx_configs_type ON configs(type, applies_to) WHERE superseded_at IS NULL;Version Invariants
Section titled “Version Invariants”// Exactly one current version per config_idforall config_id -> COUNT(WHERE superseded_at IS NULL) = 1
// Versions are contiguousforall Config(id, version > 1) -> exists Config(id, version - 1)
// No gaps in time coverageforall Config(id, v) WHERE superseded_at IS NOT NULL -> exists Config(id, v+1) WHERE effective_at = superseded_at
// At most one active Config per (type, applies_to) pairforall Config(type, applies_to) -> COUNT(WHERE superseded_at IS NULL) <= 1Concurrency Handling
Section titled “Concurrency Handling”Single-Threaded DO Serialization
Section titled “Single-Threaded DO Serialization”The Durable Object model provides single-writer semantics per entity. All Config writes for a given config_id are serialized automatically:
Request A: update Config cfg_123 (expected_version: 5)Request B: update Config cfg_123 (expected_version: 5)
+------------------+Request A ---------> | | -----> Response A (success, v6) | DO: cfg_123 |Request B ---------> | (single thread) | -----> Response B (conflict, v5 != v6) +------------------+
Requests serialize. B sees v6, expected v5, conflict detected.Optimistic Locking Pattern
Section titled “Optimistic Locking Pattern”Updates must specify the expected current version. Conflicts return an error; the client must re-read and retry.
async updateConfig( configId: string, expectedVersion: number, newSettings: object): Promise<Config> { // 1. Read current version const current = await this.sql.exec( `SELECT * FROM configs WHERE id = ? AND superseded_at IS NULL`, [configId] ).first();
if (!current) { throw new Error(`Config ${configId} not found`); }
// 2. Check expected version if (current.version !== expectedVersion) { throw new ConflictError({ expected: expectedVersion, actual: current.version, message: 'Config was modified. Re-read and retry.' }); }
const now = Date.now(); const newVersion = current.version + 1;
// 3. Supersede old version await this.sql.exec( `UPDATE configs SET superseded_at = ? WHERE id = ? AND version = ?`, [now, configId, current.version] );
// 4. Insert new version const newConfig = { ...current, version: newVersion, settings: JSON.stringify(newSettings), effective_at: now, superseded_at: null };
await this.sql.exec(` INSERT INTO configs (id, version, type, category, name, applies_to, scope, tenant_id, settings, effective_at, superseded_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ newConfig.id, newConfig.version, newConfig.type, newConfig.category, newConfig.name, newConfig.applies_to, newConfig.scope, newConfig.tenant_id, newConfig.settings, newConfig.effective_at, newConfig.superseded_at ]);
return newConfig;}Idempotency Keys
Section titled “Idempotency Keys”Config writes may fail after the write succeeds but before the response is received. Without idempotency, retrying creates duplicate versions.
async updateConfigIdempotent( configId: string, expectedVersion: number, newSettings: object, idempotencyKey: string): Promise<Config> { // 1. Check if this idempotency key was already processed const existing = await this.sql.exec( `SELECT * FROM configs WHERE id = ? AND json_extract(settings, '$.idempotency_key') = ?`, [configId, idempotencyKey] ).first();
if (existing) { // Already processed - return existing result return parseConfig(existing); }
// 2. Proceed with normal update, embedding idempotency key const settingsWithKey = { ...newSettings, idempotency_key: idempotencyKey };
return this.updateConfig(configId, expectedVersion, settingsWithKey);}When to use idempotency keys:
- API calls that may timeout and retry
- Workflow steps that may restart
- Any Config write that crosses a network boundary
Idempotency key requirements:
- Client-generated (UUID or deterministic hash of intent)
- Stored in Config settings for lookup
- TTL: keep for reconciliation window (e.g., 24 hours), then prune
Conflict Resolution
Section titled “Conflict Resolution”| Strategy | When to Use |
|---|---|
| Reject | Default. Return error, let client decide. |
| Retry | Idempotent operations. Re-read, reapply change. |
| Merge | Rarely. Only if settings are independent fields. |
// Client retry patternasync updateConfigWithRetry( configId: string, updateFn: (settings: object) => object, maxRetries: number = 3): Promise<Config> { for (let attempt = 0; attempt < maxRetries; attempt++) { const current = await this.getConfig(configId); const newSettings = updateFn(JSON.parse(current.settings));
try { return await this.updateConfig(configId, current.version, newSettings); } catch (e) { if (e instanceof ConflictError && attempt < maxRetries - 1) { continue; // Retry with fresh read } throw e; } }}Scope Precedence Resolution
Section titled “Scope Precedence Resolution”Configs follow scope precedence: asset > campaign > account. The most specific scope wins.
Lookup Algorithm
Section titled “Lookup Algorithm”async resolveConfig( configType: string, assetId: string, campaignId?: string, accountId?: string): Promise<Config | null> { // 1. Check asset scope const assetConfig = await this.getActiveConfigForEntity(configType, assetId); if (assetConfig) return assetConfig;
// 2. Check campaign scope (if campaign context provided) if (campaignId) { const campaignDO = this.env.CAMPAIGN_DO.get( this.env.CAMPAIGN_DO.idFromName(`campaign_${campaignId}`) ); const campaignConfig = await campaignDO.getActiveConfig(configType); if (campaignConfig) return campaignConfig; }
// 3. Check account scope if (accountId) { const accountDO = this.env.ACCOUNT_LEDGER.get( this.env.ACCOUNT_LEDGER.idFromName(`account_${accountId}`) ); const accountConfig = await accountDO.getActiveConfig(configType); if (accountConfig) return accountConfig; }
// 4. No Config found return null;}Resolution Decision Table
Section titled “Resolution Decision Table”| Asset Config | Campaign Config | Account Config | Result |
|---|---|---|---|
| exists | - | - | Asset Config |
| null | exists | - | Campaign Config |
| null | null | exists | Account Config |
| null | null | null | Error or platform default |
Caching Resolved Configs
Section titled “Caching Resolved Configs”For RTB hot path performance, cache resolved Configs in memory (not in cached_state table):
private configCache: Map<string, { config: Config; expiresAt: number }>;
async resolveConfigCached( configType: string, assetId: string, campaignId?: string): Promise<Config> { const cacheKey = `${configType}:${assetId}:${campaignId || 'none'}`; const cached = this.configCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) { return cached.config; }
const config = await this.resolveConfig(configType, assetId, campaignId); if (!config) { throw new Error(`No ${configType} Config found`); }
this.configCache.set(cacheKey, { config, expiresAt: Date.now() + 60_000 // 1 minute TTL });
return config;}Cross-DO Resolution Failure Modes
Section titled “Cross-DO Resolution Failure Modes”Config resolution may require calling parent DOs (campaign, account). These calls can fail. Define explicit failure policies per Config type.
Failure Policy by Config Type:
| Config Type | Category | On Parent Unavailable | Rationale |
|---|---|---|---|
| pricing | logic | Fail closed (reject) | Cannot charge without knowing rate |
| budget | policy | Fail closed (reject) | Cannot route without budget verification |
| qualification | logic | Fail open (use cached) | Can qualify later; don’t lose the call |
| routing | logic | Fail open (use cached) | Better to route somewhere than nowhere |
| hours | policy | Fail open (assume open) | Better to accept call than miss revenue |
| autonomy | policy | Fail closed (reject) | Cannot allow autonomous action without guardrails |
Implementation Pattern:
async resolveConfigWithFailover( configType: string, assetId: string, campaignId?: string, accountId?: string): Promise<Config | null> { const failurePolicy = getFailurePolicy(configType);
// 1. Check asset scope (local, always available) const assetConfig = await this.getActiveConfigForEntity(configType, assetId); if (assetConfig) return assetConfig;
// 2. Check campaign scope (cross-DO call) if (campaignId) { try { const campaignDO = this.env.CAMPAIGN_DO.get( this.env.CAMPAIGN_DO.idFromName(`campaign_${campaignId}`) ); const campaignConfig = await campaignDO.getActiveConfig(configType); if (campaignConfig) { // Cache for failover await this.cacheParentConfig(configType, campaignId, campaignConfig); return campaignConfig; } } catch (error) { // Parent DO unavailable return this.handleResolutionFailure(configType, failurePolicy, campaignId, error); } }
// 3. Check account scope (cross-DO call) if (accountId) { try { const accountDO = this.env.ACCOUNT_LEDGER.get( this.env.ACCOUNT_LEDGER.idFromName(`account_${accountId}`) ); return await accountDO.getActiveConfig(configType); } catch (error) { return this.handleResolutionFailure(configType, failurePolicy, accountId, error); } }
return null;}
function getFailurePolicy(configType: string): 'fail_closed' | 'fail_open' { const closedTypes = ['pricing', 'budget', 'autonomy', 'billing']; return closedTypes.includes(configType) ? 'fail_closed' : 'fail_open';}
async handleResolutionFailure( configType: string, policy: 'fail_closed' | 'fail_open', parentId: string, error: Error): Promise<Config | null> { // Record error Fact await this.appendFact({ type: 'error', subtype: 'config_resolution_failed', data: { config_type: configType, parent_id: parentId, policy: policy, error: error.message } });
if (policy === 'fail_closed') { throw new ConfigResolutionError( `Cannot resolve ${configType} Config: parent ${parentId} unavailable` ); }
// Fail open: try cached version const cached = await this.getCachedParentConfig(configType, parentId); if (cached) { // Record that we used stale config await this.appendFact({ type: 'lifecycle', subtype: 'stale_config_used', data: { config_type: configType, config_id: cached.id, config_version: cached.version, cache_age_ms: Date.now() - cached.cached_at } }); return cached.config; }
// No cache available, return null (caller must handle) return null;}Monitoring: Alert on config_resolution_failed error Facts. High rates indicate DO availability issues.
Config Change Propagation
Section titled “Config Change Propagation”When a parent Config changes:
| Time | State |
|---|---|
| T | Parent Config v3 is current |
| T+1 | Parent Config updated to v4 |
| T+1 to T+60 | Child DOs may still use cached v3 |
| T+60+ | Cache TTL expires, child DOs fetch v4 |
This is acceptable because:
- Config changes are infrequent
- Short cache TTL (60s) bounds propagation delay
- Facts record exact Config version applied (audit preserved)
For immediate propagation (rare), explicitly invalidate child caches via DO stub call.
Time-Based Queries
Section titled “Time-Based Queries”Point-in-Time Query Pattern
Section titled “Point-in-Time Query Pattern”To find which Config was active at time T:
SELECT * FROM configsWHERE id = ? AND effective_at <= ? AND (superseded_at IS NULL OR superseded_at > ?)async getConfigAtTime(configId: string, timestamp: number): Promise<Config | null> { const row = await this.sql.exec(` SELECT * FROM configs WHERE id = ? AND effective_at <= ? AND (superseded_at IS NULL OR superseded_at > ?) `, [configId, timestamp, timestamp]).first();
return row ? parseConfig(row) : null;}Query Pattern Explanation
Section titled “Query Pattern Explanation”| Condition | Purpose |
|---|---|
effective_at <= T | Config existed at time T |
superseded_at IS NULL | Config is still current (handles “now” queries) |
superseded_at > T | Config was current at T but later superseded |
Full History Query
Section titled “Full History Query”-- All versions of a Config, orderedSELECT * FROM configsWHERE id = ?ORDER BY version ASC;
-- Version active at each point in timeSELECT version, effective_at, superseded_at, settingsFROM configsWHERE id = ?ORDER BY effective_at;Why Time-Based Queries Matter
Section titled “Why Time-Based Queries Matter”| Use Case | Query |
|---|---|
| Audit: “What rate applied to invoice #123?” | Config at invoice creation time |
| Dispute: “Why was I charged X?” | Config at charge time |
| Recalculation: “What if we applied current rules?” | Compare original vs current Config |
| Compliance: “Prove terms at time of transaction” | Config at Fact timestamp |
Fact-Config Linkage
Section titled “Fact-Config Linkage”Recording Config on Facts
Section titled “Recording Config on Facts”Every economic Fact records both config_id and config_version:
async appendEconomicFact(fact: Fact): Promise<Fact> { // Resolve applicable Config const config = await this.resolveConfig( getConfigTypeForFact(fact.type), fact.asset_id, fact.campaign_id );
// Record Config reference on Fact fact.config_id = config.id; fact.config_version = config.version;
// Append to ledger return this.appendFact(fact);}Why Both Fields Are Required
Section titled “Why Both Fields Are Required”| Field | Purpose |
|---|---|
config_id | Identifies which Config applied |
config_version | Identifies exact version for reproducibility |
With only config_id, you cannot reproduce the calculation if the Config was updated.
With only config_version, you cannot trace which Config family applied.
Config Version Timing by Fact Type
Section titled “Config Version Timing by Fact Type”| Fact Type | Config Version At | Rationale |
|---|---|---|
| cost | Invocation time | Vendor rate locked when called |
| charge | Outcome time | Customer rate at resolution |
| payout | Outcome time | Partner rate at resolution |
| outcome | Outcome time | Rules evaluated at resolution |
| decision | Decision time | Autonomy rules at decision point |
Recalculation Query Patterns
Section titled “Recalculation Query Patterns”Recalculate with original Config:
async recalculateWithOriginalConfig(factId: string): Promise<number> { const fact = await this.getFact(factId); const originalConfig = await this.getConfigByVersion( fact.config_id, fact.config_version ); return calculateAmount(fact, originalConfig);}Recalculate with Config at different time:
async recalculateWithConfigAtTime(factId: string, timestamp: number): Promise<number> { const fact = await this.getFact(factId); const configAtTime = await this.getConfigAtTime(fact.config_id, timestamp); return calculateAmount(fact, configAtTime);}Compare original vs current:
async compareCalculations(factId: string): Promise<{ original: number; current: number; delta: number;}> { const fact = await this.getFact(factId);
const originalConfig = await this.getConfigByVersion( fact.config_id, fact.config_version ); const currentConfig = await this.getActiveConfig(fact.config_id);
const original = calculateAmount(fact, originalConfig); const current = calculateAmount(fact, currentConfig);
return { original, current, delta: current - original };}Migration Patterns
Section titled “Migration Patterns”Non-Breaking Changes
Section titled “Non-Breaking Changes”Adding optional fields is safe:
// Original settings{ "rate_per_minute": 0.02 }
// Updated settings (non-breaking){ "rate_per_minute": 0.02, "minimum_charge": 0.01 }Pattern:
- Add new optional field with default behavior
- Update logic to use new field if present
- Create new Config version with new field
- Old calculations continue to work (missing field = default)
function calculateCost(duration: number, settings: PricingSettings): number { const baseCost = duration * settings.rate_per_minute; const minimumCharge = settings.minimum_charge ?? 0; // Default to 0 if missing return Math.max(baseCost, minimumCharge);}Breaking Changes
Section titled “Breaking Changes”When calculation logic fundamentally changes:
| Step | Action |
|---|---|
| 1 | Create new Config type (e.g., pricing_v2) |
| 2 | Deploy code that handles both old and new types |
| 3 | Migrate entities to new Config type |
| 4 | Deprecate old Config type |
function calculateCharge(fact: Fact, config: Config): number { switch (config.type) { case 'pricing': return calculatePricingV1(fact, config.settings); case 'pricing_v2': return calculatePricingV2(fact, config.settings); default: throw new Error(`Unknown pricing config type: ${config.type}`); }}In-Flight Transaction Handling
Section titled “In-Flight Transaction Handling”When Config changes during an active transaction:
Principle: The Config version is locked when the Fact is created, not when the transaction completes.
async processInvocationChain(rootInvocationId: string): Promise<void> { // Get Config at chain start const rootInvocation = await this.getFact(rootInvocationId); const lockedConfig = await this.getConfigByVersion( rootInvocation.config_id, rootInvocation.config_version );
// All subsequent Facts in chain use same Config version for (const childFact of await this.getChainFacts(rootInvocationId)) { childFact.config_id = lockedConfig.id; childFact.config_version = lockedConfig.version; await this.appendFact(childFact); }}| Scenario | Behavior |
|---|---|
| Config changes during call | Invocation uses Config at call start |
| Config changes during multi-step workflow | Entire workflow uses Config at workflow start |
| Config changes between outcome and charge | Each uses Config at its own creation time |
Anti-Patterns
Section titled “Anti-Patterns”1. Modifying Config in Place
Section titled “1. Modifying Config in Place”Wrong:
await this.sql.exec( `UPDATE configs SET settings = ? WHERE id = ? AND superseded_at IS NULL`, [newSettings, configId]);Right:
// Create new version, supersede oldawait this.updateConfig(configId, expectedVersion, newSettings);Why: Violates Principle 6. Destroys audit trail. Makes historical calculations irreproducible.
2. Recording Facts Without Config Reference
Section titled “2. Recording Facts Without Config Reference”Wrong:
const fact = { type: 'charge', amount: 10.00, // Missing config_id and config_version};await this.appendFact(fact);Right:
const config = await this.resolveConfig('pricing', assetId, campaignId);const fact = { type: 'charge', amount: calculateCharge(config.settings), config_id: config.id, config_version: config.version};await this.appendFact(fact);Why: Cannot audit why charge was this amount. Cannot reproduce calculation.
3. Changing Calculation Logic Without Versioning
Section titled “3. Changing Calculation Logic Without Versioning”Wrong:
// Just change the function, same Config typefunction calculateCharge(settings) { // Changed from per-minute to per-second billing return duration_seconds * settings.rate_per_minute / 60;}Right:
// New Config type for new logicfunction calculateChargeV1(settings) { return duration_minutes * settings.rate_per_minute;}
function calculateChargeV2(settings) { return duration_seconds * settings.rate_per_second;}Why: Old Facts with config_version: 1 become irreproducible. Audit fails.
4. Querying Only Current Config for Historical Analysis
Section titled “4. Querying Only Current Config for Historical Analysis”Wrong:
// Why was invoice #123 this amount?const currentConfig = await this.getActiveConfig(configId);return calculateAmount(historicalFact, currentConfig); // Wrong!Right:
// Use Config that was active when Fact was createdconst originalConfig = await this.getConfigByVersion( historicalFact.config_id, historicalFact.config_version);return calculateAmount(historicalFact, originalConfig);Why: Config may have changed. Calculation will not match recorded amount.
5. Long Cache TTL on Resolved Configs
Section titled “5. Long Cache TTL on Resolved Configs”Wrong:
this.configCache.set(cacheKey, { config, expiresAt: Date.now() + 24 * 60 * 60 * 1000 // 24 hours});Right:
this.configCache.set(cacheKey, { config, expiresAt: Date.now() + 60_000 // 1 minute});Why: Config changes take 24 hours to propagate. Business impact.
Summary
Section titled “Summary”| Concept | Implementation |
|---|---|
| Version storage | Integer counter per config_id, new row per version |
| Supersession | Old version gets superseded_at, new version has superseded_at = null |
| Concurrency | DO single-threading + optimistic locking (expected_version) |
| Scope resolution | asset > campaign > account, stub calls to parent DOs |
| Time queries | effective_at <= T AND (superseded_at IS NULL OR superseded_at > T) |
| Fact linkage | Every economic Fact has config_id + config_version |
| Migration | Non-breaking: add optional fields. Breaking: new Config type. |
Config versioning implements Principle 6 (Configs Are Versioned). Every Fact that depends on a Config records which version was applied. Historical calculations can be reproduced exactly. The audit trail shows what rules applied when.