---
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
inputDocuments:
  - _bmad-output/RLM_NEOCORTEX_PRD.md
  - _bmad-output/RLM_NEOCORTEX_PRODUCT_BRIEF.md
  - _bmad-output/project-context.md
  - core/rlm/contracts.py
  - core/rlm/gateway_router.py
  - core/rlm/entitlement.py
  - KNOWLEDGE_GRAPH/axioms/rlm_deployment_axioms.jsonl
workflowType: 'prd'
doctrineVersion: 'Genesis Rule 17 v2.3.0'
date: 2026-02-26
author: Claude Opus 4.6 (BMAD Phase 2 Architect)
blueprintSource: _bmad-output/RLM_NEOCORTEX_PRODUCT_BRIEF.md (Band A/B/C architecture)
phase1Source: _bmad-output/RLM_NEOCORTEX_PRD.md (52 stories, 364 tests, COMPLETE)
---

# EPIC PRD: RLM Cloudflare Workers Edge Layer (Phase 2)
## GENESIS-RLMCF | Edge Middleware | Privacy-First Tiered Edge Cache

**Version**: 1.0.0
**Date**: 2026-02-26
**Status**: AUTHORITATIVE -- EXECUTION READY
**Total Stories**: 48 atomic stories across 6 modules (ALL FULL SPECS)
**Execution**: Ralph Wiggum Loop -- each story gets a FRESH context window
**Blueprint**: `_bmad-output/RLM_NEOCORTEX_PRODUCT_BRIEF.md` (Band A-D architecture)
**Phase 1**: `_bmad-output/RLM_NEOCORTEX_PRD.md` (core engine, 52 stories, COMPLETE)
**Doctrine**: Rule 17 of `GLOBAL_GENESIS_RULES.md` (14 PRD Laws enforced)
**Language**: TypeScript (Cloudflare Workers V8 runtime -- NOT Python)

---

## VISION STATEMENT

Build a Cloudflare Workers edge middleware layer that sits between customer applications and the Phase 1 Neo-Cortex FastAPI gateway, delivering sub-50ms cached memory reads via Durable Objects, tier-aware request routing via KV-backed entitlement manifests, per-tenant rate limiting at the edge, geographic data residency enforcement for Band C compliance customers, and real-time WebSocket memory update channels -- so that every SubAIVA customer experiences instant memory recall without hitting the origin server on every request.

---

## SUCCESS METRICS

| Metric | Target | Measurement |
|--------|--------|-------------|
| Edge cache hit rate for memory reads | > 70% | Cloudflare analytics `cf-cache-status` header ratio |
| P95 cached read latency (edge hit) | < 50ms | Durable Object `performance.now()` histogram |
| P95 cache-miss read latency (origin fetch) | < 300ms | Worker `fetch()` duration to FastAPI gateway |
| Entitlement manifest cache hit rate | > 95% | KV read hit ratio per tenant |
| Rate limit accuracy (false positive) | < 0.1% | Rate-limited requests that were under quota |
| Geographic routing compliance (AU data) | 100% of Band C requests stay in AU/SYD colo | `cf-ray` header colo code assertion |
| WebSocket connection stability | > 99.5% uptime per connection | Durable Object hibernation reconnect success |
| Origin server load reduction | > 60% fewer read requests hitting FastAPI | Prometheus `memory_gateway_reads_total` delta |

---

## CLARIFYING QUESTIONS (ANSWERED)

1. **Q: Why not keep everything on the FastAPI origin?** -- A: The Phase 1 gateway at `core/rlm/gateway_router.py` handles all reads by hitting PostgreSQL + Qdrant on every request. At 200+ tenants with 50+ reads/minute each, that is 10,000+ origin queries/minute. The edge layer caches hot reads in Durable Objects, reducing origin load by 60-70%. Edge latency is 10-50ms vs 150-300ms origin.

2. **Q: Can we run Python on Cloudflare Workers?** -- A: NO. Axiom `rlm_dep_ax_001` and `rlm_dep_ax_004` confirm Workers use V8 (JavaScript/TypeScript only). All edge code is TypeScript. Heavy compute (surprise scoring, decay, feedback) stays on the Python FastAPI origin. The edge layer is a thin cache/routing/compliance proxy.

3. **Q: What is the 128MB memory limit impact?** -- A: Each Worker isolate gets 128MB. This is sufficient for JSON entitlement manifests (< 1KB each), cached memory records (< 10KB each), and routing logic. We are NOT running ML, embeddings, or heavy compute at the edge. The constraint is irrelevant for our use case.

4. **Q: Where do new files go?** -- A: All edge code goes under `edge/rlm-worker/` (new directory). This is a separate TypeScript project with its own `wrangler.toml`, `package.json`, and `tsconfig.json`. It is NOT part of the Python `core/rlm/` package.

5. **Q: How does the edge layer authenticate requests?** -- A: Every request carries a `X-Tenant-ID` header and a `Authorization: Bearer <api_key>` header. The Worker validates the API key against KV (fast) or falls back to the origin `/api/v1/auth/validate` endpoint. Supabase auth remains the source of truth.

6. **Q: What about the 4-band architecture?** -- A: Band A (edge reads) is the primary deliverable. Band B (graph queries) passes through to origin. Band C (geo-compliance) is enforced at the Worker routing level. Band D (Queen/sovereign) bypasses the edge entirely -- Mac Mini traffic never touches Cloudflare.

---

## RISK ASSESSMENT

| Risk | Impact | Probability | Mitigation |
|------|--------|-------------|------------|
| Durable Object 1,000 req/s soft limit per object | HIGH at scale | Medium | Shard DO instances per tenant segment (hash ring), not 1 DO per tenant |
| KV eventual consistency (up to 60s propagation) | MEDIUM | High (by design) | Entitlement changes also invalidate DO cache via alarm; KV is warm cache, not source of truth |
| Cloudflare outage in SYD region | HIGH for AU compliance | Low | Failover to origin FastAPI directly; compliance logging captures routing path |
| Worker script size limit (10MB compressed) | LOW | Low | Edge code is thin proxy logic; no bundled ML libraries |
| WebSocket hibernation drops messages | MEDIUM | Medium | Client-side reconnect with last-event-id; DO stores pending messages in SQLite storage |
| Origin FastAPI gateway unavailable | HIGH | Low | DO serves stale cache with `X-Cache-Stale: true` header; writes queue in DO SQLite for replay |

---

## Q-TO-MODULE MAPPING TABLE

| Requirement (Product Brief Phase 2) | Module |
|----|--------|
| "Cloudflare Workers edge layer for sub-50ms memory retrieval" | M1: Edge Router + M2: Durable Object Cache |
| "Edge caching of entitlement manifests" | M3: KV Entitlement Cache |
| "Request routing based on customer tier" | M1: Edge Router + M3: KV Entitlement Cache |
| "Geographic compliance (AU/EU data residency)" | M4: Geographic Compliance Router |
| "Rate limiting per tenant at the edge" | M5: Edge Rate Limiter |
| "WebSocket support for real-time memory updates" | M6: WebSocket Memory Channel |
| "Lightweight memory reads (cached in DO)" | M2: Durable Object Cache |
| "Must call back to FastAPI gateway for writes" | M1: Edge Router (origin proxy) |
| "Handle 4-band architecture" | M1: Edge Router (band classifier) |
| "Durable Objects for edge state" | M2: Durable Object Cache |
| "KV for entitlement cache" | M3: KV Entitlement Cache |
| "R2 for large object storage" | M2: Durable Object Cache (R2 binding for bulk exports) |

---

## COMPONENT INTERFACE CONTRACTS

All cross-module calls use these TypeScript interfaces. Every module MUST implement the relevant interface.

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/contracts.ts`

```typescript
// edge/rlm-worker/src/contracts.ts
// RLM Cloudflare Workers Edge Layer -- Component Interface Contracts

// ---------------------------------------------------------------------------
// Shared types (mirror core/rlm/contracts.py enums)
// ---------------------------------------------------------------------------

export enum MemoryTier {
  DISCARD = "discard",
  WORKING = "working",
  EPISODIC = "episodic",
  SEMANTIC = "semantic",
}

export enum CustomerTier {
  STARTER = "starter",
  PROFESSIONAL = "professional",
  ENTERPRISE = "enterprise",
  QUEEN = "queen",
}

export enum Band {
  A = "edge",        // Cached reads, lightweight ops
  B = "standard",    // Graph queries, heavy compute -- pass to origin
  C = "sovereign",   // Geo-fenced, AU/EU data residency
  D = "hybrid",      // Queen/Mac Mini -- bypasses edge entirely
}

export interface MemoryRecord {
  tenant_id: string;       // UUID as string
  content: string;
  source: string;
  domain: string;
  surprise_score: number;
  memory_tier: MemoryTier;
  metadata: Record<string, unknown>;
  created_at: string;      // ISO 8601
  vector_id: string | null;
  pg_id: number | null;
}

export interface EntitlementManifest {
  tenant_id: string;
  tier: CustomerTier;
  band: Band;
  memory_limit_mb: number;
  max_memories_per_day: number;
  decay_policy: string;
  allowed_mcp_tools: string[];
  features: Record<string, boolean>;
  geo_region: string | null;   // "AU" | "EU" | null (global)
  cached_at: number;           // Unix timestamp ms
  ttl_seconds: number;         // Cache TTL
}

export interface RateLimitEntry {
  tenant_id: string;
  window_start: number;        // Unix timestamp ms
  request_count: number;
  limit: number;
  remaining: number;
  reset_at: number;            // Unix timestamp ms
}

export interface CachedMemoryPage {
  tenant_id: string;
  memories: MemoryRecord[];
  cached_at: number;
  stale: boolean;
  etag: string;
}

export interface WebSocketMessage {
  type: "memory_update" | "memory_delete" | "entitlement_change" | "ping";
  tenant_id: string;
  payload: Record<string, unknown>;
  timestamp: number;
}

// ---------------------------------------------------------------------------
// Module interfaces (Protocol equivalents)
// ---------------------------------------------------------------------------

export interface IEdgeRouter {
  /** Classify request into Band and route accordingly. */
  classifyBand(tenantId: string, path: string, manifest: EntitlementManifest): Band;
  /** Proxy request to FastAPI origin for writes and uncacheable reads. */
  proxyToOrigin(request: Request, env: Env): Promise<Response>;
  /** Serve cached response from Durable Object. */
  serveCached(tenantId: string, path: string, env: Env): Promise<Response | null>;
}

export interface IDurableObjectCache {
  /** Get cached memories for a tenant query. */
  getMemories(tenantId: string, query: string, limit: number): Promise<CachedMemoryPage | null>;
  /** Store memory page in DO SQLite storage. */
  putMemories(tenantId: string, query: string, page: CachedMemoryPage): Promise<void>;
  /** Invalidate all cached data for a tenant. */
  invalidateTenant(tenantId: string): Promise<number>;
  /** Handle WebSocket upgrade for real-time channel. */
  handleWebSocket(request: Request): Promise<Response>;
}

export interface IKVEntitlementCache {
  /** Get entitlement manifest from KV (fast path). */
  getManifest(tenantId: string, env: Env): Promise<EntitlementManifest | null>;
  /** Refresh manifest from origin and write to KV. */
  refreshManifest(tenantId: string, env: Env): Promise<EntitlementManifest>;
  /** Invalidate manifest in KV (triggered by Stripe webhook). */
  invalidateManifest(tenantId: string, env: Env): Promise<void>;
}

export interface IGeoRouter {
  /** Determine if request must be geo-fenced based on manifest. */
  requiresGeoFence(manifest: EntitlementManifest): boolean;
  /** Validate that the current colo matches the required region. */
  validateColo(request: Request, requiredRegion: string): boolean;
  /** Re-route to correct region if colo mismatch. */
  rerouteToRegion(request: Request, region: string): Response;
}

export interface IRateLimiter {
  /** Check if request is within rate limit. Returns remaining count. */
  checkLimit(tenantId: string, tier: CustomerTier): Promise<RateLimitEntry>;
  /** Increment request counter for tenant. */
  incrementCounter(tenantId: string): Promise<void>;
  /** Get current rate limit status without incrementing. */
  getStatus(tenantId: string): Promise<RateLimitEntry>;
}

export interface IWebSocketChannel {
  /** Accept WebSocket connection for tenant. */
  acceptConnection(tenantId: string, request: Request): Promise<Response>;
  /** Broadcast memory update to all connected clients for tenant. */
  broadcastUpdate(tenantId: string, message: WebSocketMessage): Promise<number>;
  /** Handle hibernation wake for cost optimization. */
  handleWake(tenantId: string): Promise<void>;
}

// ---------------------------------------------------------------------------
// Cloudflare environment bindings
// ---------------------------------------------------------------------------

export interface Env {
  // KV namespaces
  ENTITLEMENT_KV: KVNamespace;
  RATE_LIMIT_KV: KVNamespace;

  // Durable Object namespaces
  MEMORY_CACHE_DO: DurableObjectNamespace;
  RATE_LIMITER_DO: DurableObjectNamespace;

  // R2 bucket
  MEMORY_EXPORT_R2: R2Bucket;

  // Environment variables
  ORIGIN_URL: string;         // FastAPI gateway URL
  ORIGIN_API_KEY: string;     // Service-to-service auth key
  ALLOWED_REGIONS: string;    // Comma-separated: "AU,EU,US"
  RATE_LIMIT_RPM_STARTER: string;
  RATE_LIMIT_RPM_PROFESSIONAL: string;
  RATE_LIMIT_RPM_ENTERPRISE: string;
}
```

---

## WAVE EXECUTION PLAN

### Dependency DAG

```
Module 0 (Contracts + Wrangler Config) ────────────────────┐
                                                            │
Module 3 (KV Entitlement Cache) ──────────────────────────┤
Module 5 (Edge Rate Limiter) ─────────────────────────────┤──> Module 1 (Edge Router)
                                                            │         │
Module 2 (Durable Object Cache) ◄─────────────────────────┘         │
                                                                      │
Module 4 (Geographic Compliance Router) ◄─── depends on M1 + M3     │
                                                                      │
Module 6 (WebSocket Memory Channel) ◄──────── depends on M2         │
```

### Wave Plan

```
WAVE 0 (immediate, no dependencies):
  - Story 0.01: contracts.ts (Component Interface Contracts)
  - Story 0.02: wrangler.toml + package.json + tsconfig.json (Project scaffold)

WAVE 1 (all parallel -- foundation modules):
  Module 3 (KV Entitlement Cache)      -- 8 stories
  Module 5 (Edge Rate Limiter)         -- 7 stories
  Module 2 (Durable Object Cache)      -- 10 stories

WAVE 2 (depends on Wave 1):
  Module 1 (Edge Router)               -- 9 stories
  -> Requires: contracts (W0), KV cache (M3), rate limiter (M5), DO cache (M2)

WAVE 3 (depends on M1 + M3):
  Module 4 (Geographic Compliance)     -- 7 stories
  Module 6 (WebSocket Memory Channel)  -- 7 stories (depends on M2)

Stories within each module: SEQUENTIAL (each depends on prior).
Modules within each wave: PARALLEL.
```

### Fire Commands

```bash
# Wave 0 (scaffold)
fire_rwl --story 0.01 --fresh-context &
fire_rwl --story 0.02 --fresh-context &
wait

# Wave 1 (3 modules in parallel)
fire_rwl --module 3 --parallel &
fire_rwl --module 5 --parallel &
fire_rwl --module 2 --parallel &
wait

