Skip to content

Runtime pipeline and adapters

Mantle has exactly one path from YAML to a running service, and one owner for every rule along it. This page walks that pipeline, then shows what changes and what stays fixed when you swap the host underneath it.

The sealed pipeline

txt
ManifestSourceSet
  -> parse + normalize -> ParsedManifestSet
  -> link               -> LinkedManifestSet
  -> compile            -> RuntimePlan
  -> prepare deployment -> PreparedRevision
  -> bind runtime       -> MantleRuntime

Each stage's output can be constructed only by the stage that owns it, and a failed stage produces nothing the next stage can use. Parse, link and compile are pure and deterministic.

StageOwnsRejectsMust not own
Parse + normalizeYAML syntax and alias limits, the closed four-atom shape, atom-local rules, behavior-affecting defaults, source metadataUnknown keys, bad envelopes, unsupported JSON Schema keywords, invalid index and uiSchema shapesCross-atom references, handlers, storage, routes
LinkDuplicate symbols, cross-atom references, guard graphs, translations, manifest-owned route and tool collisionsUnknown Schema or Procedure references, guard self-reference and chains, duplicate HTTP paths, MCP tool-name collisionsI/O, selected modules, handler availability
CompileImmutable lookup records, authorization plans, Trigger indices, Procedure descriptors, logical View plans, the semantic fingerprintNothing new; it projects an already valid graphConnections, repositories, handlers, requests, templates, assets
PrepareStorage migrations, native Schema tables and indexes, prepared Views, handler availability, selected capability and reserved-route checks, the readiness revisionMissing handler refs, reserved-path conflicts, View.spec.sql on storage that does not declare the SQLite dialectRe-interpreting YAML, executing requests
Bind and invokeSemantic ports, handler dispatch, parameter binding, centralized authorization, content, View, Procedure, Trigger and lifecycle operationsInvalid input, unauthorized callers, lifecycle transitions the state machine forbidsDDL, route mounting, assets, HTTP, session and cache policy
Optional modules and adaptersWeb and Admin composition; request, session, cache and platform translationWhatever the platform itself rejectsRe-parsing, re-linking, or a second authorization stack

Nothing downstream may reinterpret raw manifests. That is what makes a rule verifiable exactly once, and why every rejection carries a diagnostic code instead of a guess. See Diagnostic codes.

What mantle generate produces

sh
pnpm exec mantle generate
pnpm exec mantle generate --check

generate validates and compiles the manifests directory, then writes one typed module at .mantle/generated/mantle.ts containing the sealed plan with its fingerprint, the handler types, and two entry points:

ts
import { bindMantle, createMantle, plan } from "../.mantle/generated/mantle.js";

// Eager: one preparation attempt, no caching and no retry.
const mantle = await createMantle({ storage, handlers, ports });
const notes = await mantle.views.publishedNotes();
await mantle.entries.orders.createDraft({ data, authorId: user.id });

// Or bind a runtime the host already assembled and owns the lifecycle of.
const bound = bindMantle(runtime);
await bound.runtime.archive.execute({ id, ctx });

Generated property names are deterministic lower-camel identifiers; calls keep the authored wire names internally, and a collision is an error (CODEGEN_IDENTIFIER_COLLISION). Code generation is a pure projection: it never caches, retries, mounts routes or owns host lifecycle, and the typed API keeps its raw runtime so it hides nothing. Skipping generation is valid — call runtime.executeView({ view: "published-notes" }) by name.

Core, optional products, adapters

LayerPackageResponsibility
Core@aotter/mantle-specSources, parse, normalize, link, introspection, code generation. No runtime or platform dependency.
Core@aotter/mantle-runtimeRuntimePlan, preparation contracts, semantic storage ports, MantleRuntime. No Web, Admin or platform dependency.
Optional product@aotter/mantle-webPublic HTML, Markdown mirrors, llms.txt, sitemap, SEO, preview, templates, path composition. Owns no routes.
Optional product@aotter/mantle-adminAdmin API orchestration, OAuth surfaces, the asset contract.
Optional product@aotter/mantle-admin-uiThe pre-built React Admin SPA artifact.
Adapter@aotter/mantle-cloudflare, -bun, -vercel, -indexeddbBind platform storage, lifecycle, request, session, cache and asset concerns to Core ports.

The umbrella @aotter/mantle installs Spec and Runtime only; every other subpath is an optional peer you install when you select it. Core does not reserve Admin paths or serve a UI when the module is absent.

Capability matrix

