Composing & extending
Using it as-is
host.registerModule(workorderModule);That's the whole registration surface. registerModule takes exactly one argument, and it's a frozen constant.
Wrapping it with vertical logic
The registered operations are default bindings. For custom flows, call the exported in-scope functions from your own operation — same transaction, same serialization domain, and the permission check becomes yours:
import { createWorkOrder, PERM } from '@substrat-run/engine-workorder';
host.defineOperation('acme/felanmalan-to-order', async (ctx, input) => {
assertAllowed(await ctx.check(PERM.create));
ctx.sql.exec('UPDATE acme_tickets SET status = ? WHERE id = ?', ['converted', input.ticketId]);
return createWorkOrder(ctx, {
facility: ticketFacility(ctx, input.ticketId),
customer: ticketCustomer(ctx, input.ticketId),
kind: 'felanmalan',
title: input.title,
});
});The ticket update and the order creation commit together or not at all.
demos/callout/src/routes.ts maps the seam precisely: assign/start/close go straight to the engine, while create-workorder and complete-workorder (which needs billable lines priced) route through callout/*.
Configuration
There is none, and that's deliberate. ModuleRegistration has five fields — manifest, migrations, operations, consumers, predicates — and none is config. There is no createWorkorderModule({...}) factory and no config field on the manifest. An engine cannot be told anything at registration time.
Configuration is dynamic; composition is code. When you need the engine to behave differently, you reach for one of these instead:
| You want | Use |
|---|---|
| only part of the engine | withdraws in your manifest — suppress a default binding, re-offer it behind your own operation |
| different behaviour around a transition | your own operation calling the in-scope function (above) |
| a parameterised rule | a guard predicate with guards[].config — kernel-opaque, parsed by the predicate that owns it |
| tenant-specific content | runtime data in your own tables |
| the engine off entirely for a tenant | revoke the workorder entitlement |
entitlementKey is a binary SKU gate, not a config knob: a tenant holds it or the operations don't resolve. It's checked per invoke and fails closed. One caveat — bare defineOperation glue carries no manifest and is ungated, so a vertical operation wrapping an in-scope function bypasses the entitlement check.
Adding data to an order
Never add a column upstream. A vertical needing extra fields on a work order adds its own side table keyed by the engine's order id. Another module's tables are private; the stable surface is entity ids, EntityRefs, and event payloads.
Portal reads
The engine declares the edge that makes entity-narrowed access work:
entityRelations: [{ entityType: 'workorder', parentType: 'facility' }]createWorkOrder records it with ctx.link(orderRef, input.facility), so a capability grant on a facility reaches the work orders under it. A portal customer gets an entity-narrowed workorder:read on their own facility and workorder/get — which checks per-entity — does the rest.
Reaching the outside world
Nothing in this engine talks to anything external, and it never will: module code may not fetch, and connectors handle the outside world.
The seam is workorder.completed on the event spine. A connector — a third-party capability living in the kernel-owned integrations hub, neither engine nor vertical — is what would carry it outward.
Connectors are not built yet
The connector interface, webhook ingress, and per-tenant connection config are planned, not implemented — there is no connector code in the repo today. The _substrat_outbox is real and transactional, but it drains only to in-process consumers; there is no external sink. Engines are pre-shaped for a seam that doesn't have a floor under it yet.