Skip to content

Trigger

A Trigger binds one source to one Procedure. Every external surface for a write — an HTTP endpoint, an MCP tool, an entry-lifecycle hook — is a Trigger, and there is no other way to expose a Procedure. This page is the field-level contract; the concepts are in Procedures and Triggers. Envelope rules are in Manifest envelope and conventions, and diagnostic codes are catalogued in Diagnostics.

Fields

spec accepts exactly two keys.

FieldTypeRequiredRules
sourcemappingyesDiscriminated by kind; the accepted sibling keys depend on it.
target{ procedure }yesOnly the key procedure, naming a declared Procedure (TRIGGER_TARGET_PROCEDURE_UNKNOWN).
source.kindOther keysBinds
httpmethod, pathOne REST endpoint under /api/.
mcpsurfaceOne tool on /mcp or /mcp/staff.
lifecycleschema, on, errorPolicyEntry-writer hooks on one Schema.

An unknown kind, a missing kind, or a key that does not belong to the chosen kind is INVALID_MANIFEST_ENVELOPE. One Procedure may carry several Triggers — that is how the same handler becomes an HTTP endpoint and an MCP tool without duplicating logic.

http source

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Trigger
metadata:
  name: inventory-level-http
spec:
  source:
    kind: http
    method: PUT
    path: /api/inventory/{sku}/level
  target:
    procedure: sync-inventory-level
FieldRules
methodPOST, PUT, PATCH or DELETE.
pathNon-empty string starting with /. OpenAPI {param} syntax for path params. No optional segments.

GET is deliberately absent. Reads are Views, which mount themselves from surface and need no Trigger at all; a Procedure is a write.

Path rules

PhaseRuleDiagnostic
parsepath starts with /.INVALID_MANIFEST_ENVELOPE
validatepath starts with /api/, so adapters can route public pages and Procedure endpoints without ambiguity.TRIGGER_PATH_INVALID
validate(method, path) is unique across every http Trigger.TRIGGER_PATH_COLLISION, naming the Trigger that claimed it first.
bootpath falls outside the adapter's reserved prefixes.TRIGGER_PATH_INVALID

