When a consumer wants a shape the worker does not naturally produce (M rows
from N items, an export, a joined view, an editable table), do you make the
worker produce it, or keep the worker's record raw and transform on read?
App V5 tried the first, could not, and built the second, and the second
turned out to carry far more than the case that forced it. canon/15 states
the resulting rule; this guide is the deliberation.
Audience: anyone about to change what a worker writes so a consumer can read it more easily.
The case that forced it#
Owner, 2026-08-18: "At one point we needed to output M output rows for N input rows in the pipeline (M != N), which the pipeline straight up did not support. So we had to design the job output functionality, which we later leveraged to share a lot of functionalities like exports and on-the-fly non-pipeline data-modifications, which allowed the underlying pipeline rows to re-run or fail at their pace while the output layer just shrugged off the data mutations underneath; essentially a combination of 'mutable heavy agentic worker run' + 'declarative output transform run'."
Two things in that sentence are the whole guide: the pipeline could not do it, and the thing built instead ended up owning exports and edits too.
The ways, and what each buys#
Make the worker produce the consumer's shape#
Add the export row, the merged value, the M-from-N expansion to what the worker writes per item. Cheapest first step, and it is where every pipeline starts. What it costs, visible in App V5 before the output layer: the worker's write path is now coupled to every consumer's shape; a second consumer wanting a different shape means another field or another branch in the hot path; M≠N does not fit a per-item write at all; and every re-run or failure of an item mutates something a reader was already looking at.
Right when: there is exactly one consumer, its shape is the item's shape, and it will stay that way. That describes a script, not a product.
Keep outcomes raw, transform on read#
The worker writes one outcome per item and never anything else; a transform is a declared mapping from outcomes to rows or files, computed on demand or materialized with a high-water mark. What it bought App V5: exports of any shape, edits that live beside the record instead of inside it, and readers that keep working while items retry. What it costs: a second concept to explain, a small transform engine, and the discipline not to sneak consumer shape back onto the outcome.
Right when: more than one consumer, any M≠N, any human edit, any export, any partial-progress read. That describes every module in the estate.
Materialize on write, into a separate store#
A third way, worth naming because walmart-mvp is close to it: the worker
writes raw rows and a separate step writes the derived table (rehosted
images to a public bucket, Part slices per stage), and readers read the
derived table. What it buys: fast reads and a simple reader. What it costs:
a second write path that can drift from the record and needs its own
reconciliation, and no partial reads until the derived write runs.
Right when: reads dominate and must be fast, the transform is expensive
enough that on-demand is not viable, and a reconcile step is acceptable. It
is the on-read pattern plus a cache, and it should be built as one
(materialization named by high-water mark, canon/15), not as a second
source.
The tells#
Signs the worker is being bent into a transform:
- a field on the outcome exists only for one downstream reader
- an item's outcome changes after it was written, for a reason that is not a new attempt
- "we cannot export until the job finishes"
- a request for a different shape becomes a change to the payload
- M items in, N rows out is a special case somewhere in the run loop
Signs the transform layer is doing its job:
- a new export is a new declared transform, no worker change
- a human edit lands in the app's store and the module's outcome is unchanged
- an 80-percent-done job exports as 80 percent, and says so
- the same outcomes feed three shapes
Where the line sits in the module tree#
Module-native transforms (results as CSV, a load-sheet shape the capability
declares) are the module's and listed in the manifest. Customer exports,
catalog views, and edits are the app's, over the module's outcomes. Neither
writes an outcome. canon/15-outputs-and-transforms.md.
Related#
../canon/15-outputs-and-transforms.md, the rule../canon/03-jobs-and-state.md, one outcome per item, immutable../canon/05-storage-and-persistence.md, artifacts and the state-table condition00-service-granularity.md, the headroom rule this case is the best example of