Choose the interaction before choosing the engine
A handbook can mix interaction models. Mermaid is the canonical structural contract for flow-engine scenarios, not a universal requirement:
- Hierarchical flow:
.mmd+ domain behaviours +depth-core. - Stateful experiment: explicit events/transitions +
interactive-core, with the native input of the domain. Seekubernetes-domainand Pod lifecycle. - Direct manipulation: a pure domain function, as in the cardinality lab.
All paths owe the same source traceability, observable consequences and honest limits. A relationship tree is not necessarily a message-flow graph. The flow engine is optional; scientific and pedagogical fidelity are not.
The pipeline
- Question first. Open an issue whose title is the question the lab answers. If you cannot state it as a question, the lab is not ready to be written.
- Gather the sources. The OpenTelemetry specification, the official docs, the relevant RFC or W3C document. Collect links before writing anything.
- Distil with Claude. Feed the sources and the question; produce a draft scenario (topology, levels, armed failures) and a draft of the prose.
- Verify against the sources. Every technical claim in the draft must trace back to a link. Anything that cannot be traced is cut, not softened.
- Write the model and the page. The model lives in a domain package —
packages/<domain>/src/<lab>/world.ts— and the page is a React component inapps/site/src/components/<Lab>.tsx. See Writing a model below for the two ways to build the world. - Build the real counterpart.
labs/<slug>/with a compose that runs, and OTLP fixtures captured from it feeding theotel-domaintests. - Open the PR.
Claude never runs in the reader’s browser. The published site is static.
Rules that are not negotiable
- The book Learning OpenTelemetry supplies the teaching order only. No text from it is copied or closely paraphrased, and it is never the source of a technical claim.
- A simulation that does not react to input is not a lab. It is a figure — put it in the prose.
depth-coreanddepth-uimust never learn what OpenTelemetry is. If a lab needs a new primitive, it goes indepth-uiin domain-neutral terms.pnpm boundariesenforces this.- Every level shown must be a projection of the same state. If the payload view is hand-written to “look right”, the lab is lying and must be rewritten.
- Parameters (a rate, a batch size) can live at L0. Composition — dragging a processor into a pipeline — lives at L1 or deeper. The surface stays calm.
The shape of a lab page
- The question
- The model (the simulation)
- Break it (armed failure scenarios)
- Why it works this way (prose, with links to the spec)
- Run it for real (
labs/<slug>/) - Check yourself (2–3 questions, revealable answers)
Writing a model
A lab is a WorldSpec — objects, wires, parameters — that new World(spec) runs
tick by tick. There are two ways to build one, and new labs should use the first.
From a Mermaid skeleton (the default for new labs)
Decided on 2026-09-16 (docs/superpowers/specs/2026-09-16-mermaid-como-esqueleto-design.md):
the topology of a new lab is written in Mermaid, and no new scenario is written in
.modelet.yaml. The .mmd sits next to the behaviour that animates it —
packages/otel-domain/src/anatomia/anatomy.mmd is the worked example.
The skeleton carries structure only:
- a node per object, with its
kindapplied throughclassDef(classDef servico family:processor,kind:router+gateway[gateway]:::servico); - an edge per channel, whose label is
port:message-kind—gateway -- "call:request" --> checkoutdeclares the portcalland promises that what travels it is of kindrequest; - a
subgraphper composite; - parameters,
seedandedgeTicksin the YAML front matter.
Solid and dotted strokes are visual emphasis, not semantics: a control line is
declared by the control: prefix in the label, never by the stroke.
Behaviour stays in TypeScript. compileSkeleton(skeleton, { overrides }) takes the
drawing and your overrides — init, behavior, leaf per node id — and returns
the WorldSpec. The compiler builds source, buffer, sink and static on its
own; every other kind needs an override, and it tells you so by name when it
refuses.
Hand-written in TypeScript
The eight labs that predate the format build their WorldSpec directly. Nothing is
wrong with them and nothing forces a rewrite; a lab with heavy generated wiring
(the 32-bit adder declares hundreds of wires in a loop) is clearer this way.
What the engine guarantees, and what it does not
Three ways a lab can be wrong while the world stays valid, the run completes and no error appears. Each has a guard, and a new lab is expected to be under them:
| What happens | What catches it |
|---|---|
| a behaviour emits on a port with no wire | emissoesPerdidas (depth-core), swept per domain in sem-perda.test.ts |
| a wire leaves a port nothing emits on | conferirDesenho (mermaid-skeleton) |
| an edge label names the wrong message kind | conferirDesenho (mermaid-skeleton) |
The third one only exists for skeletons: the compiler reads the message kind from
the label and drops it — WorldSpec.wires has no field for it, and what actually
travels is decided by ctx.emit(kind, …) in the behaviour. The declaration is
normative (§3 of the design) and nothing but that guard holds it to it.
conferirDesenho refuses a world that has a wire leaving a composite, and says so:
the ledger key belongs to the leaf inside, not to the composite, so every such wire
would read as carrying nothing. Measured: in the CPU lab, 67 of 68 wires a naive
count calls dead leave a composite and carry cargo normally.
The step function
Whichever way the world is built, a behaviour is a pure function of state and inbox:
init()builds the state at tick 0.behavior(state, inbox, ctx)returns the next state and what it emits. Never mutatestate.- Randomness comes from
ctx.random(), neverMath.random()— it arrives already bound to (seed, tick, node id), so a rewind lands on exactly the state that was there before. Pass the optionalsaltonly when the same leaf draws more than once in one tick and the draws must differ by purpose. ctx.phaseseparatessettle(combinational, inside the tick) fromcommit(what crosses into the next one), and the behaviour is called in both. Two things follow, and they are structural rather than conventional: onlycommitkeeps the state it returns — whatsettlereturns as state is never read, which is what separates combinational logic from a memory element; and emitting on a port whose regime does not match the phase is refused by the scheduler, by name, rather than delivering late. So readctx.phaseand act in the one that is yours.- Changing an input restarts the simulation at tick 0. That is deliberate: it is how “with this processor, what comes out changes like this” reads.
- Declare only the levels the lab actually implements.
Note on
EngineandScenario<S>.depth-corestill exports an earlier API built aroundScenario<S>withinitialState/step, and this guide used to describe it as the way to write a lab. No lab uses it: all of them go throughWorldandWorldSpec. It is kept as public surface, and whether it should be is an open question — not a path to start a new lab on.