# Wave 2
fire_rwl --module 1 --sequential

# Wave 3 (2 modules in parallel)
fire_rwl --module 4 --parallel &
fire_rwl --module 6 --parallel &
wait
```

**Estimated agents**: 3 concurrent (Wave 1) + 1 (Wave 2) + 2 (Wave 3) = 6 agent sessions total.

---

## EXECUTION PROTOCOL: RALPH WIGGUM LOOP

### Story Launch Command (copy-paste per story)

```
================================================================
RALPH WIGGUM LOOP -- Story [MODULE.STORY_N]
Story: [TITLE]
Context: FRESH -- no prior session state
Budget: [BUDGET] iterations max
Language: TypeScript (Cloudflare Workers)
================================================================

Task: Implement exactly what is specified below.
Working directory: /mnt/e/genesis-system/edge/rlm-worker/

[PASTE STORY SPEC]

Mandatory on completion:
1. All tests pass (0 failures) -- vitest
2. Add VERIFICATION_STAMP comment to implemented file
3. Return: files created + test results + stamp
================================================================
```

### Resource Authorization

- **Models**: Opus 4.6 (reasoning), Sonnet 4.6 (implementation), Haiku (fast classification)
- **Parallelism**: Max 3 workers per wave, rate maximizer at 95%
- **Storage**: Cloudflare KV + Durable Objects + R2 -- NO local files, NO C: drive
- **Test runner**: `npx vitest run` (from `edge/rlm-worker/`)
- **Deploy**: `npx wrangler deploy` (after all tests pass)

---

# WAVE 0: FOUNDATION

---

### Story 0.01: Component Interface Contracts + Shared Types

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/contracts.ts`
**Depends on**: Nothing (first story)
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Layer architect, **I need** a single contracts module defining all shared types, enums, interfaces, and the `Env` binding type, **so that** all 6 modules can develop independently against typed contracts.

**Code skeleton**: See "COMPONENT INTERFACE CONTRACTS" section above -- that is the full implementation target.

**Acceptance criteria**:
1. `import { MemoryTier, CustomerTier, Band } from './contracts'` compiles without error
2. `import { EntitlementManifest, MemoryRecord } from './contracts'` compiles without error
3. All 6 module interfaces (`IEdgeRouter`, `IDurableObjectCache`, `IKVEntitlementCache`, `IGeoRouter`, `IRateLimiter`, `IWebSocketChannel`) are importable
4. `Env` interface includes all KV, DO, R2, and env var bindings
5. `Band` enum has exactly 4 values: A, B, C, D

**Black box tests**:
- BB1: Import all public names from `contracts.ts` -- assert TypeScript compilation succeeds
- BB2: Create an `EntitlementManifest` object with all required fields -- assert type checks pass
- BB3: Assert `Band.A === "edge"` and `Band.D === "hybrid"` -- enum value correctness

**White box tests**:
- WB1: Verify `MemoryRecord` interface fields match `core/rlm/contracts.py` `MemoryRecord` dataclass exactly
- WB2: Verify `CustomerTier` enum values match Python `CustomerTier` enum values exactly

---

### Story 0.02: Project Scaffold -- wrangler.toml, package.json, tsconfig.json

**File**: `/mnt/e/genesis-system/edge/rlm-worker/wrangler.toml`
**Additional files**:
  - `/mnt/e/genesis-system/edge/rlm-worker/package.json`
  - `/mnt/e/genesis-system/edge/rlm-worker/tsconfig.json`
  - `/mnt/e/genesis-system/edge/rlm-worker/vitest.config.ts`
  - `/mnt/e/genesis-system/edge/rlm-worker/src/index.ts` (empty entry point)
**Depends on**: Nothing (parallel with 0.01)
**Budget**: 5 implement + 2 run + 1 fix + 1 stamp = 9

**As** an agent, **I need** the Cloudflare Workers project scaffolded with wrangler config, TypeScript config, and test runner, **so that** all subsequent stories have a compilable, testable project structure.

**Code skeleton**:
```toml
# edge/rlm-worker/wrangler.toml
name = "rlm-edge-worker"
main = "src/index.ts"
compatibility_date = "2025-12-01"

[vars]
ORIGIN_URL = "https://api.genesis.internal"
ALLOWED_REGIONS = "AU,EU,US"
RATE_LIMIT_RPM_STARTER = "60"
RATE_LIMIT_RPM_PROFESSIONAL = "300"
RATE_LIMIT_RPM_ENTERPRISE = "1000"

[[kv_namespaces]]
binding = "ENTITLEMENT_KV"
id = "placeholder-kv-id-entitlement"

[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "placeholder-kv-id-ratelimit"

[[durable_objects.bindings]]
name = "MEMORY_CACHE_DO"
class_name = "MemoryCacheDO"

[[durable_objects.bindings]]
name = "RATE_LIMITER_DO"
class_name = "RateLimiterDO"

[[r2_buckets]]
binding = "MEMORY_EXPORT_R2"
bucket_name = "rlm-memory-exports"

[[migrations]]
tag = "v1"
new_sqlite_classes = ["MemoryCacheDO", "RateLimiterDO"]
```

```json
// edge/rlm-worker/package.json
{
  "name": "rlm-edge-worker",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "test": "vitest run",
    "test:watch": "vitest",
    "typecheck": "tsc --noEmit"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20250214.0",
    "@cloudflare/vitest-pool-workers": "^0.8.0",
    "typescript": "^5.7.0",
    "vitest": "^3.0.0",
    "wrangler": "^3.100.0"
  }
}
```

```json
// edge/rlm-worker/tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "types": ["@cloudflare/workers-types"],
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}
```

**Acceptance criteria**:
1. `npm install` succeeds without errors
2. `npx tsc --noEmit` passes with zero type errors
3. `npx wrangler dev` starts local dev server (may fail on bindings -- that is expected)
4. `npx vitest run` executes and reports 0 tests (no tests yet)
5. `wrangler.toml` declares both KV namespaces, both DO bindings, and 1 R2 bucket

**Black box tests**:
- BB1: Run `npm install && npx tsc --noEmit` -- assert exit code 0
- BB2: Verify `wrangler.toml` has `compatibility_date` set to a 2025+ date
- BB3: Verify `package.json` has `@cloudflare/workers-types` in devDependencies

**White box tests**:
- WB1: Parse `wrangler.toml` -- assert exactly 2 `kv_namespaces`, 2 `durable_objects.bindings`, 1 `r2_buckets`
- WB2: Verify `tsconfig.json` has `"strict": true` and `"types": ["@cloudflare/workers-types"]`

---

# MODULE 2: DURABLE OBJECT CACHE
**Edge-local memory cache with SQLite storage backend.**
**Stories**: 10 | **Wave**: 1 (no dependencies beyond W0) | **Directory**: `edge/rlm-worker/src/`

**Module vision**: A Durable Object class (`MemoryCacheDO`) that stores cached memory records in its SQLite-backed storage, serves read requests without hitting the origin, and invalidates on write-through signals. Each DO instance serves a shard of tenants (hash-ring routing), keeping the 1,000 req/s soft limit manageable. Stale data is served with headers when origin is unavailable.

**Module file map**:
```
edge/rlm-worker/src/do/memory-cache.ts        <-- Stories 2.01-2.08
edge/rlm-worker/src/do/shard-router.ts         <-- Story 2.09
edge/rlm-worker/tests/do/memory-cache.test.ts  <-- Story 2.10
```

---

### Story 2.01: MemoryCacheDO Class -- Constructor and SQLite Schema

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/memory-cache.ts`
**Depends on**: Story 0.01 (contracts), Story 0.02 (scaffold)
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Durable Object, **I need** a constructor that initializes SQLite tables for cached memories, **so that** all subsequent read/write operations have a persistent local schema.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/do/memory-cache.ts
import { DurableObject } from "cloudflare:workers";
import type { Env, MemoryRecord, CachedMemoryPage } from "../contracts";

export class MemoryCacheDO extends DurableObject<Env> {
  private sql: SqlStorage;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.sql = ctx.storage.sql;
    this.initSchema();
  }

  private initSchema(): void {
    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS cached_memories (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        tenant_id TEXT NOT NULL,
        query_hash TEXT NOT NULL,
        content TEXT NOT NULL,
        source TEXT NOT NULL,
        domain TEXT NOT NULL,
        surprise_score REAL NOT NULL DEFAULT 0.0,
        memory_tier TEXT NOT NULL DEFAULT 'working',
        metadata TEXT DEFAULT '{}',
        created_at TEXT NOT NULL,
        vector_id TEXT,
        pg_id INTEGER,
        cached_at INTEGER NOT NULL,
        etag TEXT NOT NULL,
        UNIQUE(tenant_id, query_hash, vector_id)
      );
      CREATE INDEX IF NOT EXISTS idx_tenant_query
        ON cached_memories(tenant_id, query_hash);
      CREATE INDEX IF NOT EXISTS idx_tenant_cached
        ON cached_memories(tenant_id, cached_at);
    `);
  }
}
```

**Acceptance criteria**:
1. Constructor creates `cached_memories` table if not exists
2. Table has composite unique constraint on `(tenant_id, query_hash, vector_id)`
3. Two indexes are created: `idx_tenant_query` and `idx_tenant_cached`
4. Schema is idempotent -- calling `initSchema()` twice does not error
5. All columns have correct types matching `MemoryRecord` interface

**Black box tests**:
- BB1: Instantiate `MemoryCacheDO` -- assert no error thrown
- BB2: Instantiate twice in same test -- assert schema creation is idempotent
- BB3: Insert a row directly via SQL -- assert correct column types accepted

**White box tests**:
- WB1: Verify `this.sql.exec()` is called exactly once in constructor
- WB2: Verify SQL includes `CREATE TABLE IF NOT EXISTS` (not `CREATE TABLE`)

---

### Story 2.02: MemoryCacheDO -- getMemories() Read Path

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/memory-cache.ts`
**Depends on**: Story 2.01
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** to read cached memory pages from the DO, **so that** read requests are served at edge speed without origin fetch.

**Code skeleton**:
```typescript
  async getMemories(
    tenantId: string,
    queryHash: string,
    limit: number = 10,
    maxAgeMs: number = 300_000, // 5 minutes default
  ): Promise<CachedMemoryPage | null> {
    const cutoff = Date.now() - maxAgeMs;
    const rows = this.sql.exec(
      `SELECT * FROM cached_memories
       WHERE tenant_id = ? AND query_hash = ? AND cached_at > ?
       ORDER BY cached_at DESC LIMIT ?`,
      tenantId, queryHash, cutoff, limit
    ).toArray();

    if (rows.length === 0) return null;

    return {
      tenant_id: tenantId,
      memories: rows.map(this.rowToMemoryRecord),
      cached_at: rows[0].cached_at as number,
      stale: false,
      etag: rows[0].etag as string,
    };
  }

  private rowToMemoryRecord(row: Record<string, SqlStorageValue>): MemoryRecord {
    // Map SQLite row to MemoryRecord interface
    ...
  }
```

**Acceptance criteria**:
1. Returns `null` when no cached data exists for tenant+query
2. Returns `CachedMemoryPage` with correctly mapped `MemoryRecord` objects when cache hit
3. Respects `maxAgeMs` -- entries older than cutoff are treated as cache miss
4. Results ordered by `cached_at DESC` (newest first)
5. `limit` parameter caps the number of returned records

**Black box tests**:
- BB1: Query empty DO -- assert returns `null`
- BB2: Insert 5 records, query with limit=3 -- assert returns exactly 3 records
- BB3: Insert records with `cached_at` 10 minutes ago, query with `maxAgeMs=300000` -- assert returns `null` (expired)

**White box tests**:
- WB1: Verify SQL query uses parameterized binding (not string interpolation)
- WB2: Verify `rowToMemoryRecord()` maps all 11 fields from `MemoryRecord` interface

---

