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:readorders, optionally 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
completeWorkOrder(ctx, input)workorder/completevalidates + freezes billable lines
closeWorkOrder(ctx, input)workorder/close
listOrders(ctx, status?)workorder/list
getReportedLines(ctx, orderId)workorder/gettime + material for an order

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.

Not every operation has an in-scope function yet

assign, start, report-time, and report-material carry their logic inline in the operation handler, so there is no composable export behind them. A vertical that wants to report time and touch its own tables in one transaction cannot currently do it through the engine — the convention ("engine operations are thin: a permission check plus one exported in-scope function") is not yet met here. Unlike the missing create, this asymmetry is an artifact, not a decision.

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.