Skip to content

Procedure

A Procedure is a typed callable: input schema, output schema, authorization requirement, and one handler binding. It is the only atom with a code seam, and it is never exposed on its own — a Trigger is what makes it reachable. 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

FieldTypeRequiredRules
titleLocalizedTextnoAdmin label for the staff-operations surface. Absent falls back to a Title-Cased metadata.name.
descriptionLocalizedTextnoThe MCP tool description and the description field of GET /admin/api/operations.
requiresAuthorizationRequirementsnoauth.all predicates plus one optional guard.procedure. See Authorization.
inputJSON SchemayesMust be an object. Becomes the MCP tool inputSchema and the OpenAPI request body.
uiSchemaobjectnoAdmin-only. Accepts collectionAction and fields. Violations are SCHEMA_UI_INVALID.
outputJSON SchemayesChecked after the handler returns. Failure is OUTPUT_VALIDATION_FAILED (500).
handlerref | builtinyesExactly one binding shape; see below.

Both input and output are walked by the JSON Schema subset validator, so the same recognized and rejected keywords apply.

Order of operations

Every invocation — HTTP Trigger, MCP tool call, lifecycle hook, Admin operation — runs the same pipeline.

StepBehaviorFailure
1. AuthorizeEvaluate every requires.auth.all predicate against the caller context.UNAUTHENTICATED (401) when the caller carried no credential, AUTH_DENIED (403) when an authenticated caller falls short.
2. Validate inputCompile input to zod and parse the request.INPUT_VALIDATION_FAILED (400), pointing at the first failing property.
3. GuardInvoke requires.guard.procedure with the validated input and the same context.Any guard failure denies the target. Guards fail closed.
4. Dispatchref: look up the registration key and call the function. builtin: run the op.See the two handler sections.
5. Validate outputParse the handler result against output.OUTPUT_VALIDATION_FAILED (500) — this is a handler bug, not a caller error.

The value returned to the caller is the handler's own result. Output validation checks it; it does not strip unexpected fields.

handler.kind: ref

yaml
handler:
  kind: ref
  ref: approve-purchase-order

ref is an opaque registration key, not a path. It never names a file, module or export. The consumer passes a matching key in the handlers map given to the runtime or Worker, and the key is the whole contract between manifest and code.

RuleEffect
ref is a non-empty string; only kind and ref are accepted under handler.INVALID_MANIFEST_ENVELOPE
Every declared ref resolves to a registered handler.HANDLER_NOT_REGISTERED at boot, listing the registered keys as candidates.
An unregistered key reached at request time.The same HANDLER_NOT_REGISTERED code, mapped to 500 — defense in depth for embeddings that skipped boot validation.
A handler throws.Anything other than a structured error becomes INTERNAL_ERROR (500) with a safe generic message; exception details remain in internal logs.

To return a structured error instead, throw InvokeFailure carrying a diagnostic; the runtime unwraps it and returns that diagnostic with its own status. This is how a handler reports CONFLICT, ENTITLEMENT_REQUIRED or a domain-specific INPUT_VALIDATION_FAILED rather than a generic 500.

ref example

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Procedure
metadata:
  name: approve-purchase-order
spec:
  title: { en: Approve purchase order, "zh-TW": 核准採購單 }
  description: Approve a submitted order and record the approver.
  requires:
    auth:
      all:
        - { "ctx.staff": [owner, editor] }
  input:
    type: object
    additionalProperties: false
    required: [orderId, decision]
    properties:
      orderId: { type: string, x-mantle-ref: purchase-orders }
      decision: { type: string, enum: [approve, reject] }
      note: { type: string, maxLength: 2000 }
      requestId: { type: string, x-mcp-hint: idempotency-key }
  uiSchema:
    fields:
      note: { widget: textarea }
  output:
    type: object
    required: [orderId, status]
    properties:
      orderId: { type: string }
      status: { type: string, enum: [approved, rejected] }
  handler:
    kind: ref
    ref: approve-purchase-order
ts
// src/mantle/config.ts
import { approvePurchaseOrder } from "./handlers/approve-purchase-order";

export const handlers = {
  "approve-purchase-order": approvePurchaseOrder,
};

handler.kind: builtin

A shortcut over the entry-writer chokepoint for Procedures whose body is "write a row". Reach for ref as soon as there is real business logic.

yaml
handler:
  kind: builtin
  op: create | update | upsert | delete | archive
  schema: <Schema metadata.name>
  match: [<field>, ...]   # only with op: upsert
