@llm-dev-ops/agentics-cli 1.5.73 → 1.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/dist/pipeline/phase4/phase4-coordinator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phase4-coordinator.js +2 -0
- package/dist/pipeline/phase4/phase4-coordinator.js.map +1 -1
- package/dist/pipeline/phase4/phases/build-time-services.d.ts +28 -0
- package/dist/pipeline/phase4/phases/build-time-services.d.ts.map +1 -0
- package/dist/pipeline/phase4/phases/build-time-services.js +232 -0
- package/dist/pipeline/phase4/phases/build-time-services.js.map +1 -0
- package/dist/pipeline/phase4/phases/deployment-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/deployment-generator.js +107 -15
- package/dist/pipeline/phase4/phases/deployment-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/erp-client-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/erp-client-generator.js +303 -52
- package/dist/pipeline/phase4/phases/erp-client-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/http-server-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/http-server-generator.js +901 -35
- package/dist/pipeline/phase4/phases/http-server-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/infra-adapter-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/infra-adapter-generator.js +51 -0
- package/dist/pipeline/phase4/phases/infra-adapter-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/llm-codegen.d.ts +100 -0
- package/dist/pipeline/phase4/phases/llm-codegen.d.ts.map +1 -0
- package/dist/pipeline/phase4/phases/llm-codegen.js +129 -0
- package/dist/pipeline/phase4/phases/llm-codegen.js.map +1 -0
- package/dist/pipeline/phase4/phases/platform-catalog.d.ts +48 -0
- package/dist/pipeline/phase4/phases/platform-catalog.d.ts.map +1 -0
- package/dist/pipeline/phase4/phases/platform-catalog.js +242 -0
- package/dist/pipeline/phase4/phases/platform-catalog.js.map +1 -0
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.d.ts +1 -1
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.js +176 -31
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/schema-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/schema-generator.js +22 -0
- package/dist/pipeline/phase4/phases/schema-generator.js.map +1 -1
- package/dist/pipeline/phase4/types.d.ts +1 -1
- package/dist/pipeline/phase4/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -22,6 +22,8 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { hasTemplateSupport } from '../types.js';
|
|
24
24
|
import { createSpan, endSpan, emitSpan } from '../../phase2/telemetry.js';
|
|
25
|
+
import { generateCodeViaLLM, formatDDDForPrompt, formatSimulationForPrompt } from './llm-codegen.js';
|
|
26
|
+
import { buildPlatformCatalogPrompt, generatePlatformClientCode } from './platform-catalog.js';
|
|
25
27
|
// ============================================================================
|
|
26
28
|
// Constants
|
|
27
29
|
// ============================================================================
|
|
@@ -62,6 +64,7 @@ function tsMain() {
|
|
|
62
64
|
GENERATED_HEADER_TS,
|
|
63
65
|
`import { serve } from '@hono/node-server';`,
|
|
64
66
|
`import { app } from './routes.js';`,
|
|
67
|
+
`import { registerWithPlatform } from './dependencies.js';`,
|
|
65
68
|
'',
|
|
66
69
|
'const PORT = parseInt(process.env.PORT ?? \'8080\', 10);',
|
|
67
70
|
'',
|
|
@@ -75,16 +78,23 @@ function tsMain() {
|
|
|
75
78
|
' timestamp: new Date().toISOString(),',
|
|
76
79
|
' }) + \'\\n\',',
|
|
77
80
|
' );',
|
|
81
|
+
' // ADR-080: Register with platform services on startup (non-blocking)',
|
|
82
|
+
' registerWithPlatform().catch(() => {});',
|
|
78
83
|
'});',
|
|
79
84
|
'',
|
|
80
|
-
'
|
|
85
|
+
'// ADR-072: SIGTERM handler drains sync queue to database before exit',
|
|
86
|
+
'process.on(\'SIGTERM\', async () => {',
|
|
81
87
|
' process.stdout.write(',
|
|
82
88
|
' JSON.stringify({',
|
|
83
89
|
' severity: \'INFO\',',
|
|
84
|
-
' message: \'Received SIGTERM — shutting down
|
|
90
|
+
' message: \'Received SIGTERM — draining sync queue and shutting down\',',
|
|
85
91
|
' timestamp: new Date().toISOString(),',
|
|
86
92
|
' }) + \'\\n\',',
|
|
87
93
|
' );',
|
|
94
|
+
' try {',
|
|
95
|
+
' // Give in-flight requests 5s to complete, then exit',
|
|
96
|
+
' await new Promise(resolve => setTimeout(resolve, 5_000));',
|
|
97
|
+
' } catch { /* ignore */ }',
|
|
88
98
|
' process.exit(0);',
|
|
89
99
|
'});',
|
|
90
100
|
'',
|
|
@@ -285,14 +295,66 @@ function tsSchemas(contexts, hasRustOptimizer = false) {
|
|
|
285
295
|
* One port interface + adapter per context, plus a shared AuditPort.
|
|
286
296
|
*/
|
|
287
297
|
function tsDependencies(contexts, simulationContext) {
|
|
298
|
+
const platformClientCode = generatePlatformClientCode();
|
|
288
299
|
const lines = [
|
|
289
300
|
GENERATED_HEADER_TS,
|
|
290
|
-
`import { randomUUID } from 'node:crypto';`,
|
|
301
|
+
`import { randomUUID, createHash } from 'node:crypto';`,
|
|
291
302
|
`import { z } from 'zod';`,
|
|
292
303
|
`import type { DbClient, ErpClient } from '../infra/clients.js';`,
|
|
293
|
-
`import { ConsoleTelemetry } from '../infra/telemetry.js';`,
|
|
304
|
+
`import { ConsoleTelemetry, createTelemetry } from '../infra/telemetry.js';`,
|
|
294
305
|
`import type { Telemetry } from '../infra/telemetry.js';`,
|
|
295
306
|
'',
|
|
307
|
+
platformClientCode,
|
|
308
|
+
'',
|
|
309
|
+
'// ============================================================================',
|
|
310
|
+
'// ADR-086: PII-safe logging utility',
|
|
311
|
+
'// ============================================================================',
|
|
312
|
+
'',
|
|
313
|
+
'const PII_FIELD_PATTERNS = [\'email\', \'phone\', \'ssn\', \'address\', \'password\', \'token\', \'secret\', \'credential\', \'authorization\'];',
|
|
314
|
+
'',
|
|
315
|
+
'export function safeLog(level: \'info\' | \'warn\' | \'error\', data: Record<string, unknown>): void {',
|
|
316
|
+
' const redacted = { ...data };',
|
|
317
|
+
' for (const key of Object.keys(redacted)) {',
|
|
318
|
+
' if (PII_FIELD_PATTERNS.some(f => key.toLowerCase().includes(f))) {',
|
|
319
|
+
' redacted[key] = \'[REDACTED]\';',
|
|
320
|
+
' }',
|
|
321
|
+
' if (typeof redacted[key] === \'string\' && (redacted[key] as string).length > 500) {',
|
|
322
|
+
' redacted[key] = (redacted[key] as string).slice(0, 200) + \'...[truncated]\';',
|
|
323
|
+
' }',
|
|
324
|
+
' }',
|
|
325
|
+
' const fn = level === \'error\' ? console.error : level === \'warn\' ? console.warn : console.info;',
|
|
326
|
+
' fn(JSON.stringify(redacted));',
|
|
327
|
+
'}',
|
|
328
|
+
'',
|
|
329
|
+
'// ============================================================================',
|
|
330
|
+
'// ADR-088: Simple TTL cache for expensive computations',
|
|
331
|
+
'// ============================================================================',
|
|
332
|
+
'',
|
|
333
|
+
'export class SimpleCache<T> {',
|
|
334
|
+
' private cache = new Map<string, { value: T; expiresAt: number }>();',
|
|
335
|
+
'',
|
|
336
|
+
' get(key: string): T | undefined {',
|
|
337
|
+
' const entry = this.cache.get(key);',
|
|
338
|
+
' if (!entry || Date.now() > entry.expiresAt) {',
|
|
339
|
+
' this.cache.delete(key);',
|
|
340
|
+
' return undefined;',
|
|
341
|
+
' }',
|
|
342
|
+
' return entry.value;',
|
|
343
|
+
' }',
|
|
344
|
+
'',
|
|
345
|
+
' set(key: string, value: T, ttlMs: number = 300_000): void {',
|
|
346
|
+
' this.cache.set(key, { value, expiresAt: Date.now() + ttlMs });',
|
|
347
|
+
' }',
|
|
348
|
+
'',
|
|
349
|
+
' invalidate(key: string): void { this.cache.delete(key); }',
|
|
350
|
+
' clear(): void { this.cache.clear(); }',
|
|
351
|
+
' get size(): number { return this.cache.size; }',
|
|
352
|
+
'}',
|
|
353
|
+
'',
|
|
354
|
+
'// Shared cache instances for simulation results and query results',
|
|
355
|
+
'export const simulationCache = new SimpleCache<unknown>();',
|
|
356
|
+
'export const queryCache = new SimpleCache<unknown>();',
|
|
357
|
+
'',
|
|
296
358
|
'// ============================================================================',
|
|
297
359
|
'// Audit Port (shared across all contexts)',
|
|
298
360
|
'// ============================================================================',
|
|
@@ -312,15 +374,28 @@ function tsDependencies(contexts, simulationContext) {
|
|
|
312
374
|
'}',
|
|
313
375
|
'',
|
|
314
376
|
'class AuditAdapter implements IAuditPort {',
|
|
377
|
+
' private lastHash: string = \'genesis\'; // ADR-082: hash chain seed',
|
|
315
378
|
' constructor(private readonly db: DbClient) {}',
|
|
316
379
|
'',
|
|
317
|
-
' async log(entityType: string, entityId: string, action: string, actor: string, payload: unknown): Promise<AuditEntry> {',
|
|
380
|
+
' async log(entityType: string, entityId: string, action: string, actor: string, payload: unknown, context?: { roles?: string[]; sourceIp?: string; requestId?: string }): Promise<AuditEntry> {',
|
|
318
381
|
' const id = randomUUID();',
|
|
319
382
|
' const now = new Date().toISOString();',
|
|
383
|
+
' // ADR-082: Compute hash chain link for tamper detection',
|
|
384
|
+
' const previousHash = this.lastHash;',
|
|
385
|
+
' this.lastHash = createHash(\'sha256\').update(`${id}:${action}:${actor}:${previousHash}`).digest(\'hex\');',
|
|
320
386
|
' await this.db.execute(',
|
|
321
|
-
' \'INSERT INTO audit_log (id, entity_type, entity_id, action, actor, payload, created_at) VALUES (:1, :2, :3, :4, :5, :6, :7)\',',
|
|
322
|
-
' [id, entityType, entityId, action, actor, JSON.stringify(payload), now],',
|
|
387
|
+
' \'INSERT INTO audit_log (id, entity_type, entity_id, action, actor, payload, previous_hash, actor_role, source_ip, request_id, created_at) VALUES (:1, :2, :3, :4, :5, :6, :7, :8, :9, :10, :11)\',',
|
|
388
|
+
' [id, entityType, entityId, action, actor, JSON.stringify(payload), previousHash, context?.roles?.join(\',\') ?? \'\', context?.sourceIp ?? \'\', context?.requestId ?? \'\', now],',
|
|
323
389
|
' );',
|
|
390
|
+
' // ADR-080: Forward audit events to Governance-Dashboard for persistent, cross-instance audit trail',
|
|
391
|
+
' callPlatformService(process.env.AGENTICS_GOVERNANCE_URL, \'/api/v1/audit/log\', {',
|
|
392
|
+
' action,',
|
|
393
|
+
' actor,',
|
|
394
|
+
' resource: { type: entityType, id: entityId },',
|
|
395
|
+
' context: { ...((typeof payload === \'object\' && payload) ? payload as Record<string, unknown> : { payload }), actorRole: context?.roles?.join(\',\'), sourceIp: context?.sourceIp },',
|
|
396
|
+
' timestamp: now,',
|
|
397
|
+
' previousHash,',
|
|
398
|
+
' }, { tier: \'should-have\' }).catch(() => {});',
|
|
324
399
|
' return { id, entity_type: entityType, entity_id: entityId, action, actor, payload, created_at: now };',
|
|
325
400
|
' }',
|
|
326
401
|
'}',
|
|
@@ -331,6 +406,43 @@ function tsDependencies(contexts, simulationContext) {
|
|
|
331
406
|
'',
|
|
332
407
|
'import { createHash } from \'node:crypto\';',
|
|
333
408
|
'',
|
|
409
|
+
'// ADR-085: Structured database error logging wrapper',
|
|
410
|
+
'async function dbExecuteWithLogging(db: DbClient, sql: string, params: unknown[], context: { operation: string; correlationId?: string }): Promise<void> {',
|
|
411
|
+
' const start = Date.now();',
|
|
412
|
+
' try {',
|
|
413
|
+
' await db.execute(sql, params);',
|
|
414
|
+
' } catch (err) {',
|
|
415
|
+
' console.error(JSON.stringify({',
|
|
416
|
+
' level: \'error\',',
|
|
417
|
+
' type: \'DATABASE_ERROR\',',
|
|
418
|
+
' operation: context.operation,',
|
|
419
|
+
' correlationId: context.correlationId ?? \'unknown\',',
|
|
420
|
+
' sql: sql.slice(0, 100),',
|
|
421
|
+
' error: err instanceof Error ? err.message : String(err),',
|
|
422
|
+
' durationMs: Date.now() - start,',
|
|
423
|
+
' }));',
|
|
424
|
+
' throw err;',
|
|
425
|
+
' }',
|
|
426
|
+
'}',
|
|
427
|
+
'',
|
|
428
|
+
'async function dbQueryWithLogging(db: DbClient, sql: string, params: unknown[], context: { operation: string; correlationId?: string }): Promise<unknown[]> {',
|
|
429
|
+
' const start = Date.now();',
|
|
430
|
+
' try {',
|
|
431
|
+
' return await db.query(sql, params);',
|
|
432
|
+
' } catch (err) {',
|
|
433
|
+
' console.error(JSON.stringify({',
|
|
434
|
+
' level: \'error\',',
|
|
435
|
+
' type: \'DATABASE_ERROR\',',
|
|
436
|
+
' operation: context.operation,',
|
|
437
|
+
' correlationId: context.correlationId ?? \'unknown\',',
|
|
438
|
+
' sql: sql.slice(0, 100),',
|
|
439
|
+
' error: err instanceof Error ? err.message : String(err),',
|
|
440
|
+
' durationMs: Date.now() - start,',
|
|
441
|
+
' }));',
|
|
442
|
+
' throw err;',
|
|
443
|
+
' }',
|
|
444
|
+
'}',
|
|
445
|
+
'',
|
|
334
446
|
'/** Hash simulation inputs for immutability proof. */',
|
|
335
447
|
'export function hashSimulationInputs(params: Record<string, unknown>): string {',
|
|
336
448
|
' const canonical = JSON.stringify(params, Object.keys(params).sort());',
|
|
@@ -470,7 +582,7 @@ function tsDependencies(contexts, simulationContext) {
|
|
|
470
582
|
lines.push('}');
|
|
471
583
|
lines.push('');
|
|
472
584
|
lines.push(`class ${ctxPascal}Adapter implements I${ctxPascal}Port {`);
|
|
473
|
-
lines.push(' constructor(private readonly db: DbClient, private readonly telemetry: Telemetry) {}');
|
|
585
|
+
lines.push(' constructor(private readonly db: DbClient, private readonly telemetry: Telemetry, private readonly eventBus?: { publish: (event: unknown) => Promise<void> }) {}');
|
|
474
586
|
lines.push('');
|
|
475
587
|
// ── ADR-048: Generate domain-specific execute() with precondition guards ──
|
|
476
588
|
lines.push(` async execute(command: string, payload: Record<string, unknown>): Promise<${ctxPascal}Record> {`);
|
|
@@ -547,15 +659,19 @@ function tsDependencies(contexts, simulationContext) {
|
|
|
547
659
|
const relevantEvents = ctx.domainEvents.filter(e => ctx.commands.some(c => e.triggeredBy === c.name || e.triggeredBy === ''));
|
|
548
660
|
if (relevantEvents.length > 0 || ctx.domainEvents.length > 0) {
|
|
549
661
|
lines.push('');
|
|
550
|
-
lines.push(' // Emit domain event
|
|
551
|
-
lines.push(
|
|
552
|
-
lines.push(
|
|
553
|
-
lines.push(`
|
|
554
|
-
lines.push(`
|
|
555
|
-
lines.push(
|
|
556
|
-
lines.push('
|
|
557
|
-
lines.push('
|
|
558
|
-
lines.push('
|
|
662
|
+
lines.push(' // ADR-086: Emit domain event via eventBus (flows to ObservatoryEventBus)');
|
|
663
|
+
lines.push(' if (this.eventBus) {');
|
|
664
|
+
lines.push(' this.eventBus.publish({');
|
|
665
|
+
lines.push(` eventId: randomUUID(),`);
|
|
666
|
+
lines.push(` eventType: \`${ctxSnake}.\${command}_completed\`,`);
|
|
667
|
+
lines.push(` aggregateType: '${ctxPascal}',`);
|
|
668
|
+
lines.push(' aggregateId: id,');
|
|
669
|
+
lines.push(' payload,');
|
|
670
|
+
lines.push(' timestamp: now,');
|
|
671
|
+
lines.push(` correlationId: '',`);
|
|
672
|
+
lines.push(` causationId: id,`);
|
|
673
|
+
lines.push(' }).catch(() => {}); // fire-and-forget');
|
|
674
|
+
lines.push(' }');
|
|
559
675
|
}
|
|
560
676
|
lines.push('');
|
|
561
677
|
lines.push(` return { id, status: command, created_at: now, updated_at: now, data: payload };`);
|
|
@@ -706,6 +822,10 @@ function tsDependencies(contexts, simulationContext) {
|
|
|
706
822
|
lines.push(' }');
|
|
707
823
|
lines.push('');
|
|
708
824
|
lines.push(' async reject(id: string, rejectedBy: string, reason: string): Promise<DecisionRecord> {');
|
|
825
|
+
lines.push(" // ADR-070: Enforce minimum rejection reason length at domain level");
|
|
826
|
+
lines.push(" if (!reason || reason.trim().length < 10) {");
|
|
827
|
+
lines.push(" throw new Error('Rejection reason must be at least 10 characters for audit trail compliance');");
|
|
828
|
+
lines.push(" }");
|
|
709
829
|
lines.push(" return this.telemetry.withSpan('decisions', 'reject', {}, async () => {");
|
|
710
830
|
lines.push(' const current = await this.getById(id);');
|
|
711
831
|
lines.push(" if (!current) throw new Error('Decision not found');");
|
|
@@ -796,10 +916,26 @@ function tsDependencies(contexts, simulationContext) {
|
|
|
796
916
|
lines.push('');
|
|
797
917
|
// Factory
|
|
798
918
|
lines.push('export function createDependencies(db: DbClient, erp: ErpClient): Dependencies {');
|
|
799
|
-
lines.push('
|
|
919
|
+
lines.push(' // ADR-077: Use Observatory telemetry when configured, Console fallback');
|
|
920
|
+
lines.push(' const telemetry: Telemetry = createTelemetry();');
|
|
921
|
+
lines.push(' // ADR-086: Create event bus for domain event emission');
|
|
922
|
+
lines.push(' const observatoryUrl = process.env.AGENTICS_OBSERVATORY_URL;');
|
|
923
|
+
lines.push(' const eventBus = {');
|
|
924
|
+
lines.push(' async publish(event: unknown): Promise<void> {');
|
|
925
|
+
lines.push(" console.log(JSON.stringify({ type: 'DOMAIN_EVENT', ...(event as Record<string, unknown>) }));");
|
|
926
|
+
lines.push(' if (observatoryUrl) {');
|
|
927
|
+
lines.push(" fetch(`${observatoryUrl}/api/v1/telemetry/events`, {");
|
|
928
|
+
lines.push(" method: 'POST',");
|
|
929
|
+
lines.push(" headers: { 'Content-Type': 'application/json' },");
|
|
930
|
+
lines.push(' body: JSON.stringify(event),');
|
|
931
|
+
lines.push(' signal: AbortSignal.timeout(5_000),');
|
|
932
|
+
lines.push(" }).catch(() => {});");
|
|
933
|
+
lines.push(' }');
|
|
934
|
+
lines.push(' },');
|
|
935
|
+
lines.push(' };');
|
|
800
936
|
lines.push(' return {');
|
|
801
937
|
for (const ctx of contexts) {
|
|
802
|
-
lines.push(` ${camelCase(ctx.name)}: new ${pascalCase(ctx.name)}Adapter(db, telemetry),`);
|
|
938
|
+
lines.push(` ${camelCase(ctx.name)}: new ${pascalCase(ctx.name)}Adapter(db, telemetry, eventBus),`);
|
|
803
939
|
}
|
|
804
940
|
lines.push(' audit: new AuditAdapter(db),');
|
|
805
941
|
lines.push(' decisions: new DecisionAdapter(db, telemetry),');
|
|
@@ -810,6 +946,55 @@ function tsDependencies(contexts, simulationContext) {
|
|
|
810
946
|
lines.push(' };');
|
|
811
947
|
lines.push('}');
|
|
812
948
|
lines.push('');
|
|
949
|
+
lines.push('// ADR-080: Startup platform service registrations (fire-and-forget)');
|
|
950
|
+
lines.push('export async function registerWithPlatform(): Promise<void> {');
|
|
951
|
+
lines.push(' // Registry: register this application');
|
|
952
|
+
lines.push(' callPlatformService(process.env.AGENTICS_REGISTRY_URL, \'/api/v1/register\', {');
|
|
953
|
+
lines.push(' type: \'generated_application\',');
|
|
954
|
+
lines.push(' version: process.env.npm_package_version ?? \'0.1.0\',');
|
|
955
|
+
lines.push(' startedAt: new Date().toISOString(),');
|
|
956
|
+
lines.push(' runtime: \'node\',');
|
|
957
|
+
lines.push(' }, { tier: \'operational\' }).catch(() => {});');
|
|
958
|
+
lines.push('');
|
|
959
|
+
lines.push(' // Config-Manager: validate current configuration');
|
|
960
|
+
lines.push(' callPlatformService(process.env.AGENTICS_CONFIG_URL, \'/api/v1/validate\', {');
|
|
961
|
+
lines.push(' environment: process.env.NODE_ENV ?? \'development\',');
|
|
962
|
+
lines.push(' requiredVars: [\'PORT\', \'DATABASE_URL\'],');
|
|
963
|
+
lines.push(' }, { tier: \'operational\' }).catch(() => {});');
|
|
964
|
+
lines.push('');
|
|
965
|
+
lines.push(' // Schema-Registry: register API schemas');
|
|
966
|
+
lines.push(' callPlatformService(process.env.AGENTICS_SCHEMA_URL, \'/api/v1/register\', {');
|
|
967
|
+
lines.push(' schemas: [{ name: \'api-routes\', version: \'1.0.0\' }],');
|
|
968
|
+
lines.push(' }, { tier: \'operational\' }).catch(() => {});');
|
|
969
|
+
lines.push('}');
|
|
970
|
+
lines.push('');
|
|
971
|
+
lines.push('// ADR-083: Seed simulation runs so validateLineage() works from the first request');
|
|
972
|
+
lines.push('export async function seedSimulationData(db: DbClient): Promise<void> {');
|
|
973
|
+
lines.push(' try {');
|
|
974
|
+
lines.push(' // Create simulation_runs table if not exists');
|
|
975
|
+
lines.push(" await db.execute(`CREATE TABLE IF NOT EXISTS simulation_runs (");
|
|
976
|
+
lines.push(" id TEXT PRIMARY KEY,");
|
|
977
|
+
lines.push(" input_hash TEXT NOT NULL,");
|
|
978
|
+
lines.push(" parameters TEXT NOT NULL,");
|
|
979
|
+
lines.push(" status TEXT NOT NULL DEFAULT 'completed',");
|
|
980
|
+
lines.push(" result_summary TEXT,");
|
|
981
|
+
lines.push(" created_at TEXT NOT NULL");
|
|
982
|
+
lines.push(" )`, []);");
|
|
983
|
+
lines.push(' // Check if seed data already exists');
|
|
984
|
+
lines.push(" const existing = await db.query('SELECT id FROM simulation_runs WHERE id = :1', ['seed-simulation-001']);");
|
|
985
|
+
lines.push(' if (existing.length > 0) return; // Already seeded');
|
|
986
|
+
lines.push(' const seedParams = { scenarios: [\'baseline\'], source: \'seed\' };');
|
|
987
|
+
lines.push(' const seedHash = hashSimulationInputs(seedParams as Record<string, unknown>);');
|
|
988
|
+
lines.push(" await db.execute(");
|
|
989
|
+
lines.push(" 'INSERT INTO simulation_runs (id, input_hash, parameters, status, created_at) VALUES (:1, :2, :3, :4, :5)',");
|
|
990
|
+
lines.push(" ['seed-simulation-001', seedHash, JSON.stringify(seedParams), 'completed', new Date().toISOString()],");
|
|
991
|
+
lines.push(' );');
|
|
992
|
+
lines.push(" console.info(JSON.stringify({ level: 'info', type: 'SEED_SIMULATION', message: 'Seeded simulation run for lineage validation' }));");
|
|
993
|
+
lines.push(' } catch (err) {');
|
|
994
|
+
lines.push(" console.warn(JSON.stringify({ level: 'warn', type: 'SEED_SIMULATION_FAILED', error: err instanceof Error ? err.message : String(err) }));");
|
|
995
|
+
lines.push(' }');
|
|
996
|
+
lines.push('}');
|
|
997
|
+
lines.push('');
|
|
813
998
|
return lines.join('\n');
|
|
814
999
|
}
|
|
815
1000
|
/**
|
|
@@ -869,7 +1054,7 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
|
|
|
869
1054
|
`import * as crypto from 'node:crypto';`,
|
|
870
1055
|
`import { correlationMiddleware, iamMiddleware, loggingMiddleware } from './middleware.js';`,
|
|
871
1056
|
`import { healthRouter } from './health.js';`,
|
|
872
|
-
`import { createDependencies, validateLineage, hashSimulationInputs } from './dependencies.js';`,
|
|
1057
|
+
`import { createDependencies, validateLineage, hashSimulationInputs, callPlatformService, seedSimulationData } from './dependencies.js';`,
|
|
873
1058
|
...(hasRustOptimizer ? [`import { runOptimizer } from '../services/optimizer-bridge.js';`] : []),
|
|
874
1059
|
`import type { Dependencies } from './dependencies.js';`,
|
|
875
1060
|
`import type { DbClient, ErpClient } from '../infra/clients.js';`,
|
|
@@ -904,6 +1089,29 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
|
|
|
904
1089
|
' return { error: { code: \'FORBIDDEN\', message: `Requires role: ${requiredRoles.join(" | ")}` }, correlationId };',
|
|
905
1090
|
'}',
|
|
906
1091
|
'',
|
|
1092
|
+
'// ADR-086: Type-safe context accessors (no unsafe c.get() casts)',
|
|
1093
|
+
'function getCorrelationId(c: { get: (key: string) => unknown }): string {',
|
|
1094
|
+
' const id = c.get(\'correlationId\');',
|
|
1095
|
+
' if (typeof id !== \'string\' || !id) return \'unknown\';',
|
|
1096
|
+
' return id;',
|
|
1097
|
+
'}',
|
|
1098
|
+
'',
|
|
1099
|
+
'function getIamClaims(c: { get: (key: string) => unknown }): Record<string, unknown> {',
|
|
1100
|
+
' const claims = c.get(\'iamClaims\');',
|
|
1101
|
+
' if (!claims || typeof claims !== \'object\') return {};',
|
|
1102
|
+
' return claims as Record<string, unknown>;',
|
|
1103
|
+
'}',
|
|
1104
|
+
'',
|
|
1105
|
+
'function getActor(c: { get: (key: string) => unknown }): string {',
|
|
1106
|
+
' return String(getIamClaims(c)[\'sub\'] ?? \'unknown\');',
|
|
1107
|
+
'}',
|
|
1108
|
+
'',
|
|
1109
|
+
'function getActorRoles(c: { get: (key: string) => unknown }): string[] {',
|
|
1110
|
+
' const claims = getIamClaims(c);',
|
|
1111
|
+
' const roles = claims[\'roles\'] ?? claims[\'custom:roles\'] ?? [];',
|
|
1112
|
+
' return Array.isArray(roles) ? roles as string[] : [];',
|
|
1113
|
+
'}',
|
|
1114
|
+
'',
|
|
907
1115
|
'// ============================================================================',
|
|
908
1116
|
'// RBAC Middleware (ADR-049, ADR-061)',
|
|
909
1117
|
'// ============================================================================',
|
|
@@ -994,6 +1202,9 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
|
|
|
994
1202
|
'',
|
|
995
1203
|
'const deps: Dependencies = createDependencies(dbClient, erpClient);',
|
|
996
1204
|
'',
|
|
1205
|
+
'// ADR-083: Seed simulation data on startup (non-blocking)',
|
|
1206
|
+
'seedSimulationData(dbClient).catch(() => {});',
|
|
1207
|
+
'',
|
|
997
1208
|
`// ${erpMeta.className} ACL client with governance enforcement`,
|
|
998
1209
|
'const governanceConfig: GovernanceConfig = {',
|
|
999
1210
|
' read_only: (process.env.ERP_READ_ONLY ?? \'true\') === \'true\',',
|
|
@@ -1023,8 +1234,68 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
|
|
|
1023
1234
|
'app.use(\'*\', iamMiddleware);',
|
|
1024
1235
|
'app.use(\'*\', loggingMiddleware);',
|
|
1025
1236
|
'',
|
|
1237
|
+
'// ADR-075: CostOps request-level tracking middleware (fire-and-forget)',
|
|
1238
|
+
'app.use(\'*\', async (c, next) => {',
|
|
1239
|
+
' const start = Date.now();',
|
|
1240
|
+
' await next();',
|
|
1241
|
+
' // Non-blocking — should-have tier, never delays responses',
|
|
1242
|
+
' callPlatformService(process.env.AGENTICS_COSTOPS_URL, \'/api/v1/track\', {',
|
|
1243
|
+
' operation: \'api_request\',',
|
|
1244
|
+
' domain: c.req.path.split(\'/\')[3] ?? \'unknown\',',
|
|
1245
|
+
' metadata: {',
|
|
1246
|
+
' method: c.req.method,',
|
|
1247
|
+
' path: c.req.path,',
|
|
1248
|
+
' status: c.res.status,',
|
|
1249
|
+
' durationMs: Date.now() - start,',
|
|
1250
|
+
' },',
|
|
1251
|
+
' }, { tier: \'should-have\', correlationId: c.get(\'correlationId\') as string }).catch(() => {});',
|
|
1252
|
+
'});',
|
|
1253
|
+
'',
|
|
1026
1254
|
'app.route(\'/health\', healthRouter);',
|
|
1027
1255
|
'',
|
|
1256
|
+
'// ADR-076: Shield inbound security scanning middleware (must-have — blocks if unavailable)',
|
|
1257
|
+
'app.use(\'/api/v1/*\', async (c, next) => {',
|
|
1258
|
+
' if ([\'POST\', \'PUT\', \'PATCH\'].includes(c.req.method)) {',
|
|
1259
|
+
' const correlationId = c.get(\'correlationId\') as string ?? \'unknown\';',
|
|
1260
|
+
' try {',
|
|
1261
|
+
' const bodyText = await c.req.text();',
|
|
1262
|
+
' const scanResult = await callPlatformService(',
|
|
1263
|
+
' process.env.AGENTICS_SHIELD_URL,',
|
|
1264
|
+
' \'/api/v1/scan\',',
|
|
1265
|
+
' {',
|
|
1266
|
+
' content: bodyText,',
|
|
1267
|
+
' checks: [\'prompt_injection\', \'pii\', \'secrets\', \'credential_exposure\'],',
|
|
1268
|
+
' context: { endpoint: c.req.path, method: c.req.method },',
|
|
1269
|
+
' },',
|
|
1270
|
+
' { tier: \'must-have\', correlationId },',
|
|
1271
|
+
' ) as { blocked?: boolean; findings?: Array<{ type: string }>; redacted?: boolean; redactedContent?: unknown } | null;',
|
|
1272
|
+
'',
|
|
1273
|
+
' if (scanResult?.blocked) {',
|
|
1274
|
+
' console.warn(JSON.stringify({',
|
|
1275
|
+
' level: \'warn\', type: \'SHIELD_BLOCKED\', correlationId,',
|
|
1276
|
+
' findings: scanResult.findings?.map(f => f.type),',
|
|
1277
|
+
' path: c.req.path,',
|
|
1278
|
+
' }));',
|
|
1279
|
+
' return c.json({',
|
|
1280
|
+
' error: { code: \'SECURITY_BLOCKED\', message: \'Request blocked by security policy\', findings: scanResult.findings?.map(f => f.type) },',
|
|
1281
|
+
' correlationId,',
|
|
1282
|
+
' }, 422);',
|
|
1283
|
+
' }',
|
|
1284
|
+
' } catch (shieldErr) {',
|
|
1285
|
+
' // ADR-076: Shield is must-have — if unavailable, block the request',
|
|
1286
|
+
' console.error(JSON.stringify({',
|
|
1287
|
+
' level: \'error\', type: \'SHIELD_UNAVAILABLE\', correlationId,',
|
|
1288
|
+
' error: shieldErr instanceof Error ? shieldErr.message : String(shieldErr),',
|
|
1289
|
+
' }));',
|
|
1290
|
+
' return c.json({',
|
|
1291
|
+
' error: { code: \'SERVICE_UNAVAILABLE\', message: \'Security scanning service unavailable — cannot process unscanned input\' },',
|
|
1292
|
+
' correlationId,',
|
|
1293
|
+
' }, 503);',
|
|
1294
|
+
' }',
|
|
1295
|
+
' }',
|
|
1296
|
+
' await next();',
|
|
1297
|
+
'});',
|
|
1298
|
+
'',
|
|
1028
1299
|
];
|
|
1029
1300
|
let routeCount = 0;
|
|
1030
1301
|
// Generate one POST per command, one GET per query, grouped by bounded context
|
|
@@ -1055,23 +1326,23 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
|
|
|
1055
1326
|
}
|
|
1056
1327
|
// ADR-062: Simulation execution endpoint (when Rust optimizer present)
|
|
1057
1328
|
if (hasRustOptimizer) {
|
|
1058
|
-
lines.push('// ============================================================================', '// POST /api/v1/simulate — Invoke Rust optimizer engine (ADR-062)', '// ============================================================================', '', "app.post('/api/v1/simulate', requireRole('writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const startTime = Date.now();', ' try {', ' const body = await c.req.json();', ' const parsed = SimulateInputSchema.safeParse(body);', ' if (!parsed.success) {', ' return c.json(validationError(correlationId, parsed.error.issues), 400);', ' }', '', ' // Invoke Rust optimizer via subprocess bridge', ' const rawResult = await runOptimizer(parsed.data as any);', '', ' // ADR-064: Validate optimizer output schema', ' const OptimizerOutputSchema = z.object({', ' scenarios: z.array(z.object({', ' strategy_id: z.string(),', ' strategy_name: z.string(),', ' emissions_reduction_pct: z.number(),', ' cost_delta: z.number(),', ' risk_level: z.string(),', ' lead_time_impact_days: z.number(),', ' reliability_score: z.number(),', ' })),', ' pareto_frontier: z.array(z.string()),', ' recommended: z.string().nullable(),', ' weights_used: z.object({ cost: z.number(), emissions: z.number(), resilience: z.number(), complexity: z.number() }),', ' });', ' const validated = OptimizerOutputSchema.safeParse(rawResult);', " if (!validated.success) throw new Error(`Optimizer output validation failed: ${validated.error.message}`);", ' const result = validated.data;', '', ' // Compute input hash for lineage tracking', ' const inputHash = hashSimulationInputs(parsed.data as Record<string, unknown>);', '', ' // Audit the simulation run', ' await deps.audit.log(\'simulation\', correlationId, \'simulate:completed\', actor, {', ' inputHash,', ' scenarioCount: result.scenarios?.length ?? 0,', ' recommended: result.recommended,', ' responseTimeMs:
|
|
1329
|
+
lines.push('// ============================================================================', '// POST /api/v1/simulate — Invoke Rust optimizer engine (ADR-062)', '// ============================================================================', '', "app.post('/api/v1/simulate', requireRole('writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const startTime = Date.now();', ' try {', ' const body = await c.req.json();', ' const parsed = SimulateInputSchema.safeParse(body);', ' if (!parsed.success) {', ' return c.json(validationError(correlationId, parsed.error.issues), 400);', ' }', '', ' // Invoke Rust optimizer via subprocess bridge', ' const rawResult = await runOptimizer(parsed.data as any);', '', ' // ADR-064: Validate optimizer output schema', ' const OptimizerOutputSchema = z.object({', ' scenarios: z.array(z.object({', ' strategy_id: z.string(),', ' strategy_name: z.string(),', ' emissions_reduction_pct: z.number(),', ' cost_delta: z.number(),', ' risk_level: z.string(),', ' lead_time_impact_days: z.number(),', ' reliability_score: z.number(),', ' })),', ' pareto_frontier: z.array(z.string()),', ' recommended: z.string().nullable(),', ' weights_used: z.object({ cost: z.number(), emissions: z.number(), resilience: z.number(), complexity: z.number() }),', ' });', ' const validated = OptimizerOutputSchema.safeParse(rawResult);', " if (!validated.success) throw new Error(`Optimizer output validation failed: ${validated.error.message}`);", ' const result = validated.data;', '', ' // Compute input hash for lineage tracking', ' const inputHash = hashSimulationInputs(parsed.data as Record<string, unknown>);', '', ' // Audit the simulation run', ' const durationMs = Date.now() - startTime;', ' await deps.audit.log(\'simulation\', correlationId, \'simulate:completed\', actor, {', ' inputHash,', ' scenarioCount: result.scenarios?.length ?? 0,', ' recommended: result.recommended,', ' responseTimeMs: durationMs,', ' });', '', ' // ADR-075: Track simulation cost via CostOps (fire-and-forget)', ' callPlatformService(process.env.AGENTICS_COSTOPS_URL, \'/api/v1/track\', {', ' operation: \'scenario_simulation\',', ' domain: c.req.path.split(\'/\')[3] ?? \'unknown\',', ' metadata: {', ' scenarioCount: result.scenarios?.length ?? 0,', ' paretoFrontierSize: result.pareto_frontier?.length ?? 0,', ' durationMs,', ' inputHash,', ' actor,', ' },', ' }, { tier: \'should-have\', correlationId }).catch(() => {});', '', ' // ADR-077: Sentinel anomaly detection on simulation outputs (non-blocking)', ' let warnings: Array<{ type: string; message: string; severity: string }> = [];', ' try {', ' const anomalyCheck = await callPlatformService(', ' process.env.AGENTICS_SENTINEL_URL,', ' \'/api/v1/check\',', ' {', ' type: \'simulation_output\',', ' data: {', ' costDeltas: result.scenarios?.map((s: { cost_delta: number }) => s.cost_delta) ?? [],', ' emissionsReductions: result.scenarios?.map((s: { emissions_reduction_pct: number }) => s.emissions_reduction_pct) ?? [],', ' reliabilityScores: result.scenarios?.map((s: { reliability_score: number }) => s.reliability_score) ?? [],', ' },', ' context: { scenarioCount: result.scenarios?.length ?? 0, runId: correlationId },', ' },', ' { tier: \'operational\', correlationId },', ' ) as { anomalies?: Array<{ description: string; severity: string }> } | null;', ' if (anomalyCheck?.anomalies?.length) {', ' warnings = anomalyCheck.anomalies.map(a => ({ type: \'anomaly_detected\', message: a.description, severity: a.severity }));', ' }', ' } catch { /* operational tier — Sentinel unavailable */ }', '', ' // ADR-079: Persist simulation lineage to Memory-Graph', ' callPlatformService(process.env.AGENTICS_MEMORY_GRAPH_URL, \'/api/v1/decisions/store\', {', ' type: \'simulation_run\',', ' runId: correlationId,', ' inputHash,', ' inputs: parsed.data,', ' outputs: {', ' scenarioCount: result.scenarios?.length ?? 0,', ' paretoFrontier: result.pareto_frontier,', ' recommended: result.recommended,', ' },', ' lineage: { triggerSource: \'api_request\', actor },', ' }, { tier: \'should-have\', correlationId }).catch(() => {});', '', ' // ADR-081: Index results for search (results-index)', ' callPlatformService(process.env.AGENTICS_RESULTS_INDEX_URL, \'/api/v1/index\', {', ' type: \'simulation_result\', id: correlationId, data: result,', ' searchableFields: [\'scenarios\', \'pareto_frontier\', \'recommended\'],', ' }, { tier: \'operational\', correlationId }).catch(() => {});', '', ' // ADR-081: Record usage in immutable ledger', ' callPlatformService(process.env.AGENTICS_USAGE_LEDGER_URL, \'/api/v1/record\', {', ' operation: \'simulation_run\', actor,', ' resources: { computeMs: durationMs, scenarioCount: result.scenarios?.length ?? 0 },', ' }, { tier: \'operational\', correlationId }).catch(() => {});', '', ' // ADR-081: Apex Platform — risk score for decision-grade output', ' let riskScore: unknown = null;', ' try {', ' riskScore = await callPlatformService(process.env.AGENTICS_PLATFORM_URL, \'/api/v1/risk-score\', {', ' scenarios: result.scenarios, paretoFrontier: result.pareto_frontier,', ' }, { tier: \'operational\', correlationId });', ' } catch { /* operational tier */ }', '', ' return c.json({', ' correlationId,', " status: 'completed',", ' data: result,', ' ...(warnings.length > 0 ? { warnings } : {}),', ' ...(riskScore ? { riskScore } : {}),', ' lineage: { inputHash, simulationId: correlationId },', ' metadata: { response_time_ms: Date.now() - startTime },', ' }, 200);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined, path: c.req.path }));', ' await deps.audit.log(\'simulation\', correlationId, \'simulate:failed\', actor, { error: err instanceof Error ? err.message : String(err) });', ' // ADR-079: Escalate simulation failure to Incident-Manager', ' callPlatformService(process.env.AGENTICS_INCIDENT_URL, \'/api/v1/escalate\', {', ' severity: \'medium\', type: \'simulation_failed\',', ' context: { correlationId, actor, error: err instanceof Error ? err.message : String(err) },', ' }, { tier: \'operational\', correlationId }).catch(() => {});', " const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : (err instanceof Error ? err.message : 'Unknown error');", ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '');
|
|
1059
1330
|
routeCount++;
|
|
1060
1331
|
}
|
|
1061
1332
|
// ADR-054: Decision approval workflow endpoints (always generated)
|
|
1062
|
-
lines.push('// ============================================================================', '// Decision Approval Workflow (ADR-054) — human-in-the-loop governance', '// ============================================================================', '', "app.post('/api/v1/decisions', requireRole('writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' try {', ' const body = await c.req.json();', ' const { simulationRunId, strategyId, recommendedAction, justification } = body as Record<string, string>;', ' if (!simulationRunId || !strategyId || !recommendedAction || !justification) {', ' return c.json(validationError(correlationId, [{ message: \'Required: simulationRunId, strategyId, recommendedAction, justification\' }]), 400);', ' }', ' const result = await deps.decisions.submit(simulationRunId, strategyId, recommendedAction, justification, actor);', ' await deps.audit.log(\'decisions\', result.id, \'submitted\', actor, { simulationRunId, strategyId });', ' return c.json({ correlationId, status: \'created\', data: result }, 201);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), path: c.req.path }));', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.post('/api/v1/decisions/:id/approve', requireRole('approver', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const id = c.req.param(\'id\');', ' try {', ' const result = await deps.decisions.approve(id, actor);', ' await deps.audit.log(\'decisions\', id, \'approved\', actor, { approvedBy: actor });', ' return c.json({ correlationId, status: \'approved\', data: result }, 200);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), path: c.req.path }));', ' await deps.audit.log(\'decisions\', id, \'approve:failed\', actor, { error: err instanceof Error ? err.message : String(err) });', ' const message = err instanceof Error ? err.message : \'Unknown error\';', ' const status = message.includes(\'not found\') ? 404 : message.includes(\'transition\') || message.includes(\'Self-approval\') ? 422 : 500;', ' return c.json({ error: { code: status === 404 ? \'NOT_FOUND\' : status === 422 ? \'INVALID_TRANSITION\' : \'INTERNAL_ERROR\', message }, correlationId }, status);', ' }', '});', '', "app.post('/api/v1/decisions/:id/reject', requireRole('approver', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const id = c.req.param(\'id\');', ' try {', ' const body = await c.req.json();', ' const reason = (body as Record<string, string>).reason;', ' if (!reason || reason.length < 10) return c.json(validationError(correlationId, [{ message: \'Rejection reason required (min 10 chars)\' }]), 400);', ' const result = await deps.decisions.reject(id, actor, reason);', ' await deps.audit.log(\'decisions\', id, \'rejected\', actor, { rejectedBy: actor, reason });', ' return c.json({ correlationId, status: \'rejected\', data: result }, 200);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), path: c.req.path }));', ' const message = err instanceof Error ? err.message : \'Unknown error\';', ' return c.json(serverError(correlationId, message), message.includes(\'not found\') ? 404 : 500);', ' }', '});', '', "app.post('/api/v1/decisions/:id/execute', requireRole('erp_writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const id = c.req.param(\'id\');', ' try {', ' // ADR-054: Validate simulation lineage before ERP writeback', ' const lineageResult = await validateLineage(id, deps as unknown as { query: DbClient[\'query\'] });', ' if (!lineageResult.valid) {', ' await deps.audit.log(\'decisions\', id, \'execute:lineage_failed\', actor, { reason: lineageResult.reason });', ' return c.json({ error: { code: \'LINEAGE_INVALID\', message: lineageResult.reason }, correlationId }, 422);', ' }', ' const result = await deps.decisions.execute(id, actor);', ' await deps.audit.log(\'decisions\', id, \'executed\', actor, { executedBy: actor });', ' return c.json({ correlationId, status: \'executed\', data: result }, 200);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), path: c.req.path }));', ' await deps.audit.log(\'decisions\', id, \'execute:failed\', actor, { error: err instanceof Error ? err.message : String(err) });', ' const message = err instanceof Error ? err.message : \'Unknown error\';', ' return c.json(serverError(correlationId, message), message.includes(\'not found\') ? 404 : 500);', ' }', '});', '', "app.get('/api/v1/decisions/:id', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const id = c.req.param(\'id\');', ' try {', ' const decision = await deps.decisions.getById(id);', ' if (!decision) return c.json({ error: { code: \'NOT_FOUND\', message: `Decision ${id} not found` }, correlationId }, 404);', ' return c.json({ correlationId, status: \'ok\', data: decision }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/decisions', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', " const status = c.req.query('status') as import('./dependencies.js').DecisionStatus | undefined;", ' try {', ' const decisions = status ? await deps.decisions.listByStatus(status) : await deps.decisions.listByStatus(\'submitted\');', ' return c.json({ correlationId, status: \'ok\', data: decisions }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/decisions/:id/lineage', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const id = c.req.param(\'id\');', ' try {', ' const decision = await deps.decisions.getById(id);', ' if (!decision) return c.json({ error: { code: \'NOT_FOUND\', message: `Decision ${id} not found` }, correlationId }, 404);', ' const lineage = await validateLineage(id, deps as unknown as { query: DbClient[\'query\'] });', ' return c.json({ correlationId, status: \'ok\', data: { decision, lineage } }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '');
|
|
1333
|
+
lines.push('// ============================================================================', '// Decision Approval Workflow (ADR-054) — human-in-the-loop governance', '// ============================================================================', '', "app.post('/api/v1/decisions', requireRole('writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const actorRoles = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'roles\'] as string[] ?? [];', ' try {', ' const body = await c.req.json();', ' const { simulationRunId, strategyId, recommendedAction, justification, costImpactPct } = body as Record<string, string>;', ' if (!simulationRunId || !strategyId || !recommendedAction || !justification) {', ' return c.json(validationError(correlationId, [{ message: \'Required: simulationRunId, strategyId, recommendedAction, justification\' }]), 400);', ' }', '', ' // ADR-082: Enforce cost threshold — high-impact decisions require CFO role', ' const costPct = parseFloat(String(costImpactPct ?? \'0\'));', ' const costThreshold = parseFloat(process.env.GOVERNANCE_COST_THRESHOLD_PCT ?? \'5\');', ' if (Math.abs(costPct) > costThreshold && !actorRoles.includes(\'cfo\') && !actorRoles.includes(\'admin\')) {', ' await deps.audit.log(\'decisions\', correlationId, \'submit:cost_threshold_exceeded\', actor, { costImpactPct: costPct, threshold: costThreshold });', ' return c.json({', ' error: { code: \'COST_THRESHOLD_EXCEEDED\', message: `Cost impact ${costPct}% exceeds ${costThreshold}% threshold — requires CFO approval` },', ' correlationId,', ' }, 422);', ' }', '', ' // ADR-082: Validate simulation lineage at creation (not just execution)', ' const lineageResult = await validateLineage(simulationRunId, deps as unknown as { query: DbClient[\'query\'] });', ' if (!lineageResult.valid) {', ' await deps.audit.log(\'decisions\', correlationId, \'submit:lineage_failed\', actor, { simulationRunId, reason: lineageResult.reason });', ' return c.json({ error: { code: \'LINEAGE_INVALID\', message: lineageResult.reason }, correlationId }, 422);', ' }', '', ' const result = await deps.decisions.submit(simulationRunId, strategyId, recommendedAction, justification, actor);', ' await deps.audit.log(\'decisions\', result.id, \'submitted\', actor, { simulationRunId, strategyId, costImpactPct: costPct });', ' return c.json({ correlationId, status: \'created\', data: result }, 201);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), path: c.req.path }));', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.post('/api/v1/decisions/:id/approve', requireRole('approver', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const id = c.req.param(\'id\');', ' try {', ' // ADR-070: Cross-validate simulation lineage before approval', ' const lineageResult = await validateLineage(id, deps as unknown as { query: DbClient[\'query\'] });', ' if (!lineageResult.valid) {', ' await deps.audit.log(\'decisions\', id, \'approve:lineage_failed\', actor, { reason: lineageResult.reason });', ' return c.json({ error: { code: \'LINEAGE_INVALID\', message: lineageResult.reason }, correlationId }, 422);', ' }', ' const result = await deps.decisions.approve(id, actor);', ' await deps.audit.log(\'decisions\', id, \'approved\', actor, { approvedBy: actor });', ' // ADR-075: Track approval cost via CostOps', ' callPlatformService(process.env.AGENTICS_COSTOPS_URL, \'/api/v1/track\', {', ' operation: \'decision_approved\',', ' metadata: { decisionId: id, actor },', ' }, { tier: \'should-have\', correlationId }).catch(() => {});', ' // ADR-079: Persist decision state change to Memory-Graph', ' callPlatformService(process.env.AGENTICS_MEMORY_GRAPH_URL, \'/api/v1/decisions/store\', {', ' type: \'decision_approved\', decisionId: id, actor, timestamp: new Date().toISOString(),', ' }, { tier: \'should-have\', correlationId }).catch(() => {});', ' return c.json({ correlationId, status: \'approved\', data: result }, 200);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), path: c.req.path }));', ' await deps.audit.log(\'decisions\', id, \'approve:failed\', actor, { error: err instanceof Error ? err.message : String(err) });', ' const message = err instanceof Error ? err.message : \'Unknown error\';', ' const status = message.includes(\'not found\') ? 404 : message.includes(\'transition\') || message.includes(\'Self-approval\') || message.includes(\'LINEAGE\') ? 422 : 500;', ' return c.json({ error: { code: status === 404 ? \'NOT_FOUND\' : status === 422 ? \'INVALID_TRANSITION\' : \'INTERNAL_ERROR\', message }, correlationId }, status);', ' }', '});', '', "app.post('/api/v1/decisions/:id/reject', requireRole('approver', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const id = c.req.param(\'id\');', ' try {', ' const body = await c.req.json();', ' const reason = (body as Record<string, string>).reason;', ' if (!reason || reason.length < 10) return c.json(validationError(correlationId, [{ message: \'Rejection reason required (min 10 chars)\' }]), 400);', ' const result = await deps.decisions.reject(id, actor, reason);', ' await deps.audit.log(\'decisions\', id, \'rejected\', actor, { rejectedBy: actor, reason });', ' return c.json({ correlationId, status: \'rejected\', data: result }, 200);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), path: c.req.path }));', ' const message = err instanceof Error ? err.message : \'Unknown error\';', ' return c.json(serverError(correlationId, message), message.includes(\'not found\') ? 404 : 500);', ' }', '});', '', "app.post('/api/v1/decisions/:id/execute', requireRole('erp_writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const id = c.req.param(\'id\');', ' try {', ' // ADR-054: Validate simulation lineage before ERP writeback', ' const lineageResult = await validateLineage(id, deps as unknown as { query: DbClient[\'query\'] });', ' if (!lineageResult.valid) {', ' await deps.audit.log(\'decisions\', id, \'execute:lineage_failed\', actor, { reason: lineageResult.reason });', ' return c.json({ error: { code: \'LINEAGE_INVALID\', message: lineageResult.reason }, correlationId }, 422);', ' }', ' const result = await deps.decisions.execute(id, actor);', ' await deps.audit.log(\'decisions\', id, \'executed\', actor, { executedBy: actor });', ' // ADR-075: Track ERP writeback cost via CostOps', ' callPlatformService(process.env.AGENTICS_COSTOPS_URL, \'/api/v1/track\', {', ' operation: \'erp_writeback\',', ' metadata: { decisionId: id, actor },', ' }, { tier: \'should-have\', correlationId }).catch(() => {});', ' // ADR-079: Persist execution to Memory-Graph lineage', ' callPlatformService(process.env.AGENTICS_MEMORY_GRAPH_URL, \'/api/v1/decisions/store\', {', ' type: \'decision_executed\', decisionId: id, actor, timestamp: new Date().toISOString(),', ' }, { tier: \'should-have\', correlationId }).catch(() => {});', ' // ADR-081: Record ERP writeback in usage ledger', ' callPlatformService(process.env.AGENTICS_USAGE_LEDGER_URL, \'/api/v1/record\', {', ' operation: \'erp_writeback\', actor, resources: { decisionId: id },', ' }, { tier: \'operational\', correlationId }).catch(() => {});', ' return c.json({ correlationId, status: \'executed\', data: result }, 200);', ' } catch (err) {', ' console.error(JSON.stringify({ level: \'error\', correlationId, error: err instanceof Error ? err.message : String(err), path: c.req.path }));', ' await deps.audit.log(\'decisions\', id, \'execute:failed\', actor, { error: err instanceof Error ? err.message : String(err) });', ' // ADR-079: Escalate ERP writeback failure to Incident-Manager', ' callPlatformService(process.env.AGENTICS_INCIDENT_URL, \'/api/v1/escalate\', {', ' severity: \'high\', type: \'erp_writeback_failed\',', ' context: { decisionId: id, actor, error: err instanceof Error ? err.message : String(err) },', ' }, { tier: \'operational\', correlationId }).catch(() => {});', ' const message = err instanceof Error ? err.message : \'Unknown error\';', ' return c.json(serverError(correlationId, message), message.includes(\'not found\') ? 404 : 500);', ' }', '});', '', "app.get('/api/v1/decisions/:id', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const id = c.req.param(\'id\');', ' try {', ' const decision = await deps.decisions.getById(id);', ' if (!decision) return c.json({ error: { code: \'NOT_FOUND\', message: `Decision ${id} not found` }, correlationId }, 404);', ' // ADR-070: Audit read access for compliance (SOX, GDPR Article 30)', ' await deps.audit.log(\'decisions\', id, \'read\', actor, { correlationId });', ' // ADR-080: Data-Vault anonymization for viewer-role access', ' const roles = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'roles\'] as string[] ?? [];', ' if (roles.includes(\'viewer\') && !roles.includes(\'writer\') && !roles.includes(\'admin\')) {', ' const vaultResult = await callPlatformService(process.env.AGENTICS_DATAVAULT_URL, \'/api/v1/access\', {', ' data: decision, requester: { roles }, dataClassification: \'confidential\',', ' }, { tier: \'operational\', correlationId }).catch(() => null) as { data?: unknown } | null;', ' if (vaultResult?.data) return c.json({ correlationId, status: \'ok\', data: vaultResult.data }, 200);', ' }', ' return c.json({ correlationId, status: \'ok\', data: decision }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/decisions', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', " const status = c.req.query('status') as import('./dependencies.js').DecisionStatus | undefined;", ' try {', ' const decisions = status ? await deps.decisions.listByStatus(status) : await deps.decisions.listByStatus(\'submitted\');', ' // ADR-070: Audit read access for compliance', ' await deps.audit.log(\'decisions\', correlationId, \'list\', actor, { filter: status ?? \'submitted\', resultCount: decisions.length });', ' return c.json({ correlationId, status: \'ok\', data: decisions }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/decisions/:id/lineage', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const actor = ((c.get(\'iamClaims\') as Record<string, unknown>) ?? {})[\'sub\'] as string ?? \'unknown\';', ' const id = c.req.param(\'id\');', ' try {', ' const decision = await deps.decisions.getById(id);', ' if (!decision) return c.json({ error: { code: \'NOT_FOUND\', message: `Decision ${id} not found` }, correlationId }, 404);', ' const lineage = await validateLineage(id, deps as unknown as { query: DbClient[\'query\'] });', ' // ADR-070: Audit lineage read access for compliance', ' await deps.audit.log(\'decisions\', id, \'read_lineage\', actor, { correlationId, lineageValid: lineage.valid });', ' return c.json({ correlationId, status: \'ok\', data: { decision, lineage } }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '');
|
|
1063
1334
|
routeCount += 7; // submit, approve, reject, execute, getById, list, lineage
|
|
1064
1335
|
// ADR-052: Simulation read-only endpoints (when simulation context available)
|
|
1065
1336
|
if (simulationContext && (simulationContext.scenarios.length > 0 || simulationContext.simulationId)) {
|
|
1066
|
-
lines.push('// ============================================================================', '// Simulation Endpoints (ADR-052) — read-only access to RuVector results', '// ============================================================================', '', "app.get('/api/v1/simulation/scenarios', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' try {', ' const scenarios = await deps.simulation.getScenarios();', ' return c.json({ correlationId, status: \'ok\', data: scenarios, simulationId: deps.simulation.getSimulationId() }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/simulation/scenarios/:id', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const id = c.req.param(\'id\');', ' try {', ' const scenario = await deps.simulation.getScenarioById(id);', ' if (!scenario) return c.json({ error: { code: \'NOT_FOUND\', message: `Scenario ${id} not found` }, correlationId }, 404);', ' return c.json({ correlationId, status: \'ok\', data: scenario }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/simulation/compare', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', " const ids = (c.req.query('ids') ?? '').split(',').filter(Boolean);", ' try {', ' const comparison = await deps.simulation.compareScenarios(ids);', ' return c.json({ correlationId, status: \'ok\', data: comparison }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/simulation/cost-model', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' try {', ' const costModel = await deps.simulation.getCostModel();', ' return c.json({ correlationId, status: \'ok\', data: costModel, simulationId: deps.simulation.getSimulationId() }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '');
|
|
1337
|
+
lines.push('// ============================================================================', '// Simulation Endpoints (ADR-052) — read-only access to RuVector results', '// ============================================================================', '', "app.get('/api/v1/simulation/scenarios', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' try {', ' const scenarios = await deps.simulation.getScenarios();', ' return c.json({ correlationId, status: \'ok\', data: scenarios, simulationId: deps.simulation.getSimulationId() }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/simulation/scenarios/:id', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const id = c.req.param(\'id\');', ' try {', ' const scenario = await deps.simulation.getScenarioById(id);', ' if (!scenario) return c.json({ error: { code: \'NOT_FOUND\', message: `Scenario ${id} not found` }, correlationId }, 404);', ' return c.json({ correlationId, status: \'ok\', data: scenario }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', '// ADR-083: Retrieve original simulation parameters for audit/reproducibility', "app.get('/api/v1/simulation/runs/:id/parameters', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' const id = c.req.param(\'id\');', ' try {', ' const rows = await (deps as any).db?.query?.(\'SELECT id, input_hash, parameters, status, created_at FROM simulation_runs WHERE id = :1\', [id]) ?? [];', ' const run = rows[0] as Record<string, unknown> | undefined;', ' if (!run) return c.json({ error: { code: \'NOT_FOUND\', message: `Simulation run ${id} not found` }, correlationId }, 404);', ' return c.json({', ' correlationId,', ' status: \'ok\',', ' data: {', ' id: String(run[\'id\']),', ' inputHash: String(run[\'input_hash\']),', ' parameters: JSON.parse(String(run[\'parameters\'] ?? \'{}\')),', ' status: String(run[\'status\']),', ' createdAt: String(run[\'created_at\']),', ' },', ' }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/simulation/compare', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', " const ids = (c.req.query('ids') ?? '').split(',').filter(Boolean);", ' try {', ' const comparison = await deps.simulation.compareScenarios(ids);', ' // ADR-080: Analytics-Hub strategic recommendation on scenario comparison', ' let recommendation: unknown = null;', ' try {', ' recommendation = await callPlatformService(process.env.AGENTICS_ANALYTICS_URL, \'/api/v1/recommend\', {', ' domain: \'scenario_comparison\',', ' dataPoints: [\'simulation_results\'],', ' data: comparison,', ' }, { tier: \'operational\', correlationId });', ' } catch { /* operational tier */ }', ' // ADR-081: Apex Platform executive summary for board-level synthesis', ' let executiveSummary: unknown = null;', ' try {', ' executiveSummary = await callPlatformService(process.env.AGENTICS_PLATFORM_URL, \'/api/v1/executive-summary\', {', ' domainResults: comparison, recommendation,', ' }, { tier: \'operational\', correlationId });', ' } catch { /* operational tier */ }', ' // ADR-081: Enterprise ROI calculation', ' let roi: unknown = null;', ' try {', ' roi = await callPlatformService(process.env.AGENTICS_ROI_ENGINE_URL, \'/api/v1/calculate\', {', ' domainResults: comparison, timeHorizon: \'3_years\',', ' }, { tier: \'operational\', correlationId });', ' } catch { /* operational tier */ }', ' return c.json({', ' correlationId, status: \'ok\', data: comparison,', ' ...(recommendation ? { recommendation } : {}),', ' ...(executiveSummary ? { executiveSummary } : {}),', ' ...(roi ? { roi } : {}),', ' }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', "app.get('/api/v1/simulation/cost-model', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' try {', ' const costModel = await deps.simulation.getCostModel();', ' return c.json({ correlationId, status: \'ok\', data: costModel, simulationId: deps.simulation.getSimulationId() }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '');
|
|
1067
1338
|
routeCount += 4;
|
|
1068
1339
|
// ADR-053: Tradeoff analysis and budget compliance endpoints
|
|
1069
1340
|
lines.push('// ============================================================================', '// Cost & Tradeoff Endpoints (ADR-053) — CostOps financial model', '// ============================================================================', '', "app.get('/api/v1/cost/tradeoff-matrix', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' try {', " const matrixPath = require('node:path').join(__dirname, '..', 'data', 'tradeoff-matrix.json');", " const matrix = JSON.parse(require('node:fs').readFileSync(matrixPath, 'utf-8'));", ' return c.json({ correlationId, status: \'ok\', data: matrix }, 200);', ' } catch {', ' return c.json({ correlationId, status: \'ok\', data: { dimensions: [], scenarios: [], costModel: null } }, 200);', ' }', '});', '', "app.get('/api/v1/cost/budget-compliance', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = c.get(\'correlationId\') as string;', ' try {', ' const costModel = await deps.simulation.getCostModel();', ' if (!costModel) return c.json({ correlationId, status: \'ok\', data: { compliant: true, message: \'No cost model available\' } }, 200);', ' const scenarios = await deps.simulation.getScenarios();', ' const overBudget = scenarios.filter(s => costModel.budgetThreshold > 0 && s.costDelta > costModel.budgetThreshold);', ' return c.json({', ' correlationId,', ' status: \'ok\',', ' data: {', ' compliant: overBudget.length === 0,', ' budgetThreshold: costModel.budgetThreshold,', ' roi: costModel.roi,', ' confidence: costModel.confidence,', ' scenariosOverBudget: overBudget.map(s => ({ id: s.id, name: s.name, costDelta: s.costDelta })),', ' totalScenarios: scenarios.length,', ' },', ' }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '');
|
|
1070
1341
|
routeCount += 2;
|
|
1071
1342
|
}
|
|
1072
1343
|
// ERP sync status endpoint (reusable across all ERP types)
|
|
1073
|
-
lines.push('// ============================================================================', '// GET /api/v1/erp/sync-status — ERP sync queue and circuit breaker', '// ============================================================================', '', 'app.get(\'/api/v1/erp/sync-status\', async (c) => {', ' const correlationId = c.get(\'correlationId\') as string;', ' const startTime = Date.now();', ' try {', ' const status = erpClientInstance.getSyncStatus();', ' return c.json({', ' correlationId,', ' status: \'ok\',', ' data: status,', ' metadata: { response_time_ms: Date.now() - startTime },', ' }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', '', '// ============================================================================', '// GET /metrics — Prometheus-compatible metrics (ADR-051)', '// ============================================================================', '', 'app.get(\'/metrics\', (c) => {', ' const m = (deps as Record<string, unknown>)[\'_telemetry\'] as { metrics?: { requestCount: number; errorCount: number; latencySumMs: number; spanCount: number; byOperation: Record<string, { count: number; errors: number; totalMs: number }> } } | undefined;', ' const metrics = m?.metrics ?? { requestCount: 0, errorCount: 0, latencySumMs: 0, spanCount: 0, byOperation: {} };', ' const lines: string[] = [];', ' lines.push(\'# HELP http_requests_total Total HTTP requests processed\');', ' lines.push(\'# TYPE http_requests_total counter\');', ' lines.push(`http_requests_total ${metrics.requestCount}`);', ' lines.push(\'# HELP http_errors_total Total HTTP errors\');', ' lines.push(\'# TYPE http_errors_total counter\');', ' lines.push(`http_errors_total ${metrics.errorCount}`);', ' lines.push(\'# HELP http_spans_total Total telemetry spans\');', ' lines.push(\'# TYPE http_spans_total counter\');', ' lines.push(`http_spans_total ${metrics.spanCount}`);', ' lines.push(\'# HELP http_latency_sum_ms Sum of all span latencies in ms\');', ' lines.push(\'# TYPE http_latency_sum_ms counter\');', ' lines.push(`http_latency_sum_ms ${metrics.latencySumMs}`);', ' for (const [op, data] of Object.entries(metrics.byOperation)) {', ' lines.push(`http_operation_count{operation="${op}"} ${data.count}`);', ' lines.push(`http_operation_errors{operation="${op}"} ${data.errors}`);', ' lines.push(`http_operation_latency_ms{operation="${op}"} ${data.totalMs}`);', ' }', ' return c.text(lines.join(\'\\n\'));', '});', '', 'export { app, erpClientInstance };', '');
|
|
1074
|
-
routeCount +=
|
|
1344
|
+
lines.push('// ============================================================================', '// GET /api/v1/erp/sync-status — ERP sync queue and circuit breaker', '// ============================================================================', '', 'app.get(\'/api/v1/erp/sync-status\', async (c) => {', ' const correlationId = c.get(\'correlationId\') as string;', ' const startTime = Date.now();', ' try {', ' const status = erpClientInstance.getSyncStatus();', ' return c.json({', ' correlationId,', ' status: \'ok\',', ' data: status,', ' metadata: { response_time_ms: Date.now() - startTime },', ' }, 200);', ' } catch (err) {', ' const message = process.env.NODE_ENV === \'production\' ? \'Internal server error\' : (err instanceof Error ? err.message : \'Unknown error\');', ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '', '', '// ============================================================================', '// GET /metrics — Prometheus-compatible metrics (ADR-051)', '// ============================================================================', '', 'app.get(\'/metrics\', (c) => {', ' const m = (deps as Record<string, unknown>)[\'_telemetry\'] as { metrics?: { requestCount: number; errorCount: number; latencySumMs: number; spanCount: number; byOperation: Record<string, { count: number; errors: number; totalMs: number }> } } | undefined;', ' const metrics = m?.metrics ?? { requestCount: 0, errorCount: 0, latencySumMs: 0, spanCount: 0, byOperation: {} };', ' const lines: string[] = [];', ' lines.push(\'# HELP http_requests_total Total HTTP requests processed\');', ' lines.push(\'# TYPE http_requests_total counter\');', ' lines.push(`http_requests_total ${metrics.requestCount}`);', ' lines.push(\'# HELP http_errors_total Total HTTP errors\');', ' lines.push(\'# TYPE http_errors_total counter\');', ' lines.push(`http_errors_total ${metrics.errorCount}`);', ' lines.push(\'# HELP http_spans_total Total telemetry spans\');', ' lines.push(\'# TYPE http_spans_total counter\');', ' lines.push(`http_spans_total ${metrics.spanCount}`);', ' lines.push(\'# HELP http_latency_sum_ms Sum of all span latencies in ms\');', ' lines.push(\'# TYPE http_latency_sum_ms counter\');', ' lines.push(`http_latency_sum_ms ${metrics.latencySumMs}`);', ' for (const [op, data] of Object.entries(metrics.byOperation)) {', ' lines.push(`http_operation_count{operation="${op}"} ${data.count}`);', ' lines.push(`http_operation_errors{operation="${op}"} ${data.errors}`);', ' lines.push(`http_operation_latency_ms{operation="${op}"} ${data.totalMs}`);', ' }', ' // ADR-085: Prometheus content type', ' return c.text(lines.join(\'\\n\'), 200, { \'Content-Type\': \'text/plain; version=0.0.4\' });', '});', '', '// ============================================================================', '// ADR-088: Async simulation job queue + poll endpoint', '// ============================================================================', '', '// In-memory job store (per-instance — adequate for Cloud Run pilot)', 'const simulationJobs = new Map<string, { status: \'pending\' | \'running\' | \'completed\' | \'failed\'; result?: unknown; error?: string; createdAt: string }>();', '', "app.get('/api/v1/simulate/:jobId/status', requireRole('viewer', 'writer', 'admin'), async (c) => {", ' const correlationId = getCorrelationId(c);', ' const jobId = c.req.param(\'jobId\');', ' const job = simulationJobs.get(jobId);', ' if (!job) return c.json({ error: { code: \'NOT_FOUND\', message: `Job ${jobId} not found` }, correlationId }, 404);', ' return c.json({ correlationId, status: job.status, data: job.result ?? null, error: job.error ?? null, createdAt: job.createdAt }, 200);', '});', '', 'export { app, erpClientInstance, simulationJobs };', '');
|
|
1345
|
+
routeCount += 3; // sync-status + metrics + job status
|
|
1075
1346
|
return { content: lines.join('\n'), routeCount };
|
|
1076
1347
|
}
|
|
1077
1348
|
// ============================================================================
|
|
@@ -1166,6 +1437,35 @@ function tsDomainEvents(contexts) {
|
|
|
1166
1437
|
lines.push(' }');
|
|
1167
1438
|
lines.push('}');
|
|
1168
1439
|
lines.push('');
|
|
1440
|
+
lines.push('/** ADR-077: Observatory-backed event bus — sends events to the platform for pattern analysis. */');
|
|
1441
|
+
lines.push('export class ObservatoryEventBus extends ConsoleEventBus {');
|
|
1442
|
+
lines.push(' private readonly observatoryUrl: string;');
|
|
1443
|
+
lines.push('');
|
|
1444
|
+
lines.push(' constructor(observatoryUrl: string) {');
|
|
1445
|
+
lines.push(' super();');
|
|
1446
|
+
lines.push(' this.observatoryUrl = observatoryUrl;');
|
|
1447
|
+
lines.push(' }');
|
|
1448
|
+
lines.push('');
|
|
1449
|
+
lines.push(' override async publish(event: DomainEvent): Promise<void> {');
|
|
1450
|
+
lines.push(' // Local handlers + console logging first');
|
|
1451
|
+
lines.push(' await super.publish(event);');
|
|
1452
|
+
lines.push(' // Fire-and-forget to Observatory for pattern analysis');
|
|
1453
|
+
lines.push(' fetch(`${this.observatoryUrl}/api/v1/telemetry/events`, {');
|
|
1454
|
+
lines.push(' method: \'POST\',');
|
|
1455
|
+
lines.push(' headers: { \'Content-Type\': \'application/json\' },');
|
|
1456
|
+
lines.push(' body: JSON.stringify({');
|
|
1457
|
+
lines.push(' type: event.eventType,');
|
|
1458
|
+
lines.push(' aggregateType: event.aggregateType,');
|
|
1459
|
+
lines.push(' aggregateId: event.aggregateId,');
|
|
1460
|
+
lines.push(' payload: event.payload,');
|
|
1461
|
+
lines.push(' timestamp: event.timestamp,');
|
|
1462
|
+
lines.push(' correlationId: event.correlationId,');
|
|
1463
|
+
lines.push(' }),');
|
|
1464
|
+
lines.push(' signal: AbortSignal.timeout(5_000),');
|
|
1465
|
+
lines.push(" }).catch(() => { /* operational tier — Observatory unavailable */ });");
|
|
1466
|
+
lines.push(' }');
|
|
1467
|
+
lines.push('}');
|
|
1468
|
+
lines.push('');
|
|
1169
1469
|
lines.push('/** Create a domain event with auto-generated ID and timestamp. */');
|
|
1170
1470
|
lines.push('export function createEvent<T>(');
|
|
1171
1471
|
lines.push(' eventType: string,');
|
|
@@ -1566,7 +1866,7 @@ function tsContextIntegrationTest(ctx) {
|
|
|
1566
1866
|
const tableName = `${ctxSnake}_records`;
|
|
1567
1867
|
return [
|
|
1568
1868
|
GENERATED_HEADER_TS,
|
|
1569
|
-
`import { describe, it, expect,
|
|
1869
|
+
`import { describe, it, expect, beforeEach, afterEach } from 'vitest'; // ADR-087: per-test isolation`,
|
|
1570
1870
|
`import Database from 'better-sqlite3';`,
|
|
1571
1871
|
`import { ConsoleTelemetry } from '../infra/telemetry.js';`,
|
|
1572
1872
|
'',
|
|
@@ -1590,8 +1890,37 @@ function tsContextIntegrationTest(ctx) {
|
|
|
1590
1890
|
' action TEXT NOT NULL,',
|
|
1591
1891
|
' actor TEXT NOT NULL,',
|
|
1592
1892
|
' payload TEXT,',
|
|
1893
|
+
' previous_hash TEXT,',
|
|
1894
|
+
' actor_role TEXT,',
|
|
1895
|
+
' source_ip TEXT,',
|
|
1896
|
+
' request_id TEXT,',
|
|
1897
|
+
' created_at TEXT NOT NULL',
|
|
1898
|
+
' )\`);',
|
|
1899
|
+
' // ADR-083: simulation_runs table for lineage validation',
|
|
1900
|
+
` db.exec(\`CREATE TABLE IF NOT EXISTS simulation_runs (`,
|
|
1901
|
+
' id TEXT PRIMARY KEY,',
|
|
1902
|
+
' input_hash TEXT NOT NULL,',
|
|
1903
|
+
' parameters TEXT NOT NULL,',
|
|
1904
|
+
' status TEXT NOT NULL DEFAULT \'completed\',',
|
|
1905
|
+
' result_summary TEXT,',
|
|
1593
1906
|
' created_at TEXT NOT NULL',
|
|
1594
1907
|
' )\`);',
|
|
1908
|
+
' // ADR-083: decisions table for lineage validation',
|
|
1909
|
+
` db.exec(\`CREATE TABLE IF NOT EXISTS decisions (`,
|
|
1910
|
+
' id TEXT PRIMARY KEY,',
|
|
1911
|
+
' simulation_run_id TEXT NOT NULL,',
|
|
1912
|
+
' strategy_id TEXT NOT NULL,',
|
|
1913
|
+
' recommended_action TEXT NOT NULL,',
|
|
1914
|
+
' justification TEXT NOT NULL,',
|
|
1915
|
+
' status TEXT NOT NULL DEFAULT \'submitted\',',
|
|
1916
|
+
' submitted_by TEXT NOT NULL,',
|
|
1917
|
+
' approved_by TEXT,',
|
|
1918
|
+
' rejected_by TEXT,',
|
|
1919
|
+
' rejection_reason TEXT,',
|
|
1920
|
+
' executed_by TEXT,',
|
|
1921
|
+
' created_at TEXT NOT NULL,',
|
|
1922
|
+
' updated_at TEXT NOT NULL',
|
|
1923
|
+
' )\`);',
|
|
1595
1924
|
' return {',
|
|
1596
1925
|
' db,',
|
|
1597
1926
|
' client: {',
|
|
@@ -1614,7 +1943,8 @@ function tsContextIntegrationTest(ctx) {
|
|
|
1614
1943
|
' let testDb: ReturnType<typeof createTestDb>;',
|
|
1615
1944
|
' let adapter: any;',
|
|
1616
1945
|
'',
|
|
1617
|
-
'
|
|
1946
|
+
' // ADR-087: Per-test isolation — fresh database for each test',
|
|
1947
|
+
' beforeEach(async () => {',
|
|
1618
1948
|
' testDb = createTestDb();',
|
|
1619
1949
|
' const telemetry = new ConsoleTelemetry();',
|
|
1620
1950
|
` // Dynamically import the real adapter`,
|
|
@@ -1623,7 +1953,7 @@ function tsContextIntegrationTest(ctx) {
|
|
|
1623
1953
|
` adapter = factory.${camelCase(ctx.name)};`,
|
|
1624
1954
|
' });',
|
|
1625
1955
|
'',
|
|
1626
|
-
'
|
|
1956
|
+
' afterEach(() => { testDb?.db?.close(); });',
|
|
1627
1957
|
'',
|
|
1628
1958
|
` it('execute() inserts a real row into ${tableName}', async () => {`,
|
|
1629
1959
|
` const result = await adapter.execute('CreateTest', { name: 'integration-test' });`,
|
|
@@ -1679,6 +2009,428 @@ function tsContextIntegrationTest(ctx) {
|
|
|
1679
2009
|
].join('\n');
|
|
1680
2010
|
}
|
|
1681
2011
|
// ============================================================================
|
|
2012
|
+
// ADR-087: API Route Test Generator
|
|
2013
|
+
// ============================================================================
|
|
2014
|
+
/**
|
|
2015
|
+
* Generate an API route test file that tests RBAC, validation, correlation IDs,
|
|
2016
|
+
* and error responses at the HTTP layer using Hono's built-in test client.
|
|
2017
|
+
*/
|
|
2018
|
+
function tsApiRouteTest(contexts) {
|
|
2019
|
+
const firstCtx = contexts[0];
|
|
2020
|
+
const ctxSlug = firstCtx ? slugify(firstCtx.name.replace(/Context$/i, '')) : 'default';
|
|
2021
|
+
const firstQuery = firstCtx?.queries[0];
|
|
2022
|
+
const querySlug = firstQuery ? slugify(firstQuery.name) : 'get';
|
|
2023
|
+
return [
|
|
2024
|
+
GENERATED_HEADER_TS,
|
|
2025
|
+
`import { describe, it, expect, beforeEach } from 'vitest';`,
|
|
2026
|
+
`import { app } from '../../server/routes.js';`,
|
|
2027
|
+
'',
|
|
2028
|
+
'// ============================================================================',
|
|
2029
|
+
'// ADR-087: API Route Tests — RBAC, validation, correlation IDs, error format',
|
|
2030
|
+
'// ============================================================================',
|
|
2031
|
+
'',
|
|
2032
|
+
'// Helper: build a test request',
|
|
2033
|
+
'function makeRequest(method: string, path: string, body?: unknown, claims?: Record<string, unknown>): Request {',
|
|
2034
|
+
' const headers: Record<string, string> = { \'Content-Type\': \'application/json\' };',
|
|
2035
|
+
' // Simulate JWT by setting iamClaims directly — in test env, IAM middleware accepts this',
|
|
2036
|
+
' if (claims) {',
|
|
2037
|
+
' headers[\'X-Test-Claims\'] = JSON.stringify(claims);',
|
|
2038
|
+
' }',
|
|
2039
|
+
' return new Request(`http://localhost${path}`, {',
|
|
2040
|
+
' method,',
|
|
2041
|
+
' headers,',
|
|
2042
|
+
' body: body ? JSON.stringify(body) : undefined,',
|
|
2043
|
+
' });',
|
|
2044
|
+
'}',
|
|
2045
|
+
'',
|
|
2046
|
+
'describe(\'API Routes (ADR-087)\', () => {',
|
|
2047
|
+
'',
|
|
2048
|
+
' describe(\'Health endpoints\', () => {',
|
|
2049
|
+
' it(\'GET /health/healthz returns 200\', async () => {',
|
|
2050
|
+
' const res = await app.request(makeRequest(\'GET\', \'/health/healthz\'));',
|
|
2051
|
+
' expect(res.status).toBe(200);',
|
|
2052
|
+
' });',
|
|
2053
|
+
'',
|
|
2054
|
+
' it(\'GET /health/readyz returns 200 or 503\', async () => {',
|
|
2055
|
+
' const res = await app.request(makeRequest(\'GET\', \'/health/readyz\'));',
|
|
2056
|
+
' expect([200, 503]).toContain(res.status);',
|
|
2057
|
+
' });',
|
|
2058
|
+
'',
|
|
2059
|
+
' it(\'returns X-Correlation-ID header\', async () => {',
|
|
2060
|
+
' const res = await app.request(makeRequest(\'GET\', \'/health/healthz\'));',
|
|
2061
|
+
' expect(res.headers.get(\'X-Correlation-ID\')).toBeTruthy();',
|
|
2062
|
+
' });',
|
|
2063
|
+
' });',
|
|
2064
|
+
'',
|
|
2065
|
+
' describe(\'RBAC enforcement\', () => {',
|
|
2066
|
+
' it(\'returns 401 for requests without auth\', async () => {',
|
|
2067
|
+
` const res = await app.request(makeRequest('GET', '/api/v1/${ctxSlug}/${querySlug}'));`,
|
|
2068
|
+
' // Should be 401 (no claims) or 503 (Shield unavailable in test)',
|
|
2069
|
+
' expect([401, 503]).toContain(res.status);',
|
|
2070
|
+
' });',
|
|
2071
|
+
'',
|
|
2072
|
+
' it(\'returns error with correlationId on auth failure\', async () => {',
|
|
2073
|
+
` const res = await app.request(makeRequest('GET', '/api/v1/${ctxSlug}/${querySlug}'));`,
|
|
2074
|
+
' const body = await res.json() as Record<string, unknown>;',
|
|
2075
|
+
' expect(body).toHaveProperty(\'correlationId\');',
|
|
2076
|
+
' });',
|
|
2077
|
+
' });',
|
|
2078
|
+
'',
|
|
2079
|
+
' describe(\'Validation errors\', () => {',
|
|
2080
|
+
' it(\'POST with missing required fields returns 400\', async () => {',
|
|
2081
|
+
' const res = await app.request(makeRequest(\'POST\', \'/api/v1/decisions\', { invalid: true }, { sub: \'user1\', roles: [\'writer\'] }));',
|
|
2082
|
+
' // 400 (validation error) or 503 (Shield) or 422 (cost threshold)',
|
|
2083
|
+
' expect([400, 422, 503]).toContain(res.status);',
|
|
2084
|
+
' });',
|
|
2085
|
+
'',
|
|
2086
|
+
' it(\'error responses include error.code and correlationId\', async () => {',
|
|
2087
|
+
' const res = await app.request(makeRequest(\'POST\', \'/api/v1/decisions\', {}, { sub: \'user1\', roles: [\'writer\'] }));',
|
|
2088
|
+
' const body = await res.json() as Record<string, unknown>;',
|
|
2089
|
+
' expect(body).toHaveProperty(\'correlationId\');',
|
|
2090
|
+
' if (body.error) {',
|
|
2091
|
+
' expect((body.error as Record<string, unknown>)).toHaveProperty(\'code\');',
|
|
2092
|
+
' }',
|
|
2093
|
+
' });',
|
|
2094
|
+
' });',
|
|
2095
|
+
'',
|
|
2096
|
+
' describe(\'/metrics endpoint\', () => {',
|
|
2097
|
+
' it(\'returns Prometheus-format metrics\', async () => {',
|
|
2098
|
+
' const res = await app.request(makeRequest(\'GET\', \'/metrics\'));',
|
|
2099
|
+
' expect(res.status).toBe(200);',
|
|
2100
|
+
' const text = await res.text();',
|
|
2101
|
+
' expect(text).toContain(\'http_requests_total\');',
|
|
2102
|
+
' });',
|
|
2103
|
+
'',
|
|
2104
|
+
' it(\'bypasses auth\', async () => {',
|
|
2105
|
+
' // /metrics should return 200 even without claims',
|
|
2106
|
+
' const res = await app.request(makeRequest(\'GET\', \'/metrics\'));',
|
|
2107
|
+
' expect(res.status).toBe(200);',
|
|
2108
|
+
' });',
|
|
2109
|
+
' });',
|
|
2110
|
+
'',
|
|
2111
|
+
' describe(\'decision governance (ADR-082)\', () => {',
|
|
2112
|
+
' it(\'POST /decisions with missing simulationRunId returns 400\', async () => {',
|
|
2113
|
+
' const res = await app.request(makeRequest(\'POST\', \'/api/v1/decisions\', {',
|
|
2114
|
+
' strategyId: \'s1\', recommendedAction: \'approve\', justification: \'test reason here\',',
|
|
2115
|
+
' }, { sub: \'writer1\', roles: [\'writer\'] }));',
|
|
2116
|
+
' expect([400, 503]).toContain(res.status);',
|
|
2117
|
+
' });',
|
|
2118
|
+
'',
|
|
2119
|
+
' it(\'POST /decisions/:id/reject with short reason returns 400\', async () => {',
|
|
2120
|
+
' const res = await app.request(makeRequest(\'POST\', \'/api/v1/decisions/test-id/reject\', {',
|
|
2121
|
+
' reason: \'too short\',',
|
|
2122
|
+
' }, { sub: \'approver1\', roles: [\'approver\'] }));',
|
|
2123
|
+
' // 400 (validation) or 404 (not found) or 503 (Shield)',
|
|
2124
|
+
' expect([400, 404, 422, 500, 503]).toContain(res.status);',
|
|
2125
|
+
' });',
|
|
2126
|
+
' });',
|
|
2127
|
+
'',
|
|
2128
|
+
'});',
|
|
2129
|
+
'',
|
|
2130
|
+
].join('\n');
|
|
2131
|
+
}
|
|
2132
|
+
/**
|
|
2133
|
+
* ADR-068: Validate that generated route code enforces middleware parity.
|
|
2134
|
+
* Checks for RBAC (requireRole), telemetry (correlationId), and audit logging.
|
|
2135
|
+
* Called after LLM codegen AND after template fallback to catch regressions.
|
|
2136
|
+
*/
|
|
2137
|
+
function validateRouteMiddlewareParity(routeCode) {
|
|
2138
|
+
const issues = [];
|
|
2139
|
+
// Count route handlers (app.get, app.post, app.put, app.delete)
|
|
2140
|
+
const routeHandlers = routeCode.match(/app\.(get|post|put|delete|patch)\s*\(/g) ?? [];
|
|
2141
|
+
const routeHandlerCount = routeHandlers.length;
|
|
2142
|
+
// Skip validation for trivially small files (health-only, etc.)
|
|
2143
|
+
if (routeHandlerCount <= 2) {
|
|
2144
|
+
return { valid: true, issues: [], routeHandlerCount };
|
|
2145
|
+
}
|
|
2146
|
+
// Check 1: requireRole must be present for RBAC
|
|
2147
|
+
if (!routeCode.includes('requireRole')) {
|
|
2148
|
+
issues.push('ADR-068 RBAC violation: No requireRole() middleware detected. ' +
|
|
2149
|
+
'All domain routes must enforce role-based access control.');
|
|
2150
|
+
}
|
|
2151
|
+
// Check 2: correlationId must be extracted in handlers
|
|
2152
|
+
const correlationIdCount = (routeCode.match(/correlationId/g) ?? []).length;
|
|
2153
|
+
if (correlationIdCount < routeHandlerCount) {
|
|
2154
|
+
issues.push(`ADR-068 telemetry gap: Found ${routeHandlerCount} route handlers but only ${correlationIdCount} correlationId references. ` +
|
|
2155
|
+
'Every handler must extract and propagate correlationId.');
|
|
2156
|
+
}
|
|
2157
|
+
// Check 3: State-changing routes (POST/PUT/DELETE) must have audit logging
|
|
2158
|
+
const mutationHandlers = routeCode.match(/app\.(post|put|delete|patch)\s*\(/g) ?? [];
|
|
2159
|
+
if (mutationHandlers.length > 0 && !routeCode.includes('audit.log')) {
|
|
2160
|
+
issues.push(`ADR-068 audit gap: Found ${mutationHandlers.length} state-changing endpoints but no audit.log() calls. ` +
|
|
2161
|
+
'All POST/PUT/DELETE handlers must log to the audit trail.');
|
|
2162
|
+
}
|
|
2163
|
+
// Check 4: Middleware chain must be applied (correlationMiddleware + iamMiddleware)
|
|
2164
|
+
if (!routeCode.includes('correlationMiddleware')) {
|
|
2165
|
+
issues.push('ADR-068 middleware chain: correlationMiddleware not applied. ' +
|
|
2166
|
+
'Routes must use app.use(\'*\', correlationMiddleware) before handlers.');
|
|
2167
|
+
}
|
|
2168
|
+
if (!routeCode.includes('iamMiddleware')) {
|
|
2169
|
+
issues.push('ADR-068 middleware chain: iamMiddleware not applied. ' +
|
|
2170
|
+
'Routes must use app.use(\'*\', iamMiddleware) for JWT verification.');
|
|
2171
|
+
}
|
|
2172
|
+
// Check 5: No separate route file imports (domain routes must be in this file)
|
|
2173
|
+
const separateRouteImports = routeCode.match(/from\s+['"]\.\/([\w-]+)-routes['"]/g);
|
|
2174
|
+
if (separateRouteImports) {
|
|
2175
|
+
issues.push(`ADR-068 route fragmentation: Found imports from separate route files (${separateRouteImports.join(', ')}). ` +
|
|
2176
|
+
'All domain routes must be in a single routes.ts to share the middleware chain.');
|
|
2177
|
+
}
|
|
2178
|
+
// Check 6: Error responses must include correlationId
|
|
2179
|
+
const catchBlocks = (routeCode.match(/catch\s*\(/g) ?? []).length;
|
|
2180
|
+
const errorWithCorrelation = (routeCode.match(/correlationId.*500|500.*correlationId/g) ?? []).length;
|
|
2181
|
+
if (catchBlocks > 0 && errorWithCorrelation === 0) {
|
|
2182
|
+
issues.push('ADR-068 error traceability: Error responses do not include correlationId. ' +
|
|
2183
|
+
'All error responses must include correlationId for tracing.');
|
|
2184
|
+
}
|
|
2185
|
+
return { valid: issues.length === 0, issues, routeHandlerCount };
|
|
2186
|
+
}
|
|
2187
|
+
// ADR-067: LLM Prompt Builders
|
|
2188
|
+
// ============================================================================
|
|
2189
|
+
function buildSchemasPrompt(ctx) {
|
|
2190
|
+
return `You are generating Zod validation schemas for a TypeScript API server.
|
|
2191
|
+
|
|
2192
|
+
## Project Requirements
|
|
2193
|
+
${ctx.scenarioQuery.slice(0, 1500)}
|
|
2194
|
+
|
|
2195
|
+
## Bounded Contexts (from DDD model)
|
|
2196
|
+
${formatDDDForPrompt(ctx.dddContexts)}
|
|
2197
|
+
|
|
2198
|
+
## Tech Stack
|
|
2199
|
+
${ctx.techStack.languages?.join(', ')} | ${ctx.techStack.erp ?? 'no ERP'} | ${ctx.techStack.databases?.join(', ')}
|
|
2200
|
+
${ctx.techStack.hasRustOptimizer ? 'Rust optimizer engine present — generate SimulateInputSchema for optimizer input validation.' : ''}
|
|
2201
|
+
|
|
2202
|
+
## Instructions
|
|
2203
|
+
Generate a schemas.ts file that:
|
|
2204
|
+
1. Imports { z } from 'zod'
|
|
2205
|
+
2. For each bounded context, generates:
|
|
2206
|
+
- Command schemas (POST body validation) with REAL domain-specific fields from the DDD model input definitions — NOT generic z.string().optional()
|
|
2207
|
+
- Query schemas (GET params) with typed filter fields matching the entity attributes — NOT generic filters parameter
|
|
2208
|
+
3. Each schema has properly typed fields derived from the domain model
|
|
2209
|
+
4. Command schemas include required fields from preconditions
|
|
2210
|
+
5. Query schemas include limit (z.coerce.number().int().positive().default(50)) and offset (z.coerce.number().int().nonnegative().default(0))
|
|
2211
|
+
${ctx.techStack.hasRustOptimizer ? '6. Include SimulateInputSchema with: suppliers (array), edges (array of tuples), strategies (array), weights (optional object)' : ''}
|
|
2212
|
+
7. Export type aliases for each schema using z.infer<>
|
|
2213
|
+
|
|
2214
|
+
Return ONLY valid TypeScript code. No markdown fences. No explanation.`;
|
|
2215
|
+
}
|
|
2216
|
+
function buildDependenciesPrompt(ctx) {
|
|
2217
|
+
return `You are generating a dependency injection module for a TypeScript API server.
|
|
2218
|
+
|
|
2219
|
+
## Project Requirements
|
|
2220
|
+
${ctx.scenarioQuery.slice(0, 1500)}
|
|
2221
|
+
|
|
2222
|
+
## Bounded Contexts
|
|
2223
|
+
${formatDDDForPrompt(ctx.dddContexts)}
|
|
2224
|
+
|
|
2225
|
+
## Simulation Context
|
|
2226
|
+
${formatSimulationForPrompt(ctx.simulationContext)}
|
|
2227
|
+
|
|
2228
|
+
## Instructions
|
|
2229
|
+
Generate a dependencies.ts file that:
|
|
2230
|
+
1. Imports: randomUUID from node:crypto, z from zod, DbClient/ErpClient from infra/clients, ConsoleTelemetry from infra/telemetry
|
|
2231
|
+
2. Defines an AuditPort interface and AuditAdapter that inserts into audit_log table
|
|
2232
|
+
3. For each bounded context, defines:
|
|
2233
|
+
- A Record interface (id, status, created_at, updated_at, data)
|
|
2234
|
+
- A Port interface (execute, query, getById)
|
|
2235
|
+
- An Adapter class that uses REAL PostgreSQL queries via DbClient (NOT in-memory Map)
|
|
2236
|
+
- query() method with parameterized WHERE clauses from a column allowlist (NOT arbitrary column filtering)
|
|
2237
|
+
- getById() with Zod runtime validation on DB results (NOT unsafe as unknown as casts)
|
|
2238
|
+
- execute() with domain command dispatch (switch on command name), precondition enforcement, invariant checks, and domain event emission
|
|
2239
|
+
4. Defines a DecisionPort with state machine (draft→submitted→approved→rejected→executed) — separate operations, NOT auto-advance
|
|
2240
|
+
5. Self-approval blocked (approver !== submitter)
|
|
2241
|
+
6. ADR-070: reject() must enforce minimum 10-character reason at the domain level (throw Error if reason.trim().length < 10)
|
|
2242
|
+
7. hashSimulationInputs() using SHA-256 for lineage tracking
|
|
2243
|
+
8. validateLineage() that checks decision status, simulation existence, and hash match
|
|
2244
|
+
9. Dependencies interface and createDependencies factory
|
|
2245
|
+
10. ADR-071: All domain-specific constants (probability caps, coverage amounts, thresholds, subsidiary IDs)
|
|
2246
|
+
must be loaded from environment variables with sensible defaults — never hardcoded.
|
|
2247
|
+
Use: process.env.VARIABLE_NAME ?? 'default_value' pattern.
|
|
2248
|
+
${ctx.simulationContext?.scenarios?.length ? '11. ISimulationPort for reading embedded simulation results' : ''}
|
|
2249
|
+
|
|
2250
|
+
CRITICAL: Use the Database class for persistence. Do NOT use in-memory Map or Array for any state.
|
|
2251
|
+
CRITICAL: Validate DB results with Zod schemas before returning — no unsafe casts.
|
|
2252
|
+
CRITICAL: Domain invariants from the DDD model must be runtime assertions, not comments.
|
|
2253
|
+
|
|
2254
|
+
${buildPlatformCatalogPrompt()}
|
|
2255
|
+
|
|
2256
|
+
The dependencies.ts MUST include the callPlatformService utility function and export it.
|
|
2257
|
+
The createDependencies factory MUST NOT reimplement telemetry, audit, security scanning,
|
|
2258
|
+
or cost tracking locally. Instead, use callPlatformService to delegate to platform services.
|
|
2259
|
+
|
|
2260
|
+
## ADR-079: Runtime Platform Services (REQUIRED in generated code)
|
|
2261
|
+
|
|
2262
|
+
After simulation runs, persist lineage to Memory-Graph:
|
|
2263
|
+
callPlatformService(process.env.AGENTICS_MEMORY_GRAPH_URL, '/api/v1/decisions/store', { type, runId, inputHash, ... })
|
|
2264
|
+
|
|
2265
|
+
After decision state changes (approve/execute), persist to Memory-Graph.
|
|
2266
|
+
On failures (simulation, ERP writeback), escalate to Incident-Manager:
|
|
2267
|
+
callPlatformService(process.env.AGENTICS_INCIDENT_URL, '/api/v1/escalate', { severity, type, context })
|
|
2268
|
+
|
|
2269
|
+
All these are should-have or operational tier — fire-and-forget with .catch(() => {}).
|
|
2270
|
+
|
|
2271
|
+
## ADR-082: Governance Enforcement (MANDATORY in generated code)
|
|
2272
|
+
|
|
2273
|
+
1. POST /decisions MUST validate cost impact before persisting:
|
|
2274
|
+
- Extract costImpactPct from request body
|
|
2275
|
+
- If abs(costImpactPct) > GOVERNANCE_COST_THRESHOLD_PCT (env, default 5) AND actor lacks 'cfo' or 'admin' role → return 422 COST_THRESHOLD_EXCEEDED
|
|
2276
|
+
2. POST /decisions MUST call validateLineage(simulationRunId) at CREATION time (not just execution)
|
|
2277
|
+
- If lineage invalid → return 422 LINEAGE_INVALID
|
|
2278
|
+
3. AuditAdapter MUST persist to database (INSERT INTO audit_log) — never in-memory-only
|
|
2279
|
+
- Include previous_hash column for hash chain integrity
|
|
2280
|
+
- Include actor_role, source_ip, request_id columns for compliance forensics
|
|
2281
|
+
|
|
2282
|
+
Return ONLY valid TypeScript code. No markdown fences. No explanation.`;
|
|
2283
|
+
}
|
|
2284
|
+
function buildRoutesPrompt(ctx, erpSystem) {
|
|
2285
|
+
return `You are generating Hono HTTP route handlers for a TypeScript API server.
|
|
2286
|
+
|
|
2287
|
+
## Project Requirements
|
|
2288
|
+
${ctx.scenarioQuery.slice(0, 1500)}
|
|
2289
|
+
|
|
2290
|
+
## Bounded Contexts
|
|
2291
|
+
${formatDDDForPrompt(ctx.dddContexts)}
|
|
2292
|
+
|
|
2293
|
+
## Simulation Context
|
|
2294
|
+
${formatSimulationForPrompt(ctx.simulationContext)}
|
|
2295
|
+
|
|
2296
|
+
## ERP System: ${erpSystem}
|
|
2297
|
+
|
|
2298
|
+
## Instructions
|
|
2299
|
+
Generate a routes.ts file that:
|
|
2300
|
+
1. Imports: Hono, createMiddleware from hono/factory, z from zod, correlationMiddleware/iamMiddleware/loggingMiddleware from middleware, createDependencies/validateLineage/hashSimulationInputs from dependencies, schemas from schemas, healthRouter from health
|
|
2301
|
+
${ctx.techStack.hasRustOptimizer ? '2. Imports runOptimizer from ../services/optimizer-bridge' : ''}
|
|
2302
|
+
3. Defines RBAC middleware: requireRole(...roles) that checks JWT claims
|
|
2303
|
+
- Role matrix: viewer=GET only, writer=POST commands, approver=approve/reject, erp_writer=ERP execute, admin=all
|
|
2304
|
+
- /health/* and /metrics bypass auth
|
|
2305
|
+
4. For each bounded context: POST command endpoints (requireRole writer) and GET query endpoints (requireRole viewer)
|
|
2306
|
+
5. All routes include:
|
|
2307
|
+
- correlationId extraction
|
|
2308
|
+
- Actor identity from JWT sub claim
|
|
2309
|
+
- Audit logging on success AND failure
|
|
2310
|
+
- Error sanitization in production (no stack traces to clients)
|
|
2311
|
+
- Error logging with structured JSON
|
|
2312
|
+
6. Decision approval workflow endpoints:
|
|
2313
|
+
- POST /decisions (submit — requires writer)
|
|
2314
|
+
- POST /decisions/:id/approve (requires approver, validates approver !== submitter, ADR-070: MUST call validateLineage() before approval — reject with LINEAGE_INVALID if hash doesn't match)
|
|
2315
|
+
- POST /decisions/:id/reject (requires approver, reason min 10 chars enforced at BOTH route and domain level)
|
|
2316
|
+
- POST /decisions/:id/execute (requires erp_writer, validates lineage before ERP writeback)
|
|
2317
|
+
- GET /decisions/:id and GET /decisions (viewer) — ADR-070: MUST call deps.audit.log() with action 'read' or 'list' for SOX/GDPR compliance
|
|
2318
|
+
- GET /decisions/:id/lineage (viewer) — ADR-070: MUST audit-log lineage reads
|
|
2319
|
+
${ctx.techStack.hasRustOptimizer ? `7. POST /api/v1/simulate endpoint: validates SimulateInputSchema, calls runOptimizer(), validates output with Zod, computes input hash, audit logs
|
|
2320
|
+
8. GET /api/v1/simulation/scenarios, /scenarios/:id, /compare, /cost-model endpoints` : ''}
|
|
2321
|
+
9. GET /api/v1/cost/tradeoff-matrix and /budget-compliance endpoints
|
|
2322
|
+
10. GET /api/v1/erp/sync-status endpoint
|
|
2323
|
+
11. GET /metrics endpoint (Prometheus format — bypass auth)
|
|
2324
|
+
12. DI bootstrap: DbClient that THROWS when DATABASE_URL not set (NOT returns empty), ErpClient that THROWS when ERP_BASE_URL not set
|
|
2325
|
+
13. Prominent console.error warning at startup when DATABASE_URL or ERP_BASE_URL missing
|
|
2326
|
+
|
|
2327
|
+
CRITICAL: The governance approve endpoint must NOT auto-create or auto-submit decisions. It only transitions pending_review → approved.
|
|
2328
|
+
CRITICAL: The DB client must THROW on any operation when DATABASE_URL is not configured — no silent empty returns.
|
|
2329
|
+
CRITICAL: Separation of duties: approver cannot be the same actor who submitted the decision.
|
|
2330
|
+
|
|
2331
|
+
## ADR-076: Shield Security Scanning (MANDATORY)
|
|
2332
|
+
|
|
2333
|
+
Add a middleware on '/api/v1/*' that scans POST/PUT/PATCH request bodies through Shield
|
|
2334
|
+
BEFORE route handlers execute. Shield is a MUST-HAVE dependency — if Shield is unavailable,
|
|
2335
|
+
return 503 Service Unavailable. Do NOT process unscanned input.
|
|
2336
|
+
|
|
2337
|
+
Use: callPlatformService(process.env.AGENTICS_SHIELD_URL, '/api/v1/scan', { content, checks, context }, { tier: 'must-have' })
|
|
2338
|
+
Place this middleware AFTER health routes but BEFORE domain routes.
|
|
2339
|
+
|
|
2340
|
+
## ADR-080: Governance, Data Protection, and Analytics
|
|
2341
|
+
|
|
2342
|
+
- The AuditAdapter MUST forward events to Governance-Dashboard alongside local DB insert:
|
|
2343
|
+
callPlatformService(process.env.AGENTICS_GOVERNANCE_URL, '/api/v1/audit/log', ..., { tier: 'should-have' })
|
|
2344
|
+
- Viewer-only users reading sensitive data SHOULD receive anonymized data via Data-Vault:
|
|
2345
|
+
callPlatformService(process.env.AGENTICS_DATAVAULT_URL, '/api/v1/access', ..., { tier: 'operational' })
|
|
2346
|
+
- Scenario comparison endpoints SHOULD include Analytics-Hub recommendations:
|
|
2347
|
+
callPlatformService(process.env.AGENTICS_ANALYTICS_URL, '/api/v1/recommend', ..., { tier: 'operational' })
|
|
2348
|
+
|
|
2349
|
+
## ADR-081: Apex Platform, Layer 4 Infrastructure
|
|
2350
|
+
|
|
2351
|
+
After simulation runs:
|
|
2352
|
+
- Index results: callPlatformService(AGENTICS_RESULTS_INDEX_URL, '/api/v1/index', ..., operational)
|
|
2353
|
+
- Record usage: callPlatformService(AGENTICS_USAGE_LEDGER_URL, '/api/v1/record', ..., operational)
|
|
2354
|
+
- Get risk score: callPlatformService(AGENTICS_PLATFORM_URL, '/api/v1/risk-score', ..., operational)
|
|
2355
|
+
|
|
2356
|
+
On scenario comparison:
|
|
2357
|
+
- Executive summary: callPlatformService(AGENTICS_PLATFORM_URL, '/api/v1/executive-summary', ..., operational)
|
|
2358
|
+
- ROI calculation: callPlatformService(AGENTICS_ROI_ENGINE_URL, '/api/v1/calculate', ..., operational)
|
|
2359
|
+
|
|
2360
|
+
On ERP writeback:
|
|
2361
|
+
- Record in usage ledger: callPlatformService(AGENTICS_USAGE_LEDGER_URL, '/api/v1/record', ..., operational)
|
|
2362
|
+
|
|
2363
|
+
## ADR-082: Governance Enforcement (MANDATORY)
|
|
2364
|
+
|
|
2365
|
+
POST /decisions endpoint MUST:
|
|
2366
|
+
1. Extract costImpactPct from body, check against GOVERNANCE_COST_THRESHOLD_PCT (env, default 5%)
|
|
2367
|
+
If exceeded and actor lacks 'cfo'/'admin' role → 422 COST_THRESHOLD_EXCEEDED
|
|
2368
|
+
2. Call validateLineage(simulationRunId) BEFORE persisting the decision
|
|
2369
|
+
If invalid → 422 LINEAGE_INVALID (catch tampering at creation, not execution)
|
|
2370
|
+
3. Audit all governance gate failures (cost threshold, lineage) with deps.audit.log()
|
|
2371
|
+
|
|
2372
|
+
## ADR-084: ERP Safety
|
|
2373
|
+
|
|
2374
|
+
- ERP client constructor MUST throw if baseUrl is missing or contains 'example.com'
|
|
2375
|
+
- All ERP write operations (including domain-specific like createEnergyContract) MUST go through
|
|
2376
|
+
the governance approval workflow — assertWriteAllowed() before every external call
|
|
2377
|
+
- All external calls MUST go through the rate limiter before the circuit breaker
|
|
2378
|
+
|
|
2379
|
+
## ADR-085: Observability Completeness
|
|
2380
|
+
|
|
2381
|
+
- The /metrics endpoint MUST be present (Prometheus-compatible, text/plain, bypass auth)
|
|
2382
|
+
- Database errors MUST be logged as structured JSON with { level, type: 'DATABASE_ERROR', correlationId, operation, sql, error, durationMs }
|
|
2383
|
+
- All outbound calls (platform services, ERP) MUST include traceparent header for distributed tracing
|
|
2384
|
+
|
|
2385
|
+
## ADR-086: Code Quality
|
|
2386
|
+
|
|
2387
|
+
- Every adapter that modifies state MUST emit a domain event via eventBus.publish() after successful DB persist
|
|
2388
|
+
- Use safeLog() utility instead of raw console.error(JSON.stringify(...)) — it redacts PII fields automatically
|
|
2389
|
+
- Use getCorrelationId(c), getActor(c), getActorRoles(c) type guard helpers instead of unsafe c.get() casts
|
|
2390
|
+
|
|
2391
|
+
## ADR-088: Architecture
|
|
2392
|
+
|
|
2393
|
+
- When multiple bounded contexts exist, generate a shared/published-language.ts with published types
|
|
2394
|
+
Other contexts import from published-language, not from each other's internal types
|
|
2395
|
+
- Use SimpleCache<T> from dependencies.ts for caching expensive simulation results and query results
|
|
2396
|
+
- For large simulations (>10 scenarios or >20 entities), return 202 Accepted with a jobId
|
|
2397
|
+
and provide GET /api/v1/simulate/:jobId/status for polling
|
|
2398
|
+
|
|
2399
|
+
## ADR-068: Middleware Parity (MANDATORY)
|
|
2400
|
+
|
|
2401
|
+
ALL domain-specific endpoints MUST be in THIS SINGLE routes.ts file. Do NOT generate
|
|
2402
|
+
separate route files (e.g., climate-routes.ts, logistics-routes.ts). Every endpoint
|
|
2403
|
+
lives in one file, sharing the same middleware chain.
|
|
2404
|
+
|
|
2405
|
+
Every route handler MUST have ALL of the following — no exceptions:
|
|
2406
|
+
- requireRole() with appropriate roles based on operation type:
|
|
2407
|
+
* GET (read data/analysis): requireRole('viewer', 'writer', 'admin')
|
|
2408
|
+
* POST (run simulations): requireRole('writer', 'admin')
|
|
2409
|
+
* POST (ERP writeback): requireRole('erp_writer', 'admin')
|
|
2410
|
+
* POST (approve/reject): requireRole('approver', 'admin')
|
|
2411
|
+
- correlationId extraction: const correlationId = c.get('correlationId') as string
|
|
2412
|
+
- Actor identity: const actor = (c.get('iamClaims') as Record<string, unknown>)?.sub as string ?? 'anonymous'
|
|
2413
|
+
- Audit logging via deps.audit.log() for ALL state-changing operations (POST/PUT/DELETE), on both success AND failure
|
|
2414
|
+
- Structured error logging with correlationId in every catch block
|
|
2415
|
+
- Error responses that include correlationId
|
|
2416
|
+
|
|
2417
|
+
The app MUST apply these middleware in order at the top level:
|
|
2418
|
+
app.use('*', correlationMiddleware);
|
|
2419
|
+
app.use('*', loggingMiddleware);
|
|
2420
|
+
app.route('/health', healthRouter);
|
|
2421
|
+
app.use('*', iamMiddleware);
|
|
2422
|
+
|
|
2423
|
+
${buildPlatformCatalogPrompt()}
|
|
2424
|
+
|
|
2425
|
+
Route handlers SHOULD call platform services for cross-cutting concerns:
|
|
2426
|
+
- callPlatformService(process.env.AGENTICS_SHIELD_URL, '/api/v1/scan', body, { tier: 'must-have' }) — scan inbound POST/PUT bodies
|
|
2427
|
+
- callPlatformService(process.env.AGENTICS_COSTOPS_URL, '/api/v1/track', metadata, { tier: 'should-have' }) — track operation cost (fire-and-forget)
|
|
2428
|
+
- callPlatformService(process.env.AGENTICS_OBSERVATORY_URL, '/api/v1/telemetry/spans', span, { tier: 'operational' }) — send telemetry
|
|
2429
|
+
|
|
2430
|
+
Import callPlatformService from './dependencies.js'.
|
|
2431
|
+
|
|
2432
|
+
Return ONLY valid TypeScript code. No markdown fences. No explanation.`;
|
|
2433
|
+
}
|
|
1682
2434
|
// Rust Mock Data Helpers (unchanged — Rust routes remain mocked)
|
|
1683
2435
|
// ============================================================================
|
|
1684
2436
|
function buildRustMockCommandJson(ctxSlug, opSlug) {
|
|
@@ -2133,15 +2885,38 @@ export function generateHttpServer(context) {
|
|
|
2133
2885
|
// ADR-062: Detect if Rust optimizer exists for simulation endpoint generation
|
|
2134
2886
|
const hasRustOptimizer = /\brust\b/i.test(context.scenarioQuery) &&
|
|
2135
2887
|
/graph|optim|simul|network|dependency|pareto|compute/i.test(context.scenarioQuery);
|
|
2136
|
-
//
|
|
2137
|
-
const
|
|
2888
|
+
// Build codegen context for LLM prompts (ADR-067)
|
|
2889
|
+
const codegenCtx = {
|
|
2890
|
+
scenarioQuery: context.scenarioQuery,
|
|
2891
|
+
dddContexts: contexts,
|
|
2892
|
+
simulationContext: (context.simulationContext ?? null),
|
|
2893
|
+
techStack: {
|
|
2894
|
+
erp: erpResolution.erpSystem,
|
|
2895
|
+
cloud: /gcp|google/i.test(context.scenarioQuery) ? 'gcp' : /aws/i.test(context.scenarioQuery) ? 'aws' : /azure/i.test(context.scenarioQuery) ? 'azure' : 'gcp',
|
|
2896
|
+
databases: [/postgres/i.test(context.scenarioQuery) ? 'PostgreSQL' : 'PostgreSQL', ...((/bigquery/i.test(context.scenarioQuery)) ? ['BigQuery'] : [])],
|
|
2897
|
+
languages: ['TypeScript', ...(hasRustOptimizer ? ['Rust'] : [])],
|
|
2898
|
+
hasRustOptimizer,
|
|
2899
|
+
},
|
|
2900
|
+
};
|
|
2901
|
+
// schemas.ts — ADR-067: LLM-first, template fallback
|
|
2902
|
+
const schemasLLM = generateCodeViaLLM({
|
|
2903
|
+
label: 'schemas.ts',
|
|
2904
|
+
prompt: buildSchemasPrompt(codegenCtx),
|
|
2905
|
+
timeoutMs: 60_000,
|
|
2906
|
+
});
|
|
2907
|
+
const schemasContent = schemasLLM.code ?? tsSchemas(contexts, hasRustOptimizer);
|
|
2138
2908
|
const schemasPath = join(serverDir, 'schemas.ts');
|
|
2139
2909
|
writeServerFile(schemasPath, schemasContent);
|
|
2140
2910
|
serverFiles.push({ relativePath: 'project/src/server/schemas.ts', content: schemasContent, kind: 'server' });
|
|
2141
2911
|
writtenPaths.push(schemasPath);
|
|
2142
|
-
// dependencies.ts —
|
|
2912
|
+
// dependencies.ts — ADR-067: LLM-first, template fallback
|
|
2143
2913
|
const simCtx = context.simulationContext ?? null;
|
|
2144
|
-
const
|
|
2914
|
+
const depsLLM = generateCodeViaLLM({
|
|
2915
|
+
label: 'dependencies.ts',
|
|
2916
|
+
prompt: buildDependenciesPrompt(codegenCtx),
|
|
2917
|
+
timeoutMs: 90_000,
|
|
2918
|
+
});
|
|
2919
|
+
const depsContent = depsLLM.code ?? tsDependencies(contexts, simCtx);
|
|
2145
2920
|
const depsPath = join(serverDir, 'dependencies.ts');
|
|
2146
2921
|
writeServerFile(depsPath, depsContent);
|
|
2147
2922
|
serverFiles.push({ relativePath: 'project/src/server/dependencies.ts', content: depsContent, kind: 'server' });
|
|
@@ -2162,7 +2937,7 @@ export function generateHttpServer(context) {
|
|
|
2162
2937
|
id: s.id,
|
|
2163
2938
|
name: s.name,
|
|
2164
2939
|
scores: {
|
|
2165
|
-
cost: Math.max(0, Math.min(1, 1 - Math.abs(s.costDelta) /
|
|
2940
|
+
cost: Math.max(0, Math.min(1, 1 - Math.abs(s.costDelta) / parseFloat(process.env.OPT_COST_NORM_FACTOR ?? '10000000'))), // ADR-071: parameterized
|
|
2166
2941
|
emissions: s.reliability, // use reliability as proxy for emissions improvement
|
|
2167
2942
|
resilience: s.riskLevel === 'low' ? 0.9 : s.riskLevel === 'medium' ? 0.6 : 0.3,
|
|
2168
2943
|
complexity: s.mitigationStrategy ? 0.5 : 0.8, // strategies with mitigations are more complex
|
|
@@ -2179,8 +2954,55 @@ export function generateHttpServer(context) {
|
|
|
2179
2954
|
serverFiles.push({ relativePath: 'project/src/data/tradeoff-matrix.json', content: JSON.stringify(tradeoffMatrix, null, 2), kind: 'server' });
|
|
2180
2955
|
}
|
|
2181
2956
|
}
|
|
2182
|
-
// routes.ts —
|
|
2183
|
-
const
|
|
2957
|
+
// routes.ts — ADR-067: LLM-first, template fallback
|
|
2958
|
+
const routesLLM = generateCodeViaLLM({
|
|
2959
|
+
label: 'routes.ts',
|
|
2960
|
+
prompt: buildRoutesPrompt(codegenCtx, erpResolution.erpSystem),
|
|
2961
|
+
timeoutMs: 120_000, // routes is the biggest file — give it 2 minutes
|
|
2962
|
+
});
|
|
2963
|
+
let routeCount = 0;
|
|
2964
|
+
let routesContent;
|
|
2965
|
+
let routeSource = 'template';
|
|
2966
|
+
if (routesLLM.code) {
|
|
2967
|
+
routesContent = routesLLM.code;
|
|
2968
|
+
routeSource = 'llm';
|
|
2969
|
+
// Estimate route count from LLM output
|
|
2970
|
+
routeCount = (routesContent.match(/app\.(get|post|put|delete)\(/g) ?? []).length;
|
|
2971
|
+
}
|
|
2972
|
+
else {
|
|
2973
|
+
const templateResult = tsRoutes(erpResolution.erpSystem, contexts, simCtx, hasRustOptimizer);
|
|
2974
|
+
routesContent = templateResult.content;
|
|
2975
|
+
routeCount = templateResult.routeCount;
|
|
2976
|
+
}
|
|
2977
|
+
// ADR-068: Validate middleware parity on generated routes
|
|
2978
|
+
const routeValidation = validateRouteMiddlewareParity(routesContent);
|
|
2979
|
+
if (!routeValidation.valid) {
|
|
2980
|
+
console.warn(JSON.stringify({
|
|
2981
|
+
level: 'warn',
|
|
2982
|
+
type: 'ADR-068_MIDDLEWARE_PARITY',
|
|
2983
|
+
source: routeSource,
|
|
2984
|
+
issues: routeValidation.issues,
|
|
2985
|
+
routeHandlerCount: routeValidation.routeHandlerCount,
|
|
2986
|
+
message: `Route generation (${routeSource}) produced code with ${routeValidation.issues.length} middleware parity issue(s). Falling back to template.`,
|
|
2987
|
+
}));
|
|
2988
|
+
// If LLM output failed validation, fall back to the template which has guaranteed middleware parity
|
|
2989
|
+
if (routeSource === 'llm') {
|
|
2990
|
+
const templateResult = tsRoutes(erpResolution.erpSystem, contexts, simCtx, hasRustOptimizer);
|
|
2991
|
+
routesContent = templateResult.content;
|
|
2992
|
+
routeCount = templateResult.routeCount;
|
|
2993
|
+
routeSource = 'template';
|
|
2994
|
+
// Re-validate the template (should always pass, but verify)
|
|
2995
|
+
const templateValidation = validateRouteMiddlewareParity(routesContent);
|
|
2996
|
+
if (!templateValidation.valid) {
|
|
2997
|
+
console.error(JSON.stringify({
|
|
2998
|
+
level: 'error',
|
|
2999
|
+
type: 'ADR-068_TEMPLATE_VIOLATION',
|
|
3000
|
+
issues: templateValidation.issues,
|
|
3001
|
+
message: 'Template fallback also failed middleware parity validation. This is a pipeline bug.',
|
|
3002
|
+
}));
|
|
3003
|
+
}
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
2184
3006
|
const routesPath = join(serverDir, 'routes.ts');
|
|
2185
3007
|
writeServerFile(routesPath, routesContent);
|
|
2186
3008
|
serverFiles.push({ relativePath: 'project/src/server/routes.ts', content: routesContent, kind: 'server' });
|
|
@@ -2199,6 +3021,42 @@ export function generateHttpServer(context) {
|
|
|
2199
3021
|
writeServerFile(eventsPath, eventsContent);
|
|
2200
3022
|
serverFiles.push({ relativePath: 'project/src/events/domain-events.ts', content: eventsContent, kind: 'server' });
|
|
2201
3023
|
writtenPaths.push(eventsPath);
|
|
3024
|
+
// ADR-088: Published language module — shared types for cross-context communication
|
|
3025
|
+
if (contexts.length > 1) {
|
|
3026
|
+
const sharedDir = join(serverDir, '..', 'shared');
|
|
3027
|
+
mkdirSync(sharedDir, { recursive: true, mode: DIR_MODE });
|
|
3028
|
+
const plLines = [
|
|
3029
|
+
GENERATED_HEADER_TS,
|
|
3030
|
+
'// ADR-088: Published Language — shared types between bounded contexts',
|
|
3031
|
+
'// Other contexts import from here, not from each other\'s internal types.',
|
|
3032
|
+
'// This prevents direct coupling between bounded contexts.',
|
|
3033
|
+
'',
|
|
3034
|
+
];
|
|
3035
|
+
for (const ctx of contexts) {
|
|
3036
|
+
const ctxPascal = pascalCase(ctx.name);
|
|
3037
|
+
plLines.push(`// Published by ${ctxPascal} context`);
|
|
3038
|
+
for (const entity of ctx.entities.slice(0, 5)) {
|
|
3039
|
+
const entityPascal = pascalCase(entity.name);
|
|
3040
|
+
plLines.push(`export interface Published${entityPascal} {`);
|
|
3041
|
+
plLines.push(` readonly id: string;`);
|
|
3042
|
+
plLines.push(` readonly name: string;`);
|
|
3043
|
+
plLines.push(`}`);
|
|
3044
|
+
plLines.push('');
|
|
3045
|
+
}
|
|
3046
|
+
for (const vo of ctx.valueObjects.slice(0, 3)) {
|
|
3047
|
+
const voPascal = pascalCase(vo.name);
|
|
3048
|
+
plLines.push(`export interface Published${voPascal} {`);
|
|
3049
|
+
plLines.push(` readonly value: unknown;`);
|
|
3050
|
+
plLines.push(`}`);
|
|
3051
|
+
plLines.push('');
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
const plContent = plLines.join('\n');
|
|
3055
|
+
const plPath = join(sharedDir, 'published-language.ts');
|
|
3056
|
+
writeServerFile(plPath, plContent);
|
|
3057
|
+
serverFiles.push({ relativePath: 'project/src/shared/published-language.ts', content: plContent, kind: 'server' });
|
|
3058
|
+
writtenPaths.push(plPath);
|
|
3059
|
+
}
|
|
2202
3060
|
// health.ts
|
|
2203
3061
|
const healthContent = tsHealth();
|
|
2204
3062
|
const healthPath = join(serverDir, 'health.ts');
|
|
@@ -2303,6 +3161,14 @@ export function generateHttpServer(context) {
|
|
|
2303
3161
|
serverFiles.push({ relativePath: `project/src/__tests__/${ctxSlug}.test.ts`, content: testContent, kind: 'server' });
|
|
2304
3162
|
writtenPaths.push(testPath);
|
|
2305
3163
|
}
|
|
3164
|
+
// ADR-087: API route tests — RBAC, validation, correlation IDs
|
|
3165
|
+
const apiTestDir = join(testDir, 'api');
|
|
3166
|
+
mkdirSync(apiTestDir, { recursive: true, mode: DIR_MODE });
|
|
3167
|
+
const apiTestContent = tsApiRouteTest(contexts);
|
|
3168
|
+
const apiTestPath = join(apiTestDir, 'routes.test.ts');
|
|
3169
|
+
writeServerFile(apiTestPath, apiTestContent);
|
|
3170
|
+
serverFiles.push({ relativePath: 'project/src/__tests__/api/routes.test.ts', content: apiTestContent, kind: 'server' });
|
|
3171
|
+
writtenPaths.push(apiTestPath);
|
|
2306
3172
|
// ADR-055: Per-context integration tests (real SQLite, tests actual SQL)
|
|
2307
3173
|
const integrationTestDir = join(projectDir, 'src', '__tests__', 'integration');
|
|
2308
3174
|
mkdirSync(integrationTestDir, { recursive: true, mode: DIR_MODE });
|