# 6 · 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:

```
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

```yaml
name: dns-propagation-check
version: 1.1.0
description: Compares authoritative DNS answers with Cloudflare, Google, and Quad9 …
provenance:
  author: chetan <chetan@modiqo.ai>
  workspace: refactor-dns-propagation-check-dag    # the exploration that birthed it
metadata:
  status: released
  execution_model: steps_with_presentation
  flow_type: parallel
parameters:
- name: domain
  param_type: string
  required: false
  default: example.com
  description: DNS name to check, such as example.com
  example: example.com
  valid_values: null
steps:
  …
```

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:

```yaml
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](assets/dag-anatomy.svg)

**Two kinds of edges, and the distinction matters:**

| Edge | Written as | Meaning |
|---|---|---|
| **Ordering** | `depends_on: [a, b]` | *run after* — a barrier, no data implied |
| **Value** | `'@step{$.stdout.text \| fromjson \| .field}'` in `argv` | *data 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:

```python
FS, RS = chr(31), chr(30)          # unit / record separators
packed = RS.join(FS.join([label, server, values]) for ...)
print(json.dumps({'ok': True, 'packed': packed}))
```
```yaml
- '@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:

| Situation | Behavior | How |
|---|---|---|
| **Expected absence** — endpoint down, feature not selected, nothing found | Step *succeeds* with a labeled unknown | print `{"ok": true, "warning": "…"}`, exit `0` |
| **Hard fault** — invalid input, missing required tool, unusable state | Step **fails closed**; dependents show `BLOCKED`; `--resume` offered | message on stderr, nonzero exit |

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

```
  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:

```ts
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 view
out.summary(oneLine);       // --output=summary
out.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:

```ts
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

```toml
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:

```yaml
  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:

```bash
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 →](07-modalities.md)
