Alpha Version: You are viewing the ALPHA documentation. This is an experimental version and may contain breaking changes.
Skip to main content

Reventless PPX Guide

The Reventless PPX eliminates boilerplate from application code. Instead of manually declaring let name, module Id, let moduleUrl, and @s.matches(DcbTag.string), you add a single annotation and the PPX injects everything at compile time.


Setup

Add the PPX to your package's rescript.json. It must come before sury-ppx:

{
"ppx-flags": ["@reventlessdev/reventless-ppx/bin", "sury-ppx/bin"]
}

The PPX reads package.json (for the npm package name) and rescript.json (for namespace and dependencies) from the nearest parent directory. Both files must exist.


Annotations

@@reventless.spec

Use on all spec files: aggregate specs, read model specs, extension point specs, DCB slice specs, event mapping specs, and side effect specs.

What it injects:

BindingConditionValue
let nameNot already declaredDerived from filename
module IdNot already declared, and reventless-spec is a dependencyReventless.Id.String
let moduleUrlNot already declaredComputed npm specifier
open Reventless.ReadModel + let config + let subIdConfigFilename contains ReadModel, @schema type state present, let config not declaredReadModel defaults

Name derivation strips known component suffixes from the filename:

FilenameDerived name
Category.res"Category"
ProductsReadModel.res"Products"
AddCategory.res"AddCategory"
CategoriesView.res"Categories"
Products_ExtensionPoint.res"Products"
Product_Behavior.res"Product"

Stripped suffixes (each tried first with a leading underscore, e.g. _Behavior, then bare): ExtensionPointMapping, ExtensionPoint, ReadModel, Behavior, Projections, Projection, Aggregate, Plugin, Slice, Spec, View.

Dotted names in spec packages: When the rescript.json namespace ends in Spec (e.g., CatalogSpec), the PPX automatically prefixes the derived name with the plugin name:

FilenameNamespaceDerived name
Products_ExtensionPoint.resCatalogSpec"Catalog.Products"
Orders_ExtensionPoint.resOrderingSpec"Ordering.Orders"

The plugin name is the namespace with Spec stripped.

Explicit name override:

@@reventless.spec("CustomName")

Use this when the derived name doesn't match your intent. The PPX still injects module Id and moduleUrl.

module Id is skipped when reventless-spec is not in the package's rescript.json dependencies. This allows lightweight spec packages (extension point specs) to use @@reventless.spec without depending on the full framework.


@@reventless.behavior

Use on all behavior files.

What it injects:

BindingConditionValue
open SpecNot already presentOpens the spec module
module Spec = SpecNot already declaredAliases the spec module
let moduleUrlNot already declaredComputed npm specifier

Spec module derivation: strips _Behavior (or a bare Behavior) from the filename.

FilenameDerived spec
Category_Behavior.resopen Category; module Spec = Category
Order_Behavior.resopen Order; module Spec = Order
ProductDemand_Behavior.resopen ProductDemand; module Spec = ProductDemand

Explicit spec override:

@@reventless.behavior(PluginSpec)

Use this when the spec module name doesn't match {Filename minus Behavior} — for example when the spec file is named differently than the behavior file's prefix.


@@reventless.dcbTags

Use on DCB slice files outside *Slice/ folders that have entity ID fields in @schema types. Files inside any *Slice/ folder (StateChangeSlice, StateViewSlice, AutomationSlice, InboundTranslationSlice, OutboundTranslationSlice) get dcbTags automatically via @@reventless.spec — no explicit @@reventless.dcbTags needed.

What it does: Scans all @schema-annotated variant types and injects @s.matches(Reventless.DcbTag.string) on fields that match these rules (unless @s.matches(...) is already present):

Field patternTypeInjection
*Id: stringscalar@s.matches(DcbTag.string) on the type
*Id: array<string>array (singular name)@s.matches(DcbTag.string) on the element type — for cross-entity queries
*Ids: array<string>array (plural name)@s.matches(DcbTag.string) on the element type — for multi-value storage

The PPX generates the fully qualified Reventless.DcbTag.string, so no open Reventless is needed just for DCB tags.

Before (manual):

@schema
type command = AddProduct({
productId: @s.matches(DcbTag.string) string,
name: string,
})

@schema
type event = ProductAdded({
productId: @s.matches(DcbTag.string) string,
name: string,
})

After (with PPX):

@@reventless.dcbTags

@schema
type command = AddProduct({
productId: string,
name: string,
})

@schema
type event = ProductAdded({
productId: string,
name: string,
})

Combine with @@reventless.spec: Most DCB files outside slice folders use both annotations:

@@reventless.spec
@@reventless.dcbTags

@partitionTag, @noDcbTag, @dcbTag — field-level DCB tag control

These field attributes give fine-grained control over DCB tag injection. They work in any @@reventless.spec or @@reventless.behavior file, regardless of whether dcbTags auto-inference is active.

AnnotationPlaced onEffect
@partitionTagA *Id: string fieldInjects @s.matches(DcbTag.partition) — marks this field as the partition key. Required when a variant has multiple *Id fields.
@crossPartitionA string (or array<string> element) fieldInjects @s.matches(DcbTag.crossPartition). Rarely needed — cross-entity reference reads are inferred from the slice graph; this is the escape hatch only for M:N capacity reads of a slice's own event type. See @crossPartition below.
@noDcbTagA *Id: string fieldSuppresses auto-tagging — the field stays as plain string. Use when the field is payload data, not a DCB query key.
@dcbTagAny string fieldInjects @s.matches(DcbTag.string) — explicit opt-in for fields that don't follow *Id naming (e.g., sku, slug, reference).

The PPX strips all four attributes from the output AST, so the compiler never sees them as unknown attributes.

@partitionTag — multiple *Id fields:

// In a StateChangeSlice file — both productId and orderId would otherwise
// both get auto-tagged, making partition derivation ambiguous
@schema
type event =
| DemandRecorded({
@partitionTag productId: string, // partition key
orderId: string, // also tagged as DcbTag.string
})

@noDcbTag — payload-only field:

@schema
type event =
| DemandRecorded({
@partitionTag productId: string,
@noDcbTag orderId: string, // not a DCB tag — plain string in the event store
})