RuleDiagnostic
Only kind, op, schema and match are accepted; ref alongside builtin is rejected.INVALID_MANIFEST_ENVELOPE
op is one of the five; schema is a non-empty string.INVALID_MANIFEST_ENVELOPE
match appears only with op: upsert, and is a non-empty array of unique non-empty strings.INVALID_MANIFEST_ENVELOPE
schema names a declared Schema.BUILTIN_HANDLER_SCHEMA_UNKNOWN
input is an object schema.BUILTIN_HANDLER_CONTRACT_INVALID
The runtime was built without the builtin dispatcher.HANDLER_BUILTIN_NOT_IN_V010 at request time.

request_publish and publish are deliberately absent: they are lifecycle operations, not CRUD primitives.

Ops

opRuntime behaviorInput contract
createProjects input ∩ Schema.properties into data, stamps every x-mantle-bind property, generates an id and writes. status is draft, or published on a lifecycle: operational Schema. authorId is ctx.user?.id ?? null. Returns the created row.input is an object schema. No other required properties.
updateLoads the row (NOT_FOUND if absent), merges the patch over the stored data so omitted fields and existing stamps survive, writes under optimistic concurrency against the caller's expectedVersion (observed native entry.version at read time, not version+1), bumps version.id (strict type: string) and expectedVersion (strict type: number) declared under properties and listed in required.
upsert with matchReads the matched fields off the validated input and looks the row up by those data values. Found: the update path, using the caller's expectedVersion (never the preloaded row's version). Not found: the create path only when expectedVersion is omitted; a versioned write for a missing row is NOT_FOUND and does not recreate.match equals one declared uniqueIndexes tuple exactly, in order. Every matched field is a Schema property, is declared in input.properties, and appears in input.required. input must not declare id. expectedVersion must be declared as strict number; it is not globally required so create can omit it.
upsert without matchLegacy form. Create when the caller omits expectedVersion (and either omits id or the id is unknown). Update when a resolved id is present — the caller token is required and is the OCC check. A versioned write for a missing id is NOT_FOUND.expectedVersion must be declared as strict number. If id is declared it must be strict string. Neither is in required.
deleteLoads the row (NOT_FOUND if absent), runs the delete guard, then hard-deletes pinned to the row's status and version. Returns { removed }.id (strict type: string) declared and in required.
archiveLoads the row, checks the lifecycle state machine (CONFLICT on an illegal transition), then transitions to archived pinned to the version just read.id (strict type: string) declared and in required. The target Schema must be lifecycle: publishing; an operational target is rejected.

Every contract violation in the right-hand column is BUILTIN_HANDLER_CONTRACT_INVALID, reported at the offending pointer. Strict means a single scalar type — an array-valued type or nullable: true does not satisfy it.

All five ops write through the same chokepoint, which validates the projected data against the Schema, runs the write-time locale gate and performs a unique-index preflight before the write.

Side-channel input fields

input is the contract with the caller, not with the Schema. It may declare fields the collection has no column for — a CAPTCHA token, an idempotency key, a routing hint. JSON Schema's default additionalProperties: true lets them validate, and the builtin op projects input ∩ Schema.properties, so they never reach data.

They are not lost. The pre-projection input travels to the chokepoint as originalInput, and synchronous before_* lifecycle hooks receive it as their handler input. A before_create hook can therefore verify a token the row never stores. See Trigger.

Builtin upsert example

yaml
apiVersion: cms.mantle.aotter.net/v1
kind: Procedure
metadata:
  name: sync-inventory-level
spec:
  title: Sync inventory level
  description: Insert or update the stock level for one SKU in one warehouse.
  requires:
    auth:
      all:
        - ctx.auth
        - { "ctx.auth.scope": "inventory:write" }
  input:
    type: object
    required: [sku, warehouse, onHand]
    properties:
      sku: { type: string, minLength: 1 }
      warehouse: { type: string, minLength: 1 }
      onHand: { type: integer, minimum: 0 }
      countedAt: { type: integer, x-mcp-hint: timestamp-ms }
      requestId: { type: string, x-mcp-hint: idempotency-key }
      expectedVersion: { type: number }
  output:
    type: object
    required: [id, version]
    properties:
      id: { type: string }
      version: { type: number }
  handler:
    kind: builtin
    op: upsert
    schema: inventory-levels
    match: [sku, warehouse]

