Cached State Pattern
Derived state for runtime performance. Reconcilable against Facts. Disposable.
Prerequisites: PRINCIPLES.md (Principles 7, 10), PRIMITIVES.md, ledger-pattern.md, durable-objects.md
Overview
Section titled “Overview”Cached state is derived from Facts for runtime performance. It is not a primitive. It is not authoritative. It can always be rebuilt by replaying the ledger.
| Property | Description |
|---|---|
| Derived | Calculated from Facts, not stored directly |
| Disposable | Can be deleted and rebuilt (Principle 7) |
| Reconcilable | Periodically verified against ledger |
| Local | Lives in DO SQLite, never replicated |
| Fast | Enables O(1) eligibility checks on hot path |
Key Insight: Cached state is an optimization, not truth. The ledger is truth. When they disagree, the ledger wins.
Cached State Types
Section titled “Cached State Types”Each cached state type derives from specific Fact types and answers specific questions.
| Type | Derived From | Question Answered |
|---|---|---|
| BudgetState | charge, deposit, credit_issued | Can this account afford this action? |
| CapState | invocation | Has this asset hit its rate limit? |
| PrepaidBalance | deposit, charge | What’s the prepaid balance? |
| AccessState | access_granted, access_modified, access_revoked | Does this user have permission? |
| ContactCanonical* | contact_merged | Which contact_id is canonical? |
| SettlementState | charge (incurred/pending/settled) | What’s our exposure on pending settlements? |
*ContactCanonical is a special case—see section below.
BudgetState
Section titled “BudgetState”Tracks spending against available budget for RTB eligibility.
interface BudgetState { deposited: number; // Sum of deposit Facts spent: number; // Sum of charge Facts credits: number; // Sum of credit_issued Facts remaining: number; // deposited + credits - spent last_fact_id: string; // Last Fact included in calculation computed_at: number; // Timestamp of last computation}Derivation:
function deriveBudgetState(facts: Fact[]): BudgetState { let deposited = 0, spent = 0, credits = 0; let last_fact_id = null;
for (const fact of facts) { switch (fact.type) { case 'deposit': deposited += fact.amount; break; case 'charge': spent += fact.amount; break; case 'credit_issued': credits += fact.amount; break; } last_fact_id = fact.id; }
return { deposited, spent, credits, remaining: deposited + credits - spent, last_fact_id, computed_at: Date.now() };}CapState
Section titled “CapState”Tracks invocation counts against rate limits.
interface CapState { period_start: number; // Current period start timestamp period_duration: number; // Period length in ms invocation_count: number; // Count in current period last_fact_id: string; computed_at: number;}Derivation:
function deriveCapState(facts: Fact[], periodDuration: number): CapState { const periodStart = getCurrentPeriodStart(periodDuration); let count = 0; let last_fact_id = null;
for (const fact of facts) { if (fact.type === 'invocation' && fact.timestamp >= periodStart) { count++; } last_fact_id = fact.id; }
return { period_start: periodStart, period_duration: periodDuration, invocation_count: count, last_fact_id, computed_at: Date.now() };}PrepaidBalance
Section titled “PrepaidBalance”Tracks prepaid account balance (subset of BudgetState logic).
interface PrepaidBalance { balance: number; // deposits - charges last_fact_id: string; computed_at: number;}SettlementState
Section titled “SettlementState”Tracks economic exposure—charges that have been incurred but not yet settled. Answers Principle 9’s question: “What’s our exposure on pending settlements?”
interface SettlementState { pending_charges: Array<{ charge_id: string; // The incurred charge Fact ID amount: number; // Charge amount incurred_at: number; // When the charge was incurred settlement_model: string; // real-time, eventual, batch expected_settlement: number; // When settlement is expected }>; total_pending: number; // Sum of pending charge amounts last_settled_at: number; // When the last charge settled last_fact_id: string; computed_at: number;}Derivation:
function deriveSettlementState(facts: Fact[]): SettlementState { const pending = new Map<string, PendingCharge>(); let last_settled_at = 0; let last_fact_id = null;
for (const fact of facts) { if (fact.type !== 'charge') continue;
switch (fact.subtype) { case 'incurred': pending.set(fact.id, { charge_id: fact.id, amount: fact.amount, incurred_at: fact.timestamp, settlement_model: fact.data?.settlement_model || 'eventual', expected_settlement: fact.data?.expected_settlement || 0 }); break; case 'settled': case 'written_off': // Remove from pending (terminal states) pending.delete(fact.source_id); if (fact.subtype === 'settled') { last_settled_at = fact.timestamp; } break; // pending, disputed, resolved don't change pending set } last_fact_id = fact.id; }
const pending_charges = Array.from(pending.values()); return { pending_charges, total_pending: pending_charges.reduce((sum, c) => sum + c.amount, 0), last_settled_at, last_fact_id, computed_at: Date.now() };}Settlement Observability Requirements
Section titled “Settlement Observability Requirements”Settlement timing is critical for cash flow visibility and economic loop closure (Principle 9). Monitor proactively.
Metrics to emit:
interface SettlementMetrics { tenant_id: string; settlement_model: string; // real-time, eventual, batch settlement_lag_ms: number; // Time from incurred to settled pending_count: number; // Number of unsettled charges pending_total: number; // Total $ pending settlement}
// Emit on every settlementasync function emitSettlementMetrics(charge: Fact, settledAt: number) { const lag = settledAt - charge.data.incurred_at; await this.env.ANALYTICS.writeDataPoint({ blobs: [charge.tenant_id, charge.data.settlement_model], doubles: [lag], indexes: [1] // settled });}
// Emit pending snapshot periodicallyasync function emitPendingSnapshot(state: SettlementState) { await this.env.ANALYTICS.writeDataPoint({ blobs: [this.tenantId, 'pending_snapshot'], doubles: [state.total_pending, state.pending_charges.length], indexes: [0] });}SLOs by settlement model:
| Model | Expected Lag | Alert Threshold | Rationale |
|---|---|---|---|
| real-time | < 100ms | > 1s | RTB requires immediate settlement |
| eventual | < 5min | > 15min | Call qualification should complete quickly |
| batch | < 24h | > 48h | Daily batch should not slip |
Dashboard queries:
-- Settlement lag percentiles (last 24h)SELECT blob2 AS settlement_model, QUANTILE(double1, 0.50) AS p50_lag_ms, QUANTILE(double1, 0.95) AS p95_lag_ms, QUANTILE(double1, 0.99) AS p99_lag_msFROM z0_settlementWHERE timestamp > NOW() - INTERVAL '24 hours' AND index1 = 1 -- Only settledGROUP BY blob2;
-- Current pending exposure by tenantSELECT blob1 AS tenant_id, MAX(double1) AS pending_total, MAX(double2) AS pending_countFROM z0_settlementWHERE timestamp > NOW() - INTERVAL '1 hour' AND blob2 = 'pending_snapshot'GROUP BY blob1ORDER BY pending_total DESC;
-- Settlement failure rate (charges that went to write_off)SELECT blob1 AS tenant_id, COUNT(*) FILTER (WHERE index1 = 0) AS write_offs, COUNT(*) AS total, COUNT(*) FILTER (WHERE index1 = 0) * 100.0 / COUNT(*) AS failure_rateFROM z0_settlementWHERE timestamp > NOW() - INTERVAL '7 days'GROUP BY blob1;Alerting:
| Alert | Condition | Severity | Action |
|---|---|---|---|
| Settlement lag spike | p95 > 2x expected | Warning | Check external dependencies |
| High pending exposure | total_pending > threshold | Warning | Review settlement pipeline |
| Settlement failure | write_off rate > 1% | Critical | Investigate root cause |
| Stale pending charges | Any charge > 2x expected_settlement | Warning | Manual intervention may be needed |
AccessState
Section titled “AccessState”Tracks current permissions for a user on an entity.
interface AccessState { user_id: string; entity_id: string; permissions: string[]; // Current permission set granted_at: number; // When access was first granted last_modified_at: number; last_fact_id: string; computed_at: number;}Derivation: Replay access_granted, access_modified, access_revoked Facts in order. Final state is current permissions.
ContactCanonical (Special Case)
Section titled “ContactCanonical (Special Case)”Note: ContactCanonical is NOT truly disposable cached state. It is a write-through index that must be updated synchronously. This is an exception to Principle 7, documented here for transparency.
Tracks which contact_id is canonical after merges.
interface ContactCanonical { canonical_id: string; // The surviving contact_id merged_ids: string[]; // All merged contact_ids last_fact_id: string; computed_at: number;}Derivation: Replay contact_merged Facts. Build union-find structure. Canonical is the root.
Why ContactCanonical Is Different
Section titled “Why ContactCanonical Is Different”ContactCanonical is unique among cached states - it must be updated synchronously and atomically with the contact_merged Fact write. This is because:
- Identity queries happen immediately after merge
- Stale canonical ID would cause attribution errors
- There’s no “eventual” that’s acceptable for identity
Pattern:
async mergeContacts(sourceId: string, targetId: string): Promise<void> { // Single atomic operation in DO await this.sql.exec('BEGIN TRANSACTION');
// 1. Write the merge Fact await this.appendFact({ type: 'contact_merged', data: { source_id: sourceId, target_id: targetId, canonical_id: targetId } });
// 2. Update ContactCanonical immediately (same transaction) await this.setCachedState('ContactCanonical', { canonical_id: targetId, merged_ids: [...existing.merged_ids, sourceId] });
await this.sql.exec('COMMIT');}Unlike other cached states, ContactCanonical cannot tolerate any window of inconsistency.
Update Patterns
Section titled “Update Patterns”Cached state updates through three mechanisms:
| Pattern | When | Latency | Consistency |
|---|---|---|---|
| Inline update | After Fact append | Immediate | Strong |
| Lazy update | On read if stale | On-demand | Eventual |
| Scheduled reconciliation | Periodic alarm | Background | Verified |
Inline Update After Fact Append
Section titled “Inline Update After Fact Append”The primary update path. Update cached state immediately after appending a Fact.
async appendFact(fact: Fact): Promise<Fact> { // 1. Append to ledger await this.sql.exec( `INSERT INTO facts (id, type, timestamp, data) VALUES (?, ?, ?, ?)`, [fact.id, fact.type, fact.timestamp, JSON.stringify(fact)] );
// 2. Update cached state inline await this.updateCachedStateInline(fact);
// 3. Schedule replication await this.scheduleReplication();
return fact;}
async updateCachedStateInline(fact: Fact): Promise<void> { switch (fact.type) { case 'charge': await this.incrementBudgetSpent(fact.amount, fact.id); break; case 'deposit': await this.incrementBudgetDeposited(fact.amount, fact.id); break; case 'credit_issued': await this.incrementBudgetCredits(fact.amount, fact.id); break; case 'invocation': await this.incrementCapCount(fact.id); break; // ... other types }}
async incrementBudgetSpent(amount: number, factId: string): Promise<void> { await this.sql.exec(` UPDATE cached_state SET value = json_set( value, '$.spent', json_extract(value, '$.spent') + ?, '$.remaining', json_extract(value, '$.remaining') - ?, '$.last_fact_id', ?, '$.computed_at', ? ) WHERE key = 'BudgetState' `, [amount, amount, factId, Date.now()]);}Why inline? Single-threaded DO guarantees the append and update are atomic. No race between write and read.
Lazy Update on Read
Section titled “Lazy Update on Read”For cached state that may be stale, validate before returning.
async getBudgetState(): Promise<BudgetState> { const cached = await this.getCachedState('BudgetState');
// Check if any Facts exist after last computed const newFacts = await this.sql.exec(` SELECT COUNT(*) as count FROM facts WHERE id > ? AND type IN ('charge', 'deposit', 'credit_issued') `, [cached.last_fact_id]);
if (newFacts[0].count > 0) { // Stale - rebuild return this.rebuildBudgetState(); }
return cached;}Use sparingly. Inline updates should handle the common case. Lazy updates catch edge cases.
Scheduled Reconciliation
Section titled “Scheduled Reconciliation”Periodic verification that cached state matches ledger truth.
async alarm(): Promise<void> { const alarmType = await this.storage.get('alarm_type');
switch (alarmType) { case 'reconciliation': await this.runReconciliation(); break; case 'replication': await this.runReplication(); break; }}
async scheduleReconciliation(): Promise<void> { await this.storage.put('alarm_type', 'reconciliation'); await this.storage.setAlarm(Date.now() + 5 * 60 * 1000); // 5 minutes}Reconciliation
Section titled “Reconciliation”Reconciliation verifies cached state against the ledger and records any divergence.
Reconciliation Process
Section titled “Reconciliation Process”async runReconciliation(): Promise<void> { const stateTypes = ['BudgetState', 'CapState', 'PrepaidBalance'];
for (const stateType of stateTypes) { await this.reconcileState(stateType); }
// Schedule next reconciliation await this.scheduleReconciliation();}
async reconcileState(stateType: string): Promise<void> { const startTime = Date.now();
// 1. Get cached value const cached = await this.getCachedState(stateType);
// 2. Calculate from ledger const calculated = await this.calculateFromLedger(stateType);
// 3. Compare if (this.statesMatch(cached, calculated)) { return; // All good }
// 4. Record mismatch await this.recordReconciliationFact(stateType, cached, calculated, startTime);
// 5. Update cache to match ledger await this.setCachedState(stateType, calculated);}Detecting Divergence
Section titled “Detecting Divergence”Compare significant fields, ignoring metadata:
function statesMatch(cached: BudgetState, calculated: BudgetState): boolean { return ( cached.deposited === calculated.deposited && cached.spent === calculated.spent && cached.credits === calculated.credits && cached.remaining === calculated.remaining );}Recording the Reconciliation Fact
Section titled “Recording the Reconciliation Fact”Always record when cached state diverges. This is essential for observability.
async recordReconciliationFact( stateType: string, cached: CachedState, calculated: CachedState, startTime: number): Promise<void> { const factsScanned = await this.countFactsForState(stateType);
await this.appendFact({ type: 'reconciliation', subtype: 'mismatch_detected', timestamp: Date.now(), entity_id: this.entityId, tenant_id: this.tenantId, data: { cache_type: stateType, cached_value: cached, calculated_value: calculated, delta: this.computeDelta(cached, calculated), resolution: 'cache_updated', facts_scanned: factsScanned, duration_ms: Date.now() - startTime } });}
function computeDelta(cached: BudgetState, calculated: BudgetState): object { return { deposited: calculated.deposited - cached.deposited, spent: calculated.spent - cached.spent, credits: calculated.credits - cached.credits, remaining: calculated.remaining - cached.remaining };}Reconciliation Fact Schema
Section titled “Reconciliation Fact Schema”From PRIMITIVES.md:
Fact { type: "reconciliation", subtype: "mismatch_detected" | "cache_rebuilt", timestamp: number, tenant_id: string, entity_id: string, data: { cache_type: string, // BudgetState, CapState, etc. cached_value: object, // What the cache had calculated_value: object, // What the ledger says delta: object, // The difference resolution: string, // cache_updated, alert_raised, manual_review facts_scanned: number, // How many Facts were replayed duration_ms: number // How long reconciliation took }}Reconciliation Loop Prevention
Section titled “Reconciliation Loop Prevention”Critical invariant: Reconciliation Facts must NOT trigger further reconciliation.
Reconciliation works by comparing cached state against Facts. If reconciliation Facts were included in this comparison, you could create an infinite loop:
1. Reconciliation detects mismatch2. Reconciliation Fact created3. Cached state update considers new Fact4. New Fact causes apparent mismatch5. LOOP: goto 1Prevention pattern:
async calculateFromLedger(stateType: string): Promise<CachedState> { // CRITICAL: Exclude reconciliation Facts from derivation const relevantTypes = getRelevantFactTypes(stateType);
// Reconciliation Facts are NEVER relevant to any cached state if (relevantTypes.includes('reconciliation')) { throw new Error('BUG: reconciliation Facts must not affect cached state'); }
const facts = await this.sql.exec(` SELECT * FROM facts WHERE type IN (${relevantTypes.map(() => '?').join(',')}) ORDER BY timestamp `, relevantTypes);
return deriveState(stateType, facts);}
function getRelevantFactTypes(stateType: string): string[] { const mapping = { 'BudgetState': ['charge', 'deposit', 'credit_issued'], 'CapState': ['invocation'], 'SettlementState': ['charge'], // Only charge Facts, filtered by subtype in derivation // reconciliation is NEVER in any mapping }; return mapping[stateType] || [];}Required test:
test('reconciliation Facts do not affect any cached state', () => { const allStateTypes = ['BudgetState', 'CapState', 'SettlementState', 'PrepaidBalance', 'AccessState'];
for (const stateType of allStateTypes) { const relevantTypes = getRelevantFactTypes(stateType); expect(relevantTypes).not.toContain('reconciliation'); }});Resolution Actions
Section titled “Resolution Actions”| Situation | Resolution | Action |
|---|---|---|
| Small delta, explainable | cache_updated | Update cache, log for monitoring |
| Large delta, unexpected | alert_raised | Update cache, trigger alert |
| Cannot calculate | manual_review | Keep cache, alert ops team |
async determineResolution( stateType: string, delta: object): Promise<string> { // Define thresholds per state type const thresholds = { BudgetState: { remaining: 100 }, // $100 threshold CapState: { invocation_count: 10 } };
const threshold = thresholds[stateType]; if (!threshold) return 'cache_updated';
// Check if delta exceeds threshold for (const [field, maxDelta] of Object.entries(threshold)) { if (Math.abs(delta[field] || 0) > maxDelta) { return 'alert_raised'; } }
return 'cache_updated';}Cache Drift SLOs
Section titled “Cache Drift SLOs”Cached state may diverge from ledger truth between reconciliation cycles. Define acceptable drift thresholds and monitor for violations.
SLO Definitions
Section titled “SLO Definitions”| State Type | Metric | Target | Rationale |
|---|---|---|---|
| BudgetState | remaining_delta | < $10 or < 1% | Small variance acceptable for RTB; large variance = bad routing |
| CapState | count_delta | < 5 invocations | Minor over/under counting acceptable |
| SettlementState | pending_total_delta | < $100 | Settlement tracking can tolerate brief lag |
| AccessState | permission_mismatch | 0 | Security-critical; no tolerance for stale permissions |
Monitoring Requirements
Section titled “Monitoring Requirements”Metrics to emit (per reconciliation):
interface ReconciliationMetrics { entity_id: string; state_type: string; drift_detected: boolean; drift_magnitude: number; // Absolute value of largest delta drift_percentage: number; // Percentage of cached value reconciliation_duration_ms: number; facts_scanned: number;}
async function emitReconciliationMetrics(metrics: ReconciliationMetrics) { await this.env.ANALYTICS.writeDataPoint({ blobs: [metrics.entity_id, metrics.state_type], doubles: [ metrics.drift_magnitude, metrics.drift_percentage, metrics.reconciliation_duration_ms, metrics.facts_scanned ], indexes: [metrics.drift_detected ? 1 : 0] });}Alerting thresholds:
| Alert | Condition | Severity | Action |
|---|---|---|---|
| High drift rate | > 5% of reconciliations detect drift | Warning | Investigate inline update bugs |
| Large drift magnitude | BudgetState delta > $100 | Critical | Immediate investigation |
| Reconciliation timeout | Duration > 30s | Warning | Review Fact volume, optimize queries |
| AccessState drift | Any mismatch | Critical | Security review required |
Dashboard queries:
-- Drift rate by state type (last 24h)SELECT blob2 AS state_type, SUM(index1) AS drifts_detected, COUNT(*) AS total_reconciliations, SUM(index1) * 100.0 / COUNT(*) AS drift_rate_percentFROM z0_reconciliationWHERE timestamp > NOW() - INTERVAL '24 hours'GROUP BY blob2;
-- Largest drifts (last 24h)SELECT blob1 AS entity_id, blob2 AS state_type, MAX(double1) AS max_drift_magnitudeFROM z0_reconciliationWHERE timestamp > NOW() - INTERVAL '24 hours' AND index1 = 1 -- Only driftsGROUP BY blob1, blob2ORDER BY max_drift_magnitude DESCLIMIT 20;SLO Breach Response
Section titled “SLO Breach Response”| Severity | Response Time | Actions |
|---|---|---|
| Warning | 4 hours | Review, create ticket |
| Critical | 15 minutes | Page on-call, investigate immediately |
| Security (AccessState) | Immediate | Revoke and re-grant permissions, audit access logs |
Invariants:
// Drift must be detected and recorded∀ reconciliation WHERE drift_detected → ∃ Fact(reconciliation, mismatch_detected)
// SLO metrics must be emitted∀ reconciliation → metrics emitted to Analytics Engine
// Critical alerts must be actionable∀ alert(critical) → runbook exists AND on-call notifiedPerformance vs Correctness
Section titled “Performance vs Correctness”Cached state exists at the intersection of two principles:
| Principle | Implication |
|---|---|
| Principle 7: Derived State Is Disposable | Cached state can be wrong. Ledger is truth. |
| Principle 10: Budget Is Eligibility | Budget checks must be fast. RTB can’t wait for ledger replay. |
The Tension
Section titled “The Tension”RTB Hot Path (Principle 10) Ledger Truth (Principle 7) │ │ │ "Trust cached state" │ "Verify against ledger" │ Fast (O(1) read) │ Slow (O(n) replay) │ May be stale │ Always correct │ │ └──────────── Cached State ────────────┘Resolution: Trust with Verification
Section titled “Resolution: Trust with Verification”| Context | Approach | Rationale |
|---|---|---|
| RTB eligibility check | Trust cached BudgetState | Speed trumps perfection. Reconciliation catches drift. |
| Billing calculation | Verify against ledger | Accuracy required. Can afford latency. |
| Dashboard display | Use cached state | Approximate is acceptable. |
| Audit/compliance | Always replay ledger | Truth is non-negotiable. |
Implementation Pattern
Section titled “Implementation Pattern”async checkEligibility(amount: number): Promise<boolean> { // Hot path: trust cached state const budget = await this.getCachedState('BudgetState'); return budget.remaining >= amount;}
async calculateBillableAmount(): Promise<number> { // Cold path: verify against ledger const charges = await this.getFactsByType('charge'); return charges.reduce((sum, f) => sum + f.amount, 0);}When to Accept vs Verify
Section titled “When to Accept vs Verify”| Question | Accept Cached | Verify Against Ledger |
|---|---|---|
| Can they afford this call? | Yes | No |
| How much do we bill this month? | No | Yes |
| Have they hit their cap? | Yes | No |
| What’s their exact balance? | No | Yes |
| Should we route to this buyer? | Yes | No |
| Is this user authorized? | Yes* | For sensitive ops |
*AccessState is cached but should be verified for sensitive operations.
Failure Modes
Section titled “Failure Modes”Cache Corrupted
Section titled “Cache Corrupted”Symptom: Cached state has invalid values (negative balance, impossible counts).
Cause: Bug in inline update logic, schema migration error, SQLite corruption.
Resolution:
async handleCorruptedCache(stateType: string): Promise<void> { // 1. Log the corruption console.error(`Corrupted ${stateType} detected, rebuilding`);
// 2. Delete corrupted state await this.sql.exec(`DELETE FROM cached_state WHERE key = ?`, [stateType]);
// 3. Rebuild from ledger const rebuilt = await this.calculateFromLedger(stateType); await this.setCachedState(stateType, rebuilt);
// 4. Record reconciliation Fact await this.appendFact({ type: 'reconciliation', subtype: 'cache_rebuilt', data: { cache_type: stateType, reason: 'corruption_detected', facts_scanned: await this.countFactsForState(stateType) } });}Cache Stale
Section titled “Cache Stale”Symptom: Cached state is behind the ledger (missing recent Facts).
Cause: Inline update failed, Facts appended without update, DO restart race.
Resolution: Reconciliation alarm catches this. Cache is updated, divergence recorded.
// Detection during reconciliationif (cached.last_fact_id !== calculated.last_fact_id) { // Cache is stale await this.recordReconciliationFact(stateType, cached, calculated, startTime); await this.setCachedState(stateType, calculated);}Cache Diverges During Write
Section titled “Cache Diverges During Write”Symptom: N/A - this cannot happen.
Why: Single-writer DO guarantees that Fact append and cache update are atomic. No concurrent writes means no divergence during write.
Request A: append Fact → update cache → respondRequest B: (waits) → append Fact → update cache → respond
Never: Request A append + Request B append racingStorage Schema
Section titled “Storage Schema”Cached state is stored in the DO’s SQLite database.
CREATE TABLE cached_state ( key TEXT PRIMARY KEY, -- BudgetState, CapState, etc. value TEXT NOT NULL, -- JSON blob computed_at INTEGER NOT NULL, -- Timestamp of last computation facts_through TEXT -- Last fact_id included);
-- Example rowINSERT INTO cached_state (key, value, computed_at, facts_through)VALUES ( 'BudgetState', '{"deposited":10000,"spent":4500,"credits":0,"remaining":5500}', 1699000000000, 'fact_abc123');Read/Write Operations
Section titled “Read/Write Operations”async getCachedState(key: string): Promise<CachedState | null> { const row = await this.sql.exec( `SELECT value, computed_at, facts_through FROM cached_state WHERE key = ?`, [key] ).first();
if (!row) return null;
return { ...JSON.parse(row.value), computed_at: row.computed_at, last_fact_id: row.facts_through };}
async setCachedState(key: string, state: CachedState): Promise<void> { await this.sql.exec(` INSERT OR REPLACE INTO cached_state (key, value, computed_at, facts_through) VALUES (?, ?, ?, ?) `, [ key, JSON.stringify(state), state.computed_at || Date.now(), state.last_fact_id ]);}Anti-Patterns
Section titled “Anti-Patterns”1. Storing Derived Calculations as Facts
Section titled “1. Storing Derived Calculations as Facts”Wrong:
// DON'T: Store calculated totals as Factsawait this.appendFact({ type: 'budget_snapshot', data: { remaining: budget.remaining } // This is derived!});Right:
// DO: Store the event, derive the stateawait this.appendFact({ type: 'charge', amount: 50});await this.updateCachedState('BudgetState', { spent: +50 });Why: Facts are what happened. Derived state is calculation. Mixing them violates Principle 7 and makes reconciliation impossible.
2. Treating Cached State as Authoritative
Section titled “2. Treating Cached State as Authoritative”Wrong:
// DON'T: Assume cache is truth for billingasync generateInvoice(): Promise<Invoice> { const budget = await this.getCachedState('BudgetState'); return { total: budget.spent }; // Cache might be wrong!}Right:
// DO: Calculate from ledger for authoritative operationsasync generateInvoice(): Promise<Invoice> { const charges = await this.getFactsByType('charge'); const total = charges.reduce((sum, f) => sum + f.amount, 0); return { total };}Why: Billing errors are expensive. Cache is for speed, ledger is for truth.
3. Not Recording Reconciliation Events
Section titled “3. Not Recording Reconciliation Events”Wrong:
// DON'T: Silently fix cache without recordasync reconcile(): Promise<void> { const calculated = await this.calculateFromLedger('BudgetState'); await this.setCachedState('BudgetState', calculated); // No audit trail!}Right:
// DO: Record every reconciliationasync reconcile(): Promise<void> { const cached = await this.getCachedState('BudgetState'); const calculated = await this.calculateFromLedger('BudgetState');
if (!this.statesMatch(cached, calculated)) { await this.appendFact({ type: 'reconciliation', subtype: 'mismatch_detected', data: { cached, calculated, delta: this.computeDelta(cached, calculated) } }); }
await this.setCachedState('BudgetState', calculated);}Why: Reconciliation Facts are essential for debugging and observability. Silent fixes hide bugs.
4. Skipping Inline Updates for Performance
Section titled “4. Skipping Inline Updates for Performance”Wrong:
// DON'T: Defer all updates to reconciliationasync appendFact(fact: Fact): Promise<void> { await this.sql.exec(`INSERT INTO facts ...`); // Skip inline update, let reconciliation handle it}Right:
// DO: Always update inline, reconciliation is backupasync appendFact(fact: Fact): Promise<void> { await this.sql.exec(`INSERT INTO facts ...`); await this.updateCachedStateInline(fact); // Always}Why: Reconciliation runs every 5 minutes. That’s 5 minutes of stale cache causing wrong routing decisions.
5. Caching Cross-Entity State
Section titled “5. Caching Cross-Entity State”Wrong:
// DON'T: Cache state that spans entitiesinterface TenantBudgetCache { total_across_all_accounts: number; // Requires cross-DO coordination}Right:
// DO: Cache only single-entity stateinterface AccountBudgetState { remaining: number; // This account only}
// For cross-entity: query D1 (eventually consistent is acceptable for aggregates)Why: DOs are per-entity. Cross-entity state requires distributed coordination, which defeats the single-writer benefit.
Summary
Section titled “Summary”| Concept | Implementation |
|---|---|
| What cached state is | Derived from Facts, disposable, reconcilable |
| Where it lives | DO SQLite cached_state table |
| How it updates | Inline after Fact append |
| How it verifies | Periodic reconciliation alarm |
| When to trust it | RTB hot path, dashboards |
| When to verify | Billing, audit, compliance |
| On mismatch | Update cache, record reconciliation Fact |
Cached state bridges Principle 7 (Derived State Is Disposable) and Principle 10 (Budget Is Eligibility). It enables fast eligibility checks while maintaining the ledger as the source of truth. Reconciliation ensures drift is detected, recorded, and corrected.