Skip to content

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


Configs govern behavior and determine values. Because Facts reference Configs at the time of recording, Config versioning is essential for:

PurposeWhy It Matters
Audit trailKnow exactly what rules applied when
ReproducibilityRecalculate any historical Fact with original Config
Time-travel queriesAnswer “what was the pricing on date X?”
ComplianceProve what terms were in effect at any point

Core invariant: Configs are never modified in place. Changes create new versions.


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 creation
Config { id: "cfg_123", version: 1, effective_at: T1, superseded_at: null, settings: {...} }
// After update at T2
Config { id: "cfg_123", version: 1, effective_at: T1, superseded_at: T2, settings: {...} } // OLD
Config { id: "cfg_123", version: 2, effective_at: T2, superseded_at: null, settings: {...} } // CURRENT

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;
// Exactly one current version per config_id
forall config_id -> COUNT(WHERE superseded_at IS NULL) = 1
// Versions are contiguous
forall Config(id, version > 1) -> exists Config(id, version - 1)
// No gaps in time coverage
forall 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) pair
forall Config(type, applies_to) -> COUNT(WHERE superseded_at IS NULL) <= 1

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.

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;
}

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
StrategyWhen to Use
RejectDefault. Return error, let client decide.
RetryIdempotent operations. Re-read, reapply change.
MergeRarely. Only if settings are independent fields.
// Client retry pattern
async 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;
}
}
}

Configs follow scope precedence: asset > campaign > account. The most specific scope wins.

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;
}
Asset ConfigCampaign ConfigAccount ConfigResult
exists--Asset Config
nullexists-Campaign Config
nullnullexistsAccount Config
nullnullnullError or platform default

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;
}

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 TypeCategoryOn Parent UnavailableRationale
pricinglogicFail closed (reject)Cannot charge without knowing rate
budgetpolicyFail closed (reject)Cannot route without budget verification
qualificationlogicFail open (use cached)Can qualify later; don’t lose the call
routinglogicFail open (use cached)Better to route somewhere than nowhere
hourspolicyFail open (assume open)Better to accept call than miss revenue
autonomypolicyFail 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.

When a parent Config changes:

TimeState
TParent Config v3 is current
T+1Parent Config updated to v4
T+1 to T+60Child 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.


To find which Config was active at time T:

SELECT * FROM configs
WHERE 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;
}
ConditionPurpose
effective_at <= TConfig existed at time T
superseded_at IS NULLConfig is still current (handles “now” queries)
superseded_at > TConfig was current at T but later superseded
-- All versions of a Config, ordered
SELECT * FROM configs
WHERE id = ?
ORDER BY version ASC;
-- Version active at each point in time
SELECT
version,
effective_at,
superseded_at,
settings
FROM configs
WHERE id = ?
ORDER BY effective_at;
Use CaseQuery
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

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);
}
FieldPurpose
config_idIdentifies which Config applied
config_versionIdentifies 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.

Fact TypeConfig Version AtRationale
costInvocation timeVendor rate locked when called
chargeOutcome timeCustomer rate at resolution
payoutOutcome timePartner rate at resolution
outcomeOutcome timeRules evaluated at resolution
decisionDecision timeAutonomy rules at decision point

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 };
}

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:

  1. Add new optional field with default behavior
  2. Update logic to use new field if present
  3. Create new Config version with new field
  4. 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);
}

When calculation logic fundamentally changes:

StepAction
1Create new Config type (e.g., pricing_v2)
2Deploy code that handles both old and new types
3Migrate entities to new Config type
4Deprecate 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}`);
}
}

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);
}
}
ScenarioBehavior
Config changes during callInvocation uses Config at call start
Config changes during multi-step workflowEntire workflow uses Config at workflow start
Config changes between outcome and chargeEach uses Config at its own creation time

Wrong:

await this.sql.exec(
`UPDATE configs SET settings = ? WHERE id = ? AND superseded_at IS NULL`,
[newSettings, configId]
);

Right:

// Create new version, supersede old
await 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 type
function calculateCharge(settings) {
// Changed from per-minute to per-second billing
return duration_seconds * settings.rate_per_minute / 60;
}

Right:

// New Config type for new logic
function 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 created
const 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.

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.


ConceptImplementation
Version storageInteger counter per config_id, new row per version
SupersessionOld version gets superseded_at, new version has superseded_at = null
ConcurrencyDO single-threading + optimistic locking (expected_version)
Scope resolutionasset > campaign > account, stub calls to parent DOs
Time querieseffective_at <= T AND (superseded_at IS NULL OR superseded_at > T)
Fact linkageEvery economic Fact has config_id + config_version
MigrationNon-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.