Writing a backend for Phero Lang
Phero Lang is a DSL for modelling. Backends are consumers of the Core IR —
not features of the compiler. Candidate backends (ACTUS cashflow mapping,
Accord/TemplateMark document generation, additional codegen targets) live as
separate, out-of-tree crates that consume the IR; nothing registers with
the compiler. This guide is the contract for writing one. The wire-format
rules summarized here are normative in the phero-ir crate
docs (also rendered by cargo doc -p phero-ir).
The boundary
Section titled “The boundary”- A backend consumes only
phero_ir::Model(plusphero_ir::ActorModelif it deals with the authorization dimension, or the standalone artifact its surface emits —phero_ir::cmmn::CmmnModelfor the case-management backend) or the serialized artifact. Never the AST: parsing, name resolution, validation, and lowering into the IR happen upstream inphero-driver. - Backends register nothing and emit anything — Rust code, JSON Schema, Cedar policies, documents, tables.
- Two integration modes:
- Library: depend on the
phero-ircrate, deserialize withModel::from_json/ActorModel::from_json(or build the IR programmatically for tests). - Artifact: run
pherolang ir <files> -o out.phero.jsonand read the JSON. A.moxcompile emits theModelartifact; a.actorcompile emits the standaloneActorModelartifact.
- Library: depend on the
- The in-tree backends (
phero-backend-rust,phero-backend-jsonschema,phero-backend-cedar,phero-backend-cmmn— the last loweringCmmnModel+ domain model to the cmmn-rs engine’sCaseDefinitionJSON, andphero-backend-dmn— loweringDmnModel+ the markdown table contents into the dsntk engine model it evaluates, recording decision-table provenance) are reference implementations of exactly this discipline; reading them is the fastest way to learn the shapes.
The serialized-artifact contract (summary)
Section titled “The serialized-artifact contract (summary)”Root shapes, as actually serialized:
| Artifact | Root |
|---|---|
Domain IR (Model) |
{"formatVersion": N, "pheroVersion"?: "…", "packages": […]} |
Actor policy (ActorModel) |
{"formatVersion": N, "blocks"?: […]} — block-less serializes as exactly {"formatVersion": N} |
DDD design (ddd::DddModel) |
{"formatVersion": N, "applications"?: […], "modules"?: […]} (both omitted when empty) |
Event contract (events::EventModel) |
{"formatVersion": N, "events"?: […], "channels"?: […], "subscriptions"?: […]} (all omitted when empty) |
Deployment (deploy::DeployModel) |
{"formatVersion": N, "applications"?: […], "profiles"?: […], "deployments"?: […]} (all omitted when empty) |
Use case (usecases::UseCaseModel) |
{"formatVersion": N, "useCases"?: […]} (omitted when empty) |
Case management (cmmn::CmmnModel) |
{"formatVersion": N, "cases"?: […]} (omitted when empty) |
Semantic graph (graph::GraphModel) |
{"formatVersion": N, "applications"?: […], "nodes"?: […], "edges"?: […]} (all omitted when empty; built by pherolang graph build) |
| Canonical instance | {"$type": "phero.instance", "formatVersion": N, "model": "…", "objects": […]} |
Every standalone root carries its own *_FORMAT_VERSION marker. Since the
Phero Lang rename those markers are 2 (the provenance key became
pheroVersion and the instance discriminator became phero.instance), with
two exceptions that carry no provenance marker and deliberately stayed at
1: the semantic graph (GRAPH_MODEL_FORMAT_VERSION) and the graph query
envelope. phero-evidence’s receipt and proof-register format versions also
stay at 1 — their shapes did not change. pherolang artifact check
classifies non-empty roots by their disjoint section keys and falls back to
the actor reading for the shared minimal {"formatVersion": N} bytes (now
N = 2 for the bumped kinds, which is why a bare {"formatVersion": 1}
graph form is a version mismatch rather than an actor artifact).
- Casing. All field names and bare enum tags are
camelCase(formatVersion,isReadOnly,"crossReference"). Note that$type/$id/$refbelong to the instance format; IR artifacts tag their enums adjacently as{"type": "<variant>", "value": <payload>}. - Version gate. Readers accept exactly their own
FORMAT_VERSION(domain artifacts) orACTOR_MODEL_FORMAT_VERSION(actor artifacts) and reject anything else with an error naming the found and expected versions (IrError::UnsupportedFormatVersion). Canonical instances are versioned independently (phero_runtime::INSTANCE_FORMAT_VERSION). - Additive evolution. Every field added after v1 is
#[serde(default)], so older artifacts keep loading; fields empty in the common case alsoskip_serializing_if, so artifacts for models that do not use a feature (no bodies, no vocabularies, no actors, no functions, no descriptions) stay byte-identical to older output. Unknown fields are ignored on read, never denied. - Operation bodies. A
rustbody is embedded verbatim as the generated method’s body (the signature convention lives inphero_backend_rust::codegen); anexprbody is parsed, type-checked against the enclosing class and the operation’s parameters, and lowered at generate time — a parse/type/lower failure is a generate-time error naming the operation (operation '<op>' expr body …). An operation with neither body emits no method: it stays a hand-written hook. The expression language isdocs/EXPRESSIONS.md. - Functions and unknown body targets.
Package.functionscarries sigil-lowered Rune functions as signature-onlyOperations whose bodies are the ends-trimmed verbatim"rosetta"target plus — for functions whose statements the driver translated and checked — the canonical"rune"statement IR (wire contract rule 15, RUNE-BODIES). Backends skip body targets they do not own — therosettatext is never emitted as code. The Rust backend emits each lowered function as a free function in the flattened module: arustbody verbatim; a checkedrunebody as straight-line Rust (guards →assert!(expr, "guard '<name>' failed")with the phase label for unnamed guards, aliases → sequentiallets,set/addsteps → assignment/pushinto the output local, which is introduced at its declared default shape —Vec::new(),None, the Tier-1 scalar default, or the arena’s default-initialized object for a class-typed output; a0..1output therefore returnsSome/Noneexactly as the checked expressions produce them, never an invented wrap); anything else — a deferred or statement-less function — atodo!()stub named after the function. A function’sreturn_multiplicityshapes the emitted return type the same way a parameter’s multiplicity shapes its parameters:0..*→Vec<T>,0..1→Option<T>, the single-valued convention →T(phero-expr types calls to the function identically). Bareexpr-body calls (issue #63) lower to exactly these emitted functions, passingres: &Resourcewhen the callee’s body reaches the arena (a class-typed output, a->path step, or navigation on a class-typed value) — such functions take the arena as their first parameter. A"rune"payload that decodes but does not parse, type-check, or lower is a generate-time error naming the function and the slot (theexpr-body bail convention): the driver never attaches an unchecked body, so a present-but-invalid target is artifact corruption, not deferral. - Stable feature ids.
Feature::idequals its 0-based declaration index at publication time and is frozen for wire formats; backends key on it. - Golden byte-identity. The goldens under
tests/conformance/are the spec: an emitter change that shifts a byte is a contract change. When developing in this workspace,PHERO_UPDATE_FIXTURES=1regenerates them and the diff is reviewed as a spec change.
The preserve_order gotcha
Section titled “The preserve_order gotcha”serde_json::Map is a BTreeMap unless any crate in the build graph
enables serde_json’s preserve_order feature, which flips it to insertion
order. cedar-policy (a dev-dependency of phero-backend-cedar) enables it,
so a workspace that pulls in Cedar codegen inherits insertion order
everywhere. Emitters that rely on implicit map ordering will emit keys in
whatever order that build happened to produce. Canonicalize key order
yourself before serializing — canonical_key_order in
crates/phero-backend-common recursively re-inserts
keys in a pinned order; the JSON Schema and Cedar backends both run it
over their emitted documents.
Semver and formatVersion policy
Section titled “Semver and formatVersion policy”The workspace is at 0.3.0 — pre-1.0, so the usual “breaking = major” rule does not apply yet:
- Until 1.0, a breaking wire change bumps the MINOR crate version
(0.2.0, 0.3.0, …) and the affected
formatVersionmarker. Purely additive changes keep the format version and ride a normal minor/patch. - Readers accept their own version and reject any higher version with an error naming the version — they never guess. Lower versions are equally rejected: there is no migration path inside one crate version.
- Compatibility-matrix expectation. Every phero-ir release documents the
format versions it reads and writes, and out-of-tree backends declare the
formatVersion(s) they accept, so consumers can pin both sides. The matrix so far:
| phero-ir | reads (domain) | writes (domain) | reads/writes (actor) | reads/writes (instance) |
|---|---|---|---|---|
| 0.1.0 | 1 | 1 | 1 | 1 |
| 0.3.0 | 2 | 2 | 2 | 2 |
pheroVersioninside an artifact is provenance only — consumers that must not care can ignore it.- 0.3 does not read 0.2 artifacts. There is no dual-accept window: the rename moved the provenance key and the instance discriminator, so a document carrying either pre-rename marker is rejected outright (see the migration note below).
Consumer migration to 0.3
Section titled “Consumer migration to 0.3”Bump the git rev and the package names in [dependencies] to their
phero-* spellings (the full old → new table is in
REBRAND.md), then regenerate every committed artifact — expect
pheroVersion in the provenance slot and "formatVersion": 2 in every
file. A 0.2 artifact does not fail silently: pherolang artifact check
names it with the 0.2-era migration message (quoted verbatim in
REBRAND.md) rather than reporting a generic formatVersion
mismatch or an unknown root $type. The one-line regeneration command is
there too.
The test kit: pherolang artifact check
Section titled “The test kit: pherolang artifact check”Third-party backends can validate any serialized artifact against the wire format without the originating model:
pherolang artifact check out.phero.json support.actors.phero.json instance.jsonPer file it checks the invariants below, prints OK <path>, and exits 1
naming the file and violation when anything fails:
- a 0.2-era artifact (an old provenance key or the old instance discriminator) is named with the migration message before any shape is considered — it is never mistaken for a current artifact;
- the root shape matches a known artifact kind (IR artifact, actor
artifact, DDD design, event contract, deployment, use-case, or
case-management artifact, or
$type: "phero.instance"canonical instance); formatVersionis present, an unsigned integer, and supported — a higher version is rejected with an error naming it;- structural keys are camelCase (a snake_case spelling of a known key is reported with the correct spelling; model-defined keys — feature names, target names — are exempt);
- instance sanity:
$ids are well-formed (<class>/<ordinal>) and unique, every object carries$type,$refobjects carry nothing else, and every$refresolves within the document.
The checker is dogfooded in CI over every golden artifact under
tests/conformance/artifacts/, so it stays honest as the format evolves.
Backend-author checklist
Section titled “Backend-author checklist”- Consume only
phero_ir::Model/ActorModel(or the artifact) — never the AST; declare whichformatVersions you accept. - Emit deterministically; canonicalize
serde_json::Mapkey order yourself (preserve_ordergotcha above). - Pin golden byte-identity: keep golden outputs under version control and byte-compare them in your tests, the way this workspace does.
- Run
pherolang artifact checkover your consumed IR artifacts and any instance documents you produce. - Pin the
phero-irversion and record the compatibility matrix row for the format versions you read and write.