Iterate
Change a pipeline that has already run, check the edit with plan and a small run, read what it re-runs and reuses, then run it at full size
What you'll learn
Your pipeline has run for real, you've changed it (a tighter filter, a new template, a new step), and you want to know it works before it runs on the whole list.
- how to check an edit with plan and a 5-record run before the full one
- which steps an edit runs again and which it reuses from the ledger
- why people delivered under the old version don't get the new one, and what to do about it
- what the small run costs next to the full run
Goal: an edited pipeline, checked on 5 records, then run on the whole list. You'll see what the edit re-runs, what it reuses, and what happens to people who got the old version. SPEC.md, the file that fixes what gtme does, states the promise this guide checks in its Iterate story:
a pipeline change is checked before it is trusted, cheaply.
Before you start
You need gtme (Install) and a pipeline that has already run for real, with the ledger it ran against. On your own pipeline, go straight to the steps and make your own edit. Otherwise, build the practice history first, and the outputs will match this page.
This guide needs no API keys and sends nothing past your own disk. demo/enrich charges a pretend $0.01 per record and calls no vendor, and the edit spends a pretend $0.12 in all. The one deliver step writes CSV files in your folder. On your own pipeline, the small check spends 5 records' worth of each priced step and sends nothing.
Build a practice history
The practice file keeps the VPs and heads of a function in a lead list and writes each one an opening line. Make a folder with its own ledger:
mkdir -p iterate && cd iterate
export GTME_LEDGER=./ledger.db
Save the list as leads.csv:
cat > leads.csv <<'EOF'
Full Name,Email,Title,Company Website
Jane Doe,jane.doe@acme.com,VP Marketing,acme.com
Bob Stone,bob@globex.io,Head of Growth,globex.io
Carol Reyes,carol@initech.dev,Marketing Operations Manager,initech.dev
Dana Park,dana@contoso.com,Head of Growth,contoso.com
Eli Moss,eli@umbrella.co,VP Sales,umbrella.co
Fay Chen,fay@wayne.io,VP Demand Gen,wayne.io
Gus Lee,gus@hooli.com,Director of Demand Gen,hooli.com
Hana Sato,hana@stark.io,Head of Marketing,stark.io
Ivan Cruz,ivan@soylent.co,VP Revenue,soylent.co
Jo Kim,jo@tyrell.ai,Head of Sales,tyrell.ai
Kai Wren,kai@cyberdyne.io,Account Executive,cyberdyne.io
Lu Ortiz,lu@oscorp.dev,VP Marketing,oscorp.dev
EOF
Save this as leads.yaml:
Launch it armed. This spends $0 and writes 9 rows to out.csv:
gtme run leads.yaml
The run ends with this receipt:
step adapter in out empty cached filtered failed cost avoided
source csv/source 0 12 - 0 - - $0 -
keep sql/filter 12 9 - 0 3 - $0 -
opener text/compose 9 9 - 0 - - $0 -
out csv/deliver 9 9 - 0 - - $0 -
total: $0 spent
Nine people are in out.csv, each with a line about growth. The keep query reads current_values, which holds each person's latest value for every field, and returns the people to keep.
Steps
-
Edit the file. The new version adds a
scorestep, keeps only leaders who score 70 or more, and givesopenera new question. Replaceleads.yamlwith:name: leads version: 1 source: use: csv/source with: path: leads.csv columns: { full_name: Full Name, email: Email, title: Title, company_domain: Company Website } steps: - id: score use: demo/enrich with: cost_per_record_usd: 0.01 - id: keep use: sql/filter with: query: > SELECT identity_id FROM current_values WHERE field = 'title' AND (value LIKE 'VP%' OR value LIKE 'Head of%') INTERSECT SELECT identity_id FROM current_values WHERE field = 'demo.score' AND CAST(value AS INTEGER) >= 70 - id: opener use: text/compose uses: [full_name, company_domain] provides: [opener] with: template: "{{ record.full_name }}, how is {{ record.company_domain }} planning Q4 pipeline?" - id: out use: csv/deliver with: path: out.csv variables: full_name: full_name opener: leads.opener idempotency: emailPipeline leads: source csv/source, then score (demo/enrich), then keep (sql/filter), then opener (text/compose), then delivers with out (csv/deliver), once per email. Every step writes to the ledger, and cached, AI, and deliver steps read it first. scoreis a new enrich step, one that adds fields to each record.keephas a second condition, andopenerhas a new template. Leaveversion: 1alone. It's the version of the file format, and plan refuses anything else:gtme: leads.yaml: pipeline: unsupported version 2 (v0 understands version 1) -
Plan it:
gtme plan leads.yamlPlan prints the new step with its price:
pipeline leads (version 1) ... 2. score [enrich] — demo/enrich@1 entity: person reads: email, full_name requires: any of email | full_name provides: demo.note, demo.score cache: 30d est/record: $0.0100 ... plan ok — nothing has been spentIf plan names a step and a field instead, the edit reads something no earlier step provides. Fix that step before anything else. Plan checks the file against the ledger before any adapter runs (SPEC §7). It can't tell you whether the new query keeps the right people or whether the opener reads well. A small run can.
-
Cap the source at 5 records. Under the source's
with:, add:limit: 5The runner takes the first 5 records the source returns, so every later step sees at most 5. On a vendor source, it also stops paging the vendor there (the decision record, ADR-047). If none of the 5 pass your filter, raise the limit until a few do, because a check on 0 records checks nothing.
-
Dry-run it. A dry-run runs every step and holds every delivery, so you read the result before anyone gets it. This spends a pretend $0.05 and sends nothing:
gtme run leads.yaml --dry-runThe output is similar to the following:
dry run: deliver steps will resolve and receipt their variables, but nothing sends ... run 01M3K7TQM5KDT3ZZCCTQCFYM6P — done (dry run — nothing sent) step adapter in out empty cached filtered failed cost avoided source csv/source 0 5 - 0 - - $0 - score demo/enrich 5 5 - 0 - - $0.0500 - keep sql/filter 5 2 - 0 3 - $0 - opener text/compose 2 2 - 0 - - $0 - out csv/deliver 2 0 - 0 - - $0 - out: 2 already delivered total: $0.0500 (estimated) spentThe whole chain ran on 5 records for $0.0500. Two people passed
keep, andout: 2 already deliveredsays both are already inout.csv. The deliver step skips them, so there's nothing to review. A skipped delivery saves nothing, so it isn'tcached. This run can't show you the new line, and an armed run wouldn't send it to them. -
Read what the edit ran again. This counts the newest run's events per step, by reason:
gtme query "SELECT step_id, event, json_extract(detail, '$.reason') AS reason, count(*) AS n FROM step_events WHERE run_id = (SELECT max(id) FROM runs) GROUP BY 1, 2, 3 ORDER BY min(id)"It prints the following.
claimedmeans the step started work on a record:{"event":"done","n":1,"reason":null,"step_id":"source"} {"event":"claimed","n":5,"reason":null,"step_id":"score"} {"event":"done","n":5,"reason":null,"step_id":"score"} {"event":"done","n":2,"reason":"selected by predicate","step_id":"keep"} {"event":"done","n":3,"reason":"not selected by predicate","step_id":"keep"} {"event":"claimed","n":2,"reason":null,"step_id":"opener"} {"event":"done","n":2,"reason":null,"step_id":"opener"} {"event":"skipped_cache","n":2,"reason":"already_delivered","step_id":"out"} 8 rowsEach kind of step treats an edit differently:
Step What it did Why scoreCalled the adapter for all 5 It's new, so nothing was cached. Next time it reuses these scores for 30 days. keepJudged all 5 A sql/filterstep recomputes on every run, so a changed query takes effect at once, for $0.openerComposed 2 again Both had a line from the launch, but the template changed. outSkipped 2 as already delivered Both are already in out.csv. -
See why
openerran again. Compare Jane's two compositions:gtme query "SELECT e.run_id, json_extract(e.detail, '$.signature') AS signature, json_extract(e.detail, '$.input') AS input FROM step_events e JOIN identities i ON i.id = e.identity_id WHERE e.step_id = 'opener' AND e.event = 'done' AND i.identity_key = 'jane.doe@acme.com' ORDER BY e.id"It prints:
{"input":"66e6f8c7179f","run_id":"01M3K7TQHQ17DGE86AZTM95TW1","signature":"535b16ff65bd"} {"input":"66e6f8c7179f","run_id":"01M3K7TQM5KDT3ZZCCTQCFYM6P","signature":"e0f2bf60d044"} 2 rowsinputis a hash of the fields the step reads, and it's the same in both runs.signatureis a hash of the step's adapter, template, and declared fields, and the new template changed it. Atext/composeorai/*step reuses a result only when both match (ADR-039).Here that re-run was free. On an
ai/filterorai/composestep, a changed prompt or model re-judges, and pays for, every record that reaches it. A small run caps that at the records it lets through. An enrich step's cache looks only at its own fields and cache window, so edits elsewhere in the file leave it cached. -
Decide whether people who got the old line should get the new one. Idempotency, the rule that stops a repeat delivery, matches on where a record was delivered (here
out.csv) and itsidempotency:field,email. The opener isn't part of that match, so anyone already inout.csvnever gets the new one (SPEC §8).We'd leave it: people who got the old line are skipped, and everyone new gets the new one. To send the new version to everyone, including a second message to past recipients, point
outat a new file, or a new campaign on a live target. For this page, set:path: out-q4.csv -
Dry-run the small run again. This spends $0 and sends nothing:
gtme run leads.yaml --dry-runThe output is similar to the following:
... step adapter in out empty cached filtered failed cost avoided source csv/source 0 5 - 0 - - $0 - score demo/enrich 5 0 - 5 - - $0 $0.0500 keep sql/filter 5 2 - 0 3 - $0 - opener text/compose 2 0 - 2 - - $0 ? out csv/deliver 2 0 - 0 - - $0 - out: resolved variables for 2 record(s) — review, then run again without --dry-run to arm: jane.doe@acme.com full_name: "Jane Doe" opener: "Jane Doe, how is acme.com planning Q4 pipeline?" dana@contoso.com full_name: "Dana Park" opener: "Dana Park, how is contoso.com planning Q4 pipeline?" total: $0 spent, $0.0500+? avoided via cache (7 records skipped)scoreandopenercame from the ledger, and both people now resolve with the new line.?means a template has no price to count as saved. A target that updates a record in place, such asattio/assert, can takeredeliver: on_changeand re-deliver changed values instead (ADR-045). Plan refuses it on a target that appends or sends, such ascsv/deliver. -
Delete the
limit: 5line and dry-run the whole list. This spends a pretend $0.07 and sends nothing:gtme run leads.yaml --dry-runThe output is similar to the following:
... step adapter in out empty cached filtered failed cost avoided source csv/source 0 12 - 0 - - $0 - score demo/enrich 12 7 - 5 - - $0.0700 $0.0500 keep sql/filter 12 4 - 0 8 - $0 - opener text/compose 4 2 - 2 - - $0 ? out csv/deliver 4 0 - 0 - - $0 - out: resolved variables for 4 record(s) — review, then run again without --dry-run to arm: jane.doe@acme.com full_name: "Jane Doe" opener: "Jane Doe, how is acme.com planning Q4 pipeline?" ... total: $0.0700 (estimated) spent, $0.0500+? avoided via cache (7 records skipped)scorepaid for the 7 people the small run hadn't seen and reused the 5 it had. Read all 4 resolved lines, because they're exactly what the armed run sends. -
Run it armed. This spends $0 and writes 4 rows to
out-q4.csv:gtme run leads.yamlThe output is similar to the following:
... run 01M3K7TQSBH8X9R01EEYNB6ESC — done step adapter in out empty cached filtered failed cost avoided source csv/source 0 12 - 0 - - $0 - score demo/enrich 12 0 - 12 - - $0 $0.1200 keep sql/filter 12 4 - 0 8 - $0 - opener text/compose 4 0 - 4 - - $0 ? out csv/deliver 4 4 - 0 - - $0 - total: $0 spent, $0.1200+? avoided via cache (16 records skipped)Everything the armed run needed came from the two dry-runs, so it spent nothing and only delivered.
What you have now
The edited pipeline, run on the whole list, and a record of what checking it cost. out-q4.csv holds the 4 people who passed the new filter, each with the new line:
cat out-q4.csv
The output is similar to the following. The rows can come back in a different order:
identity_key,full_name,opener
jo@tyrell.ai,Jo Kim,"Jo Kim, how is tyrell.ai planning Q4 pipeline?"
jane.doe@acme.com,Jane Doe,"Jane Doe, how is acme.com planning Q4 pipeline?"
dana@contoso.com,Dana Park,"Dana Park, how is contoso.com planning Q4 pipeline?"
hana@stark.io,Hana Sato,"Hana Sato, how is stark.io planning Q4 pipeline?"
The four runs spent this, from their receipts:
| Run | Records | Spent |
|---|---|---|
| Small dry-run | 5 | $0.0500 |
| Small dry-run, new file | 5 | $0 |
| Full dry-run | 12 | $0.0700 |
| Full armed run | 12 | $0 |
That's $0.12, the same as one full run with no check, because each run reused every score the one before it paid for. With an AI step, checking costs extra only when the small run changes your mind: each new prompt re-judges the records the small run already saw.
To have Claude Code check your next edit this way, paste this line:
I edited PIPELINE. Run gtme plan on it, then add limit: 5 under source.with and dry-run it. Show me the receipt, the resolved variables, and the run's step_events counted by step and reason. If a deliver step skipped anyone as already_delivered, tell me who and ask whether to deliver to a new target. Then remove the limit, dry-run it at full size, show me the receipt and resolved variables, and run it armed only after I say go.
Replace PIPELINE with the pipeline file.
Next
gtme planlists every line plan prints after an edit.- The
step_eventstable lists the events and details the reuse queries read. - The
deliveriestable lists the target, scope, and key every delivery row carries.