runward 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE.md +13 -0
- package/README.md +128 -0
- package/dist/cli.js +76 -0
- package/dist/commands/check.js +50 -0
- package/dist/commands/doctor.js +75 -0
- package/dist/commands/init.js +90 -0
- package/dist/commands/status.js +49 -0
- package/dist/commands/update.js +48 -0
- package/dist/lib/constants.js +2 -0
- package/dist/lib/mission.js +96 -0
- package/dist/lib/paths.js +28 -0
- package/dist/lib/styles.js +60 -0
- package/dist/lib/tools.js +59 -0
- package/dist/lib/write.js +30 -0
- package/package.json +29 -0
- package/templates/mission/adr/ADR-0000-template.md +38 -0
- package/templates/mission/architecture.md +60 -0
- package/templates/mission/decision-matrix.md +37 -0
- package/templates/mission/evaluation-rubric.md +60 -0
- package/templates/mission/floor.md +47 -0
- package/templates/mission/framing.md +58 -0
- package/templates/mission/mission-contract.md +58 -0
- package/templates/mission/observability-schema.md +53 -0
- package/templates/mission/port-contract.md +63 -0
- package/templates/mission/reference-stack.md +70 -0
- package/templates/mission/runbook.md +70 -0
- package/templates/mission/shared-bricks.md +74 -0
- package/templates/mission/threat-model.md +53 -0
- package/templates/rules/async-job-guardrails.md +95 -0
- package/templates/rules/async-post-turn-pipeline.md +96 -0
- package/templates/rules/async-scheduled-maintenance.md +95 -0
- package/templates/rules/cache-three-tier-architecture.md +44 -0
- package/templates/rules/checklist-day-zero-project.md +97 -0
- package/templates/rules/checklist-pre-production-observability.md +99 -0
- package/templates/rules/checklist-pre-production-performance.md +109 -0
- package/templates/rules/checklist-pre-production-resilience.md +99 -0
- package/templates/rules/checklist-pre-production-security.md +86 -0
- package/templates/rules/config-secrets-boundary.md +57 -0
- package/templates/rules/config-typing-zod.md +78 -0
- package/templates/rules/contracts-governance.md +64 -0
- package/templates/rules/data-memory-consolidation.md +81 -0
- package/templates/rules/data-memory-invalidation.md +90 -0
- package/templates/rules/data-memory-scoring.md +154 -0
- package/templates/rules/data-migrations-forward-only.md +55 -0
- package/templates/rules/data-orphan-cleanup.md +142 -0
- package/templates/rules/data-ttl-types.md +100 -0
- package/templates/rules/eval-loop.md +52 -0
- package/templates/rules/frontier-deterministic-boundary.md +91 -0
- package/templates/rules/hexa-adapter-pattern.md +85 -0
- package/templates/rules/hexa-architecture.md +84 -0
- package/templates/rules/hexa-move-deterministic-out.md +93 -0
- package/templates/rules/hexa-recommended-stack.md +33 -0
- package/templates/rules/hexa-typescript-native.md +80 -0
- package/templates/rules/observability-alert-configuration.md +160 -0
- package/templates/rules/observability-llm-metrics.md +150 -0
- package/templates/rules/observability-startup-provider-log.md +128 -0
- package/templates/rules/observability-structured-json-logs.md +138 -0
- package/templates/rules/patterns-memory-router-tiered.md +139 -0
- package/templates/rules/patterns-prompt-compiler.md +126 -0
- package/templates/rules/patterns-request-id-propagation.md +137 -0
- package/templates/rules/process-adr-and-journal.md +50 -0
- package/templates/rules/provider-llm-auto-detection.md +54 -0
- package/templates/rules/provider-no-crash-missing-env.md +67 -0
- package/templates/rules/quality-codebase-metrics.md +115 -0
- package/templates/rules/quality-zod-input-validation.md +131 -0
- package/templates/rules/resilience-fail-open.md +65 -0
- package/templates/rules/resilience-multi-provider-fallback.md +100 -0
- package/templates/rules/resilience-retry-backoff.md +115 -0
- package/templates/rules/resilience-retryable-errors.md +107 -0
- package/templates/rules/routing-confidence-upgrade.md +89 -0
- package/templates/rules/routing-model-cost-ratios.md +68 -0
- package/templates/rules/routing-smart-complexity.md +131 -0
- package/templates/rules/scaling-db-connection-pooling.md +121 -0
- package/templates/rules/scaling-distributed-rate-limiting.md +141 -0
- package/templates/rules/scaling-state-externalization.md +84 -0
- package/templates/rules/security-prompt-injection.md +63 -0
- package/templates/rules/state-event-sourcing.md +66 -0
- package/templates/rules/tools-registry-pattern.md +153 -0
- package/templates/rules/tools-scope-atomicity.md +103 -0
- package/templates/targets/AGENTS.md +32 -0
- package/templates/workflows/architect.md +50 -0
- package/templates/workflows/brownfield.md +52 -0
- package/templates/workflows/decision-loop.md +52 -0
- package/templates/workflows/floor.md +60 -0
- package/templates/workflows/frame.md +69 -0
- package/templates/workflows/govern.md +55 -0
- package/templates/workflows/handover.md +55 -0
- package/templates/workflows/iterate.md +52 -0
- package/templates/workflows/method.md +53 -0
- package/templates/workflows/review.md +59 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Smart Routing by Complexity
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Order-of-magnitude latency cut on simple requests and a substantial cost reduction through intelligent model selection, measured on a reference system — recalibrate on your traffic
|
|
5
|
+
tags: [routing, llm, cost-optimization]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Smart Routing by Complexity
|
|
9
|
+
|
|
10
|
+
Route requests to the appropriate model based on complexity. Not every request needs Deep.
|
|
11
|
+
|
|
12
|
+
**The Problem:**
|
|
13
|
+
|
|
14
|
+
Everything goes to the same model, even "Hello" or "OK thanks".
|
|
15
|
+
|
|
16
|
+
**Routing Pattern:**
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
User message
|
|
20
|
+
↓
|
|
21
|
+
detectWithRegex() → confidence ≥ 0.6?
|
|
22
|
+
├─ YES → Use this result (~1ms, $0)
|
|
23
|
+
└─ NO → detectWithFastModel() (~100ms, ~$0.0003)
|
|
24
|
+
↓
|
|
25
|
+
ComplexityLevel
|
|
26
|
+
↓
|
|
27
|
+
┌─────────┴─────────┐
|
|
28
|
+
│ Smart Routing │
|
|
29
|
+
└─────────┬─────────┘
|
|
30
|
+
↓
|
|
31
|
+
casual → Fast ($0.005/req) ~25%
|
|
32
|
+
task → Balanced ($0.025/req) ~45%
|
|
33
|
+
planning → Balanced ($0.025/req) ~20%
|
|
34
|
+
deep → Deep ($0.10/req) ~10%
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
**Incorrect:**
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// Same model for everything
|
|
41
|
+
const response = await model.complete({
|
|
42
|
+
model: 'mid-tier-model', // BAD: Always Balanced
|
|
43
|
+
messages,
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Correct:**
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
type ComplexityLevel = 'casual' | 'task' | 'planning' | 'deep';
|
|
51
|
+
|
|
52
|
+
interface ComplexityResult {
|
|
53
|
+
level: ComplexityLevel;
|
|
54
|
+
confidence: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Step 1: Try regex first (free, instant)
|
|
58
|
+
function detectWithRegex(message: string): ComplexityResult | null {
|
|
59
|
+
// Casual patterns must match the ENTIRE (short) message, anchored on
|
|
60
|
+
// both ends. A prefix match like /^hey/i would classify
|
|
61
|
+
// "hey, can you redesign our auth architecture?" as casual and
|
|
62
|
+
// short-circuit it to the cheapest model.
|
|
63
|
+
const MAX_CASUAL_LENGTH = 25;
|
|
64
|
+
const casualPatterns = [
|
|
65
|
+
/^(hi|hello|hey|thanks|thank you|ok|okay|bye)[\s!.]*$/i,
|
|
66
|
+
/^(yes|no|sure|got it)[\s!.]*$/i,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const deepPatterns = [
|
|
70
|
+
/compare.*and.*analyze/i,
|
|
71
|
+
/design.*architecture/i,
|
|
72
|
+
/evaluate.*strategy/i,
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Test deep patterns FIRST: misrouting a deep request to a small
|
|
76
|
+
// model costs far more than misrouting a greeting to a big one.
|
|
77
|
+
for (const pattern of deepPatterns) {
|
|
78
|
+
if (pattern.test(message)) {
|
|
79
|
+
return { level: 'deep', confidence: 0.7 };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const trimmed = message.trim();
|
|
84
|
+
if (trimmed.length <= MAX_CASUAL_LENGTH) {
|
|
85
|
+
for (const pattern of casualPatterns) {
|
|
86
|
+
if (pattern.test(trimmed)) {
|
|
87
|
+
return { level: 'casual', confidence: 0.8 };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null; // Fallback to LLM classification
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 2: Use Fast for uncertain cases
|
|
96
|
+
async function detectComplexity(message: string): Promise<ComplexityResult> {
|
|
97
|
+
const regexResult = detectWithRegex(message);
|
|
98
|
+
if (regexResult && regexResult.confidence >= 0.6) {
|
|
99
|
+
return regexResult;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Use Fast for classification (~$0.0003)
|
|
103
|
+
return await classifyWithFastModel(message);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Step 3: Select model. In doubt, upgrade: a small model on a deep
|
|
107
|
+
// question costs more in retries and bad answers than the price gap.
|
|
108
|
+
function selectModel(result: ComplexityResult): string {
|
|
109
|
+
if (result.confidence < 0.6) {
|
|
110
|
+
return upgradeModel(result.level); // Safety upgrade (see Confidence Upgrade rule)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
switch (result.level) {
|
|
114
|
+
case 'casual': return 'small-fast-model';
|
|
115
|
+
case 'task':
|
|
116
|
+
case 'planning': return 'mid-tier-model';
|
|
117
|
+
case 'deep': return 'top-tier-model';
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Expected Distribution:**
|
|
123
|
+
|
|
124
|
+
| Level | Model | % Traffic | Cost |
|
|
125
|
+
|-------|-------|-----------|------|
|
|
126
|
+
| casual | Fast | 25% | $0.005 |
|
|
127
|
+
| task | Balanced | 45% | $0.025 |
|
|
128
|
+
| planning | Balanced | 20% | $0.025 |
|
|
129
|
+
| deep | Deep | 10% | $0.10 |
|
|
130
|
+
|
|
131
|
+
**Weighted average: ~$0.028/request** (vs $0.06 if all Balanced)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Database Connection Pooling
|
|
3
|
+
impact: MEDIUM
|
|
4
|
+
impactDescription: Prevents connection exhaustion under load
|
|
5
|
+
tags: [scaling, database, performance, configuration]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Database Connection Pooling
|
|
9
|
+
|
|
10
|
+
Configure connection pools to prevent exhaustion under load. Each instance needs its own pool.
|
|
11
|
+
|
|
12
|
+
**The Problem:**
|
|
13
|
+
|
|
14
|
+
Without pooling:
|
|
15
|
+
- Each request opens a new connection
|
|
16
|
+
- Connections are slow to establish
|
|
17
|
+
- Database has limited connections
|
|
18
|
+
- Under load: "too many connections" errors
|
|
19
|
+
|
|
20
|
+
**Incorrect:**
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// New connection per request
|
|
24
|
+
async function query(sql: string) {
|
|
25
|
+
const client = new Client(process.env.DATABASE_URL);
|
|
26
|
+
await client.connect(); // BAD: Slow, wasteful
|
|
27
|
+
const result = await client.query(sql);
|
|
28
|
+
await client.end();
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Correct:**
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// PostgreSQL with pg-pool
|
|
37
|
+
import { Pool } from 'pg';
|
|
38
|
+
|
|
39
|
+
const pool = new Pool({
|
|
40
|
+
connectionString: process.env.DATABASE_URL,
|
|
41
|
+
max: 20, // Max connections per instance
|
|
42
|
+
idleTimeoutMillis: 30000, // Close idle connections after 30s
|
|
43
|
+
connectionTimeoutMillis: 5000, // Fail if can't connect in 5s
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Reuse connections from pool
|
|
47
|
+
async function query<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
48
|
+
const client = await pool.connect();
|
|
49
|
+
try {
|
|
50
|
+
const result = await client.query(sql, params);
|
|
51
|
+
return result.rows;
|
|
52
|
+
} finally {
|
|
53
|
+
client.release(); // Return to pool, don't close
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Graceful shutdown
|
|
58
|
+
process.on('SIGTERM', async () => {
|
|
59
|
+
await pool.end();
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Pool Sizing Formula:**
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
connections_per_instance = (db_max_connections - reserved) / num_instances
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
- PostgreSQL max_connections: 100
|
|
70
|
+
- Reserved for admin: 5
|
|
71
|
+
- Instances: 4
|
|
72
|
+
- Pool size per instance: (100 - 5) / 4 = ~23
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Prisma Configuration:**
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// prisma/schema.prisma
|
|
79
|
+
datasource db {
|
|
80
|
+
provider = "postgresql"
|
|
81
|
+
url = env("DATABASE_URL")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Connection pool via URL
|
|
85
|
+
// DATABASE_URL="postgresql://user:pass@host:5432/db?connection_limit=20&pool_timeout=10"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**Drizzle Configuration:**
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
92
|
+
import { Pool } from 'pg';
|
|
93
|
+
|
|
94
|
+
const pool = new Pool({
|
|
95
|
+
connectionString: process.env.DATABASE_URL,
|
|
96
|
+
max: 20,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export const db = drizzle(pool);
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Monitoring:**
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// Log pool stats periodically
|
|
106
|
+
setInterval(() => {
|
|
107
|
+
console.info('[DB] Pool stats', {
|
|
108
|
+
total: pool.totalCount,
|
|
109
|
+
idle: pool.idleCount,
|
|
110
|
+
waiting: pool.waitingCount,
|
|
111
|
+
});
|
|
112
|
+
}, 60000);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Common Issues:**
|
|
116
|
+
|
|
117
|
+
| Symptom | Cause | Fix |
|
|
118
|
+
|---------|-------|-----|
|
|
119
|
+
| "too many connections" | Pool too large | Reduce max per instance |
|
|
120
|
+
| Slow queries | Pool exhausted | Increase max or optimize queries |
|
|
121
|
+
| Connection timeout | Pool full, queries slow | Add connection_timeout, optimize |
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Distributed Rate Limiting
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Enables consistent rate limiting across multiple instances
|
|
5
|
+
tags: [scaling, rate-limiting, redis, architecture]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Distributed Rate Limiting
|
|
9
|
+
|
|
10
|
+
In-memory rate limiting fails with multiple instances. Use Redis for consistent limits.
|
|
11
|
+
|
|
12
|
+
**The Problem:**
|
|
13
|
+
|
|
14
|
+
With 3 instances and 100 req/min limit:
|
|
15
|
+
- User can make 100 requests to each instance
|
|
16
|
+
- Actual rate: 300 req/min (3x limit bypass)
|
|
17
|
+
|
|
18
|
+
**Incorrect:**
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// In-memory - each instance has its own counter
|
|
22
|
+
const rateLimits = new Map<string, { count: number; resetAt: number }>();
|
|
23
|
+
|
|
24
|
+
function checkRateLimit(userId: string): boolean {
|
|
25
|
+
const record = rateLimits.get(userId);
|
|
26
|
+
// BAD: Only counts requests to this instance
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Correct:**
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import Redis from 'ioredis';
|
|
34
|
+
|
|
35
|
+
const redis = new Redis(process.env.REDIS_URL);
|
|
36
|
+
|
|
37
|
+
interface RateLimitConfig {
|
|
38
|
+
windowMs: number; // Time window in ms
|
|
39
|
+
maxRequests: number; // Max requests per window
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const defaultConfig: RateLimitConfig = {
|
|
43
|
+
windowMs: 60_000, // 1 minute
|
|
44
|
+
maxRequests: 100,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// INCR and PEXPIRE must be atomic. If the process crashes between the
|
|
48
|
+
// INCR and a separate PEXPIRE call, the key survives with no TTL and the
|
|
49
|
+
// counter never resets: that user is rate-limited forever. A Lua script
|
|
50
|
+
// runs both as one atomic operation. (Alternative without Lua:
|
|
51
|
+
// `SET key 0 PX windowMs NX` to create the key with its TTL, then INCR.)
|
|
52
|
+
const RATE_LIMIT_SCRIPT = `
|
|
53
|
+
local count = redis.call('INCR', KEYS[1])
|
|
54
|
+
if count == 1 then
|
|
55
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
|
56
|
+
end
|
|
57
|
+
return {count, redis.call('PTTL', KEYS[1])}
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
async function checkRateLimit(
|
|
61
|
+
key: string,
|
|
62
|
+
config = defaultConfig
|
|
63
|
+
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const windowKey = `ratelimit:${key}:${Math.floor(now / config.windowMs)}`;
|
|
66
|
+
|
|
67
|
+
const [count, ttl] = (await redis.eval(
|
|
68
|
+
RATE_LIMIT_SCRIPT,
|
|
69
|
+
1,
|
|
70
|
+
windowKey,
|
|
71
|
+
config.windowMs
|
|
72
|
+
)) as [number, number];
|
|
73
|
+
|
|
74
|
+
const remaining = Math.max(0, config.maxRequests - count);
|
|
75
|
+
const resetIn = ttl > 0 ? ttl : config.windowMs;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
allowed: count <= config.maxRequests,
|
|
79
|
+
remaining,
|
|
80
|
+
resetIn,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Middleware usage
|
|
85
|
+
async function rateLimitMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
86
|
+
const userId = req.user?.id || req.ip;
|
|
87
|
+
const result = await checkRateLimit(`user:${userId}`);
|
|
88
|
+
|
|
89
|
+
// Set standard rate limit headers
|
|
90
|
+
res.setHeader('X-RateLimit-Limit', config.maxRequests);
|
|
91
|
+
res.setHeader('X-RateLimit-Remaining', result.remaining);
|
|
92
|
+
res.setHeader('X-RateLimit-Reset', Math.ceil(Date.now() / 1000 + result.resetIn / 1000));
|
|
93
|
+
|
|
94
|
+
if (!result.allowed) {
|
|
95
|
+
res.setHeader('Retry-After', Math.ceil(result.resetIn / 1000));
|
|
96
|
+
return res.status(429).json({ error: 'Too Many Requests' });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
next();
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
**Fixed-window caveat — 2x burst at the boundary:**
|
|
104
|
+
|
|
105
|
+
The fixed-window counter above lets a client burst up to **2x the limit** across a window boundary: 100 requests in the last second of one window plus 100 in the first second of the next is 200 requests in two seconds, all allowed. If your downstream cannot absorb that burst, use the sliding-window variant below.
|
|
106
|
+
|
|
107
|
+
**Sliding Window Algorithm:**
|
|
108
|
+
|
|
109
|
+
For more precise rate limiting (no boundary burst), use sliding window:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
async function slidingWindowRateLimit(
|
|
113
|
+
key: string,
|
|
114
|
+
windowMs: number,
|
|
115
|
+
maxRequests: number
|
|
116
|
+
): Promise<boolean> {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
const windowStart = now - windowMs;
|
|
119
|
+
|
|
120
|
+
// Remove old entries and add new one atomically
|
|
121
|
+
const multi = redis.multi();
|
|
122
|
+
multi.zremrangebyscore(key, 0, windowStart);
|
|
123
|
+
multi.zadd(key, now, `${now}-${Math.random()}`);
|
|
124
|
+
multi.zcard(key);
|
|
125
|
+
multi.expire(key, Math.ceil(windowMs / 1000));
|
|
126
|
+
|
|
127
|
+
const results = await multi.exec();
|
|
128
|
+
const count = results![2][1] as number;
|
|
129
|
+
|
|
130
|
+
return count <= maxRequests;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**Rate Limit Tiers:**
|
|
135
|
+
|
|
136
|
+
| Tier | Limit | Use Case |
|
|
137
|
+
|------|-------|----------|
|
|
138
|
+
| Anonymous | 10/min | Public endpoints |
|
|
139
|
+
| Free | 60/min | Authenticated users |
|
|
140
|
+
| Pro | 300/min | Paid users |
|
|
141
|
+
| API | 1000/min | API keys |
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Externalize State for Scaling
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: Enables horizontal scaling by removing in-process state dependencies
|
|
5
|
+
tags: [scaling, architecture, redis, state-management]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Externalize State for Scaling
|
|
9
|
+
|
|
10
|
+
> **Scaling ≠ adding instances. Scaling = externalized state.**
|
|
11
|
+
|
|
12
|
+
In-memory state prevents horizontal scaling. Each instance has its own copy, leading to inconsistencies.
|
|
13
|
+
|
|
14
|
+
**Incorrect:**
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
// In-memory state - works for single instance only
|
|
18
|
+
const rateLimitStore = new Map<string, RateLimitData>();
|
|
19
|
+
const sessionStore = new Map<string, SessionData>();
|
|
20
|
+
const idempotencyStore = new Map<string, IdempotencyRecord>();
|
|
21
|
+
|
|
22
|
+
function rateLimit(userId: string): boolean {
|
|
23
|
+
const data = rateLimitStore.get(userId); // BAD: Instance-local
|
|
24
|
+
// ...
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
**Correct:**
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
// External state - works for any number of instances
|
|
32
|
+
import { Redis } from 'ioredis';
|
|
33
|
+
|
|
34
|
+
const redis = new Redis(process.env.REDIS_URL);
|
|
35
|
+
|
|
36
|
+
async function rateLimit(userId: string): Promise<boolean> {
|
|
37
|
+
const key = `ratelimit:${userId}`;
|
|
38
|
+
const current = await redis.incr(key);
|
|
39
|
+
|
|
40
|
+
if (current === 1) {
|
|
41
|
+
await redis.expire(key, 60); // 1 minute window
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return current <= 100; // 100 requests per minute
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**State Migration Table:**
|
|
49
|
+
|
|
50
|
+
| Component | Single Instance | Multi Instance |
|
|
51
|
+
|-----------|-----------------|----------------|
|
|
52
|
+
| Token cache | `Map<>` OK | Redis with TTL |
|
|
53
|
+
| Rate limiting | In-memory OK | Redis distributed |
|
|
54
|
+
| Session state | In-memory OK | Redis or a shared store |
|
|
55
|
+
| Locks | Not needed | Redis distributed lock |
|
|
56
|
+
| Idempotency | `Map<>` OK | Redis with TTL |
|
|
57
|
+
|
|
58
|
+
**Files to Audit:**
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
// Common patterns to find and migrate
|
|
62
|
+
middleware.ts rateLimitStore: Map<string, RateLimitData>
|
|
63
|
+
middleware.ts idempotencyStore: Map<string, IdempotencyRecord>
|
|
64
|
+
service.ts sessionStore: Map<string, SessionData>
|
|
65
|
+
import.ts importProgress: Map<string, ImportProgressRecord>
|
|
66
|
+
export.ts exportProgress: Map<string, ExportProgressRecord>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Scaling Checklist:**
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
PRE-REQUISITES
|
|
73
|
+
├── [ ] In-memory Maps identified
|
|
74
|
+
├── [ ] Rate limiting ready for Redis
|
|
75
|
+
├── [ ] DB connection pooling configured
|
|
76
|
+
├── [ ] Multi-tenant isolation OK
|
|
77
|
+
└── [ ] Healthcheck + readiness probe
|
|
78
|
+
|
|
79
|
+
MIGRATION (when multi-instance needed)
|
|
80
|
+
├── [ ] Rate limiting → Redis
|
|
81
|
+
├── [ ] Session state → Redis
|
|
82
|
+
├── [ ] Distributed locks → Redis
|
|
83
|
+
└── [ ] Load tests validated
|
|
84
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Untrusted Input, Prompt Injection and the Lethal Trifecta
|
|
3
|
+
impact: CRITICAL
|
|
4
|
+
impactDescription: Treats prompt injection as a first-rank, structural threat constrained by architecture rather than detected by heuristics
|
|
5
|
+
tags: [security, llm, injection, trifecta, untrusted-input]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## Untrusted Input, Prompt Injection and the Lethal Trifecta
|
|
9
|
+
|
|
10
|
+
> **Everything observed through a tool is data, not instructions.** Web pages, documents, retrieved memories, tool results, file contents: none of it carries authority to act.
|
|
11
|
+
|
|
12
|
+
Prompt injection is the first-rank threat of an agentic system: hostile instructions slipped into the input. The direct form comes from the user; the indirect, more pernicious form hides in retrieved content (a page, a document, a memory) and fires when the model ingests it. It is therefore intrinsic to any memory or retrieval. You do not reliably *detect* it. You *constrain* it by architecture.
|
|
13
|
+
|
|
14
|
+
**The lethal trifecta.** The worst case is three properties meeting on one unguarded path:
|
|
15
|
+
|
|
16
|
+
1. access to private data,
|
|
17
|
+
2. ingestion of untrusted content,
|
|
18
|
+
3. a means of communication to the outside.
|
|
19
|
+
|
|
20
|
+
Remove any one of the three and the path is defused.
|
|
21
|
+
|
|
22
|
+
**Operational rule, scoped to the context window:** while untrusted content is present in the context, allow only two of those three properties at once. If all three are genuinely needed, the action does not auto-execute: it requires human approval.
|
|
23
|
+
|
|
24
|
+
**Incorrect:**
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
// Untrusted page text flows straight into a tool that can exfiltrate.
|
|
28
|
+
const page = await fetch(url); // untrusted content
|
|
29
|
+
const data = await db.readPrivate(userId); // private data
|
|
30
|
+
await sendEmail(extractRecipient(page), data); // BAD: trifecta: page chose the recipient
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Correct:**
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
// Break the trifecta structurally: the recipient is only ever accepted
|
|
37
|
+
// from the user's request field. There is no code path that extracts a
|
|
38
|
+
// recipient from fetched content, so provenance is established by
|
|
39
|
+
// construction — not by detecting where a value "came from" after the fact.
|
|
40
|
+
const page = await fetch(url); // untrusted content: data only
|
|
41
|
+
const data = await db.readPrivate(userId);
|
|
42
|
+
await sendEmail(request.recipient, data); // recipient: user request field, nothing else
|
|
43
|
+
|
|
44
|
+
// If the workflow genuinely needs a recipient found in observed content,
|
|
45
|
+
// that action does not auto-execute: it is routed to human approval.
|
|
46
|
+
await requestHumanApproval({ action: 'sendEmail', to: candidateFromPage, payload: data });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Constrain, do not detect:**
|
|
50
|
+
|
|
51
|
+
- **Least privilege.** Each tool gets the narrowest scope; a third-party connector is untrusted input.
|
|
52
|
+
- **Approval on the action.** Human approval is anchored in the tool's contract and enforced by infrastructure, not by the prompt.
|
|
53
|
+
- **Suspend, do not block.** A sensitive action serialises the agent's state and rehydrates it on the human decision (fail-closed without freezing the process); low-urgency approvals are batched in a prioritised queue.
|
|
54
|
+
- **Separate the untrusted.** Keep untrusted content out of the same path as private data and an outbound channel.
|
|
55
|
+
|
|
56
|
+
**Why this is structural, not behavioural:** a guard you can argue the model out of is not a guard. Approval anchored in the tool contract, least privilege enforced by infrastructure, and a broken trifecta hold regardless of what the injected text says.
|
|
57
|
+
|
|
58
|
+
**Checklist:**
|
|
59
|
+
|
|
60
|
+
- [ ] Observed content is treated as data; it never authorises an action.
|
|
61
|
+
- [ ] No path holds all three trifecta properties while untrusted content is in context.
|
|
62
|
+
- [ ] Sensitive actions require approval anchored in the tool contract.
|
|
63
|
+
- [ ] Approval suspends-and-rehydrates; it does not freeze the process.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: State as an Immutable Journal (Event Sourcing, Replay, Provenance)
|
|
3
|
+
impact: HIGH
|
|
4
|
+
impactDescription: Makes the agent a stateless reducer over an external, replayable journal, so truth, audit and recovery are structural rather than bolted on
|
|
5
|
+
tags: [state, event-sourcing, replay, provenance, memory]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## State as an Immutable Journal
|
|
9
|
+
|
|
10
|
+
> **The agent is a stateless reducer. State lives outside, in three layers.**
|
|
11
|
+
|
|
12
|
+
An agent with hidden in-process state cannot be replayed, audited, or scaled. Keep the agent stateless and externalise state into three distinct layers, each with one job:
|
|
13
|
+
|
|
14
|
+
1. **Immutable interaction journal** - the source of truth. Append-only, never edited. It is what you audit and what you replay. Every consequential step is an event appended here.
|
|
15
|
+
2. **Derived working view** - the memory that forgets. A projection of the journal, trimmed and scored for relevance, rebuilt from the journal at any time (see *Memory Scoring*). It is a cache, not a source.
|
|
16
|
+
3. **Prompt provenance** - the reconciler. For each inference, the fingerprint of what was actually injected into the model, so you can replay exactly what the model saw even after the working view has moved on.
|
|
17
|
+
|
|
18
|
+
**Incorrect:**
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// Hidden mutable state in the agent. Not replayable, not auditable.
|
|
22
|
+
class Agent {
|
|
23
|
+
private history: Msg[] = []; // BAD: in-process truth
|
|
24
|
+
handle(input: string) {
|
|
25
|
+
this.history.push({ role: "user", text: input });
|
|
26
|
+
// ... mutate more fields the journal never sees
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Correct:**
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// Truth is the journal. The agent folds events into a view, then appends new ones.
|
|
35
|
+
// Appends carry an expected version (optimistic concurrency control): if another
|
|
36
|
+
// writer appended in the meantime, the append fails and the caller re-reads and
|
|
37
|
+
// retries, instead of two writers silently interleaving into the same run.
|
|
38
|
+
async function handle(runId: string, input: Event): Promise<Result> {
|
|
39
|
+
const events = await journal.read(runId);
|
|
40
|
+
const version = events.length; // version seen at read time
|
|
41
|
+
await journal.append(runId, [input], { expectedVersion: version });
|
|
42
|
+
const view = project([...events, input]); // derived, rebuildable
|
|
43
|
+
const result = await reducer(view); // agent is a pure fold over the view
|
|
44
|
+
await journal.append(runId, result.events, { // consequences are events too
|
|
45
|
+
expectedVersion: version + 1,
|
|
46
|
+
});
|
|
47
|
+
return result;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Replay is just folding the journal again, deterministically.
|
|
51
|
+
const replayed = project(await journal.read(runId));
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Why this matters:**
|
|
55
|
+
|
|
56
|
+
- **Audit and recovery**: the journal answers "what happened, exactly?" and lets you resume a run from any point (see the recovery runbook).
|
|
57
|
+
- **Provenance under injection**: keeping what was injected lets you investigate a bad output even after the working memory forgot it; it also pairs with the prompt provenance the single middleware records.
|
|
58
|
+
- **Scaling**: because the agent holds no hidden state, externalising the journal and the working view to a shared store is the structural precondition for running many instances — not the whole bill. The real costs come with it: **concurrency control** on appends (expected version, retry on conflict), **idempotence** of event handling (a retried append or redelivered event must not double its effect), and **causal ordering** across writers (events from concurrent instances need an order the projection can trust). Pay these on a trigger, in full knowledge, not by default.
|
|
59
|
+
|
|
60
|
+
**Checklist:**
|
|
61
|
+
|
|
62
|
+
- [ ] The agent holds no hidden mutable state; it folds over an external view.
|
|
63
|
+
- [ ] The interaction journal is append-only and is the single source of truth.
|
|
64
|
+
- [ ] Appends carry an expected version; a conflict re-reads and retries, never overwrites.
|
|
65
|
+
- [ ] The working view is a projection, rebuildable from the journal.
|
|
66
|
+
- [ ] Prompt provenance is recorded per inference for replay and audit.
|