Agent docs

Outputs and transforms

> Evidence: 0 file:line witnesses as of 2026-08-18.

Evidence: 0 file:line witnesses as of 2026-08-18. Confidence: derived, written from the seams and the estate's failures rather than from citations; treat each rule as a design call and argue with it. What changes it: the first module built against this doc (../PLAN.md forge-1) and its instance breakdown.

A job takes N items and settles N outcomes. What a caller often wants is something else: M rows for a load sheet, one file for an export, a view that joins two jobs, a table that keeps reading cleanly while items underneath are still retrying. App V5 hit this exactly ("we needed to output M output rows for N input rows in the pipeline, which the pipeline straight up did not support") and built a job-output layer that then carried exports and on-the-fly, non-pipeline data modifications, and let "the underlying pipeline rows re-run or fail at their pace while the output layer just shrugged off the data mutations underneath". The owner's summary is the design: a mutable, heavy, agentic worker run plus a declarative output transform run. This doc adds that second thing to the contract, because without it the first thing gets bent to do both.

Audience: anyone whose caller wants something other than one outcome per item, or who is about to add "just one more field" to an outcome to make an export work.

The two layers#

items ──► worker run ──► outcomes (N, immutable once written)
output transform (declarative, rerunnable, cheap)
outputs (M rows, files, views; versioned)
Worker runOutput transform
what it isthe capability over items: model calls, scrapes, vendor worka declared mapping from outcomes (one job's or several) to rows or files
costexpensive, retried, metered, heartbeatedcheap, idempotent, recomputable at will
mutabilityan outcome is written once; a re-run is a new attempt or a child joba transform is a definition; its result is a materialization that can be regenerated any time
shapeone outcome per itemany shape: M rows from N outcomes, joins across jobs, filters, pivots, one file
who owns itthe modulethe module for module-native outputs (results as CSV or xlsx, a load-sheet shape the manifest declares); the app for customer exports and catalog views

The point of separating them is exactly what App V5 found: the transform reads whatever outcomes exist right now, so a job that is 80 percent done produces an 80-percent output on demand, and an item that fails and is re-run does not corrupt anything downstream, because nothing downstream was mutated in place; it is recomputed.

The rule#

Outcomes are the record; outputs are a transform over the record. A transform never writes an outcome, and an outcome never carries a row of an output.

  • Outcomes stay one per item (03-jobs-and-state.md). The temptation this rule resists is adding "the export row" or "the merged value" onto the outcome so a downstream reader has it. That couples the worker's write path to every consumer's shape and is how a pipeline ends up unable to do M≠N.
  • A transform is declared, not coded per case: a name, the outcome fields it reads, the shape it produces, and its version. Module-native transforms are listed in the manifest (outputs on a capability, with a schema per output); app-side transforms are the app's, over the module's outcome shapes.
  • A declared field may be a prefix: a reads entry ending in .* means every key present under that prefix. Some capabilities let a person name their own output columns, and a list written when the module was built cannot name a column typed this morning. Resolution is per job and comes from the outcomes rather than from the job's params, for two reasons: an export then never promises a column no row carries, and a transform layer that read one capability's params would end up having to know all of them. Order is first appearance across the outcomes read, which is the order the module wrote the keys and so the order the person listed their fields. A wildcard column exports under its bare key, because the header is read by a person; literal paths keep full-path keying, because two of them can end in the same word. This is still a projection. It is not the mapping language that M != N, joins and pivots will need, and it is deliberately not a step toward one.
  • Materializations are artifacts (05-storage-and-persistence.md), named by job, transform, transform version, and the outcome high-water mark they were computed from, so a reader can tell whether an export is stale and regenerate it. They are never the source of anything.
  • Reading a transform on a running job is allowed and says so: the response carries the counts at the moment of computation (computed_at, settled, pending), so an 80-percent export is honest.
  • On-the-fly modifications (a human edits a value in a review UI, a bulk find-and-replace) are app-side writes to the app's store, not edits to module outcomes. If the app wants the module to see them, it submits a child job. This keeps the module's record immutable and the app's data editable, which is the split App V5 arrived at.

What the module exposes#

  • GET /jobs/{job_id}/outputs lists the transforms the capability offers and any materializations that exist, with their high-water marks.
  • GET /jobs/{job_id}/outputs/{name} computes or serves the transform: paged rows for tabular shapes, a signed artifact URL for files. ?materialize=true writes the artifact; ?format=csv|xlsx|jsonl where the manifest allows.
  • The manifest declares each output: name, produced shape (schema or file type), which outcome fields it reads, whether it supports partial jobs.

versable-runner's /results for run-file jobs, which returns "a resume-file dict" shaped for pipeline_runner.py rather than raw outcomes (docs/runner-service.md), is a transform in disguise; under this rule it is outputs/resume-file, and /outcomes stays raw.

What the app owns#

Customer exports (the load-sheet template, "upload your own xlsx and map each column", multi-sheet Excel), catalog views that join many jobs, and any edited value. speedway's Export (README.md, "Typical flow": "the built-in Speedway upload template, or upload your own loadsheet (.xlsx) and map each column") is the app-side shape; it reads parts and runs, never writes them.

Do-nots#

  • Do not add a consumer's row shape to an outcome. Declare a transform.
  • Do not mutate an outcome to reflect a human edit. Write the edit in the app; submit a child job if the module must see it.
  • Do not build an export that only works on a finished job. Read what is settled, say what is not.
  • Do not hand-code M≠N inside a capability's run. That is a transform, or a run_batch capability with a stated output schema.
  • Do not treat a materialized export as a source; name it by its high-water mark and regenerate.
  • Do not declare a fixed column list for output keys a person names at run time. It cannot be right, and it fails silently: the column is absent from the file rather than reported missing.
@versable-git/ui · reference, canon, and method, read in place