@directive-run/knowledge 0.2.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/README.md +63 -0
- package/ai/ai-adapters.md +250 -0
- package/ai/ai-agents-streaming.md +269 -0
- package/ai/ai-budget-resilience.md +235 -0
- package/ai/ai-communication.md +281 -0
- package/ai/ai-debug-observability.md +243 -0
- package/ai/ai-guardrails-memory.md +332 -0
- package/ai/ai-mcp-rag.md +288 -0
- package/ai/ai-multi-agent.md +274 -0
- package/ai/ai-orchestrator.md +227 -0
- package/ai/ai-security.md +293 -0
- package/ai/ai-tasks.md +261 -0
- package/ai/ai-testing-evals.md +378 -0
- package/api-skeleton.md +5 -0
- package/core/anti-patterns.md +382 -0
- package/core/constraints.md +263 -0
- package/core/core-patterns.md +228 -0
- package/core/error-boundaries.md +322 -0
- package/core/multi-module.md +315 -0
- package/core/naming.md +283 -0
- package/core/plugins.md +344 -0
- package/core/react-adapter.md +262 -0
- package/core/resolvers.md +357 -0
- package/core/schema-types.md +262 -0
- package/core/system-api.md +271 -0
- package/core/testing.md +257 -0
- package/core/time-travel.md +238 -0
- package/dist/index.cjs +111 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +10 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +102 -0
- package/dist/index.js.map +1 -0
- package/examples/ab-testing.ts +385 -0
- package/examples/ai-checkpoint.ts +509 -0
- package/examples/ai-guardrails.ts +319 -0
- package/examples/ai-orchestrator.ts +589 -0
- package/examples/async-chains.ts +287 -0
- package/examples/auth-flow.ts +371 -0
- package/examples/batch-resolver.ts +341 -0
- package/examples/checkers.ts +589 -0
- package/examples/contact-form.ts +176 -0
- package/examples/counter.ts +393 -0
- package/examples/dashboard-loader.ts +512 -0
- package/examples/debounce-constraints.ts +105 -0
- package/examples/dynamic-modules.ts +293 -0
- package/examples/error-boundaries.ts +430 -0
- package/examples/feature-flags.ts +220 -0
- package/examples/form-wizard.ts +347 -0
- package/examples/fraud-analysis.ts +663 -0
- package/examples/goal-heist.ts +341 -0
- package/examples/multi-module.ts +57 -0
- package/examples/newsletter.ts +241 -0
- package/examples/notifications.ts +210 -0
- package/examples/optimistic-updates.ts +317 -0
- package/examples/pagination.ts +260 -0
- package/examples/permissions.ts +337 -0
- package/examples/provider-routing.ts +403 -0
- package/examples/server.ts +316 -0
- package/examples/shopping-cart.ts +422 -0
- package/examples/sudoku.ts +630 -0
- package/examples/theme-locale.ts +204 -0
- package/examples/time-machine.ts +225 -0
- package/examples/topic-guard.ts +306 -0
- package/examples/url-sync.ts +333 -0
- package/examples/websocket.ts +404 -0
- package/package.json +65 -0
|
@@ -0,0 +1,663 @@
|
|
|
1
|
+
// Example: fraud-analysis
|
|
2
|
+
// Source: examples/fraud-analysis/src/fraud-analysis.ts
|
|
3
|
+
// Pure module file — no DOM wiring
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fraud Case Analysis — Directive Module
|
|
7
|
+
*
|
|
8
|
+
* Multi-stage fraud detection pipeline showcasing every major Directive feature:
|
|
9
|
+
* - 6 constraints with priority + `after` ordering (including competing constraints)
|
|
10
|
+
* - 6 resolvers with retry policies and custom dedup keys
|
|
11
|
+
* - 3 effects with explicit deps
|
|
12
|
+
* - 9 derivations with composition
|
|
13
|
+
* - Local PII detection + checkpoint store
|
|
14
|
+
* - DevTools panel with time-travel debugging
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
type ModuleSchema,
|
|
19
|
+
createModule,
|
|
20
|
+
createSystem,
|
|
21
|
+
t,
|
|
22
|
+
} from "@directive-run/core";
|
|
23
|
+
import { devtoolsPlugin } from "@directive-run/core/plugins";
|
|
24
|
+
import { InMemoryCheckpointStore } from "./checkpoint.js";
|
|
25
|
+
import { detectPII, redactPII } from "./pii.js";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
type CheckpointEntry,
|
|
29
|
+
type Disposition,
|
|
30
|
+
type FlagEvent,
|
|
31
|
+
type FraudCase,
|
|
32
|
+
type PipelineStage,
|
|
33
|
+
type Severity,
|
|
34
|
+
type TimelineEntry,
|
|
35
|
+
getMockEnrichment,
|
|
36
|
+
} from "./mock-data.js";
|
|
37
|
+
|
|
38
|
+
// ============================================================================
|
|
39
|
+
// Timeline (external mutable array, same pattern as ai-checkpoint)
|
|
40
|
+
// ============================================================================
|
|
41
|
+
|
|
42
|
+
export const timeline: TimelineEntry[] = [];
|
|
43
|
+
|
|
44
|
+
export function addTimeline(
|
|
45
|
+
type: TimelineEntry["type"],
|
|
46
|
+
message: string,
|
|
47
|
+
): void {
|
|
48
|
+
timeline.push({
|
|
49
|
+
time: new Date().toLocaleTimeString(),
|
|
50
|
+
type,
|
|
51
|
+
message,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// Checkpoint Store
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
export const checkpointStore = new InMemoryCheckpointStore();
|
|
60
|
+
|
|
61
|
+
// ============================================================================
|
|
62
|
+
// Analysis Helpers
|
|
63
|
+
// ============================================================================
|
|
64
|
+
|
|
65
|
+
interface AnalysisResult {
|
|
66
|
+
riskScore: number;
|
|
67
|
+
severity: Severity;
|
|
68
|
+
disposition: Disposition;
|
|
69
|
+
analysisNotes: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Deterministic risk scoring formula */
|
|
73
|
+
function analyzeWithFormula(fraudCase: FraudCase): AnalysisResult {
|
|
74
|
+
const avgSignalRisk =
|
|
75
|
+
fraudCase.signals.length > 0
|
|
76
|
+
? fraudCase.signals.reduce((sum, s) => sum + s.risk, 0) /
|
|
77
|
+
fraudCase.signals.length
|
|
78
|
+
: 50;
|
|
79
|
+
|
|
80
|
+
const totalAmount = fraudCase.events.reduce((sum, e) => sum + e.amount, 0);
|
|
81
|
+
const amountFactor = Math.min(totalAmount / 10000, 1) * 30;
|
|
82
|
+
const eventFactor = Math.min(fraudCase.events.length / 10, 1) * 20;
|
|
83
|
+
const piiFactor = fraudCase.events.some((e) => e.piiFound) ? 15 : 0;
|
|
84
|
+
|
|
85
|
+
const riskScore = Math.min(
|
|
86
|
+
100,
|
|
87
|
+
Math.round(avgSignalRisk * 0.5 + amountFactor + eventFactor + piiFactor),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
let severity: Severity = "low";
|
|
91
|
+
if (riskScore >= 80) {
|
|
92
|
+
severity = "critical";
|
|
93
|
+
} else if (riskScore >= 60) {
|
|
94
|
+
severity = "high";
|
|
95
|
+
} else if (riskScore >= 40) {
|
|
96
|
+
severity = "medium";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let disposition: Disposition = "pending";
|
|
100
|
+
let notes = `Risk: ${riskScore}/100. Signals: ${fraudCase.signals.map((s) => s.source).join(", ")}.`;
|
|
101
|
+
|
|
102
|
+
if (riskScore <= 30) {
|
|
103
|
+
disposition = "cleared";
|
|
104
|
+
notes += " Auto-cleared: low risk.";
|
|
105
|
+
} else if (riskScore <= 50) {
|
|
106
|
+
disposition = "flagged";
|
|
107
|
+
notes += " Flagged for monitoring.";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { riskScore, severity, disposition, analysisNotes: notes };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ============================================================================
|
|
114
|
+
// Schema
|
|
115
|
+
// ============================================================================
|
|
116
|
+
|
|
117
|
+
export const fraudSchema = {
|
|
118
|
+
facts: {
|
|
119
|
+
stage: t.string<PipelineStage>(),
|
|
120
|
+
flagEvents: t.array<FlagEvent>(),
|
|
121
|
+
cases: t.array<FraudCase>(),
|
|
122
|
+
isRunning: t.boolean(),
|
|
123
|
+
totalEventsProcessed: t.number(),
|
|
124
|
+
totalPiiDetections: t.number(),
|
|
125
|
+
analysisBudget: t.number(),
|
|
126
|
+
maxAnalysisBudget: t.number(),
|
|
127
|
+
riskThreshold: t.number(),
|
|
128
|
+
lastError: t.string(),
|
|
129
|
+
checkpoints: t.array<CheckpointEntry>(),
|
|
130
|
+
selectedScenario: t.string(),
|
|
131
|
+
},
|
|
132
|
+
derivations: {
|
|
133
|
+
ungroupedCount: t.number(),
|
|
134
|
+
caseCount: t.number(),
|
|
135
|
+
criticalCaseCount: t.number(),
|
|
136
|
+
pendingAnalysisCount: t.number(),
|
|
137
|
+
needsHumanReview: t.boolean(),
|
|
138
|
+
budgetExhausted: t.boolean(),
|
|
139
|
+
completionPercentage: t.number(),
|
|
140
|
+
averageRiskScore: t.number(),
|
|
141
|
+
dispositionSummary: t.object<Record<string, number>>(),
|
|
142
|
+
},
|
|
143
|
+
events: {
|
|
144
|
+
ingestEvents: { events: t.array<FlagEvent>() },
|
|
145
|
+
setRiskThreshold: { value: t.number() },
|
|
146
|
+
setBudget: { value: t.number() },
|
|
147
|
+
selectScenario: { key: t.string() },
|
|
148
|
+
reset: {},
|
|
149
|
+
},
|
|
150
|
+
requirements: {
|
|
151
|
+
NORMALIZE_EVENTS: {},
|
|
152
|
+
GROUP_EVENTS: {},
|
|
153
|
+
ENRICH_CASE: { caseId: t.string() },
|
|
154
|
+
ANALYZE_CASE: { caseId: t.string() },
|
|
155
|
+
HUMAN_REVIEW: { caseId: t.string() },
|
|
156
|
+
ESCALATE: { caseId: t.string() },
|
|
157
|
+
},
|
|
158
|
+
} satisfies ModuleSchema;
|
|
159
|
+
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// Module
|
|
162
|
+
// ============================================================================
|
|
163
|
+
|
|
164
|
+
export const fraudAnalysisModule = createModule("fraud", {
|
|
165
|
+
schema: fraudSchema,
|
|
166
|
+
|
|
167
|
+
init: (facts) => {
|
|
168
|
+
facts.stage = "idle";
|
|
169
|
+
facts.flagEvents = [];
|
|
170
|
+
facts.cases = [];
|
|
171
|
+
facts.isRunning = false;
|
|
172
|
+
facts.totalEventsProcessed = 0;
|
|
173
|
+
facts.totalPiiDetections = 0;
|
|
174
|
+
facts.analysisBudget = 300;
|
|
175
|
+
facts.maxAnalysisBudget = 300;
|
|
176
|
+
facts.riskThreshold = 70;
|
|
177
|
+
facts.lastError = "";
|
|
178
|
+
facts.checkpoints = [];
|
|
179
|
+
facts.selectedScenario = "card-skimming";
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// ============================================================================
|
|
183
|
+
// Derivations (9)
|
|
184
|
+
// ============================================================================
|
|
185
|
+
|
|
186
|
+
derive: {
|
|
187
|
+
ungroupedCount: (facts) => {
|
|
188
|
+
return facts.flagEvents.filter((e) => !e.grouped).length;
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
caseCount: (facts) => {
|
|
192
|
+
return facts.cases.length;
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
criticalCaseCount: (facts) => {
|
|
196
|
+
return facts.cases.filter((c) => c.severity === "critical").length;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
pendingAnalysisCount: (facts) => {
|
|
200
|
+
return facts.cases.filter((c) => c.enriched && !c.analyzed).length;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
needsHumanReview: (facts) => {
|
|
204
|
+
return facts.cases.some(
|
|
205
|
+
(c) => c.riskScore > facts.riskThreshold && c.disposition === "pending",
|
|
206
|
+
);
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
budgetExhausted: (facts) => {
|
|
210
|
+
return facts.analysisBudget <= 0;
|
|
211
|
+
},
|
|
212
|
+
|
|
213
|
+
completionPercentage: (facts) => {
|
|
214
|
+
const stages: PipelineStage[] = [
|
|
215
|
+
"idle",
|
|
216
|
+
"ingesting",
|
|
217
|
+
"normalizing",
|
|
218
|
+
"grouping",
|
|
219
|
+
"enriching",
|
|
220
|
+
"analyzing",
|
|
221
|
+
"complete",
|
|
222
|
+
];
|
|
223
|
+
const idx = stages.indexOf(facts.stage);
|
|
224
|
+
if (idx < 0) {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return Math.round((idx / (stages.length - 1)) * 100);
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
averageRiskScore: (facts) => {
|
|
232
|
+
if (facts.cases.length === 0) {
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const sum = facts.cases.reduce((acc, c) => acc + c.riskScore, 0);
|
|
237
|
+
|
|
238
|
+
return Math.round(sum / facts.cases.length);
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
// Composition: derives from cases (same source as caseCount)
|
|
242
|
+
dispositionSummary: (facts) => {
|
|
243
|
+
const summary: Record<string, number> = {};
|
|
244
|
+
for (const c of facts.cases) {
|
|
245
|
+
summary[c.disposition] = (summary[c.disposition] || 0) + 1;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return summary;
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
// ============================================================================
|
|
253
|
+
// Events
|
|
254
|
+
// ============================================================================
|
|
255
|
+
|
|
256
|
+
events: {
|
|
257
|
+
ingestEvents: (facts, { events }) => {
|
|
258
|
+
facts.flagEvents = [...facts.flagEvents, ...events];
|
|
259
|
+
facts.totalEventsProcessed = facts.totalEventsProcessed + events.length;
|
|
260
|
+
facts.stage = "ingesting";
|
|
261
|
+
facts.isRunning = true;
|
|
262
|
+
facts.lastError = "";
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
setRiskThreshold: (facts, { value }) => {
|
|
266
|
+
facts.riskThreshold = Math.max(50, Math.min(90, value));
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
setBudget: (facts, { value }) => {
|
|
270
|
+
facts.analysisBudget = Math.max(0, Math.min(500, value));
|
|
271
|
+
facts.maxAnalysisBudget = Math.max(facts.maxAnalysisBudget, value);
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
selectScenario: (facts, { key }) => {
|
|
275
|
+
facts.selectedScenario = key;
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
reset: (facts) => {
|
|
279
|
+
facts.stage = "idle";
|
|
280
|
+
facts.flagEvents = [];
|
|
281
|
+
facts.cases = [];
|
|
282
|
+
facts.isRunning = false;
|
|
283
|
+
facts.totalEventsProcessed = 0;
|
|
284
|
+
facts.totalPiiDetections = 0;
|
|
285
|
+
facts.lastError = "";
|
|
286
|
+
facts.checkpoints = [];
|
|
287
|
+
timeline.length = 0;
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
// ============================================================================
|
|
292
|
+
// Constraints (6 with priority + after ordering)
|
|
293
|
+
// ============================================================================
|
|
294
|
+
|
|
295
|
+
constraints: {
|
|
296
|
+
normalizeNeeded: {
|
|
297
|
+
priority: 100,
|
|
298
|
+
when: (facts) => {
|
|
299
|
+
return facts.stage === "ingesting" && facts.flagEvents.length > 0;
|
|
300
|
+
},
|
|
301
|
+
require: { type: "NORMALIZE_EVENTS" },
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
groupingNeeded: {
|
|
305
|
+
priority: 90,
|
|
306
|
+
after: ["normalizeNeeded"],
|
|
307
|
+
when: (facts) => {
|
|
308
|
+
return facts.flagEvents.some((e) => !e.grouped);
|
|
309
|
+
},
|
|
310
|
+
require: { type: "GROUP_EVENTS" },
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
enrichmentNeeded: {
|
|
314
|
+
priority: 80,
|
|
315
|
+
after: ["groupingNeeded"],
|
|
316
|
+
when: (facts) => {
|
|
317
|
+
return facts.cases.some((c) => !c.enriched && c.signals.length < 3);
|
|
318
|
+
},
|
|
319
|
+
require: (facts) => {
|
|
320
|
+
const target = facts.cases.find(
|
|
321
|
+
(c) => !c.enriched && c.signals.length < 3,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
return { type: "ENRICH_CASE", caseId: target?.id ?? "" };
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
analysisNeeded: {
|
|
329
|
+
priority: 70,
|
|
330
|
+
after: ["enrichmentNeeded"],
|
|
331
|
+
when: (facts) => {
|
|
332
|
+
return (
|
|
333
|
+
facts.analysisBudget > 0 &&
|
|
334
|
+
facts.cases.some((c) => c.enriched && !c.analyzed)
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
require: (facts) => {
|
|
338
|
+
const target = facts.cases.find((c) => c.enriched && !c.analyzed);
|
|
339
|
+
|
|
340
|
+
return { type: "ANALYZE_CASE", caseId: target?.id ?? "" };
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
humanReviewNeeded: {
|
|
345
|
+
priority: 65,
|
|
346
|
+
after: ["analysisNeeded"],
|
|
347
|
+
when: (facts) => {
|
|
348
|
+
return facts.cases.some(
|
|
349
|
+
(c) =>
|
|
350
|
+
c.analyzed &&
|
|
351
|
+
c.riskScore > facts.riskThreshold &&
|
|
352
|
+
c.disposition === "pending",
|
|
353
|
+
);
|
|
354
|
+
},
|
|
355
|
+
require: (facts) => {
|
|
356
|
+
const target = facts.cases.find(
|
|
357
|
+
(c) =>
|
|
358
|
+
c.analyzed &&
|
|
359
|
+
c.riskScore > facts.riskThreshold &&
|
|
360
|
+
c.disposition === "pending",
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
return { type: "HUMAN_REVIEW", caseId: target?.id ?? "" };
|
|
364
|
+
},
|
|
365
|
+
},
|
|
366
|
+
|
|
367
|
+
budgetEscalation: {
|
|
368
|
+
priority: 60,
|
|
369
|
+
when: (facts) => {
|
|
370
|
+
return (
|
|
371
|
+
facts.analysisBudget <= 0 &&
|
|
372
|
+
facts.cases.some(
|
|
373
|
+
(c) => c.enriched && !c.analyzed && c.disposition === "pending",
|
|
374
|
+
)
|
|
375
|
+
);
|
|
376
|
+
},
|
|
377
|
+
require: (facts) => {
|
|
378
|
+
const target = facts.cases.find(
|
|
379
|
+
(c) => c.enriched && !c.analyzed && c.disposition === "pending",
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
return { type: "ESCALATE", caseId: target?.id ?? "" };
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
|
|
387
|
+
// ============================================================================
|
|
388
|
+
// Resolvers (6)
|
|
389
|
+
// ============================================================================
|
|
390
|
+
|
|
391
|
+
resolvers: {
|
|
392
|
+
normalizeEvents: {
|
|
393
|
+
requirement: "NORMALIZE_EVENTS",
|
|
394
|
+
resolve: async (_req, context) => {
|
|
395
|
+
addTimeline("stage", "normalizing events");
|
|
396
|
+
|
|
397
|
+
const events = [...context.facts.flagEvents];
|
|
398
|
+
let piiCount = 0;
|
|
399
|
+
|
|
400
|
+
for (let i = 0; i < events.length; i++) {
|
|
401
|
+
const event = events[i];
|
|
402
|
+
|
|
403
|
+
// Run PII detection on merchant + memo fields
|
|
404
|
+
const merchantResult = await detectPII(event.merchant, {
|
|
405
|
+
types: ["credit_card", "bank_account", "ssn"],
|
|
406
|
+
});
|
|
407
|
+
const memoResult = await detectPII(event.memo, {
|
|
408
|
+
types: ["credit_card", "bank_account", "ssn"],
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const hasPii = merchantResult.detected || memoResult.detected;
|
|
412
|
+
if (hasPii) {
|
|
413
|
+
piiCount++;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
events[i] = {
|
|
417
|
+
...event,
|
|
418
|
+
piiFound: hasPii,
|
|
419
|
+
redactedMerchant: merchantResult.detected
|
|
420
|
+
? redactPII(event.merchant, merchantResult.items, "typed")
|
|
421
|
+
: event.merchant,
|
|
422
|
+
redactedMemo: memoResult.detected
|
|
423
|
+
? redactPII(event.memo, memoResult.items, "typed")
|
|
424
|
+
: event.memo,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Simulate processing delay (before fact mutations to avoid
|
|
429
|
+
// mid-resolver reconcile canceling this resolver)
|
|
430
|
+
await delay(300);
|
|
431
|
+
|
|
432
|
+
// All fact mutations at the end — no more awaits after this
|
|
433
|
+
context.facts.stage = "normalizing";
|
|
434
|
+
context.facts.flagEvents = events;
|
|
435
|
+
context.facts.totalPiiDetections =
|
|
436
|
+
context.facts.totalPiiDetections + piiCount;
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
groupEvents: {
|
|
441
|
+
requirement: "GROUP_EVENTS",
|
|
442
|
+
resolve: async (_req, context) => {
|
|
443
|
+
addTimeline("stage", "grouping events into cases");
|
|
444
|
+
|
|
445
|
+
const events = [...context.facts.flagEvents];
|
|
446
|
+
const existingCases = [...context.facts.cases];
|
|
447
|
+
|
|
448
|
+
// Group by accountId
|
|
449
|
+
const groups = new Map<string, FlagEvent[]>();
|
|
450
|
+
for (const event of events) {
|
|
451
|
+
if (event.grouped) {
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const existing = groups.get(event.accountId) ?? [];
|
|
456
|
+
existing.push(event);
|
|
457
|
+
groups.set(event.accountId, existing);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Create cases from groups
|
|
461
|
+
let caseNum = existingCases.length;
|
|
462
|
+
for (const [accountId, groupEvents] of groups) {
|
|
463
|
+
caseNum++;
|
|
464
|
+
const newCase: FraudCase = {
|
|
465
|
+
id: `case-${String(caseNum).padStart(3, "0")}`,
|
|
466
|
+
accountId,
|
|
467
|
+
events: groupEvents,
|
|
468
|
+
signals: [],
|
|
469
|
+
enriched: false,
|
|
470
|
+
analyzed: false,
|
|
471
|
+
riskScore: 0,
|
|
472
|
+
severity: "low",
|
|
473
|
+
disposition: "pending",
|
|
474
|
+
};
|
|
475
|
+
existingCases.push(newCase);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Mark all events as grouped
|
|
479
|
+
const markedEvents = events.map((e) => ({ ...e, grouped: true }));
|
|
480
|
+
|
|
481
|
+
await delay(200);
|
|
482
|
+
|
|
483
|
+
// All fact mutations at the end — no more awaits after this
|
|
484
|
+
context.facts.stage = "grouping";
|
|
485
|
+
context.facts.flagEvents = markedEvents;
|
|
486
|
+
context.facts.cases = existingCases;
|
|
487
|
+
},
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
enrichCase: {
|
|
491
|
+
requirement: "ENRICH_CASE",
|
|
492
|
+
key: (req) => `enrich-${req.caseId}`,
|
|
493
|
+
retry: { attempts: 2, backoff: "exponential" },
|
|
494
|
+
resolve: async (req, context) => {
|
|
495
|
+
addTimeline("stage", `enriching ${req.caseId}`);
|
|
496
|
+
|
|
497
|
+
const cases = [...context.facts.cases];
|
|
498
|
+
const idx = cases.findIndex((c) => c.id === req.caseId);
|
|
499
|
+
if (idx < 0) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const signals = getMockEnrichment(cases[idx].accountId);
|
|
504
|
+
|
|
505
|
+
// Simulate API call
|
|
506
|
+
await delay(400);
|
|
507
|
+
|
|
508
|
+
// All fact mutations at the end — no more awaits after this
|
|
509
|
+
cases[idx] = {
|
|
510
|
+
...cases[idx],
|
|
511
|
+
signals,
|
|
512
|
+
enriched: true,
|
|
513
|
+
};
|
|
514
|
+
context.facts.stage = "enriching";
|
|
515
|
+
context.facts.cases = cases;
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
analyzeCase: {
|
|
520
|
+
requirement: "ANALYZE_CASE",
|
|
521
|
+
key: (req) => `analyze-${req.caseId}`,
|
|
522
|
+
retry: { attempts: 1, backoff: "none" },
|
|
523
|
+
resolve: async (req, context) => {
|
|
524
|
+
addTimeline("stage", `analyzing ${req.caseId}`);
|
|
525
|
+
|
|
526
|
+
const cases = [...context.facts.cases];
|
|
527
|
+
const idx = cases.findIndex((c) => c.id === req.caseId);
|
|
528
|
+
if (idx < 0) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const fraudCase = cases[idx];
|
|
533
|
+
|
|
534
|
+
// Consume budget
|
|
535
|
+
const cost = 25 + Math.floor(fraudCase.events.length * 5);
|
|
536
|
+
|
|
537
|
+
// Deterministic analysis
|
|
538
|
+
await delay(500);
|
|
539
|
+
const result = analyzeWithFormula(fraudCase);
|
|
540
|
+
if (
|
|
541
|
+
result.disposition === "pending" &&
|
|
542
|
+
result.riskScore <= context.facts.riskThreshold
|
|
543
|
+
) {
|
|
544
|
+
result.disposition = "flagged";
|
|
545
|
+
result.analysisNotes +=
|
|
546
|
+
" Auto-flagged: below human review threshold.";
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// All fact mutations at the end — no more awaits after this
|
|
550
|
+
cases[idx] = { ...fraudCase, ...result, analyzed: true };
|
|
551
|
+
context.facts.stage = "analyzing";
|
|
552
|
+
context.facts.analysisBudget = Math.max(
|
|
553
|
+
0,
|
|
554
|
+
context.facts.analysisBudget - cost,
|
|
555
|
+
);
|
|
556
|
+
context.facts.cases = cases;
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
|
|
560
|
+
humanReview: {
|
|
561
|
+
requirement: "HUMAN_REVIEW",
|
|
562
|
+
resolve: async (req, context) => {
|
|
563
|
+
addTimeline("info", `${req.caseId} sent to human review`);
|
|
564
|
+
|
|
565
|
+
const cases = [...context.facts.cases];
|
|
566
|
+
const idx = cases.findIndex((c) => c.id === req.caseId);
|
|
567
|
+
if (idx < 0) {
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
await delay(100);
|
|
572
|
+
|
|
573
|
+
cases[idx] = {
|
|
574
|
+
...cases[idx],
|
|
575
|
+
disposition: "human_review",
|
|
576
|
+
dispositionReason: "Risk score exceeds threshold",
|
|
577
|
+
};
|
|
578
|
+
context.facts.cases = cases;
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
escalate: {
|
|
583
|
+
requirement: "ESCALATE",
|
|
584
|
+
resolve: async (req, context) => {
|
|
585
|
+
addTimeline("info", `${req.caseId} escalated (budget exhausted)`);
|
|
586
|
+
|
|
587
|
+
const cases = [...context.facts.cases];
|
|
588
|
+
const idx = cases.findIndex((c) => c.id === req.caseId);
|
|
589
|
+
if (idx < 0) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
await delay(100);
|
|
594
|
+
|
|
595
|
+
cases[idx] = {
|
|
596
|
+
...cases[idx],
|
|
597
|
+
disposition: "escalated",
|
|
598
|
+
dispositionReason: "Analysis budget exhausted",
|
|
599
|
+
};
|
|
600
|
+
context.facts.cases = cases;
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
// ============================================================================
|
|
606
|
+
// Effects (3)
|
|
607
|
+
// ============================================================================
|
|
608
|
+
|
|
609
|
+
effects: {
|
|
610
|
+
logStageChange: {
|
|
611
|
+
deps: ["stage"],
|
|
612
|
+
run: (facts, prev) => {
|
|
613
|
+
if (prev && prev.stage !== facts.stage) {
|
|
614
|
+
addTimeline("stage", `${prev.stage} → ${facts.stage}`);
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
},
|
|
618
|
+
|
|
619
|
+
logPiiDetection: {
|
|
620
|
+
deps: ["totalPiiDetections"],
|
|
621
|
+
run: (facts, prev) => {
|
|
622
|
+
if (prev && facts.totalPiiDetections !== prev.totalPiiDetections) {
|
|
623
|
+
addTimeline(
|
|
624
|
+
"pii",
|
|
625
|
+
`PII guardrail fired (${facts.totalPiiDetections} total detections)`,
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
logBudgetWarning: {
|
|
632
|
+
deps: ["analysisBudget"],
|
|
633
|
+
run: (facts, prev) => {
|
|
634
|
+
if (prev && prev.analysisBudget > 0 && facts.analysisBudget <= 0) {
|
|
635
|
+
addTimeline("budget", "analysis budget exhausted");
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// ============================================================================
|
|
643
|
+
// System
|
|
644
|
+
// ============================================================================
|
|
645
|
+
|
|
646
|
+
export const system = createSystem({
|
|
647
|
+
module: fraudAnalysisModule,
|
|
648
|
+
plugins: [devtoolsPlugin({ name: "fraud-analysis", panel: true })],
|
|
649
|
+
debug: {
|
|
650
|
+
timeTravel: true,
|
|
651
|
+
maxSnapshots: 50,
|
|
652
|
+
runHistory: true,
|
|
653
|
+
maxRuns: 100,
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// ============================================================================
|
|
658
|
+
// Helpers
|
|
659
|
+
// ============================================================================
|
|
660
|
+
|
|
661
|
+
export function delay(ms: number): Promise<void> {
|
|
662
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
663
|
+
}
|