@llm-dev-ops/agentics-cli 1.5.74 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/pipeline/phase4/phase4-coordinator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phase4-coordinator.js +2 -0
- package/dist/pipeline/phase4/phase4-coordinator.js.map +1 -1
- package/dist/pipeline/phase4/phases/build-time-services.d.ts +28 -0
- package/dist/pipeline/phase4/phases/build-time-services.d.ts.map +1 -0
- package/dist/pipeline/phase4/phases/build-time-services.js +232 -0
- package/dist/pipeline/phase4/phases/build-time-services.js.map +1 -0
- package/dist/pipeline/phase4/phases/deployment-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/deployment-generator.js +107 -15
- package/dist/pipeline/phase4/phases/deployment-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/erp-client-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/erp-client-generator.js +303 -52
- package/dist/pipeline/phase4/phases/erp-client-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/http-server-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/http-server-generator.js +748 -37
- package/dist/pipeline/phase4/phases/http-server-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/infra-adapter-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/infra-adapter-generator.js +51 -0
- package/dist/pipeline/phase4/phases/infra-adapter-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/platform-catalog.d.ts +48 -0
- package/dist/pipeline/phase4/phases/platform-catalog.d.ts.map +1 -0
- package/dist/pipeline/phase4/phases/platform-catalog.js +242 -0
- package/dist/pipeline/phase4/phases/platform-catalog.js.map +1 -0
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.d.ts +1 -1
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.js +176 -31
- package/dist/pipeline/phase4/phases/rust-optimizer-generator.js.map +1 -1
- package/dist/pipeline/phase4/phases/schema-generator.d.ts.map +1 -1
- package/dist/pipeline/phase4/phases/schema-generator.js +22 -0
- package/dist/pipeline/phase4/phases/schema-generator.js.map +1 -1
- package/dist/pipeline/phase4/types.d.ts +1 -1
- package/dist/pipeline/phase4/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
'
|
|
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
|
|
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
|
|
552
|
-
lines.push(
|
|
553
|
-
lines.push(
|
|
554
|
-
lines.push(`
|
|
555
|
-
lines.push(`
|
|
556
|
-
lines.push(
|
|
557
|
-
lines.push('
|
|
558
|
-
lines.push('
|
|
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('
|
|
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:
|
|
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 +=
|
|
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,
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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.
|
|
1739
|
-
7.
|
|
1740
|
-
8.
|
|
1741
|
-
|
|
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) /
|
|
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 });
|