Skip to content

Operations, functions & permissions

An engine has no endpoints. It exposes two surfaces, and the difference between them is the whole extension model:

  • Operations — named handlers invoked through a scope stub. Each is a default binding that does its own permission check.
  • In-scope functions — plain exports a vertical calls inside its own operation, in the same transaction, where the vertical owns the permission check.

HTTP is derived, never authored: a manifest may point at an emitted OpenAPI spec via its api field. Nothing in the engine speaks HTTP.

Operations

OperationPermissionDoes
workorder/getworkorder:read (per-entity)one order with its time and material
workorder/listworkorder:reada page of orders, optionally filtered by status
workorder/assignworkorder:assignassign a technician (order stays planned)
workorder/startworkorder:reportplannedin_progress
workorder/report-timeworkorder:reportappend a time entry
workorder/report-materialworkorder:reportappend a material line
workorder/completeworkorder:completefreeze billable lines, in_progresscompleted
workorder/closeworkorder:closecompletedclosed

workorder/get checks per-entity (ctx.check(PERM.read, orderRef(id))), which is what makes the customer-portal walk work — see Composing.

There is no workorder/create operation — and that is the design

The engine registers no create. Creation is the in-scope function createWorkOrder(ctx, …) only, because the vertical must price and label the order first: it arrives from a felanmälan, a booking, a ticket. demos/callout reaches it through callout/create-workorder.

The hole is deliberate and load-bearing — the engine owns the state machine, the vertical owns vocabulary and pricing, and the engine leaves a gap exactly where the vertical belongs. It's also why an engines-only scope can't do anything on its own: configuration is dynamic; composition is code.

In-scope functions

ts
import { createWorkOrder, completeWorkOrder, PERM } from '@substrat-run/engine-workorder';
FunctionBacksNotes
createWorkOrder(ctx, input)(no operation)the deliberate hole above
assignWorkOrder(ctx, input)workorder/assignrecords the technician; the order stays planned
startWorkOrder(ctx, input)workorder/startplannedin_progress
reportTime(ctx, input)workorder/report-timeappends a time entry, attributed to the acting principal
reportMaterial(ctx, input)workorder/report-materialappends a material line
completeWorkOrder(ctx, input)workorder/completevalidates + freezes billable lines; optional currency labels the total of a completion that has none (default SEK)
closeWorkOrder(ctx, input)workorder/close
listOrders(ctx, page)workorder/lista Page<WorkOrder>page is the kernel's PageParams (limit, sort, order, cursor, filters, total), walked by ctx.page; with total: true the result is a CountedPage that also carries the filtered count
getWorkOrder(ctx, orderId)workorder/getone order by id; throws on an id that names nothing
getReportedLines(ctx, orderId)workorder/gettime + material for an order

listOrders is paged, not filtered by a positional status?. The old listOrders(ctx, status?) returned every matching row with no bound and a hard-coded sort; it is gone rather than kept beside the paged read. Pass the status as a filter instead — listOrders(ctx, { filters: { status: 'planned' } }) — and read one order with getWorkOrder rather than walking the list for it: once the list is a page, the row you want is simply not on page one. See Lists are pages, not dumps for the walk.

None of these check permissions — that is the caller's job, by design. Calling one from your own operation without assertAllowed(await ctx.check(…)) first is a bug the linter won't catch for you.

What they return is parsed, not asserted. Every value crossing back out of this engine goes through the schema the engine publishes — workOrder, timeEntry, materialLine — the same schema you point your own operation's output at. Engine surfaces evolve additively (D-28), but that rule is held by review; the parse is what makes the failure it prevents loud. A vertical compiled against 0.3 and running against 0.4, whose row shape moved, used to read a field that had become null and render it — now the read throws at the seam. Reads name their columns for the same reason: SELECT * would publish whatever the physical table happens to hold.

Every operation is a thin binding of one of these — a permission check plus one call — so a vertical that wants to report time and touch its own tables in one transaction composes reportTime inside its own operation. The four reporting-side functions (assignWorkOrder, startWorkOrder, reportTime, reportMaterial) take exactly the input their operation declares and parse it on the way in.

Permissions

Declared in the manifest with descriptions — fuel for the permission-review diff:

KeyDescription
workorder:createCreate work orders
workorder:readRead work orders, time and material
workorder:assignAssign a technician
workorder:reportStart work, report time and material
workorder:completeComplete a work order (with billable lines)
workorder:closeClose a completed work order

workorder:create is declared even though no operation binds it: createWorkOrder is composed by verticals, which check this key themselves.

Typical role shapes: a technician gets read + report; a coordinator adds create + assign + complete; closing (the bookkeeping-facing act) can be reserved for back-office.

Entitlement

entitlementKey: 'workorder'. A tenant that doesn't hold the flag can't resolve these operations — checked per invoke, fails closed. It's a binary SKU gate, not configuration; see Composing.

The hard parts, hosted.