### Story 2.03: MemoryCacheDO -- putMemories() Write Path

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/memory-cache.ts`
**Depends on**: Story 2.01
**Budget**: 5 implement + 2 run + 1 fix + 1 stamp = 9

**As** the Edge Router, **I need** to write memory pages from origin responses into the DO cache, **so that** subsequent reads for the same tenant+query are served from edge.

**Code skeleton**:
```typescript
  async putMemories(
    tenantId: string,
    queryHash: string,
    page: CachedMemoryPage,
  ): Promise<void> {
    const now = Date.now();
    const etag = this.computeEtag(page);

    // Upsert each memory in a transaction
    for (const mem of page.memories) {
      this.sql.exec(
        `INSERT OR REPLACE INTO cached_memories
         (tenant_id, query_hash, content, source, domain,
          surprise_score, memory_tier, metadata, created_at,
          vector_id, pg_id, cached_at, etag)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        tenantId, queryHash, mem.content, mem.source, mem.domain,
        mem.surprise_score, mem.memory_tier, JSON.stringify(mem.metadata),
        mem.created_at, mem.vector_id, mem.pg_id, now, etag
      );
    }
  }

  private computeEtag(page: CachedMemoryPage): string {
    // SHA-256 of concatenated vector_ids for cache validation
    ...
  }
```

**Acceptance criteria**:
1. Writes all records from `CachedMemoryPage.memories` into SQLite
2. Uses `INSERT OR REPLACE` for idempotent upserts
3. `cached_at` is set to current timestamp (not from the page)
4. `metadata` is JSON-serialized before storage
5. `etag` is computed from content for cache validation

**Black box tests**:
- BB1: Put 5 memories, then getMemories -- assert all 5 returned
- BB2: Put same memories twice -- assert no duplicate rows (upsert)
- BB3: Put memories with complex metadata objects -- assert JSON round-trip preserves structure

**White box tests**:
- WB1: Verify `INSERT OR REPLACE` SQL syntax used (not `INSERT` or `UPDATE`)
- WB2: Verify `computeEtag()` produces different values for different page contents

---

### Story 2.04: MemoryCacheDO -- invalidateTenant() Cache Purge

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/memory-cache.ts`
**Depends on**: Story 2.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the origin server, **I need** to invalidate all cached data for a tenant when a write occurs, **so that** stale data is never served after a memory mutation.

**Code skeleton**:
```typescript
  async invalidateTenant(tenantId: string): Promise<number> {
    const result = this.sql.exec(
      `DELETE FROM cached_memories WHERE tenant_id = ?`,
      tenantId
    );
    return result.rowsWritten;
  }

  async invalidateQuery(tenantId: string, queryHash: string): Promise<number> {
    const result = this.sql.exec(
      `DELETE FROM cached_memories WHERE tenant_id = ? AND query_hash = ?`,
      tenantId, queryHash
    );
    return result.rowsWritten;
  }
```

**Acceptance criteria**:
1. `invalidateTenant()` deletes ALL cached records for the tenant
2. Returns count of deleted rows
3. `invalidateQuery()` deletes only records matching tenant + specific query hash
4. Calling invalidate on non-existent tenant returns 0 (not an error)

**Black box tests**:
- BB1: Put 10 memories for tenant A, invalidateTenant(A) -- assert returns 10, getMemories returns null
- BB2: Put memories for tenant A and B, invalidateTenant(A) -- assert tenant B memories still exist
- BB3: Invalidate non-existent tenant -- assert returns 0

**White box tests**:
- WB1: Verify SQL DELETE uses parameterized `tenant_id` (not string interpolation)
- WB2: Verify `rowsWritten` from `sql.exec()` return value is used

---

### Story 2.05: MemoryCacheDO -- Stale-While-Revalidate Fallback

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/memory-cache.ts`
**Depends on**: Story 2.02
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** the DO to serve stale cached data when the origin is unavailable, **so that** customers experience degraded service rather than total failure.

**Code skeleton**:
```typescript
  async getMemoriesStaleOk(
    tenantId: string,
    queryHash: string,
    limit: number = 10,
  ): Promise<CachedMemoryPage | null> {
    // Ignores maxAgeMs -- returns whatever is cached, even if expired
    const rows = this.sql.exec(
      `SELECT * FROM cached_memories
       WHERE tenant_id = ? AND query_hash = ?
       ORDER BY cached_at DESC LIMIT ?`,
      tenantId, queryHash, limit
    ).toArray();

    if (rows.length === 0) return null;

    const now = Date.now();
    const cachedAt = rows[0].cached_at as number;
    const isStale = (now - cachedAt) > 300_000; // > 5 minutes

    return {
      tenant_id: tenantId,
      memories: rows.map(this.rowToMemoryRecord),
      cached_at: cachedAt,
      stale: isStale,
      etag: rows[0].etag as string,
    };
  }
```

**Acceptance criteria**:
1. Returns cached data regardless of age (no `maxAgeMs` filter)
2. Sets `stale: true` when data is older than 5 minutes
3. Sets `stale: false` when data is within 5 minutes
4. Returns `null` only when no data exists at all
5. Caller uses `stale` flag to add `X-Cache-Stale: true` response header

**Black box tests**:
- BB1: Insert records 10 minutes ago, call getMemoriesStaleOk -- assert returns data with `stale: true`
- BB2: Insert records 1 minute ago, call getMemoriesStaleOk -- assert returns data with `stale: false`
- BB3: Empty cache, call getMemoriesStaleOk -- assert returns `null`

**White box tests**:
- WB1: Verify SQL query has NO `cached_at > ?` filter (unlike `getMemories()`)
- WB2: Verify stale threshold is 300,000 ms (5 minutes)

---

### Story 2.06: MemoryCacheDO -- HTTP Fetch Handler

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/memory-cache.ts`
**Depends on**: Stories 2.02, 2.03, 2.04
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router Worker, **I need** the DO to handle HTTP requests via its `fetch()` method, **so that** the Worker can communicate with the DO via standard HTTP verbs.

**Code skeleton**:
```typescript
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // GET /memories?tenant_id=X&query_hash=Y&limit=Z
    if (request.method === "GET" && path === "/memories") {
      const tenantId = url.searchParams.get("tenant_id");
      const queryHash = url.searchParams.get("query_hash");
      const limit = parseInt(url.searchParams.get("limit") ?? "10", 10);
      if (!tenantId || !queryHash) {
        return new Response("Missing tenant_id or query_hash", { status: 400 });
      }
      const page = await this.getMemories(tenantId, queryHash, limit);
      if (!page) {
        return new Response(null, { status: 404 });
      }
      return Response.json(page, {
        headers: { "ETag": page.etag, "X-Cache": "HIT" },
      });
    }

    // PUT /memories -- body is CachedMemoryPage
    if (request.method === "PUT" && path === "/memories") {
      const body = await request.json() as {
        tenant_id: string; query_hash: string; page: CachedMemoryPage;
      };
      await this.putMemories(body.tenant_id, body.query_hash, body.page);
      return new Response(null, { status: 204 });
    }

    // DELETE /invalidate?tenant_id=X
    if (request.method === "DELETE" && path === "/invalidate") {
      const tenantId = url.searchParams.get("tenant_id");
      if (!tenantId) return new Response("Missing tenant_id", { status: 400 });
      const count = await this.invalidateTenant(tenantId);
      return Response.json({ deleted: count });
    }

    return new Response("Not Found", { status: 404 });
  }
```

**Acceptance criteria**:
1. GET `/memories` with valid params returns JSON `CachedMemoryPage` with `ETag` and `X-Cache: HIT` headers
2. GET `/memories` with missing params returns 400
3. GET `/memories` cache miss returns 404
4. PUT `/memories` stores data and returns 204
5. DELETE `/invalidate` purges tenant cache and returns deleted count
6. Unknown routes return 404

**Black box tests**:
- BB1: PUT then GET -- assert 200 with matching data
- BB2: GET without tenant_id param -- assert 400
- BB3: DELETE then GET -- assert 404 (cache purged)

**White box tests**:
- WB1: Verify `Response.json()` used for JSON responses (not manual `JSON.stringify`)
- WB2: Verify `ETag` header value matches the `etag` field in the response body

---

### Story 2.07: MemoryCacheDO -- Alarm-Based Expiry Cleanup

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/memory-cache.ts`
**Depends on**: Story 2.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the DO, **I need** a scheduled alarm that cleans up expired cache entries, **so that** SQLite storage does not grow unbounded (10GB per-object limit).

**Code skeleton**:
```typescript
  async alarm(): Promise<void> {
    const maxAgeMs = 3_600_000; // 1 hour max cache age
    const cutoff = Date.now() - maxAgeMs;
    const result = this.sql.exec(
      `DELETE FROM cached_memories WHERE cached_at < ?`,
      cutoff
    );
    console.log(`[MemoryCacheDO] Alarm cleanup: deleted ${result.rowsWritten} expired entries`);

    // Re-schedule alarm for next hour
    await this.ctx.storage.setAlarm(Date.now() + 3_600_000);
  }

  // Called once on first request to ensure alarm is scheduled
  private async ensureAlarmScheduled(): Promise<void> {
    const existing = await this.ctx.storage.getAlarm();
    if (!existing) {
      await this.ctx.storage.setAlarm(Date.now() + 3_600_000);
    }
  }
```

**Acceptance criteria**:
1. `alarm()` deletes all entries with `cached_at` older than 1 hour
2. Alarm re-schedules itself for the next hour after running
3. `ensureAlarmScheduled()` is called on first fetch to bootstrap the alarm
4. If no entries to delete, alarm still completes without error

**Black box tests**:
- BB1: Insert entries with old `cached_at`, trigger alarm -- assert entries deleted
- BB2: Trigger alarm on empty table -- assert no error
- BB3: Verify alarm re-schedules after execution (check `getAlarm()` returns future timestamp)

**White box tests**:
- WB1: Verify `setAlarm()` called with `Date.now() + 3_600_000` in alarm handler
- WB2: Verify `ensureAlarmScheduled()` checks `getAlarm()` before setting (no double-schedule)

---

### Story 2.08: MemoryCacheDO -- Storage Metrics

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/memory-cache.ts`
**Depends on**: Story 2.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** an operator, **I need** a metrics endpoint on the DO that reports cache size, hit/miss counts, and tenant count, **so that** I can monitor edge cache effectiveness.

**Code skeleton**:
```typescript
  private metrics = {
    hits: 0,
    misses: 0,
    puts: 0,
    invalidations: 0,
  };

  async getMetrics(): Promise<Record<string, number>> {
    const rowCount = this.sql.exec(
      `SELECT COUNT(*) as cnt FROM cached_memories`
    ).toArray()[0].cnt as number;

    const tenantCount = this.sql.exec(
      `SELECT COUNT(DISTINCT tenant_id) as cnt FROM cached_memories`
    ).toArray()[0].cnt as number;

    return {
      ...this.metrics,
      cached_records: rowCount,
      unique_tenants: tenantCount,
    };
  }
```

**Acceptance criteria**:
1. `hits` counter increments on every successful `getMemories()` call
2. `misses` counter increments on every `getMemories()` returning null
3. `puts` counter increments on every `putMemories()` call
4. `invalidations` counter increments on every `invalidateTenant()` call
5. `cached_records` and `unique_tenants` are real-time counts from SQLite

**Black box tests**:
- BB1: Fresh DO, get metrics -- assert all counters at 0
- BB2: Do 3 puts, 2 hits, 1 miss -- assert metrics reflect exact counts
- BB3: PUT then invalidate -- assert `invalidations` incremented

**White box tests**:
- WB1: Verify `metrics.hits++` is called inside `getMemories()` success path
- WB2: Verify SQL COUNT queries use no WHERE clause for total, DISTINCT for tenants

---

### Story 2.09: Shard Router -- Hash-Ring DO Instance Selection

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/do/shard-router.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 5 implement + 2 run + 1 fix + 1 stamp = 9

**As** the Edge Router, **I need** a hash-ring function that maps tenant IDs to specific DO instances, **so that** no single DO instance exceeds the 1,000 req/s soft limit.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/do/shard-router.ts
import type { Env } from "../contracts";

const SHARD_COUNT = 64; // 64 DO shards -- supports ~64,000 req/s total

export function getShardId(tenantId: string): string {
  // Simple consistent hash: take first 8 chars of SHA-256 hex, mod SHARD_COUNT
  const hash = hashTenantId(tenantId);
  const shard = hash % SHARD_COUNT;
  return `shard-${shard.toString().padStart(4, "0")}`;
}

export function getMemoryCacheDO(
  tenantId: string,
  env: Env,
): DurableObjectStub {
  const shardId = getShardId(tenantId);
  const id = env.MEMORY_CACHE_DO.idFromName(shardId);
  return env.MEMORY_CACHE_DO.get(id);
}

function hashTenantId(tenantId: string): number {
  // FNV-1a hash for fast, well-distributed hashing
  let hash = 2166136261;
  for (let i = 0; i < tenantId.length; i++) {
    hash ^= tenantId.charCodeAt(i);
    hash = (hash * 16777619) >>> 0;
  }
  return hash;
}
```

**Acceptance criteria**:
1. Same `tenantId` always maps to the same shard (deterministic)
2. Distribution across 64 shards is roughly uniform (no shard gets > 5% of 1000 random UUIDs)
3. `getMemoryCacheDO()` returns a valid `DurableObjectStub`
4. `SHARD_COUNT` is configurable at module level
5. Shard IDs are zero-padded strings: `shard-0000` through `shard-0063`

**Black box tests**:
- BB1: Hash same UUID twice -- assert same shard ID returned
- BB2: Hash 1000 random UUIDs -- assert no shard has more than 50 tenants (5% of 1000)
- BB3: Verify shard ID format matches `shard-NNNN` pattern

**White box tests**:
- WB1: Verify FNV-1a hash produces non-zero values for common UUID formats
- WB2: Verify `idFromName()` called with the shard string, not the raw tenant ID

---

### Story 2.10: Module 2 Integration Test

**File**: `/mnt/e/genesis-system/edge/rlm-worker/tests/do/memory-cache.test.ts`
**Depends on**: Stories 2.01-2.09
**Budget**: 5 implement + 3 run + 2 fix + 1 stamp = 11

**As** the verification agent, **I need** an integration test that exercises the full DO cache lifecycle, **so that** all Module 2 stories are confirmed working together.

**Test plan**:
```typescript
// edge/rlm-worker/tests/do/memory-cache.test.ts
import { describe, it, expect } from "vitest";

describe("MemoryCacheDO Integration", () => {
  it("put-get-invalidate lifecycle", async () => {
    // PUT memories -> GET -> assert hit -> INVALIDATE -> GET -> assert miss
  });

  it("stale-while-revalidate serves expired data", async () => {
    // PUT with old timestamp -> getMemories returns null -> getMemoriesStaleOk returns data with stale=true
  });

  it("shard router distributes evenly", async () => {
    // Hash 1000 UUIDs -> assert max shard count < 5% threshold
  });

  it("alarm cleans up expired entries", async () => {
    // PUT entries with old cached_at -> trigger alarm -> assert entries deleted
  });

  it("metrics track all operations", async () => {
    // PUT, GET (hit), GET (miss) -> assert metrics counters correct
  });

  it("HTTP fetch handler routes correctly", async () => {
    // GET /memories, PUT /memories, DELETE /invalidate -> assert correct status codes
  });
});
```

**Acceptance criteria**:
1. All 6 test functions pass
2. Tests use Cloudflare `miniflare` test environment for DO simulation
3. Coverage of Module 2 code >= 85%
4. No test depends on external network (all local)

**Black box tests**:
- BB1: Full lifecycle (put -> get -> invalidate -> get) completes without error
- BB2: Stale-while-revalidate correctly flags stale data
- BB3: Shard distribution passes uniformity check

**White box tests**:
- WB1: Verify test fixtures create isolated DO instances per test
- WB2: Verify alarm scheduling is tested with time manipulation

---

# MODULE 3: KV ENTITLEMENT CACHE
**Edge-cached entitlement manifests via Cloudflare KV.**
**Stories**: 8 | **Wave**: 1 (no dependencies beyond W0) | **Directory**: `edge/rlm-worker/src/`

**Module vision**: A thin KV-backed cache layer that stores serialized `EntitlementManifest` objects per tenant, serving tier lookups in < 5ms at the edge. On cache miss, fetches from the Python origin's `GET /api/v1/entitlement/{tenant_id}` endpoint. Invalidated by Stripe webhook signals forwarded from origin.

**Module file map**:
```
edge/rlm-worker/src/kv/entitlement-cache.ts    <-- Stories 3.01-3.06
edge/rlm-worker/src/kv/auth-validator.ts        <-- Story 3.07
edge/rlm-worker/tests/kv/entitlement.test.ts    <-- Story 3.08
```

---

### Story 3.01: EntitlementCache Class -- KV Read Path

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/kv/entitlement-cache.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** to read entitlement manifests from KV in < 5ms, **so that** every request is tier-classified without origin latency.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/kv/entitlement-cache.ts
import type { Env, EntitlementManifest, CustomerTier, Band } from "../contracts";

const KV_PREFIX = "entitlement:";
const DEFAULT_TTL_SECONDS = 300; // 5 minutes

export class EntitlementCache {
  constructor(private env: Env) {}

  async getManifest(tenantId: string): Promise<EntitlementManifest | null> {
    const key = `${KV_PREFIX}${tenantId}`;
    const raw = await this.env.ENTITLEMENT_KV.get(key, { type: "json" });
    if (!raw) return null;
    return raw as EntitlementManifest;
  }
}
```

**Acceptance criteria**:
1. Returns `EntitlementManifest` on cache hit
2. Returns `null` on cache miss (key not in KV)
3. KV key format is `entitlement:{tenant_id}`
4. Uses `type: "json"` for automatic deserialization
5. Response time < 5ms for KV hit (Cloudflare guarantee)

**Black box tests**:
- BB1: Pre-populate KV with manifest, call getManifest -- assert correct object returned
- BB2: Call getManifest for non-existent tenant -- assert returns null
- BB3: Verify returned object has all `EntitlementManifest` fields

**White box tests**:
- WB1: Verify KV key uses `entitlement:` prefix concatenated with tenant ID
- WB2: Verify `{ type: "json" }` option passed to `KV.get()`

---

### Story 3.02: EntitlementCache -- Origin Fetch on Miss

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/kv/entitlement-cache.ts`
**Depends on**: Story 3.01
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** to fetch the manifest from the origin FastAPI gateway on cache miss and populate KV, **so that** the first request for any tenant still works.

**Code skeleton**:
```typescript
  async getManifestOrFetch(tenantId: string): Promise<EntitlementManifest> {
    // Try KV first
    const cached = await this.getManifest(tenantId);
    if (cached) return cached;

    // Cache miss -- fetch from origin
    const originUrl = `${this.env.ORIGIN_URL}/api/v1/entitlement/${tenantId}`;
    const response = await fetch(originUrl, {
      headers: {
        "Authorization": `Bearer ${this.env.ORIGIN_API_KEY}`,
        "Content-Type": "application/json",
      },
    });

    if (!response.ok) {
      throw new TenantNotFoundError(tenantId, response.status);
    }

    const manifest = await response.json() as EntitlementManifest;
    manifest.cached_at = Date.now();
    manifest.ttl_seconds = DEFAULT_TTL_SECONDS;

    // Write back to KV with TTL
    await this.putManifest(tenantId, manifest);
    return manifest;
  }

  async putManifest(tenantId: string, manifest: EntitlementManifest): Promise<void> {
    const key = `${KV_PREFIX}${tenantId}`;
    await this.env.ENTITLEMENT_KV.put(key, JSON.stringify(manifest), {
      expirationTtl: manifest.ttl_seconds || DEFAULT_TTL_SECONDS,
    });
  }
```

**Acceptance criteria**:
1. On KV hit, returns cached manifest without origin fetch
2. On KV miss, fetches from `ORIGIN_URL/api/v1/entitlement/{tenant_id}`
3. Includes `Authorization: Bearer` header with `ORIGIN_API_KEY`
4. Writes fetched manifest back to KV with 5-minute TTL
5. Sets `cached_at` and `ttl_seconds` fields on the manifest
6. Throws `TenantNotFoundError` if origin returns non-200

**Black box tests**:
- BB1: Empty KV, mock origin returning manifest -- assert manifest returned and KV populated
- BB2: Empty KV, mock origin returning 404 -- assert TenantNotFoundError thrown
- BB3: Pre-populated KV -- assert origin NOT fetched (no network call)

**White box tests**:
- WB1: Verify `fetch()` includes correct Authorization header
- WB2: Verify `expirationTtl` set on KV.put() call

---

### Story 3.03: EntitlementCache -- Invalidation Endpoint

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/kv/entitlement-cache.ts`
**Depends on**: Story 3.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the origin Stripe webhook handler, **I need** to invalidate the KV entitlement cache when a tier change occurs, **so that** the edge serves updated capabilities within seconds.

**Code skeleton**:
```typescript
  async invalidateManifest(tenantId: string): Promise<void> {
    const key = `${KV_PREFIX}${tenantId}`;
    await this.env.ENTITLEMENT_KV.delete(key);
  }

  async invalidateAll(): Promise<void> {
    // List all keys with prefix and delete
    const list = await this.env.ENTITLEMENT_KV.list({ prefix: KV_PREFIX });
    for (const key of list.keys) {
      await this.env.ENTITLEMENT_KV.delete(key.name);
    }
  }
```

**Acceptance criteria**:
1. `invalidateManifest()` deletes the specific tenant's KV entry
2. Next `getManifest()` returns null (forces origin re-fetch)
3. `invalidateAll()` deletes all entitlement entries (admin operation)
4. Deleting non-existent key does not throw (idempotent)

**Black box tests**:
- BB1: Put manifest, invalidate, getManifest -- assert returns null
- BB2: Invalidate non-existent tenant -- assert no error
- BB3: Put 3 manifests, invalidateAll -- assert all 3 return null on get

**White box tests**:
- WB1: Verify `KV.delete()` called with correct prefixed key
- WB2: Verify `invalidateAll()` uses `list({ prefix: KV_PREFIX })` to scope deletion

---

### Story 3.04: EntitlementCache -- Band Classification

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/kv/entitlement-cache.ts`
**Depends on**: Story 3.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** to classify each tenant request into a Band (A/B/C/D) based on their manifest, **so that** requests are routed to the correct processing path.

**Code skeleton**:
```typescript
  classifyBand(manifest: EntitlementManifest, requestPath: string): Band {
    // Band D: Queen tier -- always bypass edge
    if (manifest.tier === CustomerTier.QUEEN) return Band.D;

    // Band C: Enterprise with geo_region set
    if (manifest.tier === CustomerTier.ENTERPRISE && manifest.geo_region) {
      return Band.C;
    }

    // Band B: Graph queries or heavy compute paths
    const bandBPaths = ["/api/v1/memory/graph", "/api/v1/memory/analytics"];
    if (bandBPaths.some(p => requestPath.startsWith(p))) {
      return Band.B;
    }

    // Band A: Everything else (cached reads, writes proxied to origin)
    return Band.A;
  }
```

**Acceptance criteria**:
1. Queen tier always returns Band D regardless of path
2. Enterprise tier with `geo_region` set returns Band C
3. Graph/analytics paths return Band B for Professional tier
4. Default is Band A (edge-optimized)
5. Band classification is deterministic (same inputs = same output)

**Black box tests**:
- BB1: Queen manifest, any path -- assert Band.D
- BB2: Enterprise manifest with `geo_region: "AU"`, read path -- assert Band.C
- BB3: Professional manifest, `/api/v1/memory/graph` path -- assert Band.B
- BB4: Starter manifest, `/api/v1/memory/search` path -- assert Band.A

**White box tests**:
- WB1: Verify Queen check happens BEFORE path check (short-circuit)
- WB2: Verify Band B path list is configurable (not hardcoded inline)

---

### Story 3.05: EntitlementCache -- Tier Limits Lookup

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/kv/entitlement-cache.ts`
**Depends on**: Story 3.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Rate Limiter, **I need** to look up rate limits per tier from the manifest, **so that** rate limiting is tier-aware at the edge.

**Code skeleton**:
```typescript
export const TIER_RATE_LIMITS: Record<CustomerTier, number> = {
  [CustomerTier.STARTER]: 60,          // 60 RPM
  [CustomerTier.PROFESSIONAL]: 300,     // 300 RPM
  [CustomerTier.ENTERPRISE]: 1000,      // 1000 RPM
  [CustomerTier.QUEEN]: -1,             // Unlimited
};

  getRateLimit(manifest: EntitlementManifest): number {
    return TIER_RATE_LIMITS[manifest.tier] ?? 60; // Default to starter
  }
```

**Acceptance criteria**:
1. Starter returns 60 RPM
2. Professional returns 300 RPM
3. Enterprise returns 1000 RPM
4. Queen returns -1 (unlimited)
5. Unknown tier defaults to 60 RPM (safe fallback)

**Black box tests**:
- BB1: Starter manifest -- assert getRateLimit returns 60
- BB2: Queen manifest -- assert getRateLimit returns -1
- BB3: Manifest with unknown tier value -- assert returns 60 (default)

**White box tests**:
- WB1: Verify `TIER_RATE_LIMITS` has exactly 4 entries matching `CustomerTier` enum
- WB2: Verify nullish coalescing operator `??` used for default (not `||`)

---

### Story 3.06: TenantNotFoundError -- Custom Error Class

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/kv/entitlement-cache.ts`
**Depends on**: None (utility)
**Budget**: 3 implement + 1 run + 1 fix + 1 stamp = 6

**As** the error handling system, **I need** a typed error class for tenant-not-found cases, **so that** the Edge Router can return appropriate HTTP 404 responses.

**Code skeleton**:
```typescript
export class TenantNotFoundError extends Error {
  public readonly tenantId: string;
  public readonly originStatus: number;

  constructor(tenantId: string, originStatus: number = 404) {
    super(`Tenant not found: ${tenantId} (origin status: ${originStatus})`);
    this.name = "TenantNotFoundError";
    this.tenantId = tenantId;
    this.originStatus = originStatus;
  }
}
```

**Acceptance criteria**:
1. Extends `Error` with `tenantId` and `originStatus` properties
2. `name` property is set to `"TenantNotFoundError"`
3. Message includes tenant ID and origin status code
4. Default `originStatus` is 404

**Black box tests**:
- BB1: Throw and catch TenantNotFoundError -- assert `instanceof Error` is true
- BB2: Assert `.tenantId` property matches constructor arg
- BB3: Assert `.name` is "TenantNotFoundError"

**White box tests**:
- WB1: Verify `this.name = "TenantNotFoundError"` set in constructor
- WB2: Verify default `originStatus` is 404 when not provided

---

### Story 3.07: Auth Validator -- API Key Verification

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/kv/auth-validator.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** to validate API keys at the edge before processing any request, **so that** unauthenticated requests are rejected without hitting the origin.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/kv/auth-validator.ts
import type { Env } from "../contracts";

const AUTH_CACHE_PREFIX = "auth:";
const AUTH_CACHE_TTL = 600; // 10 minutes

export class AuthValidator {
  constructor(private env: Env) {}

  async validateRequest(request: Request): Promise<{
    valid: boolean;
    tenantId: string | null;
    error: string | null;
  }> {
    const authHeader = request.headers.get("Authorization");
    if (!authHeader?.startsWith("Bearer ")) {
      return { valid: false, tenantId: null, error: "Missing Bearer token" };
    }

    const token = authHeader.slice(7);
    const tenantId = request.headers.get("X-Tenant-ID");
    if (!tenantId) {
      return { valid: false, tenantId: null, error: "Missing X-Tenant-ID header" };
    }

    // Check KV cache for validated token
    const cacheKey = `${AUTH_CACHE_PREFIX}${token}`;
    const cached = await this.env.ENTITLEMENT_KV.get(cacheKey);
    if (cached === tenantId) {
      return { valid: true, tenantId, error: null };
    }

    // Cache miss -- validate against origin
    const response = await fetch(`${this.env.ORIGIN_URL}/api/v1/auth/validate`, {
      method: "POST",
      headers: {
        "Authorization": authHeader,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ tenant_id: tenantId }),
    });

    if (!response.ok) {
      return { valid: false, tenantId: null, error: "Invalid API key" };
    }

    // Cache the valid token -> tenant_id mapping
    await this.env.ENTITLEMENT_KV.put(cacheKey, tenantId, {
      expirationTtl: AUTH_CACHE_TTL,
    });

    return { valid: true, tenantId, error: null };
  }
}
```

**Acceptance criteria**:
1. Missing `Authorization` header returns `{ valid: false, error: "Missing Bearer token" }`
2. Missing `X-Tenant-ID` header returns `{ valid: false, error: "Missing X-Tenant-ID header" }`
3. Valid cached token returns `{ valid: true }` without origin fetch
4. Valid uncached token fetches from origin, caches result, returns `{ valid: true }`
5. Invalid token returns `{ valid: false, error: "Invalid API key" }`
6. Cached auth entries expire after 10 minutes

**Black box tests**:
- BB1: Request with no Authorization header -- assert invalid
- BB2: Request with valid token (pre-cached in KV) -- assert valid, no origin call
- BB3: Request with invalid token (origin returns 401) -- assert invalid

**White box tests**:
- WB1: Verify origin fetch uses POST method with JSON body containing tenant_id
- WB2: Verify KV.put() called with `expirationTtl: 600` after successful validation

---

### Story 3.08: Module 3 Integration Test

**File**: `/mnt/e/genesis-system/edge/rlm-worker/tests/kv/entitlement.test.ts`
**Depends on**: Stories 3.01-3.07
**Budget**: 5 implement + 3 run + 2 fix + 1 stamp = 11

**As** the verification agent, **I need** an integration test exercising the full entitlement cache lifecycle, **so that** all Module 3 stories are confirmed working together.

**Test plan**:
```typescript
// edge/rlm-worker/tests/kv/entitlement.test.ts
import { describe, it, expect } from "vitest";

describe("KV Entitlement Cache Integration", () => {
  it("cache miss fetches from origin and populates KV", async () => {});
  it("cache hit returns manifest without origin fetch", async () => {});
  it("invalidation forces re-fetch on next request", async () => {});
  it("band classification routes correctly for all 4 tiers", async () => {});
  it("auth validator rejects missing headers", async () => {});
  it("auth validator caches valid tokens in KV", async () => {});
});
```

**Acceptance criteria**:
1. All 6 test functions pass
2. Tests mock origin fetch (no real network calls)
3. Tests use miniflare KV namespace simulation
4. Coverage >= 85%

**Black box tests**:
- BB1: Full lifecycle (miss -> fetch -> cache -> hit -> invalidate -> miss) completes
- BB2: Band classification produces correct Band for each tier scenario
- BB3: Auth validator integration with entitlement cache works end-to-end

**White box tests**:
- WB1: Verify mock origin receives correct URL path and headers
- WB2: Verify KV state matches expected entries after each operation

---

# MODULE 5: EDGE RATE LIMITER
**Per-tenant rate limiting via Durable Objects sliding window.**
**Stories**: 7 | **Wave**: 1 (no dependencies beyond W0) | **Directory**: `edge/rlm-worker/src/`

**Module vision**: A Durable Object class (`RateLimiterDO`) that implements a sliding-window rate limiter per tenant. Each incoming request checks the tenant's counter against their tier limit. The DO uses SQLite storage for window tracking. Rate limit headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`) are added to every response.

