The interface between a module's runner and its payload, in both
directions. canon/01 says the seam holds only if the runner's verb set is
complete enough that a payload never grows its own lifecycle machinery; this
is the verb set. It is written language-neutrally as signatures a Python or
TypeScript runner template implements the same way. Every verb cites the
instance that showed it was needed.
Audience: anyone writing the runner template, or a payload against it.
Runner calls payload#
One entry point per capability, registered by capability id.
run(item: Item, ctx: Context) -> OutcomeItem:{item_id, input}as submitted, plus any fields a prior step or the caller supplied for this capability'srequires.Context: everything the payload may use, below. It is the only handle the payload has on the outside world.Outcome:Result | Error | NeedsReview | Skippedpercanon/03-jobs-and-state.md, withoutput,confidence,judged_by,evidence,error_type,reasonas applicable. Attempts, duration, usage refs, and timestamps are stamped by the runner, not the payload.
A capability may declare it prefers batches (run_batch(items, ctx) -> Outcomes) for payloads that call a vendor once per N items; the runner then
owns the batching, the per-item outcome fan-in, and partial failure. The
per-item contract is unchanged from the caller's point of view.
Non-retryable failure is a raised NonRetryableError(error_type, message);
anything else raised is retryable and the runner counts the attempt
(canon/04; versable-runner's NonRetryablePipelineError,
lib/pipeline/types.py). A raised DeferredError(reason, retry_after?)
means "try later, do not count this attempt": the runner re-queues after
retry_after (or its own backoff) up to the deferral ceiling, then records
error (App V5's deferred disposition, lib/tasks/task_runner.py:341-350).
These three are the only runner-typed exceptions a payload raises, and the
runner's Context is where the payload learns a breaker is open rather than
by importing a breaker.
Payload calls runner, through Context#
| Verb | Signature | What it does | Witness |
|---|---|---|---|
ctx.job | {job_id, client_job_id, tenant, caller_id, env, capability, variant, params, settings, attribution} | read-only view of the envelope | every instance passes some of this by hand |
ctx.attempt | int | which attempt this is | versable-runner retry_count on process_item |
ctx.log | log(level, message, data?) | one structured line, scoped to job, item, attempt by the runner; the payload never writes an id | speedway createJobLogger (joblog.server.ts); walmart write_stage_log |
ctx.meter | meter(name, quantity, unit, detail?, cost_usd?) | one usage event, idempotency key derived by the runner from job, item, attempt, and meter; never throws | speedway recordUsage never throws; versable-runner usage/{idx}.json |
ctx.trace | trace(name, fn) or a decorator | wraps a model or vendor call in a trace span tagged with the correlation ids; no-op without keys | walmart observability.py; versable-runner's Langfuse observe in lib/ (the leak this verb closes) |
ctx.cached | cached(key_parts, compute, ttl?) | result cache keyed by the capability's cache spec plus key_parts; marks the outcome cached on a hit | versable-runner gcs_cache.py; speedway scrapecache |
ctx.cancelled | cancelled() -> bool | true once cancel was requested; checked between units of work | speedway cancelRequested; versable-runner cancelled.marker |
ctx.on_cancel | on_cancel(fn) | register a best-effort vendor-side cancel | speedway abortExtractorRunDispatches |
ctx.checkpoint | checkpoint(cursor) and ctx.cursor | persist progress inside a long unit of work; the runner stamps the heartbeat and resumes from cursor after recovery | speedway cursor + heartbeatAt, copied ×3; the reason this verb exists |
ctx.iterate | iterate(seq, page=…) -> pages | the checkpointed iterator: yields pages, checkpoints after each, respects cancel | speedway PAGE=500 loops |
ctx.wait_for | wait_for(check, backoff, ceiling) | poll something slow with backoff and a time ceiling; the runner owns re-enqueue and terminal transitions | walmart poll_feed_status (jobs.py:636-771), rebuilt beside the runner's own retry path, in a runner file |
ctx.limit | limit(vendor) context manager or await | takes a slot from the shared outbound rate limiter for that vendor | walmart SharedRateLimit (ratelimit.py); speedway env caps |
ctx.guarded | guarded(vendor, fn) | runs an outbound call behind the vendor's circuit breaker and the fleet retry budget, breaker outermost; raises DeferredError when open or spent | App V5 @resilient, lib/breakers/decorator.py:1-11, lib/redis/retry_budget.py |
ctx.reference | reference(name) -> loaded snapshot | the versioned reference-data snapshot; the version is recorded on the outcome | services-api assets/, data/ |
ctx.artifact | artifact.put(bytes, name, content_type) -> ref | write an artifact under the tenant/job prefix; returns the reference the outcome carries | versable-runner image output (to S3, the lapse); walmart rehosted images |
ctx.judge | judge(capability_id, output) -> score | chain a judge capability declared in the manifest; the runner runs it as its own attempt and meters it under the judge | owner ruling on confidence source |
ctx.review | construct NeedsReview(reason, confidence, evidence, partial_output) | the payload's way to say a person should look | canon/10; versable-runner's RESEARCH_QUALITY_LOW error is what this replaces |
Everything else the payload might want (a queue client, a storage client, a
status write, an env var) is not on Context, on purpose. If a payload
needs it, the verb is added here first.
Runner obligations the payload can rely on#
- Every
runis called at mostmax_attemptstimes per item, with the attempt number inctx.attempt; after exhaustion the runner writesErrorwith the last error type. - Two
runs for the same item never overlap; a redelivered unit finds the outcome already written and returns without calling the payload. ctx.meterandctx.lognever raise into the payload.ctx.checkpointsurvives process death: after recovery,ctx.cursoris the last checkpoint andctx.attemptis unchanged for the same delivery.ctx.cancelled()becomes true within one unit of work of the cancel request.- Duration, attempts, usage refs, correlation ids, variant, judge, and reference versions are stamped on the outcome by the runner.
The Python and TypeScript shapes#
Same names, idiomatic types. Python: Context is a dataclass with async
methods; run is async def. TypeScript: Context is an interface; run
returns a Promise<Outcome>. The runner template ships both, tested against
the same conformance table, and a payload written against one reads like a
payload written against the other.
Banned in a payload#
- Importing a queue, storage, cache, tracing, or secrets client.
- Reading environment variables.
- Writing a status, a job record, or a usage record directly.
- Sleeping in a loop to wait for a vendor; use
ctx.wait_for. - Keeping its own cursor or heartbeat; use
ctx.checkpoint/ctx.iterate. - Returning a
Resultwith aconfidenceit did not compute or receive.