@dcbTag — non-*Id field name:

@@reventless.dcbTags

@schema
type event =
| SkuAdded({
@dcbTag sku: string, // not *Id naming, but should be a DCB tag
name: string,
})

@compositePartitionTag — composite DCB partition key

@compositePartitionTag lets you form the DynamoDB partition key from multiple fields, concatenated in declaration order with a configurable separator. Use it when a single field is too coarse for partitioning and a composite identity (e.g. environment/platform/plugin) distributes events better across partitions.

Each annotated field is still a regular DCB tag (individually queryable). The composite key is derived automatically at runtime from the stored tag values.

Syntax:

@compositePartitionTag            // uses "/" after this field (default)
@compositePartitionTag("/") // explicit default — identical behaviour
@compositePartitionTag(":") // uses ":" after this field

The separator on the last annotated field is ignored (nothing follows it).

Example — three-segment composite key env/platform/plugin:

@@reventless.spec

@schema
type event =
| PluginSynced({
@compositePartitionTag environment: string, // partition: env/...
@compositePartitionTag platformName: string, // partition: env/platform/...
@compositePartitionTag pluginName: string, // last field — sep ignored
version: string,
})
// Composite partition key value: "{environment}/{platformName}/{pluginName}"
// Each field is also individually queryable as a DcbTag.string.

Constraints:

RuleBehaviour
Non-string field annotatedNo-op — field is left untouched
@compositePartitionTag and @partitionTag on the same schemaderivePartitionTag throws at startup
Fewer than 2 fields annotatedderivePartitionTag throws at startup
A member's value is the empty stringPermitted — see below