**Module file map**:
```
edge/rlm-worker/src/ratelimit/rate-limiter-do.ts   <-- Stories 5.01-5.05
edge/rlm-worker/src/ratelimit/headers.ts            <-- Story 5.06
edge/rlm-worker/tests/ratelimit/rate-limiter.test.ts <-- Story 5.07
```

---

### Story 5.01: RateLimiterDO Class -- Constructor and Schema

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ratelimit/rate-limiter-do.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Rate Limiter DO, **I need** a SQLite schema for sliding-window counters, **so that** rate limit state survives DO eviction.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/ratelimit/rate-limiter-do.ts
import { DurableObject } from "cloudflare:workers";
import type { Env, RateLimitEntry, CustomerTier } from "../contracts";

const WINDOW_SIZE_MS = 60_000; // 1 minute sliding window

export class RateLimiterDO extends DurableObject<Env> {
  private sql: SqlStorage;

  constructor(ctx: DurableObjectState, env: Env) {
    super(ctx, env);
    this.sql = ctx.storage.sql;
    this.initSchema();
  }

  private initSchema(): void {
    this.sql.exec(`
      CREATE TABLE IF NOT EXISTS rate_windows (
        tenant_id TEXT NOT NULL,
        timestamp_ms INTEGER NOT NULL,
        PRIMARY KEY (tenant_id, timestamp_ms)
      );
      CREATE INDEX IF NOT EXISTS idx_tenant_time
        ON rate_windows(tenant_id, timestamp_ms);
    `);
  }
}
```

**Acceptance criteria**:
1. Creates `rate_windows` table with composite PK of `(tenant_id, timestamp_ms)`
2. Index on `(tenant_id, timestamp_ms)` for efficient window queries
3. Schema creation is idempotent
4. WINDOW_SIZE_MS is 60,000 (1 minute)

**Black box tests**:
- BB1: Instantiate RateLimiterDO -- assert no error
- BB2: Instantiate twice -- assert idempotent schema creation
- BB3: Insert a rate entry directly -- assert accepted by schema

**White box tests**:
- WB1: Verify `CREATE TABLE IF NOT EXISTS` syntax used
- WB2: Verify composite PRIMARY KEY on both columns

---

### Story 5.02: RateLimiterDO -- checkLimit() Sliding Window

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ratelimit/rate-limiter-do.ts`
**Depends on**: Story 5.01
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** to check if a tenant is within their rate limit using a sliding window, **so that** overloaded tenants are throttled before hitting the origin.

