The step graph
Steps run in the order you write them. after, when and next add branches, loops, joins and parallel steps, and the step that last committed is the job's stage.
The graph answers one question: which step runs next.
A branch: next picks a path, and the other is skipped.
triage: { by: 'ai', next: job => (job.input.pages > 30 ? 'clauses' : 'whole') },
clauses: { by: 'ai', after: 'triage', stage: 'translating' },
whole: { by: 'ai', after: 'triage', stage: 'translating' },
// A join: it runs after whichever path ran.
read: { by: 'ai', after: ['clauses', 'whole'], stage: 'reading' },On any step
| Key | Written | Left out | Does |
|---|---|---|---|
after | After | the step written before | Runs after these. [] starts at the top; several join; two steps with the same after run in parallel. |
when | (job) => boolean | none | Skipped unless true. |
next | (job) => <step> | End | <end> | the next written step | Where the job goes next: a step (a branch, or a loop back) or an end. |
stage | string | the step name | The stage buyers see on their timeline. |
routed | true | none | Runs only when a next or a signal's goto sends the job here, never in written order. |
A long contract goes clause by clause, a short one whole:
→ POST /contract-translation {"contract":"lease.pdf","to":"de","pages":48,"max_usd":60,"key":"long"}← job_7Hq2 · delivered · lease.de.pdf · $60.00 charged
record triage · ai | clauses · ai | read · ai | review · ai · pass
stage review
steps stp_1 triage committed · stp_2 clauses committed · stp_3 whole skipped · stp_4 read committed · stp_5 review committed
→ POST /contract-translation {"contract":"nda.pdf","to":"de","pages":6,"max_usd":60,"key":"short"}
← job_7Hq3 · delivered · nda.de.pdf · $60.00 charged
record triage · ai | whole · ai | read · ai | review · ai · pass
stage review
steps stp_1 triage committed · stp_2 clauses skipped · stp_3 whole committed · stp_4 read committed · stp_5 review committedThe record holds what ran. steps holds every step, the skipped one too, so a timeline can show the path the job didn't take. The review at the end is the one every AI job gets: you didn't write it.
next can send the job back. Revision runs draft and revise once per pass ordered, and ends at 'accepted':
draft: 'ai',
// A loop back: until the passes are done, the job goes back to draft, and draft and revise run again.
revise: { by: 'ai', next: job => (job.record.filter(e => e.type === 'step.committed' && e.step === 'revise').length + 1 < job.input.passes ? 'draft' : 'accepted') },→ POST /revision {"document":"report.pdf","to":"de","passes":2,"max_usd":45,"key":"two"}← job_7Hq4 · delivered · report.de.pdf · $45.00 charged
record draft · ai | revise · ai | draft · ai · redo | revise · ai | review · ai · passA step that runs again prints redo, and a loop spends from the same AI budget as everything else on the job, so it can't run away.
when skips a step. Two steps with the same after run at once, and a step whose after names both waits for both:
terms: { by: 'ai', when: job => job.input.glossary === undefined },
// Both run after terms, in parallel.
strings: { by: 'ai', after: 'terms' },
listing: { by: 'ai', after: 'terms' },
// Joins both, then three AI reviewers attack the strings at once.
qa: { review: 'adversarial', after: ['strings', 'listing'], of: 'strings', lenses: ['placeholders', 'length', 'tone'] },→ POST /localization {"strings":"strings.json","listing":"listing.txt","to":"de","glossary":"glossary.csv","max_usd":49,"key":"glossed"}← job_7Hv5 · delivered · strings.de.json · listing.de.txt · $49.00 charged
record terms · skipped | strings · ai | listing · ai | qa · ai · adversarial · 3 lenses · passThe order brought a glossary, so terms was skipped, and strings and listing, which follow it, ran anyway: a skipped step counts as done for the steps after it.
A step marked routed: true never runs in written order. It runs only when something sends the job there: a next, a submit's rejected or fallback, or a signal's goto. A signal's goto keeps the step it interrupted as job.record.interrupted, so the routed step's next can return the job there and the graph runs on from it. check fails step_unreachable on one nothing can reach.
The stage is the stage of the step that last committed, or its name. Buyers see it on their timeline. In the branch above, read set reading and the review after it set review, so both jobs ended at stage review. You never declare a list of stages or a state machine: the graph is both.
No call of its own: it changes what the job carries, on every surface that reads one.
Operations has every column of every row.
check gate | Why |
|---|---|
step_unreachable | A step no edge reaches never runs. |
| Written | What TypeScript says |
|---|---|
deliver: { draft: 'ai', examine: { review: 'adversarial', after: 'drat' } }, price: 49 | Type '"drat"' is not assignable to type 'After<"draft" | "examine"> | undefined'. Did you mean '"draft"'? |
deliver: { draft: 'ai', examine: { review: 'adversarial', next: () => 'fiel' }, file: 'ai' }, price: 49 | Type '"fiel"' is not assignable to type '"draft" | "examine" | "file" | End'. |
The lines that use it in the example brands, as their files write them, and what check read aloud for each file.
deliver: { check: 'ai', ack: { by: 'watcher', when: job => job.clocks.inside('7d') } },services/docket-watch.ts · 10 lines · $12 per month, charged at the start of each month.
steps check · ai → ack · patent watcher → review · ai · adversarialThe legal example runs every one of its files, captured.
submit: 'https://treasury.example.test/payments', to: 'Treasury and states', receipt: 'receipt', each: { of: job => Object.keys(job.output.amount) }, when: job => decided(job) === 'pay it',
record: { by: async (_, job) => ({ paid: job.output.amount }), when: job => decided(job) === 'pay it' },services/estimate.ts · 17 lines · $79 per job, within 48 hours, charged on delivery.
steps compute · code · cites prior-year safe harbor, cumulative by quarter → approve · owner · decide → review · ai · adversarial → pay · Treasury and states · one for each jurisdiction with an installment → record · codeThe tax example runs every one of its files, captured.
qualify: { by: 'ai', next: job => (job.output.fit ? 'book' : 'lost') },
close: { by: 'ai', next: job => (job.output.closed_value ? 'won' : 'lost'),
guard: { closed_value: (v, job) => v >= job.input.value * job.policy.floor || 'below the floor you set' } },
nurture: { by: 'nurturer', routed: true, next: job => (job.output.revived ? job.record.interrupted : 'lost') },services/deal.ts · 42 lines · nothing on effort; 5% of closed_value at won, proven by the account's payment, held 60 days by bank, 120 by card.
steps source · ai → qualify · ai → book · ai · setter when warm → meet · ai · closer from humans_from, committee or person · skipped for B2A · guard held: the account attends 10 minutes → propose · ai · the closer's span → negotiate · ai · the closer's span → close · ai · the closer's span · guard closed_value at least value × floor → nurture · nurturer · routedThe sales example runs every one of its files.