Write the rule. Skip the rest.
Mandala is meant to be the whole backend behind your frontend. You describe the application as contracts in DHL: state, actions, what must hold, what can be queried. Mandala gives you the storage, the checks, the history and the API. Your Next.js or Astro site draws the pages. This page is the tour, with what is ready and what is not. There is a section for operators at the end.
Amy is asked to make deliveries to the Moon need a second signature. In her last job that was a migration, a model change, a controller, a policy, a form and a test for each.
Here it is one line in Ship. The editor underlines her first attempt, because she compared a status to a string. She tries the contract on a throwaway node, watches it refuse the Moon, and asks Hermes to sign the upgrade.
The table, the record page, the API and the history didn’t need touching. They are read off the contract.
Nothing goes to the Moon until a second person confirms it. I’ve asked Leela.
state.status == Confirmed or state.destination != "Moon"
A small language for business rules.
A contract is a Markdown file with a namespace, a version and a fenced block of DHL. An aggregate declares typed state. Actions say who is authorized, what must hold, and how state changes. That’s the whole model.
Text with a length, integers, booleans, identities, enums, references to other records, lists with a capacity.
A contract compiles to a schema and a small deterministic program. Their digest is the artifact id that every record is bound to.
A new version names the artifact it upgrades from and how records migrate. Upgrades
Per action: how two offline devices merge, and how strongly a write must be on disk before it is acknowledged.
# Nothing ships to the Moon, or over 200 units,# without a second person confirming (Leela).action Ship() authorize dhl validate state.paid or state.credit_terms state.crates_checked == state.crates state.status == Requested or state.status == Confirmed state.status == Confirmed or state.units <= 200 state.status == Confirmed or state.destination != "Moon" apply state.status = Shipped
The whole contract (65 lines)
aggregate Delivery state public customer: Text(len=200) public destination: Text(len=100) public units: Int = 0 public crates: Int = 0 public crates_checked: Int = 0 public paid: Bool = false public credit_terms: Bool = false public requested_by: Identity public confirmed_by: Identity public status: Enum(New, Requested, Confirmed, Shipped) = New action Request(customer: Text(len=200), destination: Text(len=100), units: Int, crates: Int, credit_terms: Bool) authorize dhl validate state.status == New units > 0 crates > 0 apply state.customer = customer state.destination = destination state.units = units state.crates = crates state.credit_terms = credit_terms state.requested_by = context.signer state.status = Requested action CheckCrate() authorize dhl validate state.status == Requested state.crates_checked < state.crates apply state.crates_checked = state.crates_checked + 1 action Confirm() authorize dhl validate state.status == Requested context.signer != state.requested_by apply state.confirmed_by = context.signer state.status = Confirmed action Pay() authorize dhl validate state.status != New state.paid == false apply state.paid = true # Nothing ships to the Moon, or over 200 units,# without a second person confirming (Leela).action Ship() authorize dhl validate state.paid or state.credit_terms state.crates_checked == state.crates state.status == Requested or state.status == Confirmed state.status == Confirmed or state.units <= 200 state.status == Confirmed or state.destination != "Moon" apply state.status = Shipped
Real DHL, exactly as it runs: this contract compiles. Every action is checked against it before it happens, for people and AI alike.
The real fingerprint of this contract. Change one character of a rule and it changes. Every record says which fingerprint decided it, so “which rules were in force?” has an exact answer.
An editor that knows the language.
The language server lives in the same program as everything else, so the diagnostics you see are the compiler’s own. The Workbench, inside the Data page, is an editor with those diagnostics and completion, and a button to try a contract before it touches anything real.
validation error: invalid eq types at line 68:9 state.status == "Confirmed" or state.units <= 200
An enum is not a string. The mistake is caught when you write it, not when a record goes wrong.
mandala lspThe language server over stdio, for desktop editors. A VS Code extension exists in the repository and is not yet published.
List steps and queries; each step runs in a separate process and shows the new state or the refusal.
Installing a contract is an approval. Its actions and queries then become tools your accomplice can ask for, under the same checks.
Installing restarts Mandala today; pending work is durable and resumes. Installing without a restart is coming. The Workbench
{ "steps": [ { "action": "Request", "subject": "lumon-0412", "args": { "customer": "Lumon Industries", "destination": "Moon", "units": 120, "crates": 2, "credit_terms": true } }, { "action": "CheckCrate", "subject": "lumon-0412", "args": {} }, { "action": "CheckCrate", "subject": "lumon-0412", "args": {} }, { "action": "Ship", "subject": "lumon-0412", "args": {} }] }
The steps are in the Workbench’s real format, for the contract above. Each runs on a throwaway node, so nothing touches real records. The last one is refused by the Moon line, as it should be.
Queries are part of the contract.
DHL-Q is a pipe language: a source, then one stage per line. A query is declared next to the rules, so what can be read is known, checked and kept ready before the first record exists.
where · sort · take · drop
select · let · explode
Across contracts, with totals.
todayRanked, over the fields you name.
todayvsearch, exact, over a declared index.
traverse · shortest · asof · latest
The coming modes are built on a development branch and not in a release. What they look like · DHL-Q reference
query Open(owner: Identity, after: Text(len=100)) returns List<PayableRow> api authenticated dhlq std.business.payable | where owner == $owner and ref > $after | sort ref | take 100 | select subject as id, ref, supplier, number, amount_minor, currency, issued_on, due_on, status, source, digest, note, payment_reference
Real, from the standard library: an owner’s open payables, a page at a time. api authenticated exposes it through the generated API; without it a query stays internal.
Three interfaces from one contract.
Every contract is served as REST with an OpenAPI description, as GraphQL with queries and mutations, and as gRPC. Operations are internal unless the contract says api public or api authenticated. Your frontend submits the same governed actions and queries as everyone else, and holds no business rule of its own.
A command carries the caller’s signed assertion. The API never signs on anyone’s behalf.
A command has an idempotency key. Sent twice, it is done once and returns the original receipt.
A refused command is recorded, with the reason, and changes nothing.
In the repository, an Astro shop runs its catalogue, cart, quote and checkout on seven contracts through this API, in a preview. What it proves, and what it doesn’t
Not yet: SDKs for Next.js and Astro, typed clients generated from contracts, and sign-in for your application’s own users (OAuth, email and password) are on the roadmap. Today you call the API yourself, and an outside application talks to the App server, not to the Workspace. The full status table
compile prints the artifact id. describe writes openapi.json, schema.graphql and api.proto.
| Request | Does |
|---|---|
POST /v1/{tenant}/{namespace}/actions/{Action} | Submits a signed command |
POST /v1/{tenant}/{namespace}/queries/{Query} | Runs a declared query |
POST /v1/{tenant}/{namespace}/graphql | The same, as GraphQL |
Reaching the outside world, with limits.
Rules can’t open a socket, on purpose. Talking to other services is the job of connectors, which declare the access they need and whether each effect waits for approval. Today connectors serve your accomplice and Mandala’s background jobs. Using any connector from a contract is where this is going, and is not built.
One library for the parts every integration repeats: credentials and OAuth, HTTP with timeouts, backoff and rate limits, durable polling, webhooks checked and admitted once, a policy with revisions, a Settings page and tool descriptions.
RSS and Atom, built from scratch on the SDK as its first full example. PostHog’s transport was moved onto it with the same behaviour.
Add tools from remote MCP servers from a catalogue in Settings. You review each tool: off, read, or ask every time, with a daily ceiling per server.
Coming: Microsoft 365, IMAP and CalDAV, Slack, Shopify, Stripe and accounting packages. Effects declared by a contract itself are built in the engine for two kinds, email and PDF documents, with a durable outbox and signed receipts; they are not yet switched on in Mandala. Webhooks, SMS and payments as effects are designed.
One program. Nothing beside it.
Mandala is one executable run as one service. Storage is inside it: no database server, no queue, no cache, no container platform. The data is one directory.
A person’s Mandala, always on: the workspace and the accomplice on a machine that stays up while laptops sleep. Linux with systemd; 2 cores and 2 GB free at least, 4 GB comfortable.
The DHL engine without the workspace: it runs a business’s contracts, serves their generated API, and agrees every write among one or three voters. Today it is the dhl-engine command; a packaged edition is on its way.
The server console.
dhl-engine tui is the App server’s terminal console. It is read-mostly, works over SSH, and never edits storage.
1 OverviewVoters, leaders, lag, disk, recent errors.
2 ContractsInstalled artifacts, versions, status.
3 TenantsIdentities and delegations.
4 ActivityCertified commands, live, with refusals and reasons.
5 LogsLive tail, by level.
6 ConnectTrusted nodes and connectivity.
Everything is in the data directory. Or copy from a filesystem snapshot with no downtime. A second node is not a backup: a deletion syncs too. Backups and restore
Off, ask me, or automatic in a night window. A release is accepted only if signed by your release key. The previous binary is kept and put back after three failed starts or if health doesn’t answer in 180 seconds. Updates
Queued events, approvals and messages to send are written down before they are acted on.
More for operators in the docs: requirements, the data directory, security, monitoring and troubleshooting. “Add a server”, where Mandala sets itself up on a VPS from Settings, is built and not yet released.
You write what must be true. The rest is derived, and it can’t drift.
The licence is not published yet. The language reference and the standard library’s contracts are in the docs.
That is the goal. Data, rules, the API and history are there today. Sign-in for your users, SDKs, file delivery and public rate limits are not, so today it fits applications whose users are few and known. The status table has every line.
Rust, with a C++ storage engine linked into the same program. Contracts run as deterministic WebAssembly.
It is young, the ecosystem is small, and there is no SQL. Mandala vs web frameworks, vs backend services and vs traditional databases say where the usual tools are the better choice.