Skip to content

Composing & extending

Using it as-is

ts
host.registerModule(protocolModule);

Then supply template content — the engine ships none.

Wrapping it with vertical logic

The registered operations are default bindings over exported in-scope functions. Your own operations call them in the same transaction, and you own the permission check:

ts
import { instantiateProtocol, PROTOCOL_PERM } from '@substrat-run/engine-protocol';

host.defineOperation('cykel/start-condition-report', async (ctx, input) => {
  assertAllowed(await ctx.check(PROTOCOL_PERM.create));
  return instantiateProtocol(ctx, {
    templateKey: 'condition-report',
    entity: input.bike, // any EntityRef — the vertical owns the vocabulary
  });
});

The vertical declares the protocol → <parent> entity relation in its manifest — the engine can't know whether protocols hang off work orders, bikes, or apartments — and supplies the template content.

The completion guard — two forms

"Obligatorisk för status" (a work order can't complete until its protocol is signed) is the canonical cross-engine requirement, and there are two ways to wire it.

Composed, in code

ts
import { requireSigned } from '@substrat-run/engine-protocol';

host.defineOperation('acme/workorder-complete', async (ctx, input) => {
  assertAllowed(await ctx.check(WORKORDER_PERM.complete));
  requireSigned(ctx, input.order, 'self-inspection-electrical'); // throws if not signed
  return completeWorkOrder(ctx, input);
});

The work-order engine knows nothing of protocols, the protocol engine checks its own tables, and the vertical wires the two — star topology intact. The requireSigned call site is the compliance gate.

Declared, in the manifest

ts
guards: [{
  before: 'bike-shop/close-repair',
  predicate: 'protocol/all-signed',
  config: { templateKey: 'condition-report', entityType: 'bike', entityIdFrom: 'bikeId' },
}]

Same rule, but it appears in the reviewable manifest diff — dropping a compliance gate becomes visible rather than a deleted line in a handler.

Prefer the declarative form when the rule really is "this operation is blocked until X". Prefer the composed form when the vertical needs to name a moment it actually owns — a bike shop's pickup, where the customer accepts the report, is more than the engine's transition, and a guard on workorder/close would describe it wrongly.

Configuration

There is none at registration time. ModuleRegistration has five fields — manifest, migrations, operations, consumers, predicates — and none is config. There is no createProtocolModule({...}) factory and no config field on the manifest.

Configuration is dynamic; composition is code.

This engine shows the two sanctioned alternatives better than any other:

You wantUse
a parameterised ruleguards[].config — declared in the vertical's manifest, kernel-opaque, parsed by allSignedGuardConfig. Its entityIdFrom late-binds into your vocabulary.
tenant-specific contentruntime datadefineTemplate puts templates in the tenant's own scope. Which protocols exist is data, not config.
only part of the enginewithdraws in your manifest, then re-offer behind your own operation
different behaviour around a transitionyour own operation calling the in-scope function
the engine off for a tenantrevoke the protocol entitlement

The template mechanism is the lesson worth generalising: content that varies per tenant is data in the scope, not options in a constructor. An engine with a templates: config array would need a redeploy to add a checklist; defineTemplate needs an operation call.

Adding data to a protocol

Never add a column upstream. A vertical needing extra fields adds its own side table keyed by the engine's instance id. Another module's tables are private; the stable surface is entity ids, EntityRefs, and event payloads.

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 external signing flow is split across that boundary deliberately:

  1. Your vertical calls requestSignatures(ctx, { instanceId, method: 'scrive', parties }). That freezes the content, computes the hash once, writes a request row per party, and emits a fat protocol.signatures-requested carrying everything a connector needs to dispatch — the hash, the parties, the content ref. All inside your transaction.
  2. An executor outside the scope picks that event off the outbox and makes the HTTP call. It holds platform authority, which module code must never have.
  3. Days later the provider reports a signature, and something invokes recordSignature(ctx, { requestId, signatory, signedAt, contentHash, evidenceRef }).

Step 3 has no caller yet

Steps 1 and 2 are buildable today — the outbox and ExecutorHandler are real. Step 3 is not, and the gap is in the kernel, not this engine:

  • there is no webhook ingress anywhere. apps/router resolves hostname → target and has no such surface; kernel-design.md reserves the idea and nothing implements it. No signature verification, no replay protection, no connection store.
  • there is no inbound authority seam. ExecutorHandler has no return path into a scope, and the only stub minter — ScopeHost.getScope(principal, …) — demands a PrincipalId. A provider callback is not a principal.

So recordSignature is reachable only by a principal holding protocol:record-signature, a key deliberately held by no human role in any demo. It is shaped to be callable by that ingress when it lands; it is not a claim that BankID works today.

Tracked as #96 (webhook ingress) and #97 (inbound authority seam).

A worked example of steps 1 and the shape of 3: Meridian's anställningsavtal.

The shape to copy

The engine defines what evidence is and what must be true of it — the hash matches the frozen content, the signatory matches the request, the timestamp is the provider's. A connector supplies the evidence. What the engine does not do is pretend an asynchronous, non-principal signature fits a synchronous, principal-shaped operation: that assumption is what left the freeze window open, and the correction was a new noun (the signature request), not a better comment.

The hard parts, hosted.