@llm-dev-ops/agentics-cli 1.5.74 → 1.6.1

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.
Files changed (49) hide show
  1. package/dist/pipeline/phase2/phase2-coordinator.d.ts.map +1 -1
  2. package/dist/pipeline/phase2/phase2-coordinator.js +2 -0
  3. package/dist/pipeline/phase2/phase2-coordinator.js.map +1 -1
  4. package/dist/pipeline/phase2/phases/adr-generator.d.ts.map +1 -1
  5. package/dist/pipeline/phase2/phases/adr-generator.js +216 -3
  6. package/dist/pipeline/phase2/phases/adr-generator.js.map +1 -1
  7. package/dist/pipeline/phase2/phases/prompt-generator.d.ts +15 -0
  8. package/dist/pipeline/phase2/phases/prompt-generator.d.ts.map +1 -0
  9. package/dist/pipeline/phase2/phases/prompt-generator.js +370 -0
  10. package/dist/pipeline/phase2/phases/prompt-generator.js.map +1 -0
  11. package/dist/pipeline/phase2/types.d.ts +3 -1
  12. package/dist/pipeline/phase2/types.d.ts.map +1 -1
  13. package/dist/pipeline/phase4/phase4-coordinator.d.ts.map +1 -1
  14. package/dist/pipeline/phase4/phase4-coordinator.js +2 -0
  15. package/dist/pipeline/phase4/phase4-coordinator.js.map +1 -1
  16. package/dist/pipeline/phase4/phases/build-time-services.d.ts +28 -0
  17. package/dist/pipeline/phase4/phases/build-time-services.d.ts.map +1 -0
  18. package/dist/pipeline/phase4/phases/build-time-services.js +232 -0
  19. package/dist/pipeline/phase4/phases/build-time-services.js.map +1 -0
  20. package/dist/pipeline/phase4/phases/deployment-generator.d.ts.map +1 -1
  21. package/dist/pipeline/phase4/phases/deployment-generator.js +107 -15
  22. package/dist/pipeline/phase4/phases/deployment-generator.js.map +1 -1
  23. package/dist/pipeline/phase4/phases/erp-client-generator.d.ts.map +1 -1
  24. package/dist/pipeline/phase4/phases/erp-client-generator.js +303 -52
  25. package/dist/pipeline/phase4/phases/erp-client-generator.js.map +1 -1
  26. package/dist/pipeline/phase4/phases/http-server-generator.d.ts.map +1 -1
  27. package/dist/pipeline/phase4/phases/http-server-generator.js +748 -37
  28. package/dist/pipeline/phase4/phases/http-server-generator.js.map +1 -1
  29. package/dist/pipeline/phase4/phases/infra-adapter-generator.d.ts.map +1 -1
  30. package/dist/pipeline/phase4/phases/infra-adapter-generator.js +51 -0
  31. package/dist/pipeline/phase4/phases/infra-adapter-generator.js.map +1 -1
  32. package/dist/pipeline/phase4/phases/platform-catalog.d.ts +48 -0
  33. package/dist/pipeline/phase4/phases/platform-catalog.d.ts.map +1 -0
  34. package/dist/pipeline/phase4/phases/platform-catalog.js +242 -0
  35. package/dist/pipeline/phase4/phases/platform-catalog.js.map +1 -0
  36. package/dist/pipeline/phase4/phases/rust-optimizer-generator.d.ts +1 -1
  37. package/dist/pipeline/phase4/phases/rust-optimizer-generator.d.ts.map +1 -1
  38. package/dist/pipeline/phase4/phases/rust-optimizer-generator.js +176 -31
  39. package/dist/pipeline/phase4/phases/rust-optimizer-generator.js.map +1 -1
  40. package/dist/pipeline/phase4/phases/schema-generator.d.ts.map +1 -1
  41. package/dist/pipeline/phase4/phases/schema-generator.js +22 -0
  42. package/dist/pipeline/phase4/phases/schema-generator.js.map +1 -1
  43. package/dist/pipeline/phase4/types.d.ts +1 -1
  44. package/dist/pipeline/phase4/types.d.ts.map +1 -1
  45. package/dist/synthesis/simulation-artifact-generator.d.ts +10 -0
  46. package/dist/synthesis/simulation-artifact-generator.d.ts.map +1 -1
  47. package/dist/synthesis/simulation-artifact-generator.js +92 -1
  48. package/dist/synthesis/simulation-artifact-generator.js.map +1 -1
  49. package/package.json +1 -1