**Code skeleton**:
```typescript
  async checkLimit(tenantId: string, limit: number): Promise<RateLimitEntry> {
    const now = Date.now();
    const windowStart = now - WINDOW_SIZE_MS;

    // Count requests in current window
    const result = this.sql.exec(
      `SELECT COUNT(*) as cnt FROM rate_windows
       WHERE tenant_id = ? AND timestamp_ms > ?`,
      tenantId, windowStart
    ).toArray();

    const count = (result[0]?.cnt as number) ?? 0;
    const remaining = Math.max(0, limit - count);
    const resetAt = windowStart + WINDOW_SIZE_MS;

    return {
      tenant_id: tenantId,
      window_start: windowStart,
      request_count: count,
      limit,
      remaining,
      reset_at: resetAt,
    };
  }
```

**Acceptance criteria**:
1. Returns `RateLimitEntry` with accurate `request_count` for the current 1-minute window
2. `remaining` is `max(0, limit - count)` -- never negative
3. `reset_at` is window start + 60 seconds
4. Queen tier (`limit == -1`) always returns `remaining: -1` (unlimited)
5. Does NOT increment the counter (read-only check)

**Black box tests**:
- BB1: Fresh tenant, limit 60 -- assert remaining == 60
- BB2: Tenant with 59 requests in window, limit 60 -- assert remaining == 1
- BB3: Tenant with 60 requests in window, limit 60 -- assert remaining == 0

**White box tests**:
- WB1: Verify SQL query uses `timestamp_ms > ?` for sliding window (not `>=`)
- WB2: Verify unlimited tier check short-circuits before SQL query

---

### Story 5.03: RateLimiterDO -- incrementCounter()

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ratelimit/rate-limiter-do.ts`
**Depends on**: Story 5.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** to record each request in the sliding window, **so that** subsequent checkLimit calls reflect the current load.

**Code skeleton**:
```typescript
  async incrementCounter(tenantId: string): Promise<void> {
    const now = Date.now();
    this.sql.exec(
      `INSERT INTO rate_windows (tenant_id, timestamp_ms) VALUES (?, ?)`,
      tenantId, now
    );
  }
```

**Acceptance criteria**:
1. Inserts a new row with current timestamp
2. Multiple calls within the same millisecond do not conflict (different timestamps due to Date.now() resolution)
3. Does not check limits -- that is `checkLimit()`'s job
4. Call succeeds even for Queen tier (recording still happens for metrics)

**Black box tests**:
- BB1: Increment 5 times, then checkLimit -- assert request_count == 5
- BB2: Increment for two different tenants -- assert counts are independent
- BB3: Increment 100 times rapidly -- assert no PRIMARY KEY conflict

**White box tests**:
- WB1: Verify INSERT statement uses parameterized values
- WB2: Verify `Date.now()` called inside method (not passed as parameter)

---

### Story 5.04: RateLimiterDO -- Window Cleanup via Alarm

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ratelimit/rate-limiter-do.ts`
**Depends on**: Story 5.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the DO, **I need** an alarm that cleans up expired window entries, **so that** SQLite storage does not grow unbounded.

**Code skeleton**:
```typescript
  async alarm(): Promise<void> {
    const cutoff = Date.now() - (WINDOW_SIZE_MS * 2); // Keep 2x window for safety
    const result = this.sql.exec(
      `DELETE FROM rate_windows WHERE timestamp_ms < ?`,
      cutoff
    );
    console.log(`[RateLimiterDO] Cleanup: deleted ${result.rowsWritten} expired entries`);

    // Re-schedule for next minute
    await this.ctx.storage.setAlarm(Date.now() + 60_000);
  }

  private async ensureAlarmScheduled(): Promise<void> {
    const existing = await this.ctx.storage.getAlarm();
    if (!existing) {
      await this.ctx.storage.setAlarm(Date.now() + 60_000);
    }
  }
```

**Acceptance criteria**:
1. Deletes entries older than 2x window size (2 minutes)
2. Re-schedules itself every 60 seconds
3. `ensureAlarmScheduled()` called on first fetch
4. Empty table cleanup completes without error

**Black box tests**:
- BB1: Insert old entries, trigger alarm -- assert entries deleted
- BB2: Trigger alarm on empty table -- assert no error, 0 deleted
- BB3: Verify alarm re-scheduled after execution

**White box tests**:
- WB1: Verify cutoff uses `WINDOW_SIZE_MS * 2` (not `WINDOW_SIZE_MS`)
- WB2: Verify alarm re-schedule interval is 60,000ms

---

### Story 5.05: RateLimiterDO -- HTTP Fetch Handler

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ratelimit/rate-limiter-do.ts`
**Depends on**: Stories 5.02, 5.03
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router Worker, **I need** the Rate Limiter DO to accept HTTP requests, **so that** the Worker can check and increment limits via fetch.

**Code skeleton**:
```typescript
  async fetch(request: Request): Promise<Response> {
    const url = new URL(request.url);

    // POST /check?tenant_id=X&limit=Y
    if (request.method === "POST" && url.pathname === "/check") {
      const tenantId = url.searchParams.get("tenant_id")!;
      const limit = parseInt(url.searchParams.get("limit") ?? "60", 10);
      const entry = await this.checkLimit(tenantId, limit);
      return Response.json(entry);
    }

    // POST /increment?tenant_id=X
    if (request.method === "POST" && url.pathname === "/increment") {
      const tenantId = url.searchParams.get("tenant_id")!;
      await this.incrementCounter(tenantId);
      return new Response(null, { status: 204 });
    }

    // POST /check-and-increment?tenant_id=X&limit=Y
    if (request.method === "POST" && url.pathname === "/check-and-increment") {
      const tenantId = url.searchParams.get("tenant_id")!;
      const limit = parseInt(url.searchParams.get("limit") ?? "60", 10);
      const entry = await this.checkLimit(tenantId, limit);
      if (entry.remaining > 0 || limit === -1) {
        await this.incrementCounter(tenantId);
        entry.remaining = Math.max(0, entry.remaining - 1);
      }
      return Response.json(entry);
    }

    return new Response("Not Found", { status: 404 });
  }
```

**Acceptance criteria**:
1. POST `/check` returns `RateLimitEntry` JSON without incrementing
2. POST `/increment` increments counter and returns 204
3. POST `/check-and-increment` atomically checks and increments in one call
4. `check-and-increment` does NOT increment if limit is exceeded
5. Unknown routes return 404

**Black box tests**:
- BB1: POST /check for fresh tenant -- assert remaining equals limit
- BB2: POST /check-and-increment until limit -- assert final call has remaining == 0
- BB3: POST /check-and-increment at limit -- assert counter NOT incremented

**White box tests**:
- WB1: Verify `check-and-increment` calls `checkLimit` before `incrementCounter`
- WB2: Verify unlimited tier (`limit === -1`) always increments regardless of count

---

### Story 5.06: Rate Limit Response Headers

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ratelimit/headers.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 3 implement + 1 run + 1 fix + 1 stamp = 6

**As** the Edge Router, **I need** to add standard rate limit headers to every response, **so that** clients can monitor their usage.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/ratelimit/headers.ts
import type { RateLimitEntry } from "../contracts";

export function addRateLimitHeaders(
  response: Response,
  entry: RateLimitEntry,
): Response {
  const headers = new Headers(response.headers);
  headers.set("X-RateLimit-Limit", String(entry.limit));
  headers.set("X-RateLimit-Remaining", String(entry.remaining));
  headers.set("X-RateLimit-Reset", String(Math.ceil(entry.reset_at / 1000)));
  headers.set("RateLimit-Policy", `${entry.limit};w=60`);

  return new Response(response.body, {
    status: response.status,
    headers,
  });
}

export function rateLimitExceededResponse(entry: RateLimitEntry): Response {
  return new Response(
    JSON.stringify({
      error: "rate_limit_exceeded",
      limit: entry.limit,
      remaining: 0,
      reset_at: entry.reset_at,
    }),
    {
      status: 429,
      headers: {
        "Content-Type": "application/json",
        "X-RateLimit-Limit": String(entry.limit),
        "X-RateLimit-Remaining": "0",
        "X-RateLimit-Reset": String(Math.ceil(entry.reset_at / 1000)),
        "Retry-After": String(Math.ceil((entry.reset_at - Date.now()) / 1000)),
      },
    }
  );
}
```

**Acceptance criteria**:
1. `addRateLimitHeaders()` adds 4 headers without modifying response body or status
2. `X-RateLimit-Reset` is Unix timestamp in seconds (not milliseconds)
3. `rateLimitExceededResponse()` returns 429 with JSON body and `Retry-After` header
4. `Retry-After` is seconds until reset (not absolute timestamp)

**Black box tests**:
- BB1: Add headers to 200 response -- assert all 4 headers present
- BB2: Rate limit exceeded response -- assert status 429 and Retry-After header
- BB3: Verify X-RateLimit-Reset is in seconds (divide by 1000)

**White box tests**:
- WB1: Verify `new Response()` constructor preserves original body
- WB2: Verify `Math.ceil()` used for both Reset and Retry-After (round up)

---

### Story 5.07: Module 5 Integration Test

**File**: `/mnt/e/genesis-system/edge/rlm-worker/tests/ratelimit/rate-limiter.test.ts`
**Depends on**: Stories 5.01-5.06
**Budget**: 5 implement + 3 run + 2 fix + 1 stamp = 11

**Test plan**:
```typescript
describe("Edge Rate Limiter Integration", () => {
  it("fresh tenant has full remaining quota", async () => {});
  it("increments reduce remaining count", async () => {});
  it("exceeding limit returns 429 with correct headers", async () => {});
  it("sliding window expires old entries", async () => {});
  it("alarm cleans up expired entries", async () => {});
  it("queen tier is never rate limited", async () => {});
});
```

**Acceptance criteria**:
1. All 6 test functions pass
2. Tests use miniflare DO environment
3. Coverage >= 85%

**Black box tests**:
- BB1: Full rate limit lifecycle: check -> increment -> exhaust -> 429
- BB2: Queen tier unlimited bypass
- BB3: Sliding window expiry after 60 seconds

**White box tests**:
- WB1: Verify DO shard routing for rate limiter uses same hash as memory cache
- WB2: Verify rate limit headers format matches RFC 6585

---

# MODULE 1: EDGE ROUTER
**The main Worker entry point -- classifies, caches, proxies, and rate-limits.**
**Stories**: 9 | **Wave**: 2 (depends on M2, M3, M5) | **Directory**: `edge/rlm-worker/src/`

**Module vision**: The `fetch()` handler in `src/index.ts` that processes every incoming request. It authenticates, loads the entitlement manifest from KV, classifies the band, checks rate limits, serves from DO cache on hits, proxies to origin on misses, and populates the cache on origin response. This is the orchestration spine of the edge layer.

**Module file map**:
```
edge/rlm-worker/src/index.ts                     <-- Stories 1.01-1.07 (main handler)
edge/rlm-worker/src/router/origin-proxy.ts        <-- Story 1.08
edge/rlm-worker/tests/router/edge-router.test.ts  <-- Story 1.09
```

---

### Story 1.01: Worker Entry Point -- fetch() Handler Skeleton

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/index.ts`
**Depends on**: All Wave 0 + Wave 1 modules
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Cloudflare Worker, **I need** a `fetch()` export that handles every incoming request, **so that** the edge layer is the first thing customer requests hit.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/index.ts
import type { Env } from "./contracts";
import { MemoryCacheDO } from "./do/memory-cache";
import { RateLimiterDO } from "./ratelimit/rate-limiter-do";

export { MemoryCacheDO, RateLimiterDO };

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    try {
      // 1. CORS preflight
      if (request.method === "OPTIONS") {
        return handleCors(request);
      }

      // 2. Health check (bypass all auth)
      const url = new URL(request.url);
      if (url.pathname === "/health") {
        return Response.json({ status: "ok", edge: true, timestamp: Date.now() });
      }

      // 3. Authenticate
      // 4. Load entitlement manifest
      // 5. Classify band
      // 6. Rate limit check
      // 7. Route: cache hit OR origin proxy
      // 8. Add rate limit headers
      // 9. Return response

      return new Response("Not implemented", { status: 501 });
    } catch (err) {
      return handleError(err as Error);
    }
  },
};

function handleCors(request: Request): Response {
  return new Response(null, {
    status: 204,
    headers: {
      "Access-Control-Allow-Origin": request.headers.get("Origin") ?? "*",
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
      "Access-Control-Allow-Headers": "Authorization, Content-Type, X-Tenant-ID",
      "Access-Control-Max-Age": "86400",
    },
  });
}

function handleError(err: Error): Response {
  console.error("[EdgeRouter]", err.message);
  return Response.json(
    { error: "internal_error", message: err.message },
    { status: 500 }
  );
}
```

**Acceptance criteria**:
1. OPTIONS requests return 204 with CORS headers
2. GET `/health` returns JSON with `status: "ok"` and `edge: true`
3. Unhandled errors are caught and return 500 JSON response
4. DO classes are re-exported for wrangler binding resolution
5. All other paths return 501 (placeholder for subsequent stories)

**Black box tests**:
- BB1: OPTIONS request -- assert 204 with Access-Control headers
- BB2: GET /health -- assert 200 with `{ status: "ok", edge: true }`
- BB3: Any error thrown -- assert 500 JSON response with error message

**White box tests**:
- WB1: Verify `MemoryCacheDO` and `RateLimiterDO` are exported at module level
- WB2: Verify CORS allows `X-Tenant-ID` in `Access-Control-Allow-Headers`

---

### Story 1.02: Edge Router -- Authentication Step

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/index.ts`
**Depends on**: Story 1.01, Module 3 (AuthValidator)
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** to validate every non-health request via the AuthValidator, **so that** unauthenticated requests are rejected at the edge.

**Code skeleton**:
```typescript
      // Step 3: Authenticate
      const authValidator = new AuthValidator(env);
      const auth = await authValidator.validateRequest(request);
      if (!auth.valid) {
        return Response.json(
          { error: "unauthorized", message: auth.error },
          { status: 401 }
        );
      }
      const tenantId = auth.tenantId!;
```

**Acceptance criteria**:
1. Unauthenticated requests receive 401 JSON response
2. `tenantId` is extracted from validated auth result
3. Health endpoint is NOT authenticated (bypassed in step 2)
4. Auth failure short-circuits -- no further processing occurs

**Black box tests**:
- BB1: Request without Authorization header -- assert 401
- BB2: Request with valid Bearer token + X-Tenant-ID -- assert not 401
- BB3: Health endpoint without auth -- assert 200 (bypass)

**White box tests**:
- WB1: Verify `AuthValidator` instantiated with `env` parameter
- WB2: Verify `auth.tenantId!` non-null assertion follows `auth.valid` check

---

### Story 1.03: Edge Router -- Entitlement and Band Classification

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/index.ts`
**Depends on**: Story 1.02, Module 3 (EntitlementCache)
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** to load the tenant's entitlement manifest and classify the request band, **so that** each request is routed to the correct processing path.

**Code skeleton**:
```typescript
      // Step 4: Load entitlement manifest
      const entitlementCache = new EntitlementCache(env);
      const manifest = await entitlementCache.getManifestOrFetch(tenantId);

      // Step 5: Classify band
      const band = entitlementCache.classifyBand(manifest, url.pathname);
```

**Acceptance criteria**:
1. Manifest loaded from KV (fast) or origin (on miss)
2. Band classified based on manifest tier and request path
3. Band D (Queen) requests are immediately proxied to origin (no caching)
4. TenantNotFoundError returns 404 JSON response

