Validation¶
Four barriers, from earliest to latest. The point of the ordering is that cheap mistakes are caught before the expensive work starts.
| When | What's checked | Exception |
|---|---|---|
@module (import) |
Every parameter annotated, supported annotation, return annotated, Put/Patch/Delete parameterized |
TypeError |
check (start of run) |
Every entity type read or touched is provided by the initial Store or by an upstream step |
LookupError |
| per step, before applying | Output contract honored; Patch/Delete targets present and unambiguous; Patch fields exist |
TypeError / KeyError / ValueError |
| on apply | pydantic validation of every object and every written field | ValidationError |
How check works¶
It replays the workflow on types. It starts from store.types() and adds, after each step, the types
that step produces:
available = {Employee, Manager, Timesheet, PayrollPolicy}
1. compute_gross reads Employee ✓ Timesheet ✓, touches Employee ✓
2. add_bonus reads Manager ✓, touches Manager ✓
3. withhold reads Employee ✓ → available |= {Payslip}
4. archive reads Timesheet ✓, touches Timesheet ✓
5. report reads Payslip ✓
Move report to the front and it fails in a millisecond rather than after three hours of computation:
Workflow(report, withhold).check(store)
# LookupError: step 'report' reads Payslip, which is neither in the store
# nor produced by an upstream step
run calls check first, so you get this for free. You can also call it yourself at startup — it only
reads store.types(), so it works on a store built from your schema before any data is loaded.
What check does not catch¶
Being explicit about the limits is more useful than overselling the guarantee.
Ordering that is wrong but type-consistent. Running add_bonus before compute_gross passes the
check — both types are in the store from the start — and quietly applies a bonus to a gross of zero.
check reasons about availability, not about freshness. Ordering your steps is still your job.
An order that is wrong and type-inconsistent, on the other hand, is caught and named: when the missing type is produced further down the list, the error says which step to move and where, rather than just reporting the type as absent.
Anything about values. check never runs a module. Empty inputs, wrong numbers, an exception in your
own code — none of it is visible to it.
Application semantics¶
- A step's outputs are collected, validated, then applied. If the eighth operation is invalid, the first seven have not been written: a step never applies halfway.
- The workflow is not transactional by default. If step 4 of 5 raises, steps 1–3 are already applied.
Pass
atomic=Trueto roll the whole run back, or a copy for a non-destructive run:workflow.run(copy.deepcopy(store)). - An exception from a module, or from applying its output, is re-raised unchanged with a note naming the
step, its position and the state of the store. Your
exceptclauses keep working. - Application order is return order. A
putthen aDeleteon the same object leaves it deleted. puton an existing(type, name)replaces the object. Partial updates go throughPatch.- The
Storeis mutated in place, andrunreturns it.
Error catalogue¶
Every message morphly itself raises, with what to change.
At import, from @module¶
Annotate it. Nothing is inferred.
TypeError: compute_gross: unsupported annotation for 'store': <class 'morphly.store.Store'>. Expected list[Entity subclass] or a Config subclass.
list[X] is for entities. Scalars and parameters belong in a Config.
A module with no declared output has no contract.
list[Patch] declares nothing. The type parameter is the declaration.
At check¶
LookupError: step 'report' reads Payslip, which is neither in the store nor produced by an upstream step
At run, before applying a step¶
TypeError: add_bonus returned Patch(Employee(name='ada'), gross) but Patch[Employee] is not in its return type
Patch named a field the target doesn't have — usually a typo, or a field that only exists on a
subclass.
An empty Patch is meaningless.
The target was never stored, or an earlier step deleted it.
Two sibling types hold that name. morphly refuses to guess — narrow the declared type, or rename.
A step returned both a Delete and another operation on the same target. Application order is return
order, so the Patch would run against an object the step itself already removed. Drop one of the two.
At run, from the store¶
Put it in the store, or bind it to the step:Step(compute_gross, PayrollPolicy()).
A step holds two configs of the same type. Keep one.
Store takes entities and configs, nothing else.
On apply, from pydantic¶
1 validation error for Employee
gross
Input should be a valid number, unable to parse string as a number [type=float_parsing, input_value='lots', input_type=str]
validate_assignment=True means a Patch is validated like any other field write. Your business
invariants — validators, constrained types — apply to everything a module writes.