@@ -23,6 +23,7 @@ import { join } from 'node:path';
23
23
  import { hasTemplateSupport } from '../types.js';
24
24
  import { createSpan, endSpan, emitSpan } from '../../phase2/telemetry.js';
25
25
  import { generateCodeViaLLM, formatDDDForPrompt, formatSimulationForPrompt } from './llm-codegen.js';
26
+ import { buildPlatformCatalogPrompt, generatePlatformClientCode } from './platform-catalog.js';
26
27
  // ============================================================================
27
28
  // Constants
28
29
  // ============================================================================
@@ -63,6 +64,7 @@ function tsMain() {
63
64
  GENERATED_HEADER_TS,
64
65
  `import { serve } from '@hono/node-server';`,
65
66
  `import { app } from './routes.js';`,
67
+ `import { registerWithPlatform } from './dependencies.js';`,
66
68
  '',
67
69
  'const PORT = parseInt(process.env.PORT ?? \'8080\', 10);',
68
70
  '',
@@ -76,16 +78,23 @@ function tsMain() {
76
78
  ' timestamp: new Date().toISOString(),',
77
79
  ' }) + \'\\n\',',
78
80
  ' );',
81
+ ' // ADR-080: Register with platform services on startup (non-blocking)',
82
+ ' registerWithPlatform().catch(() => {});',
79
83
  '});',
80
84
  '',
81
- 'process.on(\'SIGTERM\', () => {',
85
+ '// ADR-072: SIGTERM handler drains sync queue to database before exit',
86
+ 'process.on(\'SIGTERM\', async () => {',
82
87
  ' process.stdout.write(',
83
88
  ' JSON.stringify({',
84
89
  ' severity: \'INFO\',',
85
- ' message: \'Received SIGTERM — shutting down gracefully\',',
90
+ ' message: \'Received SIGTERM — draining sync queue and shutting down\',',
86
91
  ' timestamp: new Date().toISOString(),',
87
92
  ' }) + \'\\n\',',
88
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 */ }',
89
98
  ' process.exit(0);',
90
99
  '});',
91
100
  '',
@@ -286,14 +295,66 @@ function tsSchemas(contexts, hasRustOptimizer = false) {
286
295
  * One port interface + adapter per context, plus a shared AuditPort.
287
296
  */
288
297
  function tsDependencies(contexts, simulationContext) {
298
+ const platformClientCode = generatePlatformClientCode();
289
299
  const lines = [
290
300
  GENERATED_HEADER_TS,
291
- `import { randomUUID } from 'node:crypto';`,
301
+ `import { randomUUID, createHash } from 'node:crypto';`,
292
302
  `import { z } from 'zod';`,
293
303
  `import type { DbClient, ErpClient } from '../infra/clients.js';`,
294
- `import { ConsoleTelemetry } from '../infra/telemetry.js';`,
304
+ `import { ConsoleTelemetry, createTelemetry } from '../infra/telemetry.js';`,
295
305
  `import type { Telemetry } from '../infra/telemetry.js';`,
296
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
+ '',
297
358
  '// ============================================================================',
298
359
  '// Audit Port (shared across all contexts)',
299
360
  '// ============================================================================',
@@ -313,15 +374,28 @@ function tsDependencies(contexts, simulationContext) {
313
374
  '}',
314
375
  '',
315
376
  'class AuditAdapter implements IAuditPort {',
377
+ ' private lastHash: string = \'genesis\'; // ADR-082: hash chain seed',
316
378
  ' constructor(private readonly db: DbClient) {}',
317
379
  '',
318
- ' 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> {',
319
381
  ' const id = randomUUID();',
320
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\');',
321
386
  ' await this.db.execute(',
322
- ' \'INSERT INTO audit_log (id, entity_type, entity_id, action, actor, payload, created_at) VALUES (:1, :2, :3, :4, :5, :6, :7)\',',
323
- ' [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],',
324
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(() => {});',
325
399
  ' return { id, entity_type: entityType, entity_id: entityId, action, actor, payload, created_at: now };',
326
400
  ' }',
327
401
  '}',
@@ -332,6 +406,43 @@ function tsDependencies(contexts, simulationContext) {
332
406
  '',
333
407
  'import { createHash } from \'node:crypto\';',
334
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
+ '',
335
446
  '/** Hash simulation inputs for immutability proof. */',
336
447
  'export function hashSimulationInputs(params: Record<string, unknown>): string {',
337
448
  ' const canonical = JSON.stringify(params, Object.keys(params).sort());',
@@ -471,7 +582,7 @@ function tsDependencies(contexts, simulationContext) {
471
582
  lines.push('}');
472
583
  lines.push('');
473
584
  lines.push(`class ${ctxPascal}Adapter implements I${ctxPascal}Port {`);
474
- 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> }) {}');
475
586
  lines.push('');
476
587
  // ── ADR-048: Generate domain-specific execute() with precondition guards ──
477
588
  lines.push(` async execute(command: string, payload: Record<string, unknown>): Promise<${ctxPascal}Record> {`);
@@ -548,15 +659,19 @@ function tsDependencies(contexts, simulationContext) {
548
659
  const relevantEvents = ctx.domainEvents.filter(e => ctx.commands.some(c => e.triggeredBy === c.name || e.triggeredBy === ''));
549
660
  if (relevantEvents.length > 0 || ctx.domainEvents.length > 0) {
550
661
  lines.push('');
551
- lines.push(' // Emit domain event for cross-context communication');
552
- lines.push(` console.log(JSON.stringify({`);
553
- lines.push(` type: 'DOMAIN_EVENT',`);
554
- lines.push(` context: '${ctxSnake}',`);
555
- lines.push(` event: \`\${command}_completed\`,`);
556
- lines.push(' aggregateId: id,');
557
- lines.push(' payload,');
558
- lines.push(' timestamp: now,');
559
- 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(' }');
560
675
  }
561
676
  lines.push('');
562
677
  lines.push(` return { id, status: command, created_at: now, updated_at: now, data: payload };`);
@@ -707,6 +822,10 @@ function tsDependencies(contexts, simulationContext) {
707
822
  lines.push(' }');
708
823
  lines.push('');
709
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(" }");
710
829
  lines.push(" return this.telemetry.withSpan('decisions', 'reject', {}, async () => {");
711
830
  lines.push(' const current = await this.getById(id);');
712
831
  lines.push(" if (!current) throw new Error('Decision not found');");
@@ -797,10 +916,26 @@ function tsDependencies(contexts, simulationContext) {
797
916
  lines.push('');
798
917
  // Factory
799
918
  lines.push('export function createDependencies(db: DbClient, erp: ErpClient): Dependencies {');
800
- lines.push(' const telemetry: Telemetry = new ConsoleTelemetry();');
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(' };');
801
936
  lines.push(' return {');
802
937
  for (const ctx of contexts) {
803
- 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),`);
804
939
  }
805
940
  lines.push(' audit: new AuditAdapter(db),');
806
941
  lines.push(' decisions: new DecisionAdapter(db, telemetry),');
@@ -811,6 +946,55 @@ function tsDependencies(contexts, simulationContext) {
811
946
  lines.push(' };');
812
947
  lines.push('}');
813
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('');
814
998
  return lines.join('\n');
815
999
  }
816
1000
  /**
@@ -870,7 +1054,7 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
870
1054
  `import * as crypto from 'node:crypto';`,
871
1055
  `import { correlationMiddleware, iamMiddleware, loggingMiddleware } from './middleware.js';`,
872
1056
  `import { healthRouter } from './health.js';`,
873
- `import { createDependencies, validateLineage, hashSimulationInputs } from './dependencies.js';`,
1057
+ `import { createDependencies, validateLineage, hashSimulationInputs, callPlatformService, seedSimulationData } from './dependencies.js';`,
874
1058
  ...(hasRustOptimizer ? [`import { runOptimizer } from '../services/optimizer-bridge.js';`] : []),
875
1059
  `import type { Dependencies } from './dependencies.js';`,
876
1060
  `import type { DbClient, ErpClient } from '../infra/clients.js';`,
@@ -905,6 +1089,29 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
905
1089
  ' return { error: { code: \'FORBIDDEN\', message: `Requires role: ${requiredRoles.join(" | ")}` }, correlationId };',
906
1090
  '}',
907
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
+ '',
908
1115
  '// ============================================================================',
909
1116
  '// RBAC Middleware (ADR-049, ADR-061)',
910
1117
  '// ============================================================================',
@@ -995,6 +1202,9 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
995
1202
  '',
996
1203
  'const deps: Dependencies = createDependencies(dbClient, erpClient);',
997
1204
  '',
1205
+ '// ADR-083: Seed simulation data on startup (non-blocking)',
1206
+ 'seedSimulationData(dbClient).catch(() => {});',
1207
+ '',
998
1208
  `// ${erpMeta.className} ACL client with governance enforcement`,
999
1209
  'const governanceConfig: GovernanceConfig = {',
1000
1210
  ' read_only: (process.env.ERP_READ_ONLY ?? \'true\') === \'true\',',
@@ -1024,8 +1234,68 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
1024
1234
  'app.use(\'*\', iamMiddleware);',
1025
1235
  'app.use(\'*\', loggingMiddleware);',
1026
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
+ '',
1027
1254
  'app.route(\'/health\', healthRouter);',
1028
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
+ '',
1029
1299
  ];
1030
1300
  let routeCount = 0;
1031
1301
  // Generate one POST per command, one GET per query, grouped by bounded context
@@ -1056,23 +1326,23 @@ function tsRoutes(erpSystem, contexts, simulationContext, hasRustOptimizer = fal
1056
1326
  }
1057
1327
  // ADR-062: Simulation execution endpoint (when Rust optimizer present)
1058
1328
  if (hasRustOptimizer) {
1059
- 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: Date.now() - startTime,', ' });', '', ' return c.json({', ' correlationId,', " status: 'completed',", ' data: result,', ' 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) });', " const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : (err instanceof Error ? err.message : 'Unknown error');", ' return c.json(serverError(correlationId, message), 500);', ' }', '});', '');
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);', ' }', '});', '');
1060
1330
  routeCount++;
