Documentation/06 · Anatomy of a play

Anatomy of a play

Where you are in the journey: you've created a play the guided way. This section slows down and names every part, so that when lint complains or a review asks "why is this one step?", you know exactly what's going on. Skim it now; return whenever you need the details.

A play is one file with two halves, plus a tiny manifest at its side:

ROTE/text4 lines
main.ts├── /** @rote-frontmatter … */     ← the contract: what runs (YAML, machine-executed)└── TypeScript below the comment   ← the presentation: how results read (human-rendered)deps.toml                          ← the tools the steps need

The runner executes the frontmatter. The presentation only renders what the steps produced. This split is load-bearing: execution is typed, deterministic, and resumable, while formatting stays free to be as human as you like.

The frontmatter contract

ROTE/yaml20 lines
name: dns-propagation-checkversion: 1.1.0description: Compares authoritative DNS answers with Cloudflare, Google, and Quad9 …provenance:  author: chetan <[email protected]>  workspace: refactor-dns-propagation-check-dag    # the exploration that birthed itmetadata:  status: released  execution_model: steps_with_presentation  flow_type: parallelparameters:- name: domain  param_type: string  required: false  default: example.com  description: DNS name to check, such as example.com  example: example.com  valid_values: nullsteps:

Things worth knowing before lint teaches them to you the hard way:

  • version is semver and immutable once pushed — every change means a bump.
  • parameters use param_type, and non-string defaults are quoted strings (default: '20', default: 'false'). Integer and boolean params are parsed from those strings.
  • provenance.workspace names the exploration workspace the play was crystallized from — the paper trail from finished artifact back to the work that proved it.
  • The whole frontmatter lives in a JSDoc comment, so a literal */ anywhere inside it (even in an embedded shell glob or code comment) ends the frontmatter early. Lint will point at it.

Steps: the DAG

The steps: map is the heart. Each step is an isolated process with an explicit place in a graph:

ROTE/yaml21 lines
steps:  validate_input:    type: process.exec    timeout_ms: 15000    argv: [python3, -c, "…", $domain, $record_type]   query_cloudflare:    type: process.exec    timeout_ms: 30000    depends_on:    - validate_input    argv:    - python3    - -c    - |2       import json, sys      domain, rtype = sys.argv[1], sys.argv[2]    - '@validate_input{$.stdout.text | fromjson | .domain}'    - '@validate_input{$.stdout.text | fromjson | .record_type}'
Anatomy of a play DAG

Two kinds of edges, and the distinction matters:

EdgeWritten asMeaning
Orderingdepends_on: [a, b]run after — a barrier, no data implied
Value'@step{$.stdout.text | fromjson | .field}' in argvdata flows — this arg is computed from that step's recorded output

Value edges are the honest ones: they make dataflow inspectable (play-dag draws them with their exact jq paths) and they're how the runner knows a step truly needs another's output, not just its completion.

Layers fall out of the graph. Steps whose dependencies are all satisfied run together; [layer 2 — 4 parallel] in your run output is the runner exploiting exactly the independence you declared. You never write concurrency code — you write edges.

