* feat(simulation): MiroFish Phase 2 — theater-limited simulation runner Adds the simulation execution layer that consumes simulation-package.json and produces simulation-outcome.json for maritime chokepoint + energy/logistics theaters, closing the WorldMonitor → MiroFish handoff loop. Changes: - scripts/seed-forecasts.mjs: 2-round LLM simulation runner (prompt builders, JSON extractor, runTheaterSimulation, writeSimulationOutcome, task queue with NX dedup lock, runSimulationWorker poll loop) - scripts/process-simulation-tasks.mjs: standalone worker entry point - proto: GetSimulationOutcome RPC + make generate - server/worldmonitor/forecast/v1/get-simulation-outcome.ts: RPC handler - server/gateway.ts: slow tier for get-simulation-outcome - api/health.js: simulationOutcomeLatest in STANDALONE + ON_DEMAND keys - tests: 14 new tests for simulation runner functions * fix(simulation): address P1/P2 code review findings from PR #2220 Security (P1 #018): - sanitizeForPrompt() applied to all entity/seed fields interpolated into Round 1 prompt (entityId, class, stance, seedId, type, timing) - sanitizeForPrompt() applied to actorId and entityIds in Round 2 prompt - sanitizeForPrompt() + length caps applied to all LLM array fields written to R2 (dominantReactions, stabilizers, invalidators, keyActors, timingMarkers) Validation (P1 #019): - Added validateRunId() regex guard - Applied in enqueueSimulationTask() and processNextSimulationTask() loop Type safety (P1 #020): - Added isOutcomePointer() and isPackagePointer() type guards in TS handlers - Replaced unsafe as-casts with runtime-validated guards in both handlers Correctness (P2 #022): - Log warning when pkgPointer.runId does not match task runId Architecture (P2 #024): - isMaritimeChokeEnergyCandidate() accepts both flat and nested topBucketId - Call site simplified to pass theater directly Performance (P2 #025): - SIMULATION_ROUND1_MAX_TOKENS raised 1800 to 2200 - Added max 3 initialReactions instruction to Round 1 prompt Maintainability (P2 #026): - Simulation pointer keys exported from server/_shared/cache-keys.ts - Both TS handlers import from shared location Documentation (P2 #027): - Strengthened runId no-op description in proto and OpenAPI spec * fix(todos): add blank lines around lists in markdown todo files * style(api): reformat openapi yaml to match linter output * test(simulation): add flat-shape filter test + getSimulationOutcome handler coverage Two tests identified as missing during PR #2220 review: 1. isMaritimeChokeEnergyCandidate flat-shape tests — covers the || candidate.topBucketId normalization added in the P1/P2 review pass. The existing tests only used the nested marketContext.topBucketId shape; this adds the flat root-field shape that arrives from the simulation-package.json JSON (selectedTheaters entries have topBucketId at root). 2. getSimulationOutcome handler structural tests — verifies the isOutcomePointer guard, found:false NOT_FOUND return, found:true success path, note population on runId mismatch, and redis_unavailable error string. Follows the readSrc static-analysis pattern used elsewhere in server-handlers.test.mjs (handler imports Redis so full integration test would require a test Redis instance).
3.3 KiB
status, priority, issue_id, tags
| status | priority | issue_id | tags | ||||
|---|---|---|---|---|---|---|---|
| complete | p1 | 020 |
|
Unvalidated as cast on getRawJson result in get-simulation-outcome.ts
Problem Statement
getRawJson returns Promise<unknown | null>. The handler casts the result with as { runId: string; outcomeKey: string; ... } | null — a TypeScript compile-time assertion with no runtime enforcement. If Redis contains a malformed value (wrong shape, missing fields, renamed keys from a schema migration), pointer.runId, pointer.outcomeKey, etc. would be undefined, and the handler returns a partially-populated GetSimulationOutcomeResponse with undefined values spread into proto fields. The same pattern exists in get-simulation-package.ts and should be fixed in both files simultaneously.
Findings
F-1 (P1): TypeScript as cast provides zero runtime protection:
// server/worldmonitor/forecast/v1/get-simulation-outcome.ts line 21
const pointer = await getRawJson(SIMULATION_OUTCOME_LATEST_KEY) as {
runId: string; outcomeKey: string; schemaVersion: string; theaterCount: number; generatedAt: number;
} | null;
// If Redis has { run_id: 'x', outcome_key: 'y' } (snake_case), pointer.runId === undefined
// Handler returns { found: true, runId: undefined, ... } — malformed response
F-2 (P2): Same pattern in get-simulation-package.ts line ~21 — fix both together.
Proposed Solutions
Option A: Add a type guard function (Recommended)
// server/worldmonitor/forecast/v1/get-simulation-outcome.ts
function isOutcomePointer(v: unknown): v is {
runId: string; outcomeKey: string; schemaVersion: string; theaterCount: number; generatedAt: number;
} {
if (typeof v !== 'object' || v === null) return false;
const p = v as Record<string, unknown>;
return typeof p['runId'] === 'string'
&& typeof p['outcomeKey'] === 'string'
&& typeof p['schemaVersion'] === 'string'
&& typeof p['theaterCount'] === 'number'
&& typeof p['generatedAt'] === 'number';
}
// In handler:
const raw = await getRawJson(SIMULATION_OUTCOME_LATEST_KEY);
if (!isOutcomePointer(raw)) {
markNoCacheResponse(ctx.request);
return NOT_FOUND; // treat malformed as not-found
}
const pointer = raw; // fully typed, no cast
Effort: Small | Risk: Low — safe degradation to NOT_FOUND on invalid data
Option B: Use zod schema validation (heavier but more maintainable)
Add a z.object({...}).safeParse() call. Only viable if zod is already in the project dependencies.
Acceptance Criteria
get-simulation-outcome.tsuses a type guard instead ofascast- Malformed Redis value returns
NOT_FOUNDresponse (not a partially-populated response) get-simulation-package.tsreceives the same fix simultaneously- TypeScript strict mode still passes after the change (no
anyintroduced) - Test: mocked
getRawJsonreturning{ run_id: 'x' }(wrong key names) → handler returnsfound: false
Technical Details
- File:
server/worldmonitor/forecast/v1/get-simulation-outcome.tslines 21-23 - File:
server/worldmonitor/forecast/v1/get-simulation-package.tslines ~21-23 (same pattern) getRawJsonreturn type:Promise<unknown | null>— correct to return unknown
Work Log
- 2026-03-24: Found by compound-engineering:review:kieran-typescript-reviewer in PR #2220 review