**Black box tests**:
- BB1: Starter tenant, read path -- assert Band.A classified
- BB2: Non-existent tenant -- assert 404 response
- BB3: Queen tenant -- assert Band.D classified

**White box tests**:
- WB1: Verify `getManifestOrFetch()` called (not just `getManifest()`)
- WB2: Verify Band D short-circuits to origin proxy immediately

---

### Story 1.04: Edge Router -- Rate Limit Enforcement

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/index.ts`
**Depends on**: Story 1.03, Module 5 (RateLimiterDO)
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** to check and enforce rate limits per tenant before processing, **so that** abusive or overloaded tenants are throttled at the edge.

**Code skeleton**:
```typescript
      // Step 6: Rate limit check
      const rateLimit = entitlementCache.getRateLimit(manifest);

      if (rateLimit !== -1) { // Skip for unlimited (Queen)
        const rateLimiterDO = getRateLimiterDO(tenantId, env);
        const checkUrl = new URL("http://do/check-and-increment");
        checkUrl.searchParams.set("tenant_id", tenantId);
        checkUrl.searchParams.set("limit", String(rateLimit));
        const rlResponse = await rateLimiterDO.fetch(
          new Request(checkUrl.toString(), { method: "POST" })
        );
        const rlEntry = await rlResponse.json() as RateLimitEntry;

        if (rlEntry.remaining <= 0) {
          return rateLimitExceededResponse(rlEntry);
        }
      }
```

**Acceptance criteria**:
1. Queen tier skips rate limiting entirely
2. Other tiers check against their tier-specific RPM limit
3. Rate limit exceeded returns 429 with standard headers
4. Atomic check-and-increment prevents race conditions
5. Rate limiter DO uses same shard routing as memory cache

**Black box tests**:
- BB1: Fresh starter tenant, first request -- assert not rate limited
- BB2: Starter tenant at 60 RPM -- assert 429 on 61st request
- BB3: Queen tenant at any volume -- assert never rate limited

**White box tests**:
- WB1: Verify `getRateLimiterDO()` shard function used (not direct DO access)
- WB2: Verify `/check-and-increment` endpoint used (not separate check + increment)

---

### Story 1.05: Edge Router -- Cache Read Path (Band A)

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/index.ts`
**Depends on**: Story 1.03, Module 2 (MemoryCacheDO)
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** to serve memory read requests from the DO cache, **so that** Band A requests achieve sub-50ms latency.

**Code skeleton**:
```typescript
      // Step 7: Route based on band
      if (band === Band.A && request.method === "GET") {
        // Try DO cache first
        const memoryCacheDO = getMemoryCacheDO(tenantId, env);
        const queryHash = computeQueryHash(url.searchParams);
        const cacheUrl = new URL("http://do/memories");
        cacheUrl.searchParams.set("tenant_id", tenantId);
        cacheUrl.searchParams.set("query_hash", queryHash);
        cacheUrl.searchParams.set("limit", url.searchParams.get("limit") ?? "10");

        const cacheResponse = await memoryCacheDO.fetch(
          new Request(cacheUrl.toString())
        );

        if (cacheResponse.status === 200) {
          // Cache HIT -- serve from edge
          return addCacheHeaders(cacheResponse, "HIT");
        }

        // Cache MISS -- fall through to origin proxy
      }
```

**Acceptance criteria**:
1. Band A GET requests check DO cache first
2. Cache HIT returns response with `X-Cache: HIT` header
3. Cache MISS falls through to origin proxy (Story 1.06)
4. Query hash computed from search params for cache key consistency
5. Non-GET requests skip cache (writes always go to origin)

**Black box tests**:
- BB1: Pre-populated cache, GET request -- assert `X-Cache: HIT` header
- BB2: Empty cache, GET request -- assert falls through (no 200 from DO)
- BB3: POST request on Band A -- assert cache NOT checked (skipped)

**White box tests**:
- WB1: Verify `getMemoryCacheDO()` uses shard router (not direct name)
- WB2: Verify `computeQueryHash()` is deterministic for same params

---

### Story 1.06: Edge Router -- Origin Proxy + Cache Population

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/index.ts`
**Depends on**: Story 1.05
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** to proxy requests to the origin FastAPI gateway on cache miss and populate the DO cache with the response, **so that** the next identical request is served from edge.

**Code skeleton**:
```typescript
      // Origin proxy for cache misses, writes, Band B, Band C, Band D
      const originResponse = await proxyToOrigin(request, env, tenantId);

      // If this was a cacheable Band A read, populate the DO cache
      if (band === Band.A && request.method === "GET" && originResponse.ok) {
        ctx.waitUntil(populateCache(tenantId, url.searchParams, originResponse.clone(), env));
      }

      // If this was a write, invalidate related DO cache
      if (request.method === "POST" || request.method === "PUT" || request.method === "DELETE") {
        ctx.waitUntil(invalidateCache(tenantId, env));
      }

      return addCacheHeaders(originResponse, "MISS");
```

**Acceptance criteria**:
1. All origin responses include `X-Cache: MISS` header
2. Successful Band A GET responses populate the DO cache asynchronously (via `waitUntil`)
3. Write operations (POST/PUT/DELETE) invalidate the tenant's DO cache
4. Cache population does NOT block the response to the client
5. Origin proxy includes `Authorization: Bearer ORIGIN_API_KEY` for service auth

**Black box tests**:
- BB1: Cache miss, origin returns 200 -- assert response has `X-Cache: MISS`
- BB2: Write request -- assert cache invalidation triggered
- BB3: Origin returns 500 -- assert cache NOT populated (only cache 2xx)

**White box tests**:
- WB1: Verify `ctx.waitUntil()` used for async cache population (non-blocking)
- WB2: Verify `originResponse.clone()` used before body consumption

---

### Story 1.07: Edge Router -- Stale-While-Revalidate on Origin Failure

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/index.ts`
**Depends on**: Stories 1.05, 1.06
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the Edge Router, **I need** to serve stale cached data when the origin is unavailable, **so that** customers experience degraded service rather than total failure.

**Code skeleton**:
```typescript
      // If origin proxy fails, try stale cache
      if (!originResponse.ok && band === Band.A && request.method === "GET") {
        const memoryCacheDO = getMemoryCacheDO(tenantId, env);
        const staleUrl = new URL("http://do/memories-stale");
        staleUrl.searchParams.set("tenant_id", tenantId);
        staleUrl.searchParams.set("query_hash", queryHash);

        const staleResponse = await memoryCacheDO.fetch(
          new Request(staleUrl.toString())
        );

        if (staleResponse.status === 200) {
          return addCacheHeaders(staleResponse, "STALE");
        }
      }
```

**Acceptance criteria**:
1. Origin 5xx triggers stale cache fallback for Band A GETs
2. Stale response includes `X-Cache: STALE` header
3. If no stale data available either, returns original origin error
4. Non-GET requests never serve stale data
5. Band B/C/D requests never serve stale data (pass through origin errors)

**Black box tests**:
- BB1: Origin returns 503, stale cache exists -- assert response with `X-Cache: STALE`
- BB2: Origin returns 503, no stale cache -- assert 503 passed through
- BB3: POST request with origin failure -- assert 503 (no stale fallback)

**White box tests**:
- WB1: Verify stale fallback only triggered on `!originResponse.ok`
- WB2: Verify stale endpoint path is `/memories-stale` (separate from fresh)

---

### Story 1.08: Origin Proxy -- Request Forwarding

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/router/origin-proxy.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 5 implement + 2 run + 1 fix + 1 stamp = 9

**As** the Edge Router, **I need** a dedicated origin proxy function that forwards requests to the FastAPI gateway, **so that** origin communication is clean and reusable.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/router/origin-proxy.ts
import type { Env } from "../contracts";

export async function proxyToOrigin(
  request: Request,
  env: Env,
  tenantId: string,
): Promise<Response> {
  const url = new URL(request.url);
  const originUrl = new URL(url.pathname + url.search, env.ORIGIN_URL);

  const headers = new Headers(request.headers);
  headers.set("Authorization", `Bearer ${env.ORIGIN_API_KEY}`);
  headers.set("X-Tenant-ID", tenantId);
  headers.set("X-Forwarded-For", request.headers.get("CF-Connecting-IP") ?? "unknown");
  headers.set("X-Edge-Colo", request.cf?.colo ?? "unknown");

  const originRequest = new Request(originUrl.toString(), {
    method: request.method,
    headers,
    body: request.method !== "GET" ? request.body : undefined,
  });

  return fetch(originRequest);
}

export function computeQueryHash(params: URLSearchParams): string {
  // Deterministic hash of sorted query params
  const sorted = [...params.entries()].sort((a, b) => a[0].localeCompare(b[0]));
  const canonical = sorted.map(([k, v]) => `${k}=${v}`).join("&");
  return fnv1aHash(canonical);
}

function fnv1aHash(str: string): string {
  let hash = 2166136261;
  for (let i = 0; i < str.length; i++) {
    hash ^= str.charCodeAt(i);
    hash = (hash * 16777619) >>> 0;
  }
  return hash.toString(16);
}
```

**Acceptance criteria**:
1. Forwards original path + query params to `ORIGIN_URL`
2. Adds `Authorization: Bearer` with `ORIGIN_API_KEY`
3. Adds `X-Tenant-ID`, `X-Forwarded-For`, `X-Edge-Colo` headers
4. Passes request body for non-GET methods
5. `computeQueryHash()` returns same hash for same params regardless of order

**Black box tests**:
- BB1: Proxy GET request -- assert origin receives correct path
- BB2: Proxy POST with body -- assert origin receives body
- BB3: `computeQueryHash({b:2, a:1})` equals `computeQueryHash({a:1, b:2})` -- assert deterministic

**White box tests**:
- WB1: Verify `CF-Connecting-IP` header used for `X-Forwarded-For`
- WB2: Verify `request.cf?.colo` used for `X-Edge-Colo`

---

### Story 1.09: Module 1 Integration Test

**File**: `/mnt/e/genesis-system/edge/rlm-worker/tests/router/edge-router.test.ts`
**Depends on**: Stories 1.01-1.08
**Budget**: 6 implement + 3 run + 2 fix + 1 stamp = 12

**Test plan**:
```typescript
describe("Edge Router Integration", () => {
  it("full read path: auth -> manifest -> band -> cache hit", async () => {});
  it("full read path: auth -> manifest -> band -> cache miss -> origin -> cache populated", async () => {});
  it("write path: auth -> rate limit -> origin proxy -> cache invalidated", async () => {});
  it("rate limit exceeded returns 429", async () => {});
  it("origin failure with stale cache returns stale data", async () => {});
  it("band D queen requests bypass cache entirely", async () => {});
  it("CORS preflight handled correctly", async () => {});
});
```

**Acceptance criteria**:
1. All 7 test functions pass
2. Tests mock origin responses (no real network)
3. Tests use miniflare with KV, DO, and R2 bindings
4. Coverage >= 85%

**Black box tests**:
- BB1: Full cache-hit flow returns 200 with `X-Cache: HIT` and rate limit headers
- BB2: Full cache-miss flow returns 200 with `X-Cache: MISS` and cache populated
- BB3: Rate-limited request returns 429 with Retry-After header

**White box tests**:
- WB1: Verify test creates isolated KV/DO state per test case
- WB2: Verify origin mock receives correct headers (Authorization, X-Tenant-ID, X-Edge-Colo)

---

# MODULE 4: GEOGRAPHIC COMPLIANCE ROUTER
**AU/EU data residency enforcement at the edge.**
**Stories**: 7 | **Wave**: 3 (depends on M1 + M3) | **Directory**: `edge/rlm-worker/src/`

**Module vision**: A geographic routing layer that ensures Band C (Enterprise compliance) tenant requests are processed only in Cloudflare colocations within the required region. Uses the `request.cf.colo` property to verify the current datacenter, and returns a redirect or error if the request is being served from outside the allowed region. This is critical for Australia's December 2026 Privacy Act amendments.

**Module file map**:
```
edge/rlm-worker/src/geo/compliance-router.ts       <-- Stories 4.01-4.05
edge/rlm-worker/src/geo/region-config.ts            <-- Story 4.06
edge/rlm-worker/tests/geo/compliance.test.ts        <-- Story 4.07
```

---

### Story 4.01: GeoComplianceRouter Class -- Region Validation

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/geo/compliance-router.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** to check if a Band C request is being processed in the correct geographic region, **so that** data residency requirements are enforced.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/geo/compliance-router.ts
import type { Env, EntitlementManifest } from "../contracts";
import { REGION_COLOS } from "./region-config";

export class GeoComplianceRouter {
  requiresGeoFence(manifest: EntitlementManifest): boolean {
    return manifest.geo_region !== null && manifest.geo_region !== undefined;
  }

  validateColo(request: Request, requiredRegion: string): boolean {
    const colo = (request.cf?.colo as string) ?? "UNKNOWN";
    const allowedColos = REGION_COLOS[requiredRegion];
    if (!allowedColos) return false;
    return allowedColos.includes(colo);
  }

  getRequestColo(request: Request): string {
    return (request.cf?.colo as string) ?? "UNKNOWN";
  }
}
```

**Acceptance criteria**:
1. `requiresGeoFence()` returns true only when `geo_region` is set (not null/undefined)
2. `validateColo()` checks `request.cf.colo` against region's allowed datacenter list
3. Returns false for unknown colos (fail-closed, not fail-open)
4. Returns false if region has no configured colos

**Black box tests**:
- BB1: Manifest with `geo_region: "AU"` -- assert requiresGeoFence returns true
- BB2: Manifest with `geo_region: null` -- assert requiresGeoFence returns false
- BB3: Request from SYD colo, required region AU -- assert validateColo returns true

**White box tests**:
- WB1: Verify `request.cf?.colo` optional chaining used (handles missing cf)
- WB2: Verify REGION_COLOS lookup is case-sensitive

---

### Story 4.02: GeoComplianceRouter -- Compliance Error Response

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/geo/compliance-router.ts`
**Depends on**: Story 4.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** to return a compliance error when a geo-fenced request is served from the wrong region, **so that** the client knows to retry or escalate.

**Code skeleton**:
```typescript
  complianceErrorResponse(
    request: Request,
    requiredRegion: string,
    actualColo: string,
  ): Response {
    return Response.json(
      {
        error: "geo_compliance_violation",
        message: `Request must be processed in ${requiredRegion} region. Current colo: ${actualColo}`,
        required_region: requiredRegion,
        actual_colo: actualColo,
        action: "retry_with_region_hint",
      },
      {
        status: 451, // Unavailable For Legal Reasons
        headers: {
          "X-Geo-Region-Required": requiredRegion,
          "X-Geo-Colo-Actual": actualColo,
        },
      }
    );
  }
```

**Acceptance criteria**:
1. Returns HTTP 451 (Unavailable For Legal Reasons) status code
2. JSON body includes `required_region`, `actual_colo`, and `action` fields
3. Response includes `X-Geo-Region-Required` header
4. Response includes `X-Geo-Colo-Actual` header

**Black box tests**:
- BB1: Generate compliance error -- assert status 451
- BB2: Assert JSON body has `error: "geo_compliance_violation"`
- BB3: Assert `X-Geo-Region-Required` header matches required region

**White box tests**:
- WB1: Verify HTTP 451 used (not 403 or 400)
- WB2: Verify response is valid JSON (Content-Type: application/json)

---

### Story 4.03: GeoComplianceRouter -- Audit Logging

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/geo/compliance-router.ts`
**Depends on**: Story 4.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the compliance system, **I need** to log every geo-routing decision for audit purposes, **so that** regulators can verify data residency enforcement.

