---
name: sf-characterize
description: Playbook phase 3 (tests before code) — capture one batch job's legacy behaviour as a characterization fixture on an isolated RAM-only SteelFrame workspace with a pinned clock, via tools/characterize.mjs. Use before porting a component, or to re-capture the legacy side for regression.
argument-hint: <spec.json path> [--out fixtures] [--raw]
---

# /sf-characterize — the legacy side runs, the fixture is the expected value

Goal: `fixtures/<name>/fixture.json` (format `steelframe-fixture/1`) for one job, captured on a workspace the
live estate never sees. Everything that runs goes through the **ephemeral lane** (`steelframe-private`, or the
script below, which uses it by itself).

## Write the spec

From the discovery note, a `<component>/<job>.characterize.json`:

```json
{
  "name": "posttran-d002",                    // fixture and workspace name (idempotency key; letters, digits, . _ -)
  "job": "CARDDEMO.JCL(POSTTRAN)",            // or "lines": ["//JOB ...", ...]
  "clock": "2022-07-18 10:20:30",             // pins ACCEPT FROM DATE/TIME and FUNCTION CURRENT-DATE (hundredths too)
  "jobNumber": 1000,                          // the first JOBID in the workspace
  "copybooks": { "<DSN>": "LIB(MEMBER)" },    // every dataset you want decoded as rows
  "prepare": [ { "member": "LIB(JOB)" } | { "name": "...", "lines": [ ...IDCAMS... ] } ],   // GDG bases, clusters
  "seed": [ { "dsn": "<INPUT DSN>", "copybook": "LIB(MEMBER)", "mode": "replace", "rows": [ ... ] } ]
}
```

Seed the **inputs the test is about** with rows you choose (every validation path, a negative amount, an
update-existing and a create-new case). Leave reference files as the estate has them: the fixture records their
before-state, so the test is self-contained whatever the estate held that day.

**Files the job writes or loads go under your own HLQ.** Write `{{USER}}` in the spec (the tool replaces it
with the key's user id) for the DDs the program opens OUTPUT / EXTEND and for new GDG generations, define them
in `prepare`, and give the deck as `lines` with those DDs changed. Reason: on a shared estate the installed
masters are UPDATE-only for ordinary users (no DELETE/DEFINE, and RACF is copied with the workspace), and a
loaded non-REUSE cluster refuses OPEN OUTPUT with FILE STATUS 37 exactly as z/OS does. The fixture keys files by
DD name, so two users' captures still compare.

## Run it

```
node tools/characterize.mjs <spec.json> --out fixtures        # add --raw to keep the whole steelframe-run/1 record
```

What happens (each step prints what it did and what it cost): `create_workspace` (clone, RAM-only) → the
`prepare` jobs (must end ≤ 4) → `load_dataset` for each seed → `run_batch {before:true, records:'both', clock}`
→ `fixtures/<name>/fixture.json` → `destroy_workspace`. Costs: workspaces unmetered; each prepare / seed / run
$0.03 (+ CPU beyond 10 s); the worked example is about $0.09.

Without the script (an MCP client only): the same five calls on `steelframe-private` with `workspace:<id>` on
every one, then `tools/fixture.mjs fromRun()` on the `run_batch` result.

## Read the fixture before you port

- `rc.maxcc` and `rc.steps[]` — the job's own verdict on itself.
- `joblog['STEP.SYSOUT']` — the DISPLAY lines; part of the behaviour.
- `datasets[dsn].role` — `input` (unchanged), `updated`, `output` (new); `before` / `after` rows and, for
  EBCDIC-at-rest datasets and datasets without a copybook, `hex`.
- Numbers are the engine's native form; compare as decimals.

If a step fails (JCL ERROR, abend), the script prints the joblog tail: a missing GDG base, a cluster that does
not exist, a copybook name — those go into `prepare` or the discovery note, never into a workaround on the live
estate. Re-run the script twice and `node tools/fixture-diff.mjs` the two fixtures: they must be identical
(that is what the pinned clock and `jobNumber` are for); if not, the difference is a fact to write down (a real
timestamp in an output, a sequence number) and to mask in your comparison.