Rules of the value-edge road (learned so you don't have to):

  • The jq expression must resolve to a scalar. $.stdout.text alone won't pass; pipe to a field or join(",").
  • The jq dialect is a subset — fromjson, field access, join, map/select, to_entries work; tojson does not.
  • To move a collection between steps, have the producer pack it into a delimited scalar field and the consumer unpack it. The convention across the modiqo plays uses ASCII separators, which no TXT record, PR title, or market question can collide with:
ROTE/python3 lines
FS, RS = chr(31), chr(30)          # unit / record separatorspacked = RS.join(FS.join([label, server, values]) for ...)print(json.dumps({'ok': True, 'packed': packed}))
ROTE/yaml1 line
- '@discover_authoritative{$.stdout.text | fromjson | .packed}'

Degrade, never die

A play that checks eight sources must not be killed by one flaky endpoint. The standard is a two-lane failure model:

SituationBehaviorHow
Expected absence — endpoint down, feature not selected, nothing foundStep succeeds with a labeled unknownprint {"ok": true, "warning": "…"}, exit 0
Hard fault — invalid input, missing required tool, unusable stateStep fails closed; dependents show BLOCKED; --resume offeredmessage on stderr, nonzero exit

The user-visible payoff is the stage ledger every good play renders:

ROTE/text4 lines
  stages  ██████████████████░░░░░░  6/8 ok  ████████  validate input   ok  █████░░░  page fetch       degraded — site fetch failed: nodename nor servname …  ░░░░░░░░  Lighthouse       skipped — Lighthouse was not requested

Full bars, honest gaps. A degraded source is a visible unknown — the play completes and tells you exactly what it couldn't know.

Destructive plays add one more rule: mutations live in their own step, gated behind an explicit parameter (apply=true), so the default run is a dry-run whose ledger shows the destructive stage as labeled skipped.

The presentation

Below the frontmatter, TypeScript renders the recorded step outcomes:

ROTE/ts11 lines
const { FlowOutput, loadPresentationContext, stepName } = await import("__ROTE_PRESENTATION_SDK__");const out = new FlowOutput();const ctx = await loadPresentationContext(); const step = ctx.step(stepName("compute_verdict"));   // literal names only — lint enforces it// step.outcome.status: completed | restored | skipped | blocked | failed// step.outcome.output.body.stdout.text: what the step printed out.human(reportText);      // default viewout.summary(oneLine);       // --output=summaryout.result(structured);     // --output=json

Three habits make presentations robust:

  1. Tolerate anything. Parse each step's stdout defensively and render a degraded row instead of throwing — the presentation also runs during --resume on partial results and during lint with synthetic bodies.
  2. stepName("literal") only. Lint rejects dynamically computed step references; the whole point is static knowledge of what reads what.
  3. Representation parity. human, summary, and result are views of one run. The contract: no semantic fact silently disappears from a view that claims to be complete. Truncate in the human view only with a declared count ("… and 12 more in the JSON result"); make result the canonical superset; mark the summary as intentionally lossy. The plays on the hub carry this declaration explicitly:
ROTE/ts8 lines
out.result({  …,  representations: {    human:   "complete — ledger, verdict, and every check with detail",    json:    "canonical — adds raw fields the human view condenses",    summary: "intentionally lossy — verdict and counts only",  },});

deps.toml: honest requirements

ROTE/toml15 lines
schema_version = 1 [[tools]]id = "python3"command = "python3"required = true [[tools.install]]manager = "brew"package = "python@3" [[tools]]id = "lighthouse"command = "lighthouse"required = false          # the play degrades gracefully without it

Declare exactly what the steps call — no more (phantom deps block users needlessly), no less (missing deps turn into runtime mysteries). Optional tools pair with a degrade path in the step that uses them.

Timeouts, fan-out, and other step powers

Every step carries an explicit timeout_ms, sized to its worst honest case: ~15s for local validation, 45–90s for a network read, 120s+ for batch API work. The runtime defaults to 30s when you omit it — a decision you didn't make.

For per-item work, the grammar has declarative fan-out:

ROTE/yaml5 lines
  process_items:    type: process.exec    for_each: '$.items'          # one step instance per item    max_concurrency: 4           # bounded parallelism for this group    argv: [python3, -c, "…", $item, $item_index]

And process.exec is only one step type — adapter steps (endpoint: adapter/<id> with method:/params:), adapter.auth.ensure, and the browser.* family exist too. Section 5 covers when each reach is the right one; rote grammar steps is the exhaustive reference.

The self-check

Before you ship, point the x-ray at your own play:

rote play run https://play.modiqo.ai/modiqo/play-dag play=./main.ts

If it shows 1 step · 1 layer for multi-source work, you've written a monolith — the runner can't parallelize, checkpoint, resume, or blame per source, because you hid the structure inside one script. If it shows steps but (no edges), your DAG is implicit and unprovable. The published exemplars to compare against: modiqo/hello (9 steps · 2 layers) and modiqo/play-dag itself (4 steps · 3 layers).


Next: 7 · Modalities: how steps reach the world →