**Code skeleton**:
```typescript
  logComplianceDecision(
    tenantId: string,
    requiredRegion: string,
    actualColo: string,
    decision: "allow" | "deny",
    ctx: ExecutionContext,
    env: Env,
  ): void {
    const entry = {
      timestamp: new Date().toISOString(),
      tenant_id: tenantId,
      required_region: requiredRegion,
      actual_colo: actualColo,
      decision,
    };

    // Non-blocking log to origin audit endpoint
    ctx.waitUntil(
      fetch(`${env.ORIGIN_URL}/api/v1/compliance/audit`, {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${env.ORIGIN_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(entry),
      }).catch((err) => console.error("[GeoAudit] Failed to log:", err))
    );

    console.log(`[GeoCompliance] ${decision}: tenant=${tenantId} region=${requiredRegion} colo=${actualColo}`);
  }
```

**Acceptance criteria**:
1. Logs every compliance decision (allow AND deny)
2. Sends audit entry to origin asynchronously (does not block response)
3. Audit log includes timestamp, tenant_id, region, colo, decision
4. Failed audit logging does not crash the request (caught error)

**Black box tests**:
- BB1: Log allow decision -- assert console.log called with "allow"
- BB2: Log deny decision -- assert origin audit endpoint called
- BB3: Origin audit endpoint fails -- assert no exception propagated

**White box tests**:
- WB1: Verify `ctx.waitUntil()` used for async audit logging
- WB2: Verify `.catch()` on fetch to prevent unhandled rejection

---

### Story 4.04: GeoComplianceRouter -- Smart Location Hints

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/geo/compliance-router.ts`
**Depends on**: Story 4.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** to provide location hints when redirecting geo-non-compliant requests, **so that** Cloudflare's Smart Placement can route the retry to the correct region.

**Code skeleton**:
```typescript
  locationHintHeaders(requiredRegion: string): Record<string, string> {
    const hints: Record<string, string> = {
      "AU": "wnam", // Western North America closest to Australia via Pacific
      "EU": "eeur", // Eastern Europe
      "US": "enam", // Eastern North America
    };
    return {
      "CF-IPCountry-Hint": requiredRegion,
      "X-Geo-Location-Hint": hints[requiredRegion] ?? "auto",
    };
  }
```

**Acceptance criteria**:
1. AU region returns appropriate Cloudflare location hint
2. EU region returns appropriate hint
3. Unknown region defaults to "auto"
4. Hints are returned as header key-value pairs

**Black box tests**:
- BB1: Region "AU" -- assert hint includes "wnam"
- BB2: Region "EU" -- assert hint includes "eeur"
- BB3: Region "UNKNOWN" -- assert hint is "auto"

**White box tests**:
- WB1: Verify fallback uses nullish coalescing `?? "auto"`
- WB2: Verify `CF-IPCountry-Hint` header name matches Cloudflare convention

---

### Story 4.05: GeoComplianceRouter -- Integration with Edge Router

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/geo/compliance-router.ts`
**Depends on**: Stories 4.01-4.04
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the Edge Router, **I need** a single `enforceCompliance()` method that combines all geo-routing steps, **so that** the main `fetch()` handler has one clean integration point.

**Code skeleton**:
```typescript
  enforceCompliance(
    request: Request,
    manifest: EntitlementManifest,
    tenantId: string,
    ctx: ExecutionContext,
    env: Env,
  ): Response | null {
    // Returns null if compliant (proceed), Response if blocked
    if (!this.requiresGeoFence(manifest)) return null;

    const requiredRegion = manifest.geo_region!;
    const actualColo = this.getRequestColo(request);
    const isCompliant = this.validateColo(request, requiredRegion);

    this.logComplianceDecision(tenantId, requiredRegion, actualColo,
      isCompliant ? "allow" : "deny", ctx, env);

    if (!isCompliant) {
      return this.complianceErrorResponse(request, requiredRegion, actualColo);
    }

    return null; // Compliant -- proceed with request
  }
```

**Acceptance criteria**:
1. Returns `null` when request is geo-compliant (proceed with normal flow)
2. Returns 451 Response when request is NOT geo-compliant
3. Always logs the compliance decision (both allow and deny)
4. Non-geo-fenced manifests return null immediately (no colo check)

**Black box tests**:
- BB1: Non-geo-fenced manifest -- assert returns null
- BB2: Geo-fenced AU manifest from SYD colo -- assert returns null (compliant)
- BB3: Geo-fenced AU manifest from US colo -- assert returns 451 Response

**White box tests**:
- WB1: Verify `logComplianceDecision()` called for BOTH allow and deny
- WB2: Verify `manifest.geo_region!` non-null assertion follows `requiresGeoFence()` check

---

### Story 4.06: Region Configuration -- Colo-to-Region Mapping

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/geo/region-config.ts`
**Depends on**: None
**Budget**: 3 implement + 1 run + 1 fix + 1 stamp = 6

**As** the Geo Compliance Router, **I need** a mapping of region codes to allowed Cloudflare datacenter codes, **so that** colo validation has accurate reference data.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/geo/region-config.ts

/**
 * Cloudflare datacenter (colo) codes grouped by geographic region.
 * Source: https://www.cloudflarestatus.com/ (datacenter list)
 */
export const REGION_COLOS: Record<string, string[]> = {
  AU: [
    "SYD", // Sydney
    "MEL", // Melbourne
    "BNE", // Brisbane
    "PER", // Perth
    "ADL", // Adelaide
  ],
  EU: [
    "LHR", // London
    "CDG", // Paris
    "FRA", // Frankfurt
    "AMS", // Amsterdam
    "MAD", // Madrid
    "MXP", // Milan
    "ARN", // Stockholm
    "WAW", // Warsaw
    "ZRH", // Zurich
    "DUB", // Dublin
  ],
  US: [
    "IAD", // Ashburn
    "EWR", // Newark
    "ORD", // Chicago
    "DFW", // Dallas
    "LAX", // Los Angeles
    "SJC", // San Jose
    "SEA", // Seattle
    "ATL", // Atlanta
    "MIA", // Miami
  ],
  NZ: [
    "AKL", // Auckland
  ],
};

export function getRegionForColo(colo: string): string | null {
  for (const [region, colos] of Object.entries(REGION_COLOS)) {
    if (colos.includes(colo)) return region;
  }
  return null;
}
```

**Acceptance criteria**:
1. AU region includes SYD, MEL, BNE, PER, ADL
2. EU region includes at least 10 major European colos
3. US region includes at least 9 major US colos
4. `getRegionForColo("SYD")` returns "AU"
5. `getRegionForColo("UNKNOWN")` returns null

**Black box tests**:
- BB1: REGION_COLOS["AU"] includes "SYD" -- assert true
- BB2: getRegionForColo("FRA") -- assert returns "EU"
- BB3: getRegionForColo("INVALID") -- assert returns null

**White box tests**:
- WB1: Verify all colo codes are 3-letter uppercase strings
- WB2: Verify no duplicate colo codes across regions

---

### Story 4.07: Module 4 Integration Test

**File**: `/mnt/e/genesis-system/edge/rlm-worker/tests/geo/compliance.test.ts`
**Depends on**: Stories 4.01-4.06
**Budget**: 5 implement + 3 run + 2 fix + 1 stamp = 11

**Test plan**:
```typescript
describe("Geographic Compliance Router Integration", () => {
  it("non-geo-fenced tenant passes through", async () => {});
  it("AU tenant from SYD colo is compliant", async () => {});
  it("AU tenant from US colo is blocked with 451", async () => {});
  it("compliance decisions are audit-logged", async () => {});
  it("location hints are correct for each region", async () => {});
  it("region config has no duplicate colos", async () => {});
});
```

**Acceptance criteria**:
1. All 6 test functions pass
2. Tests mock `request.cf.colo` for different datacenter scenarios
3. Audit logging is verified (mock origin audit endpoint)
4. Coverage >= 85%

**Black box tests**:
- BB1: Full compliance flow for AU compliant request
- BB2: Full compliance flow for AU non-compliant request (US colo)
- BB3: Non-geo-fenced tenant passes without colo check

**White box tests**:
- WB1: Verify `request.cf` is properly mocked in tests
- WB2: Verify audit log endpoint receives correct JSON body

---

# MODULE 6: WEBSOCKET MEMORY CHANNEL
**Real-time memory update push via Durable Object WebSockets.**
**Stories**: 7 | **Wave**: 3 (depends on M2) | **Directory**: `edge/rlm-worker/src/`

**Module vision**: A WebSocket upgrade handler inside `MemoryCacheDO` that allows clients to receive real-time memory update notifications. Uses the Cloudflare WebSocket Hibernation API for cost optimization (DO evicted from memory during inactivity, WebSocket stays connected). When the origin writes a memory, it sends an invalidation signal to the edge, which broadcasts to all connected WebSocket clients for that tenant.

**Module file map**:
```
edge/rlm-worker/src/ws/websocket-channel.ts        <-- Stories 6.01-6.05
edge/rlm-worker/src/ws/message-types.ts             <-- Story 6.06
edge/rlm-worker/tests/ws/websocket.test.ts          <-- Story 6.07
```

---

### Story 6.01: WebSocketChannel -- Connection Upgrade Handler

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ws/websocket-channel.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** a SubAIVA client, **I need** to upgrade an HTTP request to a WebSocket connection, **so that** I receive real-time memory update notifications.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/ws/websocket-channel.ts
import type { WebSocketMessage } from "../contracts";

export class WebSocketChannel {
  private sessions: Map<string, WebSocket[]> = new Map();

  handleUpgrade(request: Request, tenantId: string): Response {
    const upgradeHeader = request.headers.get("Upgrade");
    if (upgradeHeader !== "websocket") {
      return new Response("Expected WebSocket upgrade", { status: 426 });
    }

    const [client, server] = Object.values(new WebSocketPair());

    // Accept with hibernation API for cost optimization
    this.ctx.acceptWebSocket(server, [tenantId]);

    return new Response(null, { status: 101, webSocket: client });
  }
}
```

**Acceptance criteria**:
1. Non-WebSocket requests return 426 (Upgrade Required)
2. Valid WebSocket upgrade returns 101 with WebSocket pair
3. `acceptWebSocket()` called with tenant tag for hibernation API
4. Server-side WebSocket is stored for later broadcasting

**Black box tests**:
- BB1: Request without Upgrade header -- assert 426
- BB2: Request with `Upgrade: websocket` -- assert 101
- BB3: Multiple WebSocket connections for same tenant -- all accepted

**White box tests**:
- WB1: Verify `WebSocketPair()` constructor used
- WB2: Verify `acceptWebSocket()` receives `[tenantId]` as tags

---

### Story 6.02: WebSocketChannel -- Hibernation Handlers

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ws/websocket-channel.ts`
**Depends on**: Story 6.01
**Budget**: 5 implement + 2 run + 1 fix + 1 stamp = 9

**As** the DO, **I need** hibernation-aware WebSocket message and close handlers, **so that** the DO can be evicted from memory while keeping connections alive.

**Code skeleton**:
```typescript
  // Hibernation API handlers -- called when DO wakes up
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): void {
    const data = JSON.parse(message as string) as WebSocketMessage;

    if (data.type === "ping") {
      ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
      return;
    }

    // Client should not send other message types -- log and ignore
    console.log(`[WS] Unexpected client message type: ${data.type}`);
  }

  async webSocketClose(
    ws: WebSocket,
    code: number,
    reason: string,
    wasClean: boolean,
  ): void {
    ws.close(code, reason);
    console.log(`[WS] Connection closed: code=${code} reason=${reason} clean=${wasClean}`);
  }

  async webSocketError(ws: WebSocket, error: unknown): void {
    console.error(`[WS] Error:`, error);
    ws.close(1011, "Internal error");
  }
```

**Acceptance criteria**:
1. `webSocketMessage` handles ping/pong for keepalive
2. Unknown message types are logged but do not crash the DO
3. `webSocketClose` properly closes the server-side socket
4. `webSocketError` closes with 1011 (Internal Error) code
5. All handlers are async and compatible with Hibernation API

**Black box tests**:
- BB1: Send ping message -- assert pong received with timestamp
- BB2: Send unknown message type -- assert no error, connection stays open
- BB3: Close connection -- assert webSocketClose handler runs

**White box tests**:
- WB1: Verify `ws.close(code, reason)` called in close handler
- WB2: Verify error handler uses code 1011

---

### Story 6.03: WebSocketChannel -- Broadcast Memory Updates

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ws/websocket-channel.ts`
**Depends on**: Story 6.01
**Budget**: 5 implement + 2 run + 2 fix + 1 stamp = 10

**As** the origin server, **I need** to broadcast memory update notifications to all WebSocket clients for a tenant, **so that** clients receive real-time updates without polling.

**Code skeleton**:
```typescript
  async broadcastToTenant(
    tenantId: string,
    message: WebSocketMessage,
  ): Promise<number> {
    const sockets = this.ctx.getWebSockets(tenantId);
    const payload = JSON.stringify(message);
    let sent = 0;

    for (const ws of sockets) {
      try {
        ws.send(payload);
        sent++;
      } catch (err) {
        // Socket may have disconnected -- close it
        try { ws.close(1011, "Send failed"); } catch { /* ignore */ }
      }
    }

    return sent;
  }
```

**Acceptance criteria**:
1. Sends message to all WebSocket connections tagged with `tenantId`
2. Returns count of successfully sent messages
3. Failed sends close the broken socket (cleanup)
4. Uses `ctx.getWebSockets(tag)` from Hibernation API
5. Message is JSON serialized once, then sent to all

**Black box tests**:
- BB1: 3 connections for tenant A, broadcast -- assert 3 sent
- BB2: 1 broken connection among 3 -- assert 2 sent, broken one closed
- BB3: 0 connections for tenant -- assert 0 sent (no error)

**White box tests**:
- WB1: Verify `ctx.getWebSockets(tenantId)` called with correct tag
- WB2: Verify `JSON.stringify()` called once (not per socket)

---

### Story 6.04: WebSocketChannel -- Invalidation Signal from Origin

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ws/websocket-channel.ts`
**Depends on**: Story 6.03
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the origin FastAPI server, **I need** to POST an invalidation signal to the edge DO, which then broadcasts to WebSocket clients, **so that** the write-through pattern triggers real-time updates.

**Code skeleton**:
```typescript
  // Handle POST /ws/broadcast from origin
  async handleBroadcastRequest(request: Request): Promise<Response> {
    const body = await request.json() as {
      tenant_id: string;
      message: WebSocketMessage;
    };

    const sent = await this.broadcastToTenant(body.tenant_id, body.message);
    return Response.json({ sent, tenant_id: body.tenant_id });
  }
```

**Acceptance criteria**:
1. Accepts POST with `tenant_id` and `message` in JSON body
2. Calls `broadcastToTenant()` with the provided data
3. Returns count of sent messages in JSON response
4. Invalid JSON body returns 400

**Black box tests**:
- BB1: POST valid broadcast body -- assert 200 with sent count
- BB2: POST with empty body -- assert 400
- BB3: POST for tenant with no connections -- assert `{ sent: 0 }`

