Sim execution model

The sim engine has the same architectural shape as fit's recipe engine — selector → list → dispatch → run → aggregate → render — but the per-scenario executor is kind-specific. This doc walks the run.

What you'll understand after this:

- The two executors (load, chaos) and what each does.

- How sequential vs. parallel execution affects scenarios that share state.

- The shape of the per-kind executor result.

- How the run aggregates into a SignalEnvelope and render-only RunPresentation.


The lifecycle

opensip sim --recipe <name>
  → simulationTool.action(opts)
       → executeSim(args)                                                   ↓
            → loadProjectConfig + load sim-domain plugins                     │
            → resolve recipe (or default)                                    │
            → expand selector → scenario list                                │
            → for each scenario (in mode order):                             │
                 ┌─────────────────────────────────────────────────────┐    │
                 │  await scenario.run(ctx)   /* polymorphic */        │    │
                 │                                                     │    │
                 │  Extension points (not runtime dispatch — the       │    │
                 │  RunnableScenario produced by define*Scenario       │    │
                 │  already carries the kind-bound run() method):      │    │
                 │      kind 'load'   → kinds/load/executor            │    │
                 │      kind 'chaos'  → kinds/chaos/executor           │    │
                 └─────────────────────────────────────────────────────┘    │
            → aggregate results into SignalEnvelope + RunPresentation         │
       → render (Ink or JSON)                                                │
       → set exit code (1 if any scenario failed)                            ▼
                                                                       shell prompt

Both executors live under packages/simulation/engine/src/kinds/<kind>/executor.ts. Each accepts a kind-specific config (validated at define*Scenario time, carrying the BYO target + workload) and a ScenarioExecutionContext (signal/abort, correlation id, logger).


The executors

Load executor

packages/simulation/engine/src/kinds/load/executor.ts

The load executor delegates to the shared runLoadWindow driver

(packages/simulation/engine/src/framework/execution/run-load-window.ts). Its job:

The result type is LoadScenarioExecutorResult — carries the SimulationMetrics plus the assertion verdicts.

A load scenario that's interrupted (signal abort, timeout) drains in-flight requests, then returns the metrics collected up to the abort.

Chaos executor

packages/simulation/engine/src/kinds/chaos/executor.ts

The chaos executor drives the same shared runLoadWindow driver, but wraps the target with the fault model (packages/simulation/engine/src/framework/execution/fault-model.ts) for the steady-state window. The full sequence:

The result type is ChaosScenarioExecutorResult — steady + recovery SimulationMetrics, the per-phase assertion verdicts, and the ChaosEvent[]. Pass/fail is the AND of every steady-state and recovery assertion.

The shipped fault vocabulary (packages/simulation/engine/src/framework/execution/fault-spec.ts) is the client-side set: 'latency' (delay the call), 'abort' (cancel the in-flight request), 'drop' (skip it, counting a client-observed failure). Server-side faults (500s, killed dependencies) are not injected here — point the target at a fault-injectable endpoint you control (see the honesty boundary in 01-scenarios-and-recipes.md).


Sequential vs. parallel

The recipe's execution.mode decides ordering:

The recipe service (packages/simulation/engine/src/recipes/service.ts) dispatches based on mode. Sequential dispatch awaits each scenario's result before starting the next; parallel uses a Promise.all-with-concurrency wrapper similar to fit's parallel dispatcher.

A recipe's kind selector narrows the selected scenarios to one or more kinds before they run — non-matching scenarios (which have real side effects) are never executed.


The aggregated result

After every scenario runs, the recipe service builds the run's SignalEnvelope (each scenario is a unit that produces signals, ADR-0011) and returns it inside the shared render-only RunPresentation (packages/contracts/src/run-presentation.ts):

interface RunPresentation {
  type: 'run-presentation';
  tool: 'simulation';
  envelope: SignalEnvelope;     // the run's signals + verdict + per-scenario units
  verboseDetail?: VerboseDetail;
}

There is no pass/fail boolean on the result: the run verdict lives on envelope.verdict (ADR-0035 — the tool declares its policy; the host derives the findings exit code from the envelope when the signals are delivered, so sim never calls setExitCode for scenario failures).

RunPresentation is the single run-shaped CommandResult union member the renderer consumes. It replaced the old per-tool *DoneResult variants, including sim-done. The shared render path derives the per-scenario table from envelope.units (one unit per scenario — slug = scenario id, passed, durationMs, error?). The --json output never serializes RunPresentation; it wraps the same envelope in a CommandOutcome (the byte-identical SignalEnvelope fit and graph emit, nested under .envelope). See 70-reference/04-json-output-schema.md.

Per-kind details (the load p99, the chaos recovery time) are not in the envelope. They're in the executor result, which rides in the session's session_tool_payload row persisted to the project-local SQLite store (<project>/opensip-cli/.runtime/datastore.sqlite) via SessionRepo. The dashboard reads the session record to show full per-kind detail; the CLI summary stays compact.


Exit semantics

Same shape as fit:

| Code | Meaning |

|---|---|

| 0 | Every scenario passed. |

| 1 | At least one scenario failed (assertion violation, predicate failure, timeout). |

| 2 | Runtime error before scenarios could run (config invalid, plugin failed to load, agent provider unreachable). |

CI integrations can gate on the exit code or parse the JSON output — same as fit.


What this doesn't do

A few intentional non-features:


Where the example lands

For acme-api running opensip sim --recipe pre-deploy:

The session record carries the full per-scenario results; the dashboard shows latency histograms, the chaos recovery curve, and the assertion verdicts.


What's next