This requires inventory-levels to declare uniqueIndexes: [[sku, warehouse]] — the same fields, in the same order. requestId is a side-channel field: it validates, reaches before_* hooks, and is never written to data. expectedVersion is the observed native entry.version at read time (not version+1). Omit it to create; send it to update. First-party Admin binds and hides it; HTTP and MCP callers supply it themselves.

The response shape

Every builtin op except delete returns the persisted EntryRow.

FieldTypeNotes
idstringGenerated on create.
collectionstringThe Schema's metadata.name.
statusdraft | published | archived
versionnumberOptimistic-concurrency counter; bumps on every persisted update.
dataobjectThe projected, stamped Schema fields.
authorIdstring | null
createdAt, updatedAtnumberUnix epoch milliseconds.
localestringPresent only when the row carries data.locale.

An HTTP Trigger wraps a success as { "ok": true, "data": <EntryRow> } with status 200.

Warning Declare output against the row, not the envelope. output: { type: object, required: [id], properties: { id: { type: string } } } checks that an id came back. Output validation does not strip the other fields, so a caller still receives the whole row; use a ref handler with an explicit projection when the response must be smaller.

delete returns { removed: boolean } instead.

Conflicts and idempotency

A unique-index preflight runs before every write, and the database's own constraints catch the races the preflight misses. Both surface as CONFLICT (409), as do a stale expectedVersion and an illegal lifecycle transition. There is no automatic retry — the caller decides whether to re-read and try again.

Idempotency has no grammar key. The convention is an input property marked x-mcp-hint: idempotency-key: Admin generates and hides one UUID per form submission, and other callers generate one and reuse it across retries of the same logical request. The handler is responsible for acting on it.

Optimistic concurrency uses the reserved input name expectedVersion — the version the caller read, not the next version. First-party Admin and SDK bind-and-hide that property from the OCC target row; other callers send it themselves. There is no x-mcp-hint for OCC. On CONFLICT (409) Admin keeps the operator's business fields and requires an explicit re-read; it does not retry with the latest version. New reserved Procedure input names need an ADR.

Deferred lifecycle hooks have a stronger guarantee to work with: delivery is at-least-once, and handlers key on ${ctx.event.id}:${ctx.event.trigger} — stable across enqueue fallback, queue retries and replay. See Deferred hooks on Queues.

uiSchema

Admin presentation only. It never affects input validation, the MCP tool schema or the OpenAPI document. Roots are closed: collectionAction and fields.

KeyRule
collectionActionA declared Schema name. Admin offers the Procedure as an action on that collection's list page. A non-empty string that names no Schema is SCHEMA_UI_INVALID; a Schema that declares collectionAction is rejected outright.
fields.<field>.widgetOnly textarea. <field> must be a top-level property of input with a string type. Anything else is SCHEMA_UI_INVALID.

Staff operations in Admin

Admin derives its operations surface from the manifest graph — there is no extra grammar. A Procedure is staff-operable when either condition holds:

  1. Some Trigger targets it with source.kind: mcp and source.surface: staff — the same predicate that builds the /mcp/staff tool catalog.
  2. Some Trigger targets it with source.kind: http and the Procedure's requires.auth.all includes a ctx.staff predicate.
EndpointBehavior
GET /admin/api/operationsLists the staff-operable Procedures the calling staff member may actually run.
POST /admin/api/operations/:nameInvokes one, through the same pipeline as any other caller.

Each listed operation carries name, title, description, input, uiSchema, triggers (the distinct kinds that qualified it, so a Procedure can be both), rowBindings, and targetCollection (the builtin handler schema, or null).

rowBindings come from x-mantle-ref on the Procedure's input properties. An input property referencing a declared, non-translates Schema produces { collection, inputField, rowField }, and Admin offers the operation from that collection's row menu with the field pre-filled and read-only. rowField is the target Schema's same-named property when it has one, otherwise the lone field of a single single-field unique index, otherwise the reserved id column. Refs to unknown collections or to translation children produce no binding and no error.

When input declares expectedVersion, Admin treats that reserved name as magic: it captures the OCC target's current version at read time, submits it, and does not render an editable version field. Changing the selected target rebinds version (an organization row must not supply a membership mutation's version). A resolvable OCC target or a row-bound dialog must have that captured version before Run is enabled, including matched upsert where the field is declared but not globally required. If expectedVersion is in input.required and no target can be resolved, submit stays disabled. Collection create / no-row dialogs may omit it when it is not required. Builtin operations also expose targetCollection (the handler schema) so Admin can pick the mutated collection over a contextual parent.

Worked end-to-end examples live in Commerce transaction and Procurement approvals.

Source