**White box tests**:
- WB1: Verify `request.json()` parsed before broadcast
- WB2: Verify response includes `tenant_id` for correlation

---

### Story 6.05: WebSocketChannel -- Auto-Response for Ping

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ws/websocket-channel.ts`
**Depends on**: Story 6.01
**Budget**: 4 implement + 2 run + 1 fix + 1 stamp = 8

**As** the cost optimizer, **I need** the DO to set up auto-response for ping messages, **so that** keepalive pings do not wake the DO from hibernation.

**Code skeleton**:
```typescript
  // Called during WebSocket accept to configure auto-response
  private configureAutoResponse(): void {
    this.ctx.setWebSocketAutoResponse(
      new WebSocketRequestResponsePair(
        JSON.stringify({ type: "ping" }),
        JSON.stringify({ type: "pong", timestamp: 0, auto: true })
      )
    );
  }
```

**Acceptance criteria**:
1. `setWebSocketAutoResponse()` configured with ping/pong pair
2. Auto-response pong includes `auto: true` flag for client-side differentiation
3. Auto-response does NOT wake the DO from hibernation
4. Called during WebSocket accept setup

**Black box tests**:
- BB1: Send ping while DO is hibernating -- assert pong received (auto-response)
- BB2: Auto-response pong includes `auto: true`
- BB3: Manual pong (when DO is awake) does NOT include `auto: true`

**White box tests**:
- WB1: Verify `WebSocketRequestResponsePair` constructor called with JSON strings
- WB2: Verify `configureAutoResponse()` called in `handleUpgrade()`

---

### Story 6.06: WebSocket Message Types

**File**: `/mnt/e/genesis-system/edge/rlm-worker/src/ws/message-types.ts`
**Depends on**: Story 0.01 (contracts)
**Budget**: 3 implement + 1 run + 1 fix + 1 stamp = 6

**As** the type system, **I need** detailed WebSocket message type definitions, **so that** both client and server have typed message contracts.

**Code skeleton**:
```typescript
// edge/rlm-worker/src/ws/message-types.ts
import type { MemoryRecord } from "../contracts";

export interface MemoryUpdateMessage {
  type: "memory_update";
  tenant_id: string;
  payload: {
    memory: MemoryRecord;
    action: "created" | "updated";
  };
  timestamp: number;
}

export interface MemoryDeleteMessage {
  type: "memory_delete";
  tenant_id: string;
  payload: {
    memory_id: string;
    vector_id: string | null;
  };
  timestamp: number;
}

export interface EntitlementChangeMessage {
  type: "entitlement_change";
  tenant_id: string;
  payload: {
    old_tier: string;
    new_tier: string;
  };
  timestamp: number;
}

export interface PingMessage {
  type: "ping";
  tenant_id: string;
  payload: Record<string, never>;
  timestamp: number;
}

export interface PongMessage {
  type: "pong";
  timestamp: number;
  auto?: boolean;
}

export type ClientMessage = PingMessage;
export type ServerMessage = MemoryUpdateMessage | MemoryDeleteMessage | EntitlementChangeMessage | PongMessage;
```

**Acceptance criteria**:
1. All 5 message types are defined with discriminated union on `type` field
2. `ClientMessage` union includes only ping (clients are read-only)
3. `ServerMessage` union includes all server-initiated message types
4. Each message has `timestamp` field for ordering
5. `PongMessage` has optional `auto` field for hibernation differentiation

**Black box tests**:
- BB1: TypeScript compiles with all message types -- assert no errors
- BB2: Discriminated union narrowing works: `if (msg.type === "memory_update")` narrows type
- BB3: `ClientMessage` only allows `PingMessage`

**White box tests**:
- WB1: Verify `MemoryUpdateMessage.payload.memory` is typed as `MemoryRecord`
- WB2: Verify `auto` field on `PongMessage` is optional (`auto?: boolean`)

---

### Story 6.07: Module 6 Integration Test

**File**: `/mnt/e/genesis-system/edge/rlm-worker/tests/ws/websocket.test.ts`
**Depends on**: Stories 6.01-6.06
**Budget**: 5 implement + 3 run + 2 fix + 1 stamp = 11

**Test plan**:
```typescript
describe("WebSocket Memory Channel Integration", () => {
  it("WebSocket upgrade succeeds and receives pong", async () => {});
  it("broadcast sends to all connected clients", async () => {});
  it("disconnected client is cleaned up on broadcast", async () => {});
  it("auto-response pong works during hibernation", async () => {});
  it("POST /ws/broadcast from origin triggers client notification", async () => {});
  it("message types are correctly discriminated", async () => {});
});
```

**Acceptance criteria**:
1. All 6 test functions pass
2. Tests use miniflare WebSocket simulation
3. Coverage >= 85%
4. Hibernation behavior tested with time manipulation

**Black box tests**:
- BB1: Full WebSocket lifecycle: connect -> receive broadcast -> disconnect
- BB2: Origin POST triggers client notification
- BB3: Multiple tenants have isolated WebSocket channels

**White box tests**:
- WB1: Verify `getWebSockets()` tag isolation between tenants
- WB2: Verify auto-response configuration is set during accept

---

# GLOBAL VERIFICATION CHECKLIST

These gates apply to ALL stories across ALL modules. Per-story tests catch local bugs; global gates catch systemic violations.

## Code Quality Gates

```
[ ] All TypeScript files compile with `tsc --noEmit` (zero errors)
[ ] All files pass Ruff/ESLint equivalent (`tsc --strict` enabled)
[ ] No `any` types except in `contracts.ts` (where `Record<string, unknown>` is explicit)
[ ] All functions have JSDoc comments describing purpose and parameters
[ ] No console.log in production paths (only in error handlers and audit logging)
[ ] All imports use named imports (no `import *`)
```

## Testing Gates

```
[ ] All vitest tests pass: `npx vitest run` exits with 0 failures
[ ] Total test count >= 144 (6 integration tests x 6 modules + 36 module stories x ~3 tests each)
[ ] No test depends on network (all origin calls mocked)
[ ] No test depends on real KV/DO/R2 bindings (all use miniflare simulation)
[ ] Coverage >= 85% across all source files
[ ] Each module integration test exercises the full module lifecycle
```

## Architecture Gates

```
[ ] No Python code in edge/rlm-worker/ (TypeScript only)
[ ] No direct PostgreSQL/Qdrant/Redis connections (all via origin proxy)
[ ] All DO access goes through shard router (no direct idFromName on tenant ID)
[ ] All KV access uses the `ENTITLEMENT_KV` binding with correct prefix
[ ] All rate limiting uses `RATE_LIMITER_DO` binding
[ ] Contracts.ts enums match core/rlm/contracts.py enums exactly
[ ] No hardcoded origin URL (always from env.ORIGIN_URL)
[ ] No hardcoded API keys (always from env.ORIGIN_API_KEY)
```

## Performance Gates

```
[ ] DO cache read < 50ms (measured in test with performance.now())
[ ] KV entitlement read < 10ms (Cloudflare KV edge guarantee)
[ ] Rate limit check-and-increment < 20ms (DO SQLite operation)
[ ] Origin proxy adds < 10ms overhead to request (header manipulation only)
[ ] Worker script compressed size < 1MB (well under 10MB limit)
[ ] No blocking operations in main fetch() path
```

## Compliance Gates

```
[ ] Band C requests validated against REGION_COLOS before processing
[ ] Geo compliance violations return HTTP 451 (not 403 or 400)
[ ] All compliance decisions are audit-logged (both allow and deny)
[ ] No memory data cached for Band C tenants outside their region
[ ] Data residency headers included in all Band C responses
```

## Scale Gates (200+ tenant readiness)

```
[ ] Shard router distributes 1000 UUIDs across 64 shards with < 5% max deviation
[ ] Rate limiter sliding window handles 1000 RPM without precision loss
[ ] DO alarm cleanup prevents SQLite storage growth (stays well under 10GB limit)
[ ] KV entitlement cache has TTL (entries expire, not unbounded)
[ ] WebSocket hibernation configured (DO evicted during inactivity)
[ ] Stale-while-revalidate fallback prevents cascading failure when origin is down
```

## Verification Stamp Format (TypeScript)

```typescript
// VERIFICATION_STAMP
// Story: [STORY_ID]
// Verified By: [AGENT_ID]
// Verified At: [TIMESTAMP]
// Tests: [PASS_COUNT]/[TOTAL_COUNT]
// Coverage: [PERCENTAGE]%
```

---

# STORY SUMMARY TABLE

| Story | Module | Title | File | Budget |
|-------|--------|-------|------|--------|
| 0.01 | W0 | Contracts + Shared Types | `src/contracts.ts` | 8 |
| 0.02 | W0 | Project Scaffold | `wrangler.toml` + `package.json` + `tsconfig.json` | 9 |
| 2.01 | M2 | MemoryCacheDO -- Constructor + Schema | `src/do/memory-cache.ts` | 10 |
| 2.02 | M2 | MemoryCacheDO -- getMemories() | `src/do/memory-cache.ts` | 10 |
| 2.03 | M2 | MemoryCacheDO -- putMemories() | `src/do/memory-cache.ts` | 9 |
| 2.04 | M2 | MemoryCacheDO -- invalidateTenant() | `src/do/memory-cache.ts` | 8 |
| 2.05 | M2 | MemoryCacheDO -- Stale-While-Revalidate | `src/do/memory-cache.ts` | 10 |
| 2.06 | M2 | MemoryCacheDO -- HTTP Fetch Handler | `src/do/memory-cache.ts` | 10 |
| 2.07 | M2 | MemoryCacheDO -- Alarm Expiry Cleanup | `src/do/memory-cache.ts` | 8 |
| 2.08 | M2 | MemoryCacheDO -- Storage Metrics | `src/do/memory-cache.ts` | 8 |
| 2.09 | M2 | Shard Router -- Hash-Ring | `src/do/shard-router.ts` | 9 |
| 2.10 | M2 | Module 2 Integration Test | `tests/do/memory-cache.test.ts` | 11 |
| 3.01 | M3 | EntitlementCache -- KV Read | `src/kv/entitlement-cache.ts` | 8 |
| 3.02 | M3 | EntitlementCache -- Origin Fetch | `src/kv/entitlement-cache.ts` | 10 |
| 3.03 | M3 | EntitlementCache -- Invalidation | `src/kv/entitlement-cache.ts` | 8 |
| 3.04 | M3 | EntitlementCache -- Band Classification | `src/kv/entitlement-cache.ts` | 8 |
| 3.05 | M3 | EntitlementCache -- Tier Limits | `src/kv/entitlement-cache.ts` | 8 |
| 3.06 | M3 | TenantNotFoundError | `src/kv/entitlement-cache.ts` | 6 |
| 3.07 | M3 | Auth Validator | `src/kv/auth-validator.ts` | 10 |
| 3.08 | M3 | Module 3 Integration Test | `tests/kv/entitlement.test.ts` | 11 |
| 5.01 | M5 | RateLimiterDO -- Constructor + Schema | `src/ratelimit/rate-limiter-do.ts` | 8 |
| 5.02 | M5 | RateLimiterDO -- checkLimit() | `src/ratelimit/rate-limiter-do.ts` | 10 |
| 5.03 | M5 | RateLimiterDO -- incrementCounter() | `src/ratelimit/rate-limiter-do.ts` | 8 |
| 5.04 | M5 | RateLimiterDO -- Alarm Cleanup | `src/ratelimit/rate-limiter-do.ts` | 8 |
| 5.05 | M5 | RateLimiterDO -- HTTP Fetch Handler | `src/ratelimit/rate-limiter-do.ts` | 8 |
| 5.06 | M5 | Rate Limit Headers | `src/ratelimit/headers.ts` | 6 |
| 5.07 | M5 | Module 5 Integration Test | `tests/ratelimit/rate-limiter.test.ts` | 11 |
| 1.01 | M1 | Worker Entry Point -- fetch() | `src/index.ts` | 10 |
| 1.02 | M1 | Authentication Step | `src/index.ts` | 8 |
| 1.03 | M1 | Entitlement + Band Classification | `src/index.ts` | 8 |
| 1.04 | M1 | Rate Limit Enforcement | `src/index.ts` | 10 |
| 1.05 | M1 | Cache Read Path (Band A) | `src/index.ts` | 10 |
| 1.06 | M1 | Origin Proxy + Cache Population | `src/index.ts` | 10 |
| 1.07 | M1 | Stale-While-Revalidate Fallback | `src/index.ts` | 10 |
| 1.08 | M1 | Origin Proxy Function | `src/router/origin-proxy.ts` | 9 |
| 1.09 | M1 | Module 1 Integration Test | `tests/router/edge-router.test.ts` | 12 |
| 4.01 | M4 | GeoComplianceRouter -- Region Validation | `src/geo/compliance-router.ts` | 8 |
| 4.02 | M4 | Compliance Error Response | `src/geo/compliance-router.ts` | 8 |
| 4.03 | M4 | Audit Logging | `src/geo/compliance-router.ts` | 8 |
| 4.04 | M4 | Smart Location Hints | `src/geo/compliance-router.ts` | 8 |
| 4.05 | M4 | enforceCompliance() Integration | `src/geo/compliance-router.ts` | 8 |
| 4.06 | M4 | Region Config -- Colo Mapping | `src/geo/region-config.ts` | 6 |
| 4.07 | M4 | Module 4 Integration Test | `tests/geo/compliance.test.ts` | 11 |
| 6.01 | M6 | WebSocket Upgrade Handler | `src/ws/websocket-channel.ts` | 10 |
| 6.02 | M6 | Hibernation Handlers | `src/ws/websocket-channel.ts` | 9 |
| 6.03 | M6 | Broadcast Memory Updates | `src/ws/websocket-channel.ts` | 10 |
| 6.04 | M6 | Origin Invalidation Signal | `src/ws/websocket-channel.ts` | 8 |
| 6.05 | M6 | Auto-Response Ping | `src/ws/websocket-channel.ts` | 8 |
| 6.06 | M6 | Message Types | `src/ws/message-types.ts` | 6 |
| 6.07 | M6 | Module 6 Integration Test | `tests/ws/websocket.test.ts` | 11 |

**Total Stories**: 48
**Total Budget**: 420 iterations
**Total Modules**: 6 + Wave 0 (2 foundation stories)

---

# COMPLETION CERTIFICATE

```
PRD: RLM Cloudflare Workers Edge Layer (Phase 2)
Code: GENESIS-RLMCF
Stories: 48 atomic stories across 6 modules + 2 foundation
Tests specified: >= 144 BB tests + >= 96 WB tests = 240+ total test cases
Wave plan: 4 waves (W0 parallel, W1 3-parallel, W2 sequential, W3 2-parallel)
Language: TypeScript (Cloudflare Workers V8 runtime)
Directory: edge/rlm-worker/
Blueprint: _bmad-output/RLM_NEOCORTEX_PRODUCT_BRIEF.md
Phase 1: _bmad-output/RLM_NEOCORTEX_PRD.md (52 stories, 364 tests, COMPLETE)
Doctrine: Genesis Rule 17 v2.3.0 (all 14 PRD laws enforced)
Author: Claude Opus 4.6 (BMAD Phase 2 Architect)
Date: 2026-02-26
Status: AUTHORITATIVE -- EXECUTION READY
```

---

*PRD completed: 2026-02-26*
*BMAD Phase 2 Architecture: COMPLETE*
*Next: Execute Wave 0, then parallel Wave 1 via RWL swarm*