Only well-prefixed paths are tracked for collisions, so a path missing /api/ produces one diagnostic rather than two. The Cloudflare Worker reserves /admin, /_mantle, /api/auth, /api/views, /oauth, /mcp, anything starting /.well-known/oauth, and the exact registrations * and /*; a prefix matches the path itself or a / or { boundary after it. See Conventional Worker.

Routing and binding

BehaviorDetail
Path paramsEach {param} binds to the identically named field on the target Procedure's input, which must declare it.
PrecedenceThe invocation merges the body first and the path params second, so the path wins over a same-named body field.
Trailing slash/api/posts and /api/posts/ are the same route; the root / is preserved.
Percent-encodingEach request segment is decoded once, so /api/by%2Dtag matches the literal /api/by-tag. Malformed encoding such as %GG is a routing miss (404), not a 500.
BodyMust be a JSON object. Anything else is INPUT_VALIDATION_FAILED (400) with HTTP Trigger request body must be a JSON object. A body over 1 MiB is the same code at 413.
Empty bodyA DELETE, or any request without a JSON content type, is treated as {} — bind those inputs through path params.

A success is { "ok": true, "data": <handler result> }; a failure is { "ok": false, "diagnostic": ... } at the diagnostic's mapped status. http Triggers are also what the OpenAPI emitter projects into operations.

mcp source

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Trigger
metadata:
  name: approve-purchase-order-mcp
spec:
  source:
    kind: mcp
    surface: staff
  target:
    procedure: approve-purchase-order
surfaceEndpointGate
public/mcpBearer token.
staff/mcp/staffBearer token plus a staff role read from storage on every invocation.

surface is discovery only. It decides which tools appear in tools/list on which endpoint; it authorizes nothing. The target Procedure's requires.auth.all predicates and its optional guard are re-evaluated on every tools/call against the authenticated caller, exactly as they are over HTTP. A public-surface Procedure that requires ctx.staff is discoverable on /mcp and will still be denied there.

The tool name is derived from the Procedure's metadata.name, not the Trigger's: lower-cased, with - replaced by _. Only one Trigger may claim a given (surface, tool name) pair; a second is MCP_TOOL_NAME_COLLISION. The same code also fires when the mangled name hits a reserved generic tool name or prefix, or a Schema's or another Procedure's segment — see Reserved names.

The tool carries the Procedure's title and description, with a short authorization summary appended to the description. output is not surfaced; MCP clients infer the response shape from the tools/call result. See MCP and agents.

lifecycle source

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Trigger
metadata:
  name: 010-verify-purchase-token
spec:
  source:
    kind: lifecycle
    schema: purchase-orders
    on: [before_create]
    errorPolicy: abort
  target:
    procedure: verify-purchase-token
FieldTypeRequiredDefaultRules
schemastringyesA declared Schema (LIFECYCLE_SCHEMA_UNKNOWN).
onLifecycleHook[]yesNon-empty; every entry from the closed list below.
errorPolicyabort | continuenoabort for before_*, continue for after_*See below.

errorPolicy: abort is rejected at parse time when any after_* hook appears in on: an after_* hook runs once the response has already been sent, so an abort could never reach the caller. Split the after_* hooks into their own Trigger, or declare continue.

InfoSchema.spec.lifecycle (publishing / operational) is a different domain that shares the word. That setting governs which states an entry may be in; a lifecycle Trigger governs what fires around a mutation. See Schema.

Hooks

HookFires
before_createBefore the insert.
after_createAfter the insert.
before_updateBefore an update or any status transition whose target is not published — this includes unpublish and archive.
after_updateAfter an update or such a transition.
before_deleteBefore the delete.
after_deleteAfter the delete, only when a row was actually removed.
before_publishBefore a transition to published.
after_publishAfter a transition to published.

There are no unpublish-specific or archive-specific hooks. Do not read before_update / after_update as edit-only.

Error policy

PhaseDefaultBehavior
before_*abortA throwing hook cancels the surrounding mutation and the caller receives the hook's own diagnostic. A hook that rejects a write on purpose raises LIFECYCLE_HOOK_REJECTED (409). Under continue the failure is logged and the mutation proceeds.
after_*continueThe committed mutation stands. On the inline or waitUntil path a failure is logged and swallowed. With a deferred dispatcher wired in, the failure reaches the delivery adapter so its at-least-once retry and dead-letter policy can run. Neither path ever rolls back.

Handler input and ctx.event

Hook input is phase-specific.

PhaseHandler input
before_*The original pre-projection Procedure input, so a hook can read side-channel fields the row never stores — a CAPTCHA token, a client nonce. It falls back to the row's data when there is no caller input.
after_*The persisted entry.data only. Deferred envelopes deliberately never carry arbitrary request input.

Every hook handler also receives ctx.event:

FieldValue
idStable event id, unchanged across enqueue fallback and deferred retries.
triggerThe firing Trigger's metadata.name.
hookThe hook name.
schemaThe watched Schema name.
entrynull only on before_create; the pre-mutation row for the other before_* hooks; the persisted post-mutation row for every after_*.

Deferred handlers key on ${ctx.event.id}:${ctx.event.trigger}. See Procedure.

Ordering and coverage

When several lifecycle Triggers bind the same (schema, hook), they fire alphabetically by Trigger.metadata.name. Choose names that sort the way you want them to run — the 010-, 020- convention exists for exactly this, and the code generator handles the leading digits.

Hooks are wired through a repository decorator that wraps the single entry-writer chokepoint, so Staff MCP, Admin and builtin Procedure writes all fire the same hooks. There is no write path that bypasses them.

For deferred after_* delivery, the ordered Trigger-name list is captured into one versioned envelope carrying the persisted row and a small identity snapshot. Every captured Trigger runs before a failure is reported back, so a retry may replay Triggers that already succeeded — hence the idempotency key. Queue acceptance is not transactional with the entry write, the waitUntil fallback is best-effort, and exactly-once is not promised. See Deferred hooks on Queues.

Source