CloudflareBunVercel FunctionsIndexedDB
StorageD1 through the SQLite chainCaller-owned bun:sqlite DatabaseAny injected MantleStorageAdapter; optional /libsql Turso driverOne application-owned IndexedDB database
Public View RESTYesYesYesNot mounted; call the runtime directly
HTTP TriggersYesYesYesNot mounted; runtime.invokeTrigger
Admin, Auth, OAuthYesAbsentAbsentAbsent
MCP /mcp, /mcp/staffYesAbsentAbsentAbsent (WebMCP is a separate browser binding)
Public web pagesOpt-in via mountPublicRoutes plus templates and a resolverAbsentAbsentAbsent
Cache policyOwned, applied at the final boundaryHost-ownedHost-ownedn/a
View.spec.sqlSupportedSupportedSupported on a SQLite-family driverRejected at preparation
Optional capabilitiesR2 media, Queues deferred hooks, KV catalog cacheNonePlatform waitUntilNone
Host still ownsApplication routes and frontendBun.serve, auth, CSRF, database shutdownWeb handler, auth, CSRF, route compositionDatabase naming, persistence requests, UI invalidation, sync

The Manifest does not change across that row. What changes is which surfaces exist to reach it.

Storage ports

A storage adapter prepares one RuntimePlan into semantic ports. Three are required:

PortRole
MantleStorageAdapter / PreparedMantleStoragePrepares one plan into a revision: migrations, native Schema tables and indexes, prepared Views
EntryRepository and EntryReaderEntry writes and reads
ViewQueryExecutorExecutes compiled logical View plans

Two are optional and only when a feature needs them: MediaStorage for upload flows, and DeferredHookDispatcher for at-least-once after_* delivery. DatabaseDriver is not a portability contract — it is the reusable SQLite/D1 seam. A PostgreSQL, MongoDB or application-owned-table adapter implements the semantic ports directly rather than emulating D1.

Declarative Views compile to logical plans once, and preparation lowers those plans to native queries. View.spec.sql is the exception: it is explicitly SQLite-only in v0.1. Storage that does not declare that dialect rejects such a View at preparation with VIEW_DIALECT_UNSUPPORTED, before mutating any state. Mantle does not guess a translation and ships no universal query driver. A View that must run everywhere uses from with a filter AST; see View.

Embedding each adapter

Cloudflare, through the conventional facade:

ts
import { createMantleWorker } from "@aotter/mantle/cloudflare";
import { plan } from "../.mantle/generated/mantle.js";

export default createMantleWorker({ plan });

Bun, with the server and SQLite handle staying yours:

ts
import { Database } from "bun:sqlite";
import { createBunMantle } from "@aotter/mantle-bun";

const database = new Database("app.sqlite");
const mantle = createBunMantle({ plan, database, handlers });

Bun.serve({
  async fetch(request) {
    return (await mantle.handle(request)) ?? new Response("not found", { status: 404 });
  },
});

Vercel Functions, with storage injected:

ts
import { SqliteMantleStorageAdapter } from "@aotter/mantle-runtime";
import { createVercelMantle } from "@aotter/mantle-vercel";
import { LibsqlDatabaseDriver } from "@aotter/mantle-vercel/libsql";

const mantle = createVercelMantle({
  plan,
  handlers,
  storage: new SqliteMantleStorageAdapter(new LibsqlDatabaseDriver(client)),
});

Browser IndexedDB, with no HTTP transport at all:

ts
import { bootMantleRuntime } from "@aotter/mantle-runtime";
import { IndexedDbMantleStorageAdapter } from "@aotter/mantle-indexeddb";

const storage = new IndexedDbMantleStorageAdapter({ databaseName: "my-app" });
const runtime = await bootMantleRuntime({ plan, storage, handlers });
await runtime.invokeTrigger({ trigger: "rename-board-mcp", input, ctx });

handle() returns null for a path Mantle does not own, so the host keeps its own routes. Never treat a Vercel Function's filesystem or /tmp as durable state.

The adapter-boundary rule

Platform bindings belong at the composition root only: the Worker entry, createMantleWorker options, the bindings hook and wrangler.jsonc. Procedure handlers receive them through ctx.env.

Application code does not bypass Mantle's storage ports to write Schema tables, site_config, media, or Auth tables. Each Schema is a native table, but Mantle still owns its metadata columns, lifecycle checks, and optimistic concurrency. Use Manifests, runtime use cases, runtime.entries, runtime.siteConfig, generated bindMantle(runtime), and Views instead. An application may own separate tables behind its own repository; that is different from writing around Core's invariants.

If a normal feature cannot be expressed through a purpose-shaped surface, treat that as a gap in the abstraction rather than teaching the project Mantle's internals. Internals change between versions; the ports do not.

Source