1061
1331
  }
1062
1332
  // ADR-054: Decision approval workflow endpoints (always generated)
1063
- 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);', ' }', '});', '');
1064
1334
  routeCount += 7; // submit, approve, reject, execute, getById, list, lineage
1065
1335
  // ADR-052: Simulation read-only endpoints (when simulation context available)
1066
1336
  if (simulationContext && (simulationContext.scenarios.length > 0 || simulationContext.simulationId)) {
1067
- 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);', ' }', '});', '');
1068
1338
  routeCount += 4;
1069
1339
  // ADR-053: Tradeoff analysis and budget compliance endpoints
1070
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);', ' }', '});', '');
1071
1341
  routeCount += 2;
1072
1342
  }
1073
1343
  // ERP sync status endpoint (reusable across all ERP types)
1074
- 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 };', '');
1075
- routeCount += 2; // sync-status + metrics
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
1076
1346
  return { content: lines.join('\n'), routeCount };
1077
1347
  }
1078
1348
  // ============================================================================
@@ -1167,6 +1437,35 @@ function tsDomainEvents(contexts) {
1167
1437
  lines.push(' }');
1168
1438
  lines.push('}');
1169
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('');
1170
1469
  lines.push('/** Create a domain event with auto-generated ID and timestamp. */');
1171
1470
  lines.push('export function createEvent<T>(');
1172
1471
  lines.push(' eventType: string,');
@@ -1567,7 +1866,7 @@ function tsContextIntegrationTest(ctx) {
1567
1866
  const tableName = `${ctxSnake}_records`;
1568
1867
  return [
1569
1868
  GENERATED_HEADER_TS,
1570
- `import { describe, it, expect, beforeAll, afterAll } from 'vitest';`,
1869
+ `import { describe, it, expect, beforeEach, afterEach } from 'vitest'; // ADR-087: per-test isolation`,
1571
1870
  `import Database from 'better-sqlite3';`,
1572
1871
  `import { ConsoleTelemetry } from '../infra/telemetry.js';`,
1573
1872
  '',
@@ -1591,8 +1890,37 @@ function tsContextIntegrationTest(ctx) {
1591
1890
  ' action TEXT NOT NULL,',
1592
1891
  ' actor TEXT NOT NULL,',
1593
1892
  ' payload TEXT,',
1893
+ ' previous_hash TEXT,',
1894
+ ' actor_role TEXT,',
1895
+ ' source_ip TEXT,',
1896
+ ' request_id TEXT,',
1594
1897
  ' created_at TEXT NOT NULL',
1595
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,',
1906
+ ' created_at TEXT NOT NULL',
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
+ ' )\`);',
1596
1924
  ' return {',
1597
1925
  ' db,',
1598
1926
  ' client: {',
@@ -1615,7 +1943,8 @@ function tsContextIntegrationTest(ctx) {
1615
1943
  ' let testDb: ReturnType<typeof createTestDb>;',
1616
1944
  ' let adapter: any;',
1617
1945
  '',
1618
- ' beforeAll(async () => {',
1946
+ ' // ADR-087: Per-test isolation — fresh database for each test',
1947
+ ' beforeEach(async () => {',
1619
1948
  ' testDb = createTestDb();',
1620
1949
  ' const telemetry = new ConsoleTelemetry();',
1621
1950
  ` // Dynamically import the real adapter`,
@@ -1624,7 +1953,7 @@ function tsContextIntegrationTest(ctx) {
1624
1953
  ` adapter = factory.${camelCase(ctx.name)};`,
1625
1954
  ' });',
1626
1955
  '',
1627
- ' afterAll(() => { testDb?.db?.close(); });',
1956
+ ' afterEach(() => { testDb?.db?.close(); });',
1628
1957
  '',
1629
1958
  ` it('execute() inserts a real row into ${tableName}', async () => {`,
1630
1959
  ` const result = await adapter.execute('CreateTest', { name: 'integration-test' });`,
@@ -1680,7 +2009,181 @@ function tsContextIntegrationTest(ctx) {
1680
2009
  ].join('\n');
1681
2010
  }
1682
2011
  // ============================================================================
2012
+ // ADR-087: API Route Test Generator
1683
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
+ }
1684
2187
  // ADR-067: LLM Prompt Builders
1685
2188
  // ============================================================================
1686
2189
  function buildSchemasPrompt(ctx) {
@@ -1735,15 +2238,47 @@ Generate a dependencies.ts file that:
1735
2238
  - execute() with domain command dispatch (switch on command name), precondition enforcement, invariant checks, and domain event emission
1736
2239
  4. Defines a DecisionPort with state machine (draft→submitted→approved→rejected→executed) — separate operations, NOT auto-advance
1737
2240
  5. Self-approval blocked (approver !== submitter)
1738
- 6. hashSimulationInputs() using SHA-256 for lineage tracking
1739
- 7. validateLineage() that checks decision status, simulation existence, and hash match
1740
- 8. Dependencies interface and createDependencies factory
1741
- ${ctx.simulationContext?.scenarios?.length ? '9. ISimulationPort for reading embedded simulation results' : ''}
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' : ''}
1742
2249
 
1743
2250
  CRITICAL: Use the Database class for persistence. Do NOT use in-memory Map or Array for any state.
1744
2251
  CRITICAL: Validate DB results with Zod schemas before returning — no unsafe casts.
1745
2252
  CRITICAL: Domain invariants from the DDD model must be runtime assertions, not comments.
1746
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
+
1747
2282
  Return ONLY valid TypeScript code. No markdown fences. No explanation.`;
1748
2283
  }
1749
2284
  function buildRoutesPrompt(ctx, erpSystem) {
@@ -1776,11 +2311,11 @@ ${ctx.techStack.hasRustOptimizer ? '2. Imports runOptimizer from ../services/opt
1776
2311
  - Error logging with structured JSON
1777
2312
  6. Decision approval workflow endpoints:
1778
2313
  - POST /decisions (submit — requires writer)
1779
- - POST /decisions/:id/approve (requires approver, validates approver !== submitter)
1780
- - POST /decisions/:id/reject (requires approver, reason min 10 chars)
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)
1781
2316
  - POST /decisions/:id/execute (requires erp_writer, validates lineage before ERP writeback)
1782
- - GET /decisions/:id and GET /decisions (viewer)
1783
- - GET /decisions/:id/lineage (viewer)
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
1784
2319
  ${ctx.techStack.hasRustOptimizer ? `7. POST /api/v1/simulate endpoint: validates SimulateInputSchema, calls runOptimizer(), validates output with Zod, computes input hash, audit logs
1785
2320
  8. GET /api/v1/simulation/scenarios, /scenarios/:id, /compare, /cost-model endpoints` : ''}
1786
2321
  9. GET /api/v1/cost/tradeoff-matrix and /budget-compliance endpoints
@@ -1793,6 +2328,107 @@ CRITICAL: The governance approve endpoint must NOT auto-create or auto-submit de
1793
2328
  CRITICAL: The DB client must THROW on any operation when DATABASE_URL is not configured — no silent empty returns.
1794
2329
  CRITICAL: Separation of duties: approver cannot be the same actor who submitted the decision.
1795
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
+
1796
2432
  Return ONLY valid TypeScript code. No markdown fences. No explanation.`;
1797
2433
  }
1798
2434
  // Rust Mock Data Helpers (unchanged — Rust routes remain mocked)
@@ -2301,7 +2937,7 @@ export function generateHttpServer(context) {
2301
2937
  id: s.id,
2302
2938
  name: s.name,
2303
2939
  scores: {
2304
- cost: Math.max(0, Math.min(1, 1 - Math.abs(s.costDelta) / 10_000_000)), // normalize cost
2940
+ cost: Math.max(0, Math.min(1, 1 - Math.abs(s.costDelta) / parseFloat(process.env.OPT_COST_NORM_FACTOR ?? '10000000'))), // ADR-071: parameterized
2305
2941
  emissions: s.reliability, // use reliability as proxy for emissions improvement
2306
2942
  resilience: s.riskLevel === 'low' ? 0.9 : s.riskLevel === 'medium' ? 0.6 : 0.3,
2307
2943
  complexity: s.mitigationStrategy ? 0.5 : 0.8, // strategies with mitigations are more complex
@@ -2326,8 +2962,10 @@ export function generateHttpServer(context) {
2326
2962
  });
2327
2963
  let routeCount = 0;
2328
2964
  let routesContent;
2965
+ let routeSource = 'template';
2329
2966
  if (routesLLM.code) {
2330
2967
  routesContent = routesLLM.code;
2968
+ routeSource = 'llm';
2331
2969
  // Estimate route count from LLM output
2332
2970
  routeCount = (routesContent.match(/app\.(get|post|put|delete)\(/g) ?? []).length;
2333
2971
  }
@@ -2336,6 +2974,35 @@ export function generateHttpServer(context) {
2336
2974
  routesContent = templateResult.content;
2337
2975
  routeCount = templateResult.routeCount;
2338
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
+ }
2339
3006
  const routesPath = join(serverDir, 'routes.ts');
2340
3007
  writeServerFile(routesPath, routesContent);
2341
3008
  serverFiles.push({ relativePath: 'project/src/server/routes.ts', content: routesContent, kind: 'server' });
@@ -2354,6 +3021,42 @@ export function generateHttpServer(context) {
2354
3021
  writeServerFile(eventsPath, eventsContent);
2355
3022
  serverFiles.push({ relativePath: 'project/src/events/domain-events.ts', content: eventsContent, kind: 'server' });
2356
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
+ }
2357
3060
  // health.ts
2358
3061
  const healthContent = tsHealth();
2359
3062
  const healthPath = join(serverDir, 'health.ts');
@@ -2458,6 +3161,14 @@ export function generateHttpServer(context) {
2458
3161
  serverFiles.push({ relativePath: `project/src/__tests__/${ctxSlug}.test.ts`, content: testContent, kind: 'server' });
2459
3162
  writtenPaths.push(testPath);
2460
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);
2461
3172
  // ADR-055: Per-context integration tests (real SQLite, tests actual SQL)
2462
3173
  const integrationTestDir = join(projectDir, 'src', '__tests__', 'integration');
2463
3174
  mkdirSync(integrationTestDir, { recursive: true, mode: DIR_MODE });