Empty tag values. A composite key often describes a hierarchy, and a member of that hierarchy can be genuinely absent — an entity owned at the outer level has no inner-level name to give. The framework accepts an empty tag value rather than forcing a placeholder that fabricates identity:

  • The event records the tag with its empty value, on every backend.
  • The value participates in the composite key (key:value pairs joined with #), so the composite partition key, composite reads and the OCC fences all behave exactly as they do for a non-empty member.
  • The value is not individually indexed. On DynamoDB each tag key gets a tag_<key> GSI whose hash key is that attribute, and a key attribute cannot hold an empty string — so the adapter skips the attribute and the index goes sparse (its intended mechanism). Reads by the partition are unaffected; they go against the base table.

The one consequence to design around: a @crossPartition read of a tag key resolves through that per-tag index, so it never returns events whose value for that key is empty (querying for an empty key value is not expressible in DynamoDB either). If a value must be findable across partitions, it must be non-empty.

Placement: Place @compositePartitionTag before the field name, exactly like @partitionTag:

// CORRECT
@compositePartitionTag environment: string

// WRONG — annotation on the type, not the field (silently ignored)
environment: @compositePartitionTag string

@crossPartition — cross-partition (secondary-tag) reads

You usually don't need this. When a slice reads another entity's lifecycle by its id (e.g. AddProduct checking a categoryId that Category owns), the framework infers the cross-partition read from the slice graph — declare the consumed events and the foreign field, and write no annotation. @crossPartition is the escape hatch for the one case inference can't see: an M:N capacity read where a slice reads its own event type by a secondary key across all of that key's partitions (below). A @crossPartition that the framework resolves as the slice's own partition is flagged as a contradiction at build time.

A DCB event is stored under exactly one partition (its @partitionTag). A single-tag decision read of any other tag the event carries is, by default, partition-scoped — it only sees events whose partition key is that tag, so a tag that is secondary on the event is invisible to such a read.

@crossPartition opts a tag into a cross-partition read: a single-tag read of that key returns every event carrying it across all partitions. This is the canonical shape for an M:N capacity invariant — a slice both produces and reads an event that ties two entities, partitioned by one, so it must also read by the other; inference treats that own-stream read as partition-scoped, so you opt in explicitly.

// Course subscription: partition by courseId; studentId is read across every
// course partition the student appears in. The annotation goes on BOTH the
// command (its tags build the read query) and the produced event (its tags
// drive partitioning, GSI indexing, and the fence) — never on consumedEvent.
@schema
type command =
| SubscribeStudent({
@partitionTag courseId: string, // → clause [courseId] — partition read
@crossPartition studentId: string, // → clause [studentId] — cross-partition read
})

@schema
type event =
| StudentSubscribed({
@partitionTag courseId: string,
@crossPartition studentId: string,
})

SubscribeStudent then builds two single-tag reads — "all of the course" (by courseId) AND "all of the student" (by studentId) — instead of one composite read of the exact {course, student} pair.

How it works under the hood (no slice-contract change beyond the annotation):

  • Read routing. A @crossPartition clause reads the per-tag tag_<key> GSI (a Query for keys + a BatchGet/GetItem for payloads) instead of the base-table partition. GSI reads are eventually consistent — the append fence catches any staleness at commit, costing at most a retry.
  • Fence scope follows read scope. A @crossPartition tag's consistency fence is bumped by every carrier (primary or secondary), so optimistic concurrency detects a concurrent secondary-tag writer. Partition-scoped tags keep the narrow "bump only your own partition" rule.

Notes and constraints:

  • Default is partition-scoped. Leave the annotation off unless you genuinely need the cross-partition fold — it makes the tag's fence hotter (every writer of that tag contends on one fence) and the read O(entity degree). For capacity checks ("≤ N …"), bound the read with a count/limit rather than folding the whole set.
  • Scope is a property of the tag key and must agree across every event type that carries it — the fence is driven by writers, so a key cannot be cross-partition for one producer and partition-scoped for another. Dcb_Builder reports a scope mismatch at build time.
  • Placement: before the field name, like @partitionTag. Works on a string field or the element type of an array<string> field.

@noApi — exclude commands from GraphQL/MCP exposure

Use on command types or individual command variants to exclude them from automatic GraphQL mutation and MCP tool generation.

AnnotationPlaced onEffect
@noApi@schema type commandEntire command type hidden from API
@noApiA single variant in a command typeOnly that variant hidden, others remain public

Type-level @noApi — entire command hidden:

// Driven by an extension reacting to another plugin's events — never by a client.
@schema @noApi
type command =
| RecordDemand({@partitionTag productId: string, orderId: string})
| RevokeDemand({@partitionTag productId: string, orderId: string})

All variants of this command type are excluded from GraphQL mutations and MCP tools. Use this for commands that only ever arrive from an extension, an automation, or another internal path.

Variant-level @noApi — individual variants hidden:

@schema
type command =
| CancelOrder({orderId: string}) // Public — exposed as GraphQL mutation + MCP tool
| @noApi ReopenOrder({orderId: string}) // Internal — hidden from API

The @noApi annotation is stripped from the compiled output by the PPX. Filtering happens at schema generation time in Plugin_Builder (for Aggregates) and Dcb_Builder (for StateChangeSlices).


@lifecycle — mark the field a record's lifecycle lives in

The field whose value identifies where the record is in its life: the enum a command's @allowedStates is written in terms of, the one a board draws its columns from, a progress tracker walks and a state diagram renders. AutoUI's command-menu filter reads it per row and matches it against each command's @allowedStates set.

There are two ways to declare it, and a record uses whichever fits.

Name the field lifecycle and write nothing. The name is the declaration:

@@reventless.spec

@schema
type lifecycle = Placed | Shipped | Cancelled

@schema
type state = {
orderId: string,
customerId: string,
lifecycle: lifecycle,
}

Or annotate whatever the field is called, for a record whose lifecycle field has an honest name of its own:

@schema
type accountStatus = Active | Deactivated

@schema
type state = {
customerId: string,
@lifecycle accountStatus: accountStatus,
}

The PPX emits the annotated field name into stateAnnotationSpec.lifecycle (sury metadata attached to the state schema). Codegen reads it when building queryableDef.lifecycleField.

An enum-shaped field is not automatically a candidate. A field tracking a background job's progress — an export, a delivery attempt, a geocoder — is enum-shaped and often named like a status, and annotating it would section the list by how far that job got and filter the command menu against states no command mentions. The annotation is keyed on lifecycle rather than status for exactly this reason — a record often carries several statuses and at most one lifecycle.

Resolution order (codegen, Plugin_Structure.lifecycleFieldFromStateSchema): (1) field annotated @lifecycle; (2) field literally named "lifecycle" whose shape is an enum (a free-text lifecycle: string does not count — @allowedStates filtering needs states to compare against); (3) None, and the per-row filter is inert.

Why the convention is keyed on lifecycle and not status. status is a promiscuous name — geocoding progress, todo-queue progress, translation audit outcome and plugin connection state all wear it — so a convention keyed on it genuinely guesses, and guesses often. lifecycle is a deliberate word nobody types by accident, so matching it is close to reading a declaration written in the field name.

A framework-generated row declares rather than following the convention. Its field name is chosen by the framework and read by every client, so it is the wrong thing to make load-bearing: the platform's own Plugins read model keeps its published status field and annotates it.

Constraint: at most one @lifecycle per state record. The PPX errors on duplicate annotations within the same record.

Renamed from @status. The old spelling is a compile error naming this one; there is no alias, because a silently-accepted old name is exactly the ambiguity the rename removes.


@groupBy — section the list view by a field

Use on a single @schema type state field in a ReadModel or StateViewSlice spec to mark the field the UI's list view should group rows by. AutoUI's list view renders the read model's rows in sections keyed on the annotated field, ordered by the field's enum declaration order when the field is an enum.

@@reventless.spec

@schema
type kind = Domain | PlatformInfrastructure | Commercial | Marketplace

@schema
type state = {
pluginId: string,
name: string,
@groupBy kind: kind,
}

The PPX emits the field name into stateAnnotationSpec.groupBy (sury metadata attached to the state schema), and SuryToJsonSchema.deriveObjectSchema stamps x-reventless-group-by: true on the named field of the read model's JSON Schema. The UI reads that extension property; there is no runtime or projection change — this is a schema hint only.

Section ordering: when the group field is an enum, the UI orders sections by the enum's declaration order. To change the section order, reorder the enum constructors — no UI change required.

Constraint: at most one @groupBy per state record. The PPX errors on duplicate annotations within the same record. Stacks with other field annotations (e.g. @scan @groupBy kind: kind makes the field both server-side filterable and the list section key).


@live — declare the view's live-updates default

Use on the @schema type state declaration (not a field) of a ReadModel or StateViewSlice spec to declare whether live updates make sense for the view. @live(false) marks investigative/historical views (catalogues, comparisons, audit histories) where a live-updates control is pointless; @live(true) marks operational views where one should be offered.

@@reventless.spec

@live(false)
@schema
type state = {
@id productId: string,
name: string,
}

The PPX emits the bool into stateAnnotationSpec.live (sury metadata attached to the state schema), and SuryToJsonSchema.deriveObjectSchema stamps top-level x-reventless-live: bool on the read model's JSON Schema. The framework only transports the declaration — UI consumers decide what to do with it (the annotation typically governs whether a Live control is offered at all). Absent annotation ⇒ absent key ⇒ the consumer's own default applies. There is no runtime or projection change — this is a schema hint only.

Constraint: the payload is exactly one bool literal (@live(true) or @live(false)); anything else is a compile error. Only ReadModel and StateViewSlice (incl. Stream) spec files accept it — the PPX errors on a @live state declaration in any other spec file.


@allowedStates — per-variant command state guard

Use on individual command variants in an aggregate or DCB-slice @schema type command to declare which lifecycle states the command is meaningful in. The payload is an expression list of status-type constructor references. AutoUI hides the command on rows whose status isn't in the set.

// In Order.res (an Order aggregate spec):
@@reventless.spec

@schema
type command =
| Place({customerId: string, productIds: array<string>})
| @allowedStates([Orders.Placed]) Ship
| @allowedStates([Orders.Placed]) Cancel
AnnotationPlaced onEffect
@allowedStates([…])A single command variantVariant is shown only on rows where the row's lifecycleField value is in the set
(no annotation)A single command variantVariant is always shown (back-compat default)
@allowedStates([])A single command variantVariant is never shown (defensive "never available" form)

The PPX extracts each constructor's leaf identifier as a string and emits let commandSchema = ReventlessInfra.Api.markAllowedStates(commandSchema, [|("Ship", [|"Placed"|]); …|]) attaching the per-variant map to the schema metadata. The wire format on Platform_ComponentDefinitions is commandDef.allowedStates: option<array<string>>None means "always show", Some([…]) means "filter".

Supports payloadless and payload variants uniformly. The PPX walks the attribute payload as syntactic AST and extracts the leaf identifier; it works for Submitted (payloadless), OrdersStatus.Submitted (qualified payloadless), Shipped({trackingNumber}) (payload), and any combination. AutoUI's filter compares against the row's serialised status tag — sury emits payloadless variants as bare JSON strings and payload variants as {TAG: …, …} objects, both surfacing the constructor name.

Limitation: no compile-time existence check. The constructor name is extracted as a string without verifying the constructor actually exists on the read model's status type. A typo (@allowedStates([Orders.Plased])) compiles cleanly; the filter just never matches that string. ReScript's dependency analysis runs pre-PPX, so a witness-binding approach (emitting let _ = Orders.Placed) would force ReScript to build the witness's module first — which it doesn't see as a dep, breaking the build order. Runtime cross-validation against the status-field schema is captured as future work in AllowedStatesAnnotation.ml.


@id, @compositeId — partition key derivation

Use on @schema type state fields in ReadModel and StateViewSlice spec files. The PPX generates let makeId from the annotated field(s). This replaces a manual let makeId declaration.

AnnotationUsageGenerated code
@idOne string fieldlet makeId = (state: state) => state.fieldName
@compositeIdMultiple string fieldslet makeId = (state: state) => `${state.f1}/${state.f2}/...`
@compositeId(~sep=":")Multiple string fields, custom separatorSame with : between segments

@id — simple entity key:

@@reventless.spec

@schema
type state = {
@id productId: string,
name: string,
price: float,
}
// PPX generates: let makeId = (state: state) => state.productId

@compositeId — multi-segment key:

@@reventless.spec

@schema
type state = {
@compositeId tenantId: string,
@compositeId productId: string,
name: string,
}
// PPX generates: let makeId = (state: state) => `${state.tenantId}/${state.productId}`

Constraints: @id and @compositeId cannot both appear on the same type. Both require string fields.


@subId, @compositeSubId — sort key derivation

Use on @schema type state fields in ReadModel and StateViewSlice spec files. The PPX generates let subIdConfig from the annotated field(s). This replaces the default let subIdConfig = None injected by @@reventless.spec.

AnnotationUsageGenerated code
@subIdOne string fieldlet subIdConfig = Some({ subIdField: "fieldName", getSubId: state => state.fieldName })
@compositeSubIdMultiple string fieldsSynthetic _subId attribute: let subIdConfig = Some({ subIdField: "_subId", getSubId: state => `${state.f1}/${state.f2}/...` })
@compositeSubId(~sep=":")Multiple string fields, custom separatorSame with :

@subId — version as sort key:

@@reventless.spec

@schema
type state = {
@id productId: string,
@subId version: string,
name: string,
}
// Enables: productById(id: ID!): ProductByIdConnection!
// with sort key args: prefix, from, to, eq, reverse, limit, nextToken

@compositeSubId — composite sort key:

@@reventless.spec

@schema
type state = {
@id orderId: string,
@compositeSubId createdAt: string,
@compositeSubId lineItemId: string,
amount: float,
}
// PPX generates: let subIdConfig = Some({ subIdField: "_subId", getSubId: ... })
// Stored _subId value: "{createdAt}/{lineItemId}"

Constraints: @subId and @compositeSubId cannot both appear on the same type. @subId requires a string field.


@index, @indexSubId — secondary index annotations

Use on @schema type state fields to declare DynamoDB secondary indexes. The PPX aggregates all index annotations and generates let config with an indexes array.

@index — simple secondary index (no sort key):

@schema
type state = {
@id productId: string,
@index categoryId: string,
name: string,
}
// secondary index: partition key = categoryId, ALL projection
// Query field generated:
// ProductByCategoryId(categoryId: String!, first: Int, after: String,
// last: Int, before: String,
// includeRetired: Boolean): ProductConnection!
// Pages forward on first/after. `last`/`before` are declared but refused —
// the cursor is the store's forward-only continuation token, and both
// backends say so rather than answering the forward page.

@index with projection options:

// KEYS_ONLY projection
@index({projection: "KEYS_ONLY"}) categoryId: string,

// INCLUDE projection
@index({projection: "INCLUDE", fields: ["name", "price"]}) categoryId: string,

Named @index with @indexSubId — secondary index with sort key:

Use the same name on both annotations to link them. The named index gets both a partition key and a sort key.

@schema
type state = {
@id productId: string,
@index("byCategoryDate") categoryId: string,
@indexSubId("byCategoryDate") createdAt: string,
name: string,
}
// secondary index: partition = categoryId, sort = createdAt

Composite secondary index keys — annotate multiple fields with the same name:

@schema
type state = {
@id productId: string,
@index("byTenantCategory") tenantId: string,
@index("byTenantCategory") categoryId: string, // composite pk: tenantId/categoryId
@indexSubId("byTenantCategory") region: string,
@indexSubId("byTenantCategory") createdAt: string, // composite sk: region/createdAt
name: string,
}
// Synthetic attributes injected at save: _byTenantCategory_pk, _byTenantCategory_sk

Authorization — restrict secondary index access by Cognito group:

@index({group: "admin", authTable: "PlatformAuth"}) tenantId: string,

Constraints: @indexSubId("name") without a matching @index("name") is an error.


@resolves, @resolvesMany — cross-table resolvers

Use on @schema type state fields to generate virtual GraphQL fields that resolve IDs to objects from another QueryDb table.

@resolves — resolve a single ID to its object:

@schema
type state = {
@id orderId: string,
@resolves({table: "Products", field: "product"}) productId: string,
quantity: int,
}
// Adds virtual GraphQL field: product: Product
// Resolved by GetItem on the Products table using productId

**@resolves via secondary index:

@resolves({table: "Orders", field: "currentOrder", via: "byProductId"}) productId: string,
// Resolved by querying Orders table's byProductId secondary index

@resolvesMany — resolve an array of IDs:

@schema
type state = {
@id cartId: string,
@resolvesMany({table: "Products", field: "products"}) productIds: array<string>,
}
// Adds virtual GraphQL field: products: [Catalog_Product!]
// Resolved by BatchGetItem on the Products table

Note: @resolves and @resolvesMany use record payload syntax (({key: "value"})), not labeled-arg syntax. The keywords ~to and ~as are reserved in ReScript and cannot be used as labeled args.

The target must be a queryable of the same plugin. table names another ReadModel or StateViewSlice by its spec name; the field is emitted with that view's GraphQL type ({Plugin}_{Singular}). A target the plugin does not expose is refused at build time, and so is a plugin: key naming a different plugin: each plugin's GraphQL document has to be valid standalone before the merge, and on AWS the target table's grant is attached to the declaring plugin's API role. To read across a plugin boundary, query the other view directly.

The target must be guarded the way the declaring view is. A cross-table field is read through its parent and answers under the parent's authorization, so a target declaring a different @authorize rule is refused at build time. A target open to everyone is allowed — it can only narrow.

The target's rules narrow the rows. The row comes out of the target's table, so the target's @owner and @retired declarations decide what the caller sees — on every backend. A cross-table field takes no includeRetired argument, so a retired row never travels through one; {list}Refs plus @namedWhenRetired is the door that still names a row the archive took.

Nullability. The single form is nullable (the foreign key may name a row that was never written); the batch form is [T!] — ids that match nothing drop out, so the list is shorter rather than null-holed.


@storageRef, @offload — object-store field markers

These field markers declare that a field's value lives in one of the platform's object stores, and provision that store. Place them before the field name (like the DCB tag markers).

@storageRef — a field holding a store ref (string / array<string>):

The value is a store-minted origin-relative path; the ref is the value the reader renders. @storageRef("store") names a store of this plugin; @storageRef("Plugin.store") points at another plugin's.

@schema
type command =
| ChangeProductImage({
@dcbTag productId: string,
@storageRef("productImages") imageUrl: string,
})

@offload — an inline-or-reference field:

A small value stays embedded in the command/event; a large one is content-addressed to the store by the client (which uploads the bytes before issuing the command) and carried as a reference — so the event shrinks and byte-identical values across versions dedupe to one object. A reader resolves either arm back to the value with Offload.resolve.

@schema
type event =
| ReportGenerated({
reportId: string,
@offload("reports") body: option<reportBody>,
})
  • Forms: @offload("store"), @offload("plugin.store"), and the record form @offload({store: "store"}); works on a field of type X or option<X>.
  • Per-field threshold: @offload({store: "s", threshold: 16384}) sets the inline-vs-offloaded byte cut. A client resolves the effective cut with Offload.effectiveThreshold — precedence per-field marker → platform default → 8 KB — and passes it to Offload.prepare when uploading. Retuning it is always safe; it only changes how future values split.

Constraints: the field's inner type must be a plain named type (reportBody, M.t); for an array<…> or type-parameterised inner type, apply the schema by hand with @s.matches(Reventless.Offload.optionSchema(~store="s", <schema>)).

@owner — the field that ties a row to its caller

@owner names the field holding the id of the principal a record belongs to. It goes on a field of a @schema type command variant, a @schema type state, or both. Place it before the field name, like the other field markers.

Two things follow, both enforced server-side:

  • On a command: the framework overwrites the field with the authenticated caller's id before publishing. An absent field and a forged field therefore produce the same row — the client's value is ignored, not trusted.
  • On a queryable's state: reads of that view are narrowed to the caller's own rows, on every transport.
// StateChangeSlice/PlaceOrder.res
@schema
type command =
PlaceOrder({
@partitionTag orderId: string,
@noDcbTag @owner customerId: string,
})

// StateViewSliceStream/Orders.res
@schema
type state = {
orderId: string,
@owner customerId: string,
lifecycle: lifecycle,
}
  • One per record or variant payload. A second @owner is a compile error: every reader resolves the owner by taking the first marked field, so the second would be inert and the view would scope on whatever declaration order happened to put first.
  • string or option<string> only. A row has one owner, so an array field cannot be one — annotating it is a compile error rather than a marker no reader looks at. The f?: string form works too.
  • It composes; it never subtracts. The marker is applied after the DCB-tag passes and wraps whatever schema the field already resolved to, so an owner field keeps its DCB tag (@owner customerId in a slice), its partition key (@partitionTag @owner), or its reference (@ref("Seller") @owner). Use @noDcbTag when the owner field is payload rather than a query key.

Who is exempt is deployment configuration, not part of the annotation: a caller whose group is listed in REVENTLESS_ELEVATED_GROUPS is neither stamped nor scoped, and an internal system caller is exempt too. A deployment that declares @owner and configures no elevated groups is warned per view at startup — otherwise its operators would silently see only their own rows.

The scope gets an index. The rows that belong to me is the most frequent read a deployed application makes, so @owner on a state also provisions the index that serves it: a GSI named _owner, partitioned by the owner field and sorted by the record's @subId when it declares one and by id when it does not, projecting every attribute. The generated list resolver then reads it — a scoped caller gets a Query whose key condition is the ownership predicate, while an exempt caller keeps the table read unchanged. Without it the predicate is a filter applied after the page is read, so a caller who owns a hundred of a million rows pays for the million and receives short pages.

Two consequences worth knowing:

  • Creating the index is slow, once. DynamoDB builds a GSI on the control plane and the deploy blocks until it is ACTIVE — on the order of ten minutes, largely independent of table size. It happens on the deploy that first adds @owner to a view and not again. Existing rows need no rebuild: UpdateTable populates the new index from the rows that already carry its key attributes, which for this index is every row the view has (id is the table's key, and the owner field is the one you just annotated — if the projection already wrote it).
  • Check the owner field's cardinality. The index is well distributed when owners are users. It is a hot partition when one "owner" is a tenant holding most of the estate.

The index carries no query field of its own — a <view>By<OwnerField> door any caller could name is not the read it serves — and it is skipped when the field already carries an @index, since the author's index is then the one the read targets. A view small enough to accept the scan declines it with @owner({index: false}), which is also the only case the "keys no index" deploy-time warning still fires for.


@retired — what withdraws a row from ordinary reads

@retired names what means this row is retired from ordinary use — a deactivated customer, an archived category. Place it before the field name, on a @schema type state field of a ReadModel or StateViewSlice spec.

It has two forms, and which one is right depends on whether the record has a lifecycle.

The boolean form — the field is a flag, and the row is retired when it is true:

@schema
type state = {
@id customerId: string,
displayName: string,
@retired deactivated: bool,
}

The state form — the field is the record's @lifecycle, and the row is retired in one of its states:

@schema
type accountStatus =
| Active
| Deactivated

@schema
type state = {
@id customerId: string,
@displayName email: string,
@retired(Deactivated) @lifecycle accountStatus: accountStatus,
}

Reach for the state form whenever the record has a lifecycle at all, and the reason is what it does to commands. Without it, a record whose retirement is part of its life carries the same fact twice — once as the boolean the query layer filters on, once as a value of the enum that board columns, group sections, the progress tracker and @allowedStates are all expressed in terms of. Two sources of one truth, free to drift, with no rule keeping them in step.

With one field, @allowedStates becomes the command-applicability mechanism with no new annotation at all:

| @allowedStates([Active]) UpdateEmail({email: string})
| @allowedStates([Active]) Deactivate
| @allowedStates([Deactivated]) Reactivate

That last line is the point. A consumer filtering a per-row command menu against allowedStates already exists and already works; what was missing was never a way to describe a command's stance on retirement — it was retirement being expressible in the vocabulary that stance is already written in.

The boolean form stays the right choice where retirement genuinely is a flag rather than a state: a Products view with an archived boolean and no lifecycle should not have to invent a two-valued enum.

Two things follow from the one annotation, and the second is why this is not a presentation hint like @groupBy:

  • On the schema: SuryToJsonSchema.deriveObjectSchema stamps x-reventless-retired: {label?, showWhenFalse, value?} on the named property, so a consumer renders retirement as a state of the row rather than as one more data column. value is present only in the state form, and its absence is what tells a consumer which form the view declared.
  • On every read: rows that are retired — the flag true, or the field equal to the named state — are withheld from callers who are not exempt — the list query, the single-entity query, the by-ids and by-index doors, and the payload of a live change frame. Codegen carries the field name as queryableDef.retiredField and the state as retiredValue, so a client holding the def holds the whole predicate; it is pushed into SQL and into the DynamoDB FilterExpression rather than applied to the returned page, so first: 2 yields two live rows.

Payload forms. Bare, a state constructor, a string label, or a record:

@retired deactivated: bool,
@retired(Deactivated) accountStatus: accountStatus,
@retired("Archived") archived: bool,
@retired({label: "Archived", showWhenFalse: true}) archived: bool,
@retired({value: Deactivated, label: "Closed"}) accountStatus: accountStatus,

The state is a constructor reference, not a string literal, matching @allowedStates — the two annotations name states in the same vocabulary, which is the whole reason the state form exists. A string would read the same and check nothing; what survives the constructor reference (a value that is not one of the field's declared cases) is reported when the plugin structure is built, where the schema is in hand.

The label defaults to empty, which the schema emitter omits so a consumer can tell "not stated" from "stated as empty" and derive one from the field name. showWhenFalse defaults to false and always travels: it asks a consumer to surface the flag in its negative state too, and since a non-exempt caller never receives a retired row, a default-on marker would appear on every row they can read and carry no information.

  • One per state record. A second @retired is a compile error. Two retirement flags do not narrow the read further, they leave it undecided — the query layer tests a single field.
  • The field type check inverts on the payload. With no state named, the field must be bool or option<bool> (the f?: bool form works too) — the predicate is "is this true?", and on a non-boolean there is nothing to evaluate. With a state named, the field must NOT be a boolean, because it holds the enum that state belongs to. Either mistake is a compile error with the message for that branch; getting it wrong would leave the annotation riding the schema, rendering as a marker and narrowing nothing.
  • The state form must sit on the record's @lifecycle field. A retirement state anywhere else keeps the read narrowing but loses the command filtering that motivates it, because @allowedStates is written in terms of the lifecycle field — a state no command can name. Reported when the structure is built, along with a state the field does not declare.
  • Absent means not retired, on all four backends. A row written before the annotation existed carries no flag, and excluding those would empty the view the day someone adds @retired.
  • Annotation or nothing. Unlike @lifecycle, there is no conventional fallback: a boolean named archived that nobody annotated stays exactly as visible as it was. Guessing wrong here makes rows disappear.

Reaching the archive. A list query gains an includeRetired: Boolean argument, honoured only for a caller who was going to be allowed those rows anyway; a scoped caller passing it is ignored rather than refused. Note that elevation alone does not lift the restriction — an exempt caller is excluded until they ask, because an archive that is always underfoot is not an archive.

Who is exempt is the same deployment-wide REVENTLESS_ELEVATED_GROUPS that @owner uses, and deliberately so: two views must not disagree about who an operator is. There is no per-annotation elevation list.

@retired neither needs nor implies @scan. @scan widens the client's filter surface; this predicate is the resolver's, derived from who the caller is. A <field>Eq on the filter input would only ever return an empty page for the callers who could pass it.

Cost. An equality predicate over an enum-valued attribute indexes exactly as one over a boolean does, so nothing below changes between the two forms. The framework warns at deploy time when the annotated field keys no index, mirroring the @owner warning and for the same pathology: a FilterExpression is applied after the page is read, so pages shrink as the archive's share of the table grows. It warns rather than refuses — a small table may legitimately accept the cost. @scan deliberately does not silence it: it adds no index and removes no read unit.

Live updates. The state-change channel is keyed by view and entity and shared by every subscriber, so a publish cannot be scoped per caller. A save that leaves the row retired therefore publishes metadata onlyUpdated with no state — which asks every subscriber to refetch and lets the query layer answer for each of them. Un-retiring publishes full state as before. A subscriber still learns that an entity with a given id changed; removing that residual would need per-caller channels.


Tagged unions as state fields

A variant held by a @schema type state field is one fact with several shapes, and it is the honest alternative to several fields that nothing keeps in step:

@schema
type geolocation =
| Pending({requestedFor: string})
| Located({point: Reventless.GeoPoint.t})
| Unresolvable({reason: string})

@schema
type state = {
@id customerId: string,
geolocation: geolocation,
}

The value is stored as sury encodes it — {"TAG":"Located","point":{…}} — and reaches GraphQL as a union with one object type per arm. Consumers select it with inline fragments (... on Ordering_CustomersGeolocationLocated { point { lat lng } }), never bare.

There is no annotation to write. Inside a ReadModel or StateViewSlice spec, the PPX names the union on its schema — <Plugin>_<Spec><Type>, the same shape the enum beside it is already emitted under — and every member type is that name plus the arm's own. The name has to live on the schema because two halves of the framework need it and neither can derive it from the other: the SDL emitter reaches a field through a path, and the write path, which stamps each stored value with the __typename GraphQL resolves the member by, has only the schema in hand. Elsewhere — a union declared in a framework module, say — the same line is written by hand:

let geolocationSchema = Reventless.TaggedUnion.named(~name="Geolocation", geolocationSchema)

A union with no name is not emitted as one: the field falls back to String, and the deploy log names the view and the field so it is not silent.

Every arm must declare at least one named field. Three shapes are compile errors, and all three compile, encode and decode perfectly well — what refuses them is GraphQL:

RefusedWhy
| Pendingpayload-less, so it is the bare string "Pending" on the wire; a union member must be an object type
| Pending({})an object, but the member type it implies has zero fields, which is invalid
| Located(GeoPoint.t)its field is published under the compiler's name _0 — in the SDL, in the stored row, and in every consumer's query

The pressure this creates is usually productive: an arm that seems to carry nothing normally carries when, or what it was trying, and the declaration is the right place to be asked.

An enum is untouched. A variant whose arms all carry nothing is an enum, emitted as one, and none of the above applies to it.

Keys, filters, sorts and predicates are refused on a union field: @id, @subId (and the composite forms), @index, @indexSubId, @scan, @scanSort, @groupBy, @lifecycle and @retired — including @retired on an arm. All of them end up comparing the field to a scalar, and this field's value is an object whose shape depends on the row. The comparison would compile, publish a filter input, and never match. Put the annotation on a scalar field beside the union. "List the rows whose geolocation is Unresolvable" wants a derived arm-name field beside the union, not a filter over it.

Command and event variants are unaffected. They are decomposed into one mutation per constructor, so a payload-less command like Deactivate stays exactly as it is.


Examples

Aggregate spec

// Category.res
@@reventless.spec

@schema
type command =
| Add({name: string})
| Rename({name: string})
| Archive

@schema
type event =
| Added({name: string})
| Renamed({name: string})
| Archived

@schema
type error =
| CategoryAlreadyExists
| CategoryNotFound
| CategoryAlreadyArchived

PPX injects: let name = "Category", module Id = Reventless.Id.String, let moduleUrl = "...".

Behavior

// CategoryBehavior.res
@@reventless.behavior

@schema
type state =
| NotCreated
| Active({name: string})
| Archived

let initialState = NotCreated

let evolve = (state, event) =>
switch (state, event) {
| (NotCreated, Added({name})) => Active({name: name})
| (Active(_), Renamed({name})) => Active({name: name})
| (Active(_), Category.Archived) => Archived
| _ => state
}

let decide = (state, command) =>
switch (state, command) {
| (NotCreated, Add({name})) => Ok([Added({name: name})])
| (Active(_), Add(_)) => Error(CategoryAlreadyExists)
| (Active(_), Archive) => Ok([Category.Archived])
| (Archived, Archive) => Ok([]) // idempotent
| _ => Error(CategoryNotFound)
}

PPX injects: open Category, module Spec = Category, let moduleUrl = "...".

Read model spec

// CategoriesReadModel.res
@@reventless.spec

@schema
type state = {
name: string,
archived: bool,
}

PPX derives: let name = "Categories" (strips ReadModel suffix). Because the filename contains ReadModel and the file has @schema type state with no let config, the PPX also auto-injects:

open Reventless.ReadModel
let config = config()
let subIdConfig = None

To override, declare let config explicitly — the PPX skips injection when let config is present.

Extension point spec (in a *Spec package)

// Products_ExtensionPoint.res (in CatalogSpec namespace)
@@reventless.spec

@schema
type command = unit

@schema
type event =
| ProductBecameAvailable({productId: string, name: string, price: float})
| ProductPriceChanged({productId: string, price: float})

@schema
type directive = unit

PPX derives: let name = "Catalog.Products" (namespace CatalogSpec"Catalog" + filename → "Products"). No module Id injected (no reventless-spec dependency).

DCB StateChangeSlice

// AddCategory.res
@@reventless.spec
@@reventless.dcbTags

type state = {exists: bool, archived: bool}

let initialState = {exists: false, archived: false}

@schema
type consumedEvent =
| CategoryAdded
| CategoryArchived

let evolve = (state, event) =>
switch event {
| CategoryAdded => {exists: true, archived: false}
| CategoryArchived => {...state, archived: true}
}

@schema
type command = AddCategory({categoryId: string, name: string})

@schema
type error = CategoryAlreadyExists

@schema
type event = CategoryAdded({categoryId: string, name: string})

let decide = (state, command) =>
switch command {
| AddCategory({categoryId, name}) =>
if state.exists {
Error(CategoryAlreadyExists)
} else {
Ok([CategoryAdded({categoryId, name})])
}
}

PPX injects let name = "AddCategory", module Id, let moduleUrl, and @s.matches(Reventless.DcbTag.string) on the categoryId fields in both command and event types.


Conventions

PPX ordering

reventless-ppx must come before sury-ppx in ppx-flags. The reventless PPX injects @s.matches annotations that sury-ppx then processes into schema code.

Namespace conventions

Package typeNamespace patternEffect on name derivation
Spec packageCatalogSpecDotted names: "Catalog.Products"
Plugin packageCatalogPluginSimple names: "Category"
Platform packagetrue or customSimple names

When to use explicit names

Use @@reventless.spec("ExplicitName") when:

  • The desired name differs from the filename (rare)
  • The file is a framework-internal component (e.g., PluginSpec.res"Plugin" works, but being explicit is clearer)

Use @@reventless.behavior(SpecName) when:

  • The spec module name doesn't match {filename minus Behavior} (e.g., PluginBehavior.res opens PluginSpec, not Plugin)

Files that cannot use PPX annotations

These patterns cannot be auto-generated:

  • moduleUrl for ExtensionPoint inline modules inside functor bodies — requires top-level %raw captured by closure
  • Spec definitions inside inner modules in test fixtures

For these cases, use the manual declarations.


@@reventless.mappings

File-level attribute on <Plural>_Projections.res (multi-source ReadModel projections in ReadModel/) and <Entity>_Mappings.res (Aggregate event-mapping siblings in Aggregate/).

What it injects (at the top of the file):

BindingConditionValue
open Reventless.<Domain>Not already openedProjection (in ReadModel/) or EventMapping (in Aggregate/) or AutomationSlice (in AutomationSlice/)
open Reventless.MessageIn ReadModel/ and not already opened
module TargetNot already declaredAlias to the spec module (<Stem> with _Mappings / _Projections suffix stripped)
module MNot already declaredReventless.<Domain>.Mappings.Make(Target)
module type MappingNot already declaredM.Mapping
let moduleUrlNot already declaredComputed npm specifier
let counter = NoneIn Aggregate/ and not already declared

The PPX also scans inner modules: any module X = { ... } containing both let name = "..." and @schema type event is treated as a DCB Source — module Id = Reventless.Id.String is injected (if absent) and dcbTags are applied to the event type's *Id fields.

Before (manual):

open Reventless.Message
open Reventless.Projection

module ProductMapping = Mapping.Make(
Product,
Products,
{
open Product
let project = ({event, id, _}) =>
switch event {
| Added({name}) => Set(id, {Products.name: name})
| _ => Ignore
}
},
)

module M = Mappings.Make(Products)
module type Mapping = M.Mapping
let moduleUrl: string = %raw(`import.meta.url`)
let mappings: array<module(Mapping)> = [module(ProductMapping)]

After (with PPX):

@@reventless.mappings

module ProductMapping = Mapping.Make(
Product,
Products,
{
open Product
let project = ({event, id, _}) =>
switch event {
| Added({name}) => Set(id, {Products.name: name})
| _ => Ignore
}
},
)

let mappings: array<module(Mapping)> = [module(ProductMapping)]

The plugin generator references the projections module directly: module ProductsReadModel = Platform.ReadModel.Make(Products, Products_Projections). No more @reventless.projections wrapper module in Plugin.res.


@reventless.delegate

Use on Delegate module bindings outside *ExtensionPointMapping* files that need the same auto-transformation. Inside *ExtensionPointMapping* files, any module named Delegate is auto-transformed by @@reventless.spec without this attribute. Works at any nesting depth.

What it injects (into the module body):

BindingConditionValue
module IdNot already declaredReventless.Id.String
@schema type command = unitNot already declaredSury generates commandSchema from this
dcbTags on @schema type eventEvent type present@s.matches(Reventless.DcbTag.string) on *Id: string fields
@schema type error = unitNot already declaredSury generates errorSchema from this
let moduleUrlNot already declaredComputed npm specifier

Before (manual):

module Delegate = {
let name = "CatalogEventLog"
module Id = Id.String
@schema type command = unit
@schema
type event =
| ProductAdded({productId: @s.matches(DcbTag.string) string, name: string, price: float})
| ProductPriceChanged({productId: @s.matches(DcbTag.string) string, price: float})
@schema type error = unit
let commandSchema = S.unit
let moduleUrl: string = %raw(`import.meta.url`)
}

After (with PPX):

@reventless.delegate
module Delegate = {
let name = "CatalogEventLog"
@schema
type event =
| ProductAdded({productId: string, name: string, price: float})
| ProductPriceChanged({productId: string, price: float})
}

The developer only writes let name and the @schema type event. Everything else is auto-generated. The @s.matches(Reventless.DcbTag.string) annotation is applied automatically to *Id: string fields via the same logic as @@reventless.dcbTags.


What the PPX replaces

Before (manual)After (PPX)
open Reventless(not needed for specs — module Id uses fully qualified path)
module Id = Id.StringAuto-injected by @@reventless.spec
let name = "Category"Derived from filename
let moduleUrl: string = %raw(\import.meta.url`)`Computed at compile time
open Spec; module Spec = SpecAuto-injected by @@reventless.behavior
@s.matches(DcbTag.string) on *Id/*Ids fieldsAuto-injected by @@reventless.dcbTags (or automatically in *Slice/ folders)
@s.matches(DcbTag.partition) on partition key fieldUse @partitionTag field annotation
@s.matches(DcbTag.string) on non-*Id fieldUse @dcbTag field annotation
Suppress auto-tagging on a *Id fieldUse @noDcbTag field annotation
module M = Mappings.Make(...) + boilerplateAuto-injected by @@reventless.mappings (file-level)
open Reventless.ReadModel; let config = config(); let subIdConfig = NoneAuto-injected by @@reventless.spec for *ReadModel* files
module Id, @schema command/error = unit, @s.matches, moduleUrl in DelegateAuto-injected in *ExtensionPointMapping* files; use @reventless.delegate elsewhere
let makeId = ... in ReadModel/StateViewSlice specUse @id or @compositeId on @schema type state fields
let subIdConfig = Some({...}) in ReadModel/StateViewSlice specUse @subId or @compositeSubId on @schema type state fields
let config = config(~indexes=[...]) with manual indexConfig recordsUse @index/@indexSubId on @schema type state fields
Manual idResolverConfig/idsResolverConfig entries in let configUse @resolves/@resolvesMany on @schema type state fields
Hand-written @s.matches(StorageRef.forStore(...)) / @s.matches(Offload.optionSchema(...))Use @storageRef / @offload field annotations
Hand-written @s.matches(Owner.string) on the caller-identifying fieldUse the @owner field annotation