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
SignalEnvelopeand render-onlyRunPresentation.
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:
- Ramp + pace. Increase the issue rate from 0 to
workload.rpsoverworkload.rampUpseconds, then sustain fordurationseconds. - Drive the target. Per request: call
config.target, bounded byworkload.concurrencyin-flight; resolve = success, throw/abort = failure. Measure real wall-clock latency. - Collect. Aggregate
SimulationMetrics— total/successful/failed requests, error count, p50/p95/p99 latency. - Assert. Run each
assertionagainst the measured metrics. The scenario passes iff every assertion passes.
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:
- Build the fault model from
config.fault(with an injectable RNG —Math.randomin production, a stub in tests). Run the steady-state window:runLoadWindow({ workload }, ctx, { windowMs: duration*1000, target: faultModel.wrap(config.target) }). Per request, atfault.probabilitythe model perturbs the real call — adds latency, aborts it, or drops it (recording aChaosEvent); otherwise the call passes through unperturbed. - Run the recovery window:
runLoadWindow({ workload }, ctx, { windowMs: recoveryWindowMs, target: config.target })— the bare target, faults lifted. - Evaluate the
steadyStateAssertionsagainst the steady window's measured metrics. - Evaluate the
recoveryAssertionsagainst the recovery window's measured metrics.
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:
sequential— one scenario at a time. The default for sim recipes. Required for load and chaos scenarios that drive the same target system: running them in parallel would create cross-contamination (latency injected for chaos #1 affects load #2's measurements).parallel— N scenarios at once, bounded bymaxParallel. Safe for scenarios that fan out across independent inputs and don't share a target system.
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:
- No retry. A failed scenario fails the run. The framework doesn't retry on the assumption that flakiness is itself a failure mode worth surfacing. If a scenario is genuinely non-deterministic, the author should fix it (use a deterministic RNG, raise tolerances) rather than depending on retry.
- No cross-scenario state. Each scenario's executor gets a fresh context. Scenario A can't tell Scenario B "I left a record in the database for you." Composition happens at the recipe level (run A then B), not at the data level.
- No incremental mode. The full recipe always runs. Unlike fit, where the gate baseline lets you tolerate existing failures, sim has no equivalent. A failing load scenario fails the run, every run.
- No live progress streaming over JSON.
--jsonmode prints the result after the run completes. Live streaming (which fit's Ink view does) is the table-mode path; CI consumers see the final shape.
Where the example lands
For acme-api running opensip sim --recipe pre-deploy:
- The recipe selects
{ type: 'tags', include: ['load', 'chaos'] }. Three scenarios match:api-checkout-burst(load),payment-burst(load),checkout-resilient-under-fault(chaos). - Mode is
sequential. Total expected duration: ~5 minutes. - Scenario 1 (
api-checkout-burst) runs for 30s. Hits 200 RPS, p99 = 173ms, error rate = 0.4%. All assertions pass. ✓ - Scenario 2 (
payment-burst) runs for 60s. Hits 50 RPS, p99 = 1100ms — fails thep99-under-500msassertion. ✗ - Scenario 3 (
checkout-resilient-under-fault) runs a 30s steady-state window with latency/drop faults injected at 10%, then a 10s recovery window. The recovery window's p95 stays at 1300ms — fails the recoveryASSERTIONS.lowLatency('p95', 500)assertion. ✗ - Two scenario units failed, so
envelope.verdict.passed === false; the host derives exit code 1 from the envelope verdict. CI's pre-deploy job blocks the 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
../80-implementation/— how the CLI dispatches both fit and sim. Plugin loading, session writing, persistence layout.../50-extend/01-plugin-authoring.md— author your first sim scenario.