Domain model & invariants
The state machine
Declared, in engines/workorder/src/lifecycle.ts, and emitted to engines/workorder/model.json — this is the first engine to adopt a lifecycle. The table below is written from that declaration; the declaration is the source, and pnpm lint:model --check is what keeps this page from drifting away from it.
in_progressadmits report-material · report-time — none of which move itplannedadmits assign · report-material · report-time — none of which move it
status · starts at planned. Drawn from the engine’s declared lifecycle in model.json — the same artifact lint:model --check gates, so this picture cannot drift from the code.| Operation | Legal in | Moves to | Permission |
|---|---|---|---|
workorder/create | — | planned (the declared initial) | workorder:create |
workorder/assign | planned | — | workorder:assign |
workorder/start | planned | in_progress | workorder:report |
workorder/report-time | planned, in_progress | — | workorder:report |
workorder/report-material | planned, in_progress | — | workorder:report |
workorder/complete | in_progress | completed | workorder:complete |
workorder/close | completed | closed | workorder:close |
The rows with no target are allow entries — legal in that state, moving nothing. Assignment is a field, not a state, which is why assign leaves the order in planned.
in_progress is the one state marked extensible, so a vertical may refine it with substates (awaiting_parts, pending_customer_approval). completed and closed carry billing consequences and admit none. closed is terminal.
Invalid transitions throw a conflict carrying reason: 'invalid_transition' — a completed order cannot be started, a planned order cannot be completed. The engine owns this; no caller can skip a state.
Tables
Created by migration 0001-init, inside each scope's own database:
workorder_orders— id, sequentialnumber(per scope),facilityandcustomeras opaqueEntityRefcolumns, vertical-definedkind, title/description, status, assignment, provenance (created_by,created_at,completed_at).workorder_time_entries— order, technician, decimalhours, note,reported_at.workorder_material_lines— order, article, decimalqty, note, reporter,reported_at.
Notice what's absent: no facility table, no customer table, no price columns. The engine references facilities and customers as opaque refs the vertical owns, and it records quantities, not prices — quantities are facts, prices are business decisions.
The invariants
- The state machine cannot skip states (above).
- Time and material are append-only. There is no update or delete operation for reported entries. Corrections are a vertical-level concern (a compensating entry), never a silent edit.
- The reporter is the principal. Time entries record
ctx.principalas the technician; material lines record the reporter. Attribution comes from the ambient context, not from the input — a caller cannot claim someone else did the work. - Every mutation emits a fat event (events).
Completion: the pricing boundary
complete is where the engine meets money, and the boundary is precise: the vertical prices, the engine freezes.
await stub.invoke('workorder/complete', {
orderId,
billable: [
{
article: 'TIME', description: 'Servicetekniker', qty: '2.5', unit: 'h',
unitPrice: moneyOf('850', 'SEK'), lineTotal: moneyOf('2125', 'SEK'),
sourceType: 'time', sourceId: timeEntryId,
},
// ...
],
});Each billable line carries provenance (sourceType: 'time' | 'material', sourceId) back to the reported entry it prices. The engine validates the lines (Zod + exact decimal arithmetic), sums the total with addMoney, transitions the order, and emits workorder.completed with the full billable snapshot in the payload.
The engine never derives a price from a reported entry — it has no rates to derive from. It checks that what it was handed is arithmetically sound, then makes it permanent.