Skip to content

Composing & extending

Using it as-is

ts
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:

ts
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 wantUse
only part of the enginewithdraws in your manifest — suppress a default binding, re-offer it behind your own operation
different behaviour around a transitionyour own operation calling the in-scope function (above)
a parameterised rulea guard predicate with guards[].config — kernel-opaque, parsed by the predicate that owns it
tenant-specific contentruntime data in your own tables
the engine off entirely for a tenantrevoke 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:

ts
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.

The hard parts, hosted.