Modules and workflows¶
A module is a function with @module on it. That's the entire authoring interface — no base class, no
abstract methods, no registration.
@module
def compute_gross(
employees: list[Employee],
sheets: list[Timesheet],
policy: PayrollPolicy,
) -> list[Patch[Employee]]: ...
The decorator reads the annotations at import time and builds the contract. A bad signature fails when the module is imported, not when the workflow reaches it.
Injection rules (parameters)¶
| Annotation | Receives | Error |
|---|---|---|
list[X], X: Entity |
store.all(X) — a list, possibly empty |
TypeError if X is not an Entity |
X, X: Config |
the step's config, else store.one(X) |
LookupError if missing / ambiguous |
| missing | — | TypeError at declaration |
other (dict, str, Store, …) |
— | TypeError at declaration |
Two shapes, deliberately. There is no way to ask for one entity by name, a filtered subset, or the store itself — see Non-goals.
An empty list is a normal input, not an error: a payroll run with no managers gives add_bonus an empty
list and it returns no patches.
Isolation¶
Injected values are deep-copied by default. A module can sort them, mutate them, tear them apart — none of it reaches the shared state.
@module
def sneaky(employees: list[Employee]) -> None:
employees[0].gross = 999.0 # no effect
Workflow(sneaky).run(store)
store.find(Employee, "ada").gross # unchanged
This is what makes modules safe to write in isolation: the only way to change anything is to say so in the
return type. Workflow.run(copy_inputs=False) turns the copy off when volume demands it — the guarantee
then disappears, and that's a conscious choice, not a default.
Output rules (return value)¶
The return annotation is required and forms the output contract.
| Return | Effect |
|---|---|
None |
No change. Read-only module: export, monitoring, metrics. |
an Entity / a Config |
Upsert. |
a Put |
The same upsert, said explicitly. |
an iterable of Entity / Config / Put / Patch / Delete |
Applied in order. |
-> list[Payslip] # creates / replaces Payslip instances
-> list[Put[Payslip]] # the same, spelled out
-> list[Patch[Employee]] # updates a few fields
-> list[Delete[Timesheet]] # deletes
-> list[Payslip | Delete[Timesheet]] # several types, several operations
-> None # touches nothing
Put is sugar, never a requirement: a bare entity keeps meaning creation. It earns its place in a wide
union — -> list[Put[Payslip] | Patch[Employee] | Delete[Timesheet]] reads as the three verbs at a
glance, where a bare Payslip among the wrappers is easy to miss.
Returning something the signature doesn't declare is a TypeError:
TypeError: undeclared returned Patch(Employee(name='ada'), gross)
but Patch[Employee] is not in its return type
Produced versus touched¶
The contract draws a distinction the checker relies on:
- produced (
-> list[Payslip]or-> list[Put[Payslip]]): the type may not exist yet — this step brings it into being; - touched (
Patch[X],Delete[X]): the type must already be there, in the initial store or from an upstream step.
That's the whole basis of validation.
Application semantics¶
- A step's outputs are collected, validated, then applied. A step never applies halfway.
- Application order is return order. A
putthen aDeleteon the same object leaves it deleted. - Returning a full entity, bare or in a
Put, replaces any object under the same(type, name). Partial updates go throughPatch.
Step — per-step configuration¶
A workflow can run the same module twice with different parameters. Configs therefore live on the step, not on the module:
class ReportPolicy(Config):
detailed: bool = False
@module
def report(slips: list[Payslip], policy: ReportPolicy) -> None: ...
Workflow(
compute_gross,
withhold,
Step(report, ReportPolicy(detailed=False), name="summary"),
Step(report, ReportPolicy(detailed=True), name="audit_log"),
)
Step(module, *configs, name=None)—namedefaults to the function's name, suffixed on collision (report,report_2).- Resolving a
Config: the step's configs first, thenstore.one(...). - A bare module passed to
Workflowis equivalent toStep(module).
A config bound to a step is the right default for anything that varies per occurrence. Put it in the store when it's genuinely global to the run.
Workflow¶
| Method | Effect |
|---|---|
run(store, *, copy_inputs=True, on_step=None, reuse=None, record=False, atomic=False) |
check, then runs the steps in order. Returns the mutated store. |
check(store) |
Validates the chaining without running anything. |
explain() |
One line per step: reads, produced and ~touched types. |
to_mermaid() |
The same dataflow as a Mermaid flowchart. |
The order is yours¶
Workflow is a list, not a scheduler. It will not reorder your steps, and it does not try to infer a
dependency graph. What it guarantees is that an order which cannot work is rejected before anything
runs.
That distinction matters: check catches a step reading a type nobody provides. It does not catch a
step reading a type that exists but hasn't been computed yet — running add_bonus before compute_gross
is a silent mistake, because both types are in the store from the start. See
what check does not catch.
to_mermaid — export the graph¶
explain() reads well line by line; to_mermaid() renders the same reads/produces/touches edges as
a graph, for a reviewer who won't open the code:
flowchart LR
Employee --> compute_gross
Timesheet --> compute_gross
compute_gross -.-> Employee
Manager --> add_bonus
add_bonus -.-> Manager
Employee --> withhold
withhold --> Payslip
Timesheet --> archive
archive -.-> Timesheet
Payslip --> report
A solid arrow is a read (Type --> step) or a production (step --> Type); a dashed arrow is a Patch or
Delete (step -.-> Type). It is generated from the signatures, so it cannot go stale.
on_step — the observability hook¶
workflow.run(store, on_step=lambda step, ops, store: log.info("%s: %s", step.name, [repr(o) for o in ops]))
compute_gross: ["Patch(Employee(name='ada'), gross)", "Patch(Employee(name='bob'), gross)"]
add_bonus: []
withhold: ["Payslip(name='p_ada')", "Payslip(name='p_bob')"]
archive: ["Delete(Timesheet(name='ada-w1'))", "Delete(Timesheet(name='bob-w1'))"]
Called after each step is applied, with the operations it just wrote — ops is the list the module
returned, already validated. add_bonus running empty is visible directly, instead of two identical
Store(...) lines. One hook covers logs, metrics, progress bars, intermediate snapshots and writing
outputs to disk — see Recipes.
store.history — which step wrote this¶
on_step observes a run as it happens. history answers the question you ask afterwards, when a field
holds a number nobody expected:
store.history(payslip)
# [Write(step='withhold', action='put', fields=()),
# Write(step='pay_out', action='patch', fields=('net',))]
Every write a run makes goes through one place, so recording it is free of any bookkeeping on your side.
This is the runtime counterpart of explain() and to_mermaid(): the graph says which steps may write a
type, the history says which ones did. Writes made by calling put/patch/drop yourself are not
recorded, and the log outlives the object — a deleted entity keeps its history, ending with its delete.
reuse — skip steps whose inputs haven't changed¶
A step's inputs are typed and pydantic, therefore comparable. reuse takes advantage of that: pass in a
previous workflow.last_run, and a step whose reads are identical to that run is skipped — its outcome is
restored from an in-memory snapshot instead of calling the module again.
Recording is opt-in, because it is not free: record=True snapshots the store after every step and
serialises everything every step reads, so it costs one deep copy of the store per step. You pay it in a
notebook, where it buys back whole reruns; you don't pay it in a batch that will never be replayed.
workflow.run(store, record=True)
# ... edit compute_gross ...
workflow.run(loaded_store(), reuse=workflow.last_run) # add_bonus, withhold, archive: skipped
The first step whose reads differ, and every step after it, runs for real — a downstream step reading
unchanged data still counts as unchanged, even if an upstream step re-ran and happened to produce the same
values. In-memory only, for the lifetime of the Workflow object: nothing is written to disk, and there is
no invalidation to configure.