| name | sim-scaffold |
| description | Scaffold simulation-testable modules with sans-IO pattern, proptest state machine tests, and fault injection points — prevents retrofitting costs by making code DST-ready from the start |
| user-invocable | true |
Scaffold Simulation-Testable Module
Generate boilerplate for new modules that are deterministic simulation testing
(DST) ready from the start. Prevents the costly retrofitting that FoundationDB
avoided by building simulation infrastructure before writing database code.
Evidence Base
| Source | Pattern Used |
|---|
| Firezone (sans-IO blog) | Pure state machine core, effects as return values |
| sled (simulation.html) | receive(msg, at) -> [(msg, destination)] pattern |
| Stateright | Actor trait: on_msg(state, msg) -> Vec<Action> |
| FoundationDB (SIGMOD 2021) | Simulation-first architecture, SimContext |
| proptest | State machine testing with prop_state_machine! |
When to Use
- Creating a new module in
gossip-coordination/
- Adding a new gossip protocol component
- Building a new pipeline stage that touches coordination
- Any new code that manages distributed state, leases, or shard lifecycle
When NOT to Use
- Adding helper functions to existing modules (use
/sim-review instead)
- Detection engine rules or regex patterns
- Pure data types with no state transitions
Procedure
Step 1: Determine Module Type
Ask the user which type of module they are building:
| Type | Description | Template |
|---|
| A: Coordination module | Manages distributed state (shards, leases, epochs) | State machine + InMemory backend |
| B: Gossip protocol | Message-passing protocol between nodes | Sans-IO state machine |
| C: Pipeline component | Processing stage with checkpointing | Effect-based with trait boundaries |
Step 2: Gather Invariants
Ask the user:
- What are the key state transitions? (e.g., Open -> Acquired -> Checkpointing -> Done)
- What invariants must hold? (e.g., "only one worker holds a lease at a time")
- What are the error conditions? (e.g., "lease expired", "fence rejected")
- What external dependencies exist? (e.g., persistence, network, timers)
Step 3: Generate Scaffold
Type A: Coordination Module
Generate this file structure under the appropriate crate:
src/<module>/
mod.rs — Public API, module docs, re-exports
state.rs — State machine types
logic.rs — Pure state transition functions
sim.rs — Simulation harness (behind test-support feature)
tests/
proptest_sm.rs — proptest state machine tests
invariants.rs — Invariant assertion functions
Prerequisite: LogicalTime must be defined in
gossip_contracts::coordination before the scaffold compiles. If the
coordination contracts module is still a doc stub, define LogicalTime
there first (or use a temporary type alias).
state.rs template:
use gossip_contracts::coordination::LogicalTime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum State {
}
#[derive(Debug, Clone)]
pub enum Input {
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
}
logic.rs template:
use super::state::{Effect, Input, State};
use gossip_contracts::coordination::LogicalTime;
pub fn transition(
state: &State,
input: &Input,
now: LogicalTime,
) -> (State, Vec<Effect>) {
match (state, input) {
_ => (state.clone(), vec![]),
}
}
#[cfg(test)]
mod tests {
use super::*;
}
sim.rs template:
#![cfg(feature = "test-support")]
use std::collections::BinaryHeap;
use rand::rngs::StdRng;
use rand::SeedableRng;
use gossip_contracts::coordination::LogicalTime;
use super::logic;
use super::state::{Effect, Input, State};
pub struct SimContext {
pub rng: StdRng,
pub clock: LogicalTime,
pub seed: u64,
}
impl SimContext {
pub fn new(seed: u64) -> Self {
Self {
rng: StdRng::seed_from_u64(seed),
clock: LogicalTime::default(),
seed,
}
}
pub fn advance(&mut self, ticks: u64) {
self.clock = self.clock.advance(ticks);
}
}
#[derive(Debug, Clone, Default)]
pub struct FaultConfig {
pub effect_drop_rate: f64,
pub expire_lease_after: Option<usize>,
pub inject_pause: bool,
}
pub struct SimHarness {
pub ctx: SimContext,
pub states: Vec<State>,
pub effects_log: Vec<(usize, Effect)>,
pub fault_config: FaultConfig,
op_count: usize,
}
impl SimHarness {
pub fn new(seed: u64, initial_states: Vec<State>) -> Self {
Self {
ctx: SimContext::new(seed),
states: initial_states,
effects_log: Vec::new(),
fault_config: FaultConfig::default(),
op_count: 0,
}
}
pub fn apply(&mut self, instance: usize, input: &Input) -> Vec<Effect> {
let (new_state, effects) =
logic::transition(&self.states[instance], input, self.ctx.clock);
self.states[instance] = new_state;
self.op_count += 1;
for effect in &effects {
self.effects_log
.push((instance, effect.clone()));
}
effects
}
pub fn check_invariants(&self) -> Vec<String> {
let mut violations = Vec::new();
violations
}
}
tests/proptest_sm.rs template:
#![cfg(all(test, feature = "test-support"))]
use proptest::prelude::*;
use super::sim::{FaultConfig, SimHarness};
use super::state::Input;
fn arb_input() -> impl Strategy<Value = Input> {
prop_oneof![
]
}
fn arb_input_sequence(max_len: usize) -> impl Strategy<Value = Vec<Input>> {
prop::collection::vec(arb_input(), 1..=max_len)
}
proptest! {
#[test]
fn invariants_hold_sunny_day(
seed in any::<u64>(),
inputs in arb_input_sequence(50),
) {
let mut harness = SimHarness::new(seed, vec![]);
for input in &inputs {
harness.apply(0, input);
let violations = harness.check_invariants();
prop_assert!(
violations.is_empty(),
"Invariant violations after {:?}: {:?}",
input,
violations,
);
}
}
#[test]
fn invariants_hold_with_faults(
seed in any::<u64>(),
inputs in arb_input_sequence(50),
drop_rate in 0.0..0.5f64,
) {
let mut harness = SimHarness::new(seed, vec![]);
harness.fault_config = FaultConfig {
effect_drop_rate: drop_rate,
..Default::default()
};
for input in &inputs {
harness.apply(0, input);
let violations = harness.check_invariants();
prop_assert!(
violations.is_empty(),
"Invariant violations under faults (drop_rate={}) after {:?}: {:?}",
drop_rate,
input,
violations,
);
}
}
}
tests/invariants.rs template:
#![cfg(all(test, feature = "test-support"))]
use super::state::State;
pub fn check_invariant_name(states: &[State]) -> Result<(), String> {
Ok(())
}
Type B: Gossip Protocol Module
Generate this file structure:
src/<module>/
mod.rs — Public API, module docs
protocol.rs — Sans-IO state machine
messages.rs — Protocol message types
sim.rs — SimNetwork connecting N protocol instances
tests/
proptest_convergence.rs — Convergence property tests
proptest_partitions.rs — Partition tolerance tests
protocol.rs template (sans-IO pattern):
use std::collections::VecDeque;
use gossip_contracts::coordination::LogicalTime;
use super::messages::{GossipMessage, NodeId, Transmit};
#[derive(Debug, Clone)]
pub struct ProtocolConfig {
pub node_id: NodeId,
pub gossip_interval: u64,
pub failure_timeout: u64,
pub fanout: usize,
}
pub struct GossipProtocol {
config: ProtocolConfig,
outbox: VecDeque<Transmit>,
next_gossip: Option<LogicalTime>,
}
impl GossipProtocol {
pub fn new(config: ProtocolConfig, now: LogicalTime) -> Self {
Self {
next_gossip: Some(now.advance(config.gossip_interval)),
outbox: VecDeque::new(),
config,
}
}
pub fn handle_input(&mut self, msg: &GossipMessage, now: LogicalTime) {
match msg {
_ => {}
}
}
pub fn poll_transmit(&mut self) -> Option<Transmit> {
self.outbox.pop_front()
}
pub fn poll_timeout(&self) -> Option<LogicalTime> {
self.next_gossip
}
pub fn handle_timeout(&mut self, now: LogicalTime) {
if self.next_gossip.is_some_and(|t| now >= t) {
self.do_gossip_round(now);
self.next_gossip = Some(now.advance(self.config.gossip_interval));
}
}
fn do_gossip_round(&mut self, _now: LogicalTime) {
}
}
sim.rs template (SimNetwork):
#![cfg(feature = "test-support")]
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use rand::rngs::StdRng;
use rand::SeedableRng;
use gossip_contracts::coordination::LogicalTime;
use super::messages::{NodeId, Transmit};
use super::protocol::{GossipProtocol, ProtocolConfig};
#[derive(Debug)]
struct ScheduledEvent {
time: LogicalTime,
kind: EventKind,
}
#[derive(Debug)]
enum EventKind {
Deliver(Transmit),
Timeout(NodeId),
}
impl PartialEq for ScheduledEvent {
fn eq(&self, other: &Self) -> bool {
self.time == other.time
}
}
impl Eq for ScheduledEvent {}
impl PartialOrd for ScheduledEvent {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for ScheduledEvent {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
Reverse(self.time).cmp(&Reverse(other.time))
}
}
#[derive(Debug, Clone, Default)]
pub struct NetworkFaults {
pub drop_rate: f64,
pub max_delay: u64,
pub partitions: Vec<(NodeId, NodeId)>,
}
pub struct SimNetwork {
pub nodes: Vec<GossipProtocol>,
pub rng: StdRng,
pub clock: LogicalTime,
event_queue: BinaryHeap<ScheduledEvent>,
pub faults: NetworkFaults,
pub seed: u64,
}
impl SimNetwork {
pub fn new(seed: u64, n: usize, config_fn: impl Fn(usize) -> ProtocolConfig) -> Self {
let clock = LogicalTime::default();
let nodes = (0..n)
.map(|i| GossipProtocol::new(config_fn(i), clock))
.collect::<Vec<_>>();
let mut net = Self {
nodes,
rng: StdRng::seed_from_u64(seed),
clock,
event_queue: BinaryHeap::new(),
faults: NetworkFaults::default(),
seed,
};
for (i, node) in net.nodes.iter().enumerate() {
if let Some(t) = node.poll_timeout() {
net.event_queue.push(ScheduledEvent {
time: t,
kind: EventKind::Timeout(NodeId(i as u64)),
});
}
}
net
}
pub fn run(&mut self, max_ticks: u64) -> usize {
let deadline = self.clock.advance(max_ticks);
let mut events_processed = 0;
while let Some(event) = self.event_queue.pop() {
if event.time > deadline {
self.event_queue.push(event);
break;
}
self.clock = event.time;
events_processed += 1;
match event.kind {
EventKind::Deliver(transmit) => {
let dst = transmit.destination.0 as usize;
if dst < self.nodes.len() {
self.nodes[dst].handle_input(&transmit.message, self.clock);
self.drain_outbox(dst);
}
}
EventKind::Timeout(node_id) => {
let idx = node_id.0 as usize;
if idx < self.nodes.len() {
self.nodes[idx].handle_timeout(self.clock);
self.drain_outbox(idx);
if let Some(t) = self.nodes[idx].poll_timeout() {
self.event_queue.push(ScheduledEvent {
time: t,
kind: EventKind::Timeout(node_id),
});
}
}
}
}
}
events_processed
}
fn drain_outbox(&mut self, node_idx: usize) {
while let Some(transmit) = self.nodes[node_idx].poll_transmit() {
self.event_queue.push(ScheduledEvent {
time: self.clock.advance(1),
kind: EventKind::Deliver(transmit),
});
}
}
pub fn check_convergence(&self) -> bool {
true
}
}
Type C: Pipeline Component
Generate this file structure:
src/<component>/
mod.rs — Public API
state.rs — Processing state types
logic.rs — Pure processing logic
traits.rs — I/O trait boundaries
sim.rs — Simulation with mock I/O
tests/
proptest_processing.rs
Use the same SimContext pattern as Type A, but the trait boundaries focus on
I/O operations (read source data, write checkpoints, emit findings) rather
than distributed coordination.
Step 4: Verify Scaffold Compiles
After generating the scaffold:
cargo check --all-features
If compilation fails, fix the generated code. Common issues:
- Missing imports (add
use statements)
- Type mismatches with existing crate types
- Feature gate misalignment
Step 5: Generate Initial Tests
Run the generated proptest to verify the scaffold works:
cargo test --features test-support -- <module>::tests
The initial tests should pass (they test the empty/default state machine).
Step 6: Report
SIM-SCAFFOLD REPORT
════════════════════
Module: <name>
Type: {Coordination | Gossip Protocol | Pipeline Component}
Files created: {count}
Pattern: {sans-IO | state machine + effects | trait boundary}
Invariants to implement:
1. {invariant from Step 2}
2. {invariant from Step 2}
Next steps:
1. Implement state transitions in logic.rs
2. Add proptest strategies for Input variants
3. Implement invariant checks in sim.rs
4. Run /sim-review on completed implementation
Quick Reference: Module Types
| Type A: Coordination | Type B: Gossip Protocol | Type C: Pipeline |
|---|
| Manages | Distributed state (shards, leases, epochs) | Message-passing between nodes | Processing stages with checkpoints |
| Core pattern | State machine + InMemory backend | Sans-IO (handle_input/poll_transmit/poll_timeout/handle_timeout) | Effect-based with trait boundaries |
| sim-review boundary | STRICT | STRICT | MODERATE |
| Time model | LogicalTime parameter on every op | LogicalTime parameter on every method | LogicalTime at checkpoint boundaries |
| Key test | Proptest state machine (invariants after every transition) | Proptest convergence + partition tolerance | Proptest processing correctness |
| Sim harness | SimHarness (single-node state machine) | SimNetwork (N-node discrete-event sim) | SimContext + mock I/O traits |
| Fault injection | Lease expiry, concurrent access, clock advance | Message loss/reorder, partitions, delays | I/O failure, corrupt input |
Related Skills
/sim-review — Review existing code for DST compatibility
/sim-run — Execute simulation tests
/test-strategy — Choose between unit/property/fuzz/simulation testing
/dist-sys-auditor — Validate distributed systems design decisions