Event contracts (.evt)
Overview
Section titled “Overview”A transport-independent event-contract layer over .mox domain models. The
domain model defines the data; the .evt contract declares the events
flowing through the system (named contracts with typed payload fields), the
channels that publish them, and the subscriptions that deliver event
sets to named consumers — with nothing about brokers, wire encodings, or
delivery guaranteed. Parsed by phero-syntax (parse_evt, the chumsky
parser) at compile time, lowered and validated by phero-driver
(compile_evt_str) against the imported domains, and emitted as the
versioned phero_ir::events::EventModel artifact (the phero-ir crate).
The .evt DSL lives alongside the .mox domain model as a complementary
input: an event never declares types of its own — its payload fields resolve
against the classes, enums, datatypes, interfaces, and vocabularies the
imported domains declare. Downstream consumers ingest the artifact exactly
like the ActorModel and DddModel artifacts. No in-tree backend exists
yet: CloudEvents and AsyncAPI generation are the planned consumers of this
artifact and are not implemented — the artifact carries the declarations
those generators need (CloudEvents attributes, delivery policy, governance
metadata, filters), never their execution.
The normative contract — entry points, resolution semantics, and the
validation rules — is documented on phero_driver::compile_evt_str; this page
is the language reference.
Artifact wire format
Section titled “Artifact wire format”EventModel is a standalone, versioned wire artifact following the phero-ir
wire format contract (see also the phero_ir::events module
docs):
- Versioned independently of the domain-model marker via
EVENT_MODEL_FORMAT_VERSION(= 1), theActorModelprecedent.EventModel::from_jsonrejects absent/wrong versions withIrError::UnsupportedFormatVersion(an absent version reports as 0). - All struct field names serialize as
camelCase(formatVersion,events,channels,subscriptions). There are no tagged enums in this artifact: a publication is exactly{"event": "<name>"}, and a subscription’s consumer is a bare string. In a checked application the name must resolve:pherolang check <dir>runs the union integrity pass (phero_driver::check_integrity, register row V12) over the import-connected application and errors when a consumer names no declared.deploycomponent — a dead inbox, not a valid contract. Checking the contract alone stays per-surface and silent. - Payload fields reuse the domain IR’s
TypeRef(adjacent tagging:{"type": "class", "value": {...}}), serialized under the"type"key — there is no parallel wire-level type shape. - Names are plain strings at this layer:
publishesentries andeventslists reference events by name; resolving them is a driver/consumer concern, never a wire concern. The production side is the.dddservice operation’semitsclause (docs/DDD.md) and the.dmndecision’semitsblock (docs/DMN.md): the union integrity pass checks that emitted events are declared here and that declared events are emitted somewhere. - The slice-2 delivery model rides under the same discipline: an event’s
owner/classification/retentionstrings,piiarray, andcloudEventsobject ({"type": ..., "source": ..., "subject": ...}— the"type"key renamed for serde,subjectomitted when absent), and a subscription’swhenstring plusdeliveryobject ({"mode": "at_least_once", "maxAttempts": 5, ...}) are all omitted when absent (see Delivery model & governance). - Unknown fields are ignored on deserialize; every field added after v1
must keep the
#[serde(default)]+ skip discipline (the optionalversionis omitted when absent; so are thescopekeys — a channel/subscription without a scope clause serializes byte-identical to the pre-scope format, and the version stays 1).
Minimal artifact: an empty model serializes as exactly {"formatVersion":2}.
let compilation = phero_driver::compile_evt_str("orders.evt", source, &domains, &phero_driver::DomainImports::default());let json = compilation.model.unwrap().to_json_pretty()?; // versioned artifactlet model = phero_ir::events::EventModel::from_json(&json)?; // version-gated reloadExcerpt of the committed golden artifact
(tests/conformance/events/orders.evt.json) — the fully-loaded event and
the billing subscription show every slice-2 shape:
{ "formatVersion": 2, "events": [ { "name": "OrderPlaced", "version": "1.0.0", "fields": [ { "name": "orderId", "type": { "type": "primitive", "value": "string" } }, { "name": "total", "type": { "type": "primitive", "value": "int" } }, { "name": "status", "type": { "type": "enum", "value": { "package": "nz.example.orders", "name": "OrderStatus" } } } ], "owner": "order_team", "classification": "confidential", "retention": "7y", "pii": ["orderId"], "cloudEvents": { "type": "nz.example.orders.orderplaced.v1", "source": "/orders", "subject": "{orderId}" } } ], "channels": [ { "name": "orders", "scope": "account", "publishes": [ { "event": "OrderPlaced" }, { "event": "OrderCancelled" } ] } ], "subscriptions": [ { "name": "billing", "events": ["OrderPlaced", "OrderCancelled"], "consumer": "billing_service", "scope": "account", "when": "orderId != \"\"", "delivery": { "mode": "at_least_once", "maxAttempts": 5, "backoffSeconds": 10, "timeoutSeconds": 30, "deadLetter": "orders" } } ]}Philosophy
Section titled “Philosophy”- Transport-independent contracts — events, channels, and
subscriptions say nothing about brokers or encodings; a backend chooses
the transport (CloudEvents, AsyncAPI, MQTT topics, …) exactly the way
the
.dddsearch projection leaves the engine to the backend. - Validated against the real domain — payload field types type-check
against the imported
.moxmodels; a typo’d type is a compile error, not a silent generation mismatch. - Names as authored — the artifact keeps every reference exactly as
written; consumers resolve
publishesandeventsnames against the declaring file. - Declarations, not behaviors — the delivery model (slice 2) is exactly as far as the contract goes: delivery policy, governance metadata, CloudEvents attributes, and filters are declarations the artifact carries for consumers. Webhook targets are runtime/tenant data and are deliberately not modeled — the DSL stays transport-independent (see the slice-2 scoping notes below).
Grammar
Section titled “Grammar”The normative EBNF for the .evt surface lives exactly once, in the parser
module docs under .evt event-contract
sources
(crates/phero-syntax/src/parser/mod.rs) — the single authority for evt_file,
evt_import, event_decl, field, channel_decl, publishes_entry, and
subscription_decl; this page keeps only the language-level rules.
Language-level rules:
event <name> (version "<string>")? { <member>* }— a named contract with its typed payload fields (name: Type;, one per line). Theversionclause is a free-form string pinned into the artifact verbatim; Phero Lang attaches no semantics to it (no comparison, no ordering — consumers decide what a version means). Members may appear in any source order; the formatter canonicalizes them to fields → governance →cloudEvents.- Governance lines (inside an event body, each ending in
;):owner <bare-name>;,classification <word>;,retention "<period-string>";— three verbatim labels the driver never interprets (the retention period is whatever string the source authored, e.g."7y"kept as7y), andpii [ <payload-field>* ];— the payload fields carrying personal data (every entry must name a field of the same event). cloudEvents { type "<string>" source "<string>" (subject "<string>")? }— the CloudEvents attribute declarations. Once the block is present,typeandsourceare required and must be non-empty;subjectis optional. When present, the declarations are authoritative for a downstream generator (see the slice-2 section). The entries carry no;— the block’s grammar is bracket-only.channel <name> ("of" <scope>)? { publishes <event>; ... }— a named channel with the events published on it. Apublishesentry is channel membership, nothing more. The optionalofclause names the channel’s scope: the topic-template dimension its topics are parameterized by — traderX’s/accounts/{id}/tradesfamily declares aschannel account-trades of accounts { ... }, the transport binding the{id}segment to the consumer’s slice.subscription <name> { events [ <event>* ] consumer <name> ("scope" <name>)? ("when" (...))? (delivery {...})? }— a named delivery rule: the listed events go to the one consumer, optionally bound to one slice of a scoped topic family (consumer web_front_end_angular scope accounts), optionally filtered by awhencondition, and optionally carrying a delivery policy. The clauses are a fixed chain in the canonical orderevents→consumer→scope→when→delivery. Inside the list, the contextualconsumerword ends the list, so a missing]never swallows the consumer line.when ( <expression> )— a subscription filter whose interior is raw phero-expr text (stored verbatim). The driver types it boolean against every event in the subscription’s list — a filter is a routing decision per event, so a field absent from any subscribed event is an error by design (rule 13 below).delivery { mode <word>; (maxAttempts <int>;)? (backoffSeconds <int>;)? (timeoutSeconds <int>;)? (deadLetter <channel>;)? }— the per-subscription delivery policy.modeis required once the block is present and must be one ofat_most_once,at_least_once,effectively_once; the three tuning knobs must be positive; the dead-letter reference must name a channel declared in the same file.- Field types are a phero-syntax primitive (
String,int, …) or any declared domain kind — class, enum, datatype, interface, vocabulary — resolved against the imported domains (below). - Imports come first:
import "<path>"(no trailing;, the.actorproduction) must precede the first declaration; a laterimportis a reported, recovered syntax error. Duplicate imports are a driver concern, not a syntax error (the CLI reads the file once). - The three declaration kinds may be interleaved in any order; the artifact groups them per kind and the formatter canonicalizes to import → event → channel → subscription order (source order kept within a kind).
- Keywords and contextual words:
event,channel,publishes,subscriptionare real keywords of the shared chumsky lexer — reserved in.mox,.actor, and.dddsources too, escapable with^like every keyword.whenis the shared real condition keyword. Everything else the new clauses use is contextual — ordinary identifiers special only in their clause/block positions:owner,classification,retention,pii,cloudEventsas event-body member introducers;type/source/subjectinside thecloudEventsblock (typeis the real.moxdatatype keyword and stays usable as a payload field name — the qname-segment rule);deliveryas a subscription clause (reserved right after the consumer name —scope ^deliveryescapes);mode/maxAttempts/backoffSeconds/timeoutSeconds/deadLetterinside thedeliveryblock; and the slice-1 wordsevents,consumer,of,scope. Escaped forms (^owner,^delivery, …) never match. - Webhook targets remain a deliberate exclusion — runtime/tenant data,
not contract syntax (see the slice-2 section). Event fields stay plain
typed fields — no constraints, facets,
readonly/id, or multiplicities.
Resolution & validation
Section titled “Resolution & validation”Payload field types resolve against the union namespace of the imported
domains (the same rule multi-file .mox compiles follow): the
phero-syntax primitives resolve everywhere; every other reference follows
the .mox cross-package rules (a bare single-segment name must be unique
across all domain packages; a qualified pkg.Name must match exactly one
package — ambiguity and unknown names report through the same diagnostics
the .mox and .ddd surfaces use).
Validation rules (any error-severity diagnostic anywhere — the contract file or an imported domain — drops the artifact; warnings do not):
- Syntax — parse errors are reported against the
.evtfile. - Imports — every
import "<path>"must name a provided domain, elseimported file "<path>" was not provided. - Field types — a payload field type that is neither a primitive nor
resolvable in the domain namespaces errors as
unknown type 'X'. - Publications — every
publishes Xentry must name an event declared in the same file, elseunknown event 'X' on channel 'C'. - Subscription events — every entry of a subscription’s
events [...]list must name an event declared in the same file, elseunknown event 'X' in subscription 'S'. - Uniqueness — event, channel, and subscription names are unique
within the file (
duplicate event 'X'/duplicate channel 'X'/duplicate subscription 'X', each with help'X' is already declared in this file), and payload field names are unique within their event (duplicate payload field 'x' in event 'Y'). - Subscription shape — an empty
events [...]list errors assubscription 'S' subscribes to no events; an empty consumer errors assubscription 'S' names an empty consumer(only reachable through parser recovery, whose syntax error fires alongside — both name the subscription, so the pairing is deterministic). - CloudEvents attributes — once an event carries a
cloudEventsblock, itstypeandsourcemust be non-empty (cloudEvents type on event 'X' must not be empty/ thesourcetwin). When present, the declarations are authoritative for a downstream generator — they override any derivedtype/sourcemapping (the generator’s integration seam, below). - Governance
pii— everypiientry must name a payload field of the same event (unknown payload field 'x' in pii of event 'Y'). Theowner/classification/retentionlines are never validated beyond syntax — verbatim labels. - Delivery mode — the
modeentry must be one of the three vocabulary wordsat_most_once,at_least_once,effectively_once(unknown delivery mode 'x' on subscription 'S', help listing them). - Delivery tuning knobs —
maxAttempts,backoffSeconds, andtimeoutSecondsmust be positive ('maxAttempts' on subscription 'S' must be positive; likewise the other two). - Dead-letter channel — the
deadLetterentry must name a channel declared in the same file (unknown dead-letter channel 'x' on subscription 'S'). - Subscription
when— the filter’s interior (sliced strictly inside the parens, trimmed) must parse as a phero-expr condition and typebooleanagainst every event in the subscription’sevents [...]list. The expression is typed with a synthetic per-event class — the subscribed event’s payload fields as the implicitself, registered over the imported domains’ class union (DomainTypes::insert_class, never a fakeModel). A field absent from any subscribed event is an error by design: a filter is a routing decision per event, and an event without the field cannot be routed (the help names the subscription and, for per-event failures, the event). A parse failure leaves the filter out of the artifact; a type failure still lowers the verbatim interior (the.cmmnprecedent) — the artifact drops on any error either way.
Scope labels are never validated: a channel’s of <scope> clause and
a subscription’s scope <name> binding are opaque topic-template labels
carried verbatim into the artifact (ChannelDef::scope /
SubscriptionDef::scope) — no resolution, no uniqueness, no cross-checks.
Interpretation (which payload field carries the key, which transport
segment it binds) is a consumer/backend concern, never a driver concern.
The same is true of the governance labels (owner, classification,
retention) and the CloudEvents attribute values: stored verbatim,
never interpreted.
Diagnostics are ordered deterministically: the contract file’s parse
diagnostics first, then the import errors, then each domain’s own
diagnostics (import order), then the contract’s semantic diagnostics
grouped by declaration kind — events, then channels, then subscriptions —
each group in source order, matching the artifact’s declaration order.
Within an event: its fields (source order), then pii, then
cloudEvents; within a subscription: its events [...] list, then
when, then delivery. Every diagnostic is tagged with its file’s path,
so hosts (and the CLI) render them grouped per file. On any error the
result’s model is None; domains_model still carries the imported
domains when they lowered.
CLI usage
Section titled “CLI usage”pherolang check orders.evt # validate contract + imported domainspherolang ir orders.evt -o orders.evt.json # emit the EventModel artifactpherolang fmt --check orders.evt # canonical formatting gatepherolang artifact check orders.evt.json # validate the serialized artifactTry it on the canonical fixture from the repository root:
cargo run -p phero-cli -- check tests/conformance/events/orders.evtcargo run -p phero-cli -- ir tests/conformance/events/orders.evt -o orders.evt.json- Import paths resolve relative to the
.evtfile’s directory; each imported.moxis read from disk and compiled through the ordinary domain pipeline. A missing import file is a clean error naming the resolved path. An imported domain may itself declareimport schemaandimport sigil— the CLI provides that content too, so event fields target lowered JSON-Schema and Rune types like any other class. - Diagnostics come from every file involved and render grouped per file,
the way
.actorcompiles render them. pherolang iron an.evtfile emits only the event artifact; the domain IR is emitted by runningiron the.moxfiles themselves.pherolang fmtformats.evtfiles with the canonical layout: imports hoisted into a tight first section, then declarations grouped by kind (event → channel → subscription, source order within a kind), fields asname: Type;one per line, event bodies reordered to fields → governance →cloudEventswith canonical;on every statement line,publishesentries with a canonical trailing;, a channel’sof <scope>clause on the header line, and a subscription body of theevents [ A B ]line, theconsumer Cline carrying an optionalscope Stail, thewhen (...)filter (interior verbatim), and thedelivery { ... }block with oneword value;entry per line in canonical order. Stdin is treated as.mox.- Directory batch mode (
pherolang check <dir>) picks.evtfiles up like every other surface: each contract in the directory compiles independently, imports resolved relative to it. pherolang artifact checkclassifies a non-empty event artifact by itsevents/channels/subscriptionskeys (rule 5 of the classifier’s precedence list). The empty event artifact serializes as exactly{"formatVersion":2}and therefore classifies as an ActorModel — a documented shape-sniffing limitation the empty IFML model shares (see the classification rules in the CLI’sartifact_checkmodule).
Worked example
Section titled “Worked example”tests/conformance/events/orders.evt, the canonical fixture, contracts the
sample order flow over tests/conformance/events/orders.mox. The fence
below is the fixture verbatim, its import repointed so it compiles from
this document (the docs fences are compiled by CI):
import "../tests/conformance/events/orders.mox"
event OrderPlaced version "1.0.0" { orderId: String; total: int; status: OrderStatus; owner order_team; classification confidential; retention "7y"; pii [ orderId ]; cloudEvents { type "nz.example.orders.orderplaced.v1" source "/orders" subject "{orderId}" }}
event OrderCancelled version "1.1.0" { orderId: String; reason: String;}
channel orders of account { publishes OrderPlaced; publishes OrderCancelled;}
subscription billing { events [ OrderPlaced OrderCancelled ] consumer billing_service scope account when (orderId != "") delivery { mode at_least_once; maxAttempts 5; backoffSeconds 10; timeoutSeconds 30; deadLetter orders; }}
subscription analytics { events [ OrderPlaced ] consumer analytics_service when (total > 0)}Notes on the example: status: OrderStatus resolves to the enum the
imported domain declares (see the golden excerpt above — it serializes as a
qualified enum reference into nz.example.orders); billing and
analytics subscribe to overlapping event sets, each with exactly one
consumer; the orders channel and the billing subscription carry an
account scope (a topic-family dimension, carried verbatim — the
analytics subscription shows the scope-less shape); nothing in the file
names a transport. The billing filter can reference orderId because
both subscribed events carry it; the analytics filter may use total
because it lists only OrderPlaced — the same filter on billing would
be rule 13’s routing error. deadLetter orders points at the declared
channel.
A filter comparing a string literal against a String-typed payload field
still type-checks — strings support equality — and warns under the
expression guard lint (rule L3 of docs/EXPRESSIONS.md; one warning per
filter, deduped across the per-event universes), suggesting the field be
declared as an enum so the compared literals are checked. The empty string
is excepted: orderId != "" here is an emptiness check (a sentinel can
never be a declared member), so it stays silent.
Delivery model & governance (slice 2)
Section titled “Delivery model & governance (slice 2)”Slice 2 adds the delivery model as declarations the artifact carries for consumers — never behaviors the driver executes. The issue-#31 scoping decisions, recorded here as the contract:
- CloudEvents attribute declarations override derived mappings. An
event’s
cloudEvents { type ... source ... subject ... }block is the integration seam with the downstream generator: when present, its values are authoritative for the generated CloudEvents envelope — the generator does not derivetypefrom the event name orsourcefrom the package. Absent, the generator’s own derivation applies. The driver validates only non-emptiness (rule 8); the CE spec’s URI-reference / reverse-DNS shape checks are the generator’s concern. - Webhook targets are runtime/tenant data — deliberately NOT modeled.
A subscription says which consumer receives which events under
what policy; where that consumer is reachable (URLs, queue ARNs,
connection strings) is deployment/tenant state that changes
independently of the contract. Modeling it would couple the
transport-independent DSL to a specific delivery topology. The
.deploysurface is where environment binding belongs. - Transports, schema-registry publication, and event
versioning/compatibility matrices are downstream concerns / future
slices. The artifact names no broker, no wire encoding, no registry,
and carries no compatibility contract between
versionstrings — a backend chooses the transport (CloudEvents, AsyncAPI, MQTT topics, …) exactly the way the.dddsearch projection leaves the engine to the backend. - Delivery policy + governance + filters are carried, not executed.
at_most_once/at_least_once/effectively_once, the tuning knobs, the dead-letter channel, the PII list, and thewhenfilters are contract facts a consumer implements; Phero Lang’s driver validates their shape (mode vocabulary, positive knobs, resolvable channel and field references, boolean filters) and stops there.
How the pieces compose on a subscription: events [...] selects the
routed event set, when narrows it per event (typed against every listed
event — rule 13), delivery states the reliability contract, and the
consumer binds it all to a transport endpoint that the artifact never
names.
Conformance
Section titled “Conformance”tests/conformance/events/ holds the canonical fixture set: orders.mox
(the domain), orders.evt (the contract, formatter-canonical), and
orders.evt.json (the committed artifact). The driver golden test
(crates/phero-driver/tests/evt_golden.rs) byte-compares the compiled
artifact, round-trips it through from_json, and pins the negative
diagnostics; the wire shape is pinned by
crates/phero-ir/tests/event_model.rs; the formatter golden
tests/conformance/fmt/events.fmt.evt is byte-compared by
crates/phero-syntax/tests/fmt.rs, and the CI fmt gate covers
tests/conformance/events/. Regenerate goldens with
PHERO_UPDATE_FIXTURES=1 cargo test --workspace.
The examples/ side ships support.evt and ecommerce.evt (the latter
with a vocabulary-typed payload field), compiled and byte-pinned against
examples/artifacts/ by crates/phero-driver/tests/examples_artifacts.rs.