Top up
Re-run a launched pipeline on an overlapping export, preview what it will spend, and prove from the ledger it paid less and delivered nobody twice
What you'll learn
You launched a pipeline, a fresh export or list just arrived with some of the same people, some new ones, and some changed rows, and you want to run it again without paying or sending twice.
- how to read what a top-up will spend before it spends
- how to prove from the ledger that overlapping people were skipped and nobody was delivered twice
- why a changed row doesn't make an enrich step pay again
- the two cases that do pay or send again: a fact past its cache window, and a new delivery file or campaign
Goal: a top-up run that pays only for new people and delivers nobody twice. It re-runs a launched pipeline over a new export, and the ledger proves the result. SPEC.md, the file that fixes what gtme does, states the promise this guide checks in its Top-up story:
re-running a pipeline against overlapping data costs less and delivers nothing twice.
Before you start
You need gtme (Install) and a pipeline you've already run armed, for real with no flag, plus a new export for it. Run the top-up against the same ledger as the launch. A different ledger has no history, so gtme would pay for and deliver to everyone again. On your own ledger, go straight to the steps. 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. The one deliver step writes to CSV files in your folder.
Build a practice history
The practice file is a webinar follow-up: score each attendee, keep anyone scoring 70 or more, and write them to follow-up.csv. Make a folder with its own ledger:
mkdir -p top-up && cd top-up
export GTME_LEDGER=./ledger.db
Save this pipeline as attendees.yaml:
score is an enrich step, one that adds fields to each record, and cache: 30d is its cache window: a score written in the last 30 days counts as fresh. idempotency: email on follow_up keys each delivery on the email address.
Save the first export and launch:
cat > attendees.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
EOF
gtme run attendees.yaml
The run ends with this receipt:
step adapter in out empty cached filtered failed cost avoided
source csv/source 0 4 - 0 - - $0 -
score demo/enrich 4 4 - 0 - - $0.0400 -
keep sql/filter 4 2 - 0 2 - $0 -
follow_up csv/deliver 2 2 - 0 - - $0 -
total: $0.0400 (estimated) spent
Jane and Dana scored 70 or more, passed keep, and went to follow-up.csv. Now the new export arrives. Jane and Dana are back, Bob's title changed, Carol is gone, and Gus and Hana are new. Replace the file:
cat > attendees.csv <<'EOF'
Full Name,Email,Title,Company Website
Jane Doe,jane.doe@acme.com,VP Marketing,acme.com
Bob Stone,bob@globex.io,VP Growth,globex.io
Dana Park,dana@contoso.com,Head of Growth,contoso.com
Gus Lee,gus@hooli.com,Director of Demand Gen,hooli.com
Hana Sato,hana@stark.io,CMO,stark.io
EOF
Steps
-
Preview the top-up. Simulate, the lowest rung of the gate ladder, spends and sends nothing, and keeps nothing:
gtme run attendees.yaml --simulateThe output is similar to the following:
simulate: recorded responses only — no network, no spend, nothing sends, nothing persists ... run 01M3K60VSDD1YXKTZXJNFP22M6 — done (SIMULATED — recorded responses only; nothing sent, nothing persisted) step adapter in out empty cached filtered failed cost avoided source csv/source 0 5 - 0 - - $0 - score demo/enrich 5 2 - 3 - - $0.0200 $0.0300 keep sql/filter 5 4 - 0 1 - $0 - follow_up csv/deliver 4 0 - 0 - - $0 - follow_up: 2 already delivered follow_up: resolved variables for 2 record(s) — review, then run again without --dry-run to arm: gus@hooli.com full_name: "Gus Lee" score: "91" hana@stark.io full_name: "Hana Sato" score: "81" total: $0.0200 (estimated) spent, $0.0300 avoided via cache (3 records skipped)The
scorerow says the top-up pays for two people:cached 3,$0.0200. Jane, Bob, and Dana have fresh scores, so it pays for Gus and Hana only. The line under the table,follow_up: 2 already delivered, counts Jane and Dana, who are already infollow-up.csv, and the resolved variables list the two people it will write. A withheld delivery saves nothing, so it isn't incachedoravoided, and the total's3 records skippedis the three scores. The hint names--dry-runbecause simulate borrows dry-run's wording; the armed run is next only because this file is keyless.Simulate works on a copy of the ledger, so its cache skips are exactly the armed run's (the decision record, ADR-039). Its results are not: a vendor search returns recorded responses, and a model step returns canned text for $0. With either kind of step, or a live delivery target such as a campaign, run a dry-run next and read the same counts there before you arm.
-
Run the top-up armed, the run with no flag. This spends the pretend $0.02 and writes two rows to
follow-up.csv:gtme run attendees.yamlThe output is similar to the following:
run 01M3K60VTDBY3DB55F58J4DWD0 (attendees) ... step adapter in out empty cached filtered failed cost avoided source csv/source 0 5 - 0 - - $0 - score demo/enrich 5 2 - 3 - - $0.0200 $0.0300 keep sql/filter 5 4 - 0 1 - $0 - follow_up csv/deliver 4 2 - 0 - - $0 - follow_up: 2 already delivered total: $0.0200 (estimated) spent, $0.0300 avoided via cache (3 records skipped)The receipt matches the preview.
avoided $0.0300is the "costs less" half of the promise.A person whose last attempt failed, or came back with nothing written, has no fresh value, so a top-up tries them again and pays for them. One whose last attempt wrote some of the step's fields counts as done and is skipped.
-
Prove the skips from the ledger. This lists every record the newest run skipped, and why:
gtme query "SELECT i.identity_key, e.step_id, json_extract(e.detail, '$.reason') AS reason FROM step_events e JOIN identities i ON i.id = e.identity_id WHERE e.run_id = (SELECT max(id) FROM runs) AND e.event = 'skipped_cache' ORDER BY e.id"It prints:
{"identity_key":"jane.doe@acme.com","reason":"fresh_in_ledger","step_id":"score"} {"identity_key":"bob@globex.io","reason":"fresh_in_ledger","step_id":"score"} {"identity_key":"dana@contoso.com","reason":"fresh_in_ledger","step_id":"score"} {"identity_key":"jane.doe@acme.com","reason":"already_delivered","step_id":"follow_up"} {"identity_key":"dana@contoso.com","reason":"already_delivered","step_id":"follow_up"} 5 rowsEvery overlapping person has a
skipped_cacheevent onscore, in the ledger'sstep_eventstable.fresh_in_ledgermeans every field the step provides already had a value inside its window, so the runner called no adapter and wrote no cost (SPEC §7).already_deliveredis the delivery check (SPEC §8). It shares the event name, but the receipt counts it asalready delivered, notcached. -
Check the
deliveriestable, where each delivery row names its target file or campaign as itsscope, to see that nobody was delivered twice to this file:gtme query "SELECT idempotency, run_id FROM deliveries WHERE scope = 'follow-up.csv' ORDER BY created_at"The output is similar to the following. Rows written in the same moment can come back in either order:
{"idempotency":"jane.doe@acme.com","run_id":"01M3K60RE7XV6CVE2J7FJD37B5"} {"idempotency":"dana@contoso.com","run_id":"01M3K60RE7XV6CVE2J7FJD37B5"} {"idempotency":"gus@hooli.com","run_id":"01M3K60VTDBY3DB55F58J4DWD0"} {"idempotency":"hana@stark.io","run_id":"01M3K60VTDBY3DB55F58J4DWD0"} 4 rowsThere's one row per person. Jane's and Dana's rows still carry the launch's run id, and the top-up added rows for Gus and Hana only. That's the "delivers nothing twice" half.
-
Look at Bob, whose title changed:
gtme show bob@globex.io --provenance --fields title,demo.scoreIt prints:
{ "entity_type": "person", "fields": { "demo.score": { "confidence": 1, "created_at": "2026-09-28T04:57:51.309Z", "run_id": "01M3K60RE7XV6CVE2J7FJD37B5", "source": "demo/enrich@1", "value": 69 }, "title": { "confidence": 1, "created_at": "2026-09-28T04:57:54.766Z", "run_id": "01M3K60VTDBY3DB55F58J4DWD0", "source": "csv/source@1", "value": "VP Growth" } }, "identity_key": "bob@globex.io", "identity_key_tier": "email" }A changed row doesn't make an enrich step pay again. The title is the top-up's, and the score is still the launch's. The cache check looks only at the fields a step writes, so Bob keeps his old score, and stays filtered out, until the window runs out. gtme has no flag to re-score one person early.
If a step's output depends on a field that changes often, give it a shorter
cache:. A model step,ai/*, works the other way and judges again when the fields it reads change (ADR-039).
When a top-up pays or sends again
A fact past its cache window gets paid for again. Ask the ledger when each score goes stale:
gtme query "SELECT i.identity_key,
datetime(f.created_at, '+30 days') AS pays_again_after
FROM current_fields f
JOIN identities i ON i.id = f.identity_id
WHERE f.field = 'demo.score' AND f.source LIKE 'demo/enrich%'
ORDER BY f.created_at"
The output is similar to the following:
{"identity_key":"bob@globex.io","pays_again_after":"2026-10-28 04:57:51"}
{"identity_key":"jane.doe@acme.com","pays_again_after":"2026-10-28 04:57:51"}
{"identity_key":"carol@initech.dev","pays_again_after":"2026-10-28 04:57:51"}
{"identity_key":"dana@contoso.com","pays_again_after":"2026-10-28 04:57:51"}
{"identity_key":"gus@hooli.com","pays_again_after":"2026-10-28 04:57:54"}
{"identity_key":"hana@stark.io","pays_again_after":"2026-10-28 04:57:54"}
6 rows
Times are UTC. A top-up after those times calls demo/enrich again for each person in that export, writes a new score beside the old one, and the receipt shows the spend. Carol isn't in the new export, so nothing touches her. Change the 30 to the step's window, which gtme plan prints on each paid step's cache: line. Setting cache: 0d turns the cache off, and gtme plan warns that every run then pays for every record.
A new delivery file or campaign is a new delivery. Idempotency holds per target and scope: the file for csv/deliver, the campaign for Instantly. In attendees.yaml, point follow_up at a new file:
with:
path: q4-invite.csv
Run it armed. This writes to a new file, q4-invite.csv, and spends nothing:
gtme run attendees.yaml
The receipt ends with:
...
score demo/enrich 5 0 - 5 - - $0 $0.0500
keep sql/filter 5 4 - 0 1 - $0 -
follow_up csv/deliver 4 4 - 0 - - $0 -
total: $0 spent, $0.0500 avoided via cache (5 records skipped)
Every score came from the cache, and all four kept people were delivered to the new file. Jane now has one row per file:
gtme query "SELECT scope, run_id FROM deliveries
WHERE idempotency = 'jane.doe@acme.com' ORDER BY created_at"
{"run_id":"01M3K60RE7XV6CVE2J7FJD37B5","scope":"follow-up.csv"}
{"run_id":"01M3K618NR59147FXH3BCHCVZW","scope":"q4-invite.csv"}
2 rows
A new campaign is a new decision, per the decision record, ADR-044. To keep someone from a second touch across campaigns, add a suppression group to the deliver step.
What you have now
A topped-up pipeline, and two queries that prove it paid less and delivered nobody twice. follow-up.csv holds each kept person once:
cat follow-up.csv
The output is similar to the following:
identity_key,full_name,score
jane.doe@acme.com,Jane Doe,100
dana@contoso.com,Dana Park,75
gus@hooli.com,Gus Lee,91
hana@stark.io,Hana Sato,81
To have Claude Code run the next top-up this way, paste this line:
Top up PIPELINE with the new export, using the same GTME_LEDGER as the launch: run it with --simulate and show me the receipt, then dry-run it if it has a vendor or model step or a live target, then after I say go, run it armed and prove with gtme query that the overlapping people were skipped_cache on every paid step and nobody already in deliveries for this target and scope got a second row.
Replace PIPELINE with the pipeline file.
Next
- The
step_eventstable lists the columns and events the skip proof reads. - The
deliveriestable lists the target, scope, and key every delivery row carries. gtme querylists every flag of the command the proofs use.