Deployment packages

A Sutra project is a single deployment package. sutra create app produces exactly one; sutra package <dir> seals it into one immutable, content-addressed .sutra archive; sutra deploy activates that archive on a running engine. There is no build step in between — you author declarative resources, seal them, and hand the sealed archive to a generic engine binary that never needs your source.

Package interior

<package>/
├── package.yaml             # manifest: labels {tenant, module, version}, engine.minContract
├── bpmn/**/*.bpmn            # BPMN 2.0 processes
├── rules/                    # DMN decision tables (.dmn) + the .srl rule DSL, by extension
├── scripts/**/*.{hbs,xsl,xslt}     # derive/compute (Handlebars + XSLT)
├── templates/                # render output (Handlebars + XSLT)
├── schemas/                  # each leaf folder under here = one codec
│   └── <codec>/*.xsd + codec-manifest.yaml
├── migrations/<store>/**     # per-datastore SQL migrations
├── channels.yaml             # transport channels (bind codec URNs; hot-reloaded on flip)
└── datastores.yaml           # datastore declarations

Every artifact folder supports nested subfolders — bpmn/orders/checkout/…, templates/eu/invoices/… — and discovery is recursive. sutra create deployment <name> --from packages/my-app scaffolds a sibling package as an explicit copy; packages never inherit from one another.

package.yaml — labels, not scope

labels:
  "module": "money-transfer"
  "tenant": "default"
  "version": "1.0.0"
engine:
  minContract: 1

tenant / module / version are opaque labels — selectors for observability, routing, and row-level-security partitioning (tenant_id) — never a resource-tree dimension. A package is fully self-contained: there is no tenants/<id>/ overlay tree, no shared-library modules/ folder, and no inheritance between packages. What a tenant "sees" is simply every process in the package deployed under its labels.

Referencing resources — convention over configuration

Because the package is self-contained, resources are referenced by their local key, derived from the folder tree — never by tenant/module/version:

ResourceResolved by
BPMN processprocessId — globally unique within the package
Rule (.dmn / .srl)its relative path under rules/
Script / templateits full relative path (folder + filename + extension)
Codeca URN — see below

Codecs are the one exception. A globally-named codec — a format the engine links in (json, xml, yaml, csv, raw-text, raw-bytes) or a codec crate a distribution force-links — is urn:sutra:codec:<name>; a package-defined codec is named by its path under schemas/, / folded to :schemas/transfer/ becomes urn:transfer, schemas/hr/employee/ becomes urn:hr:employee. channels.yaml binds that URN:

# examples/money-transfer/.../channels.yaml
channels:
  - name: transfer-request
    transport: http
    bind: "POST /channels/transfer-request"
    codec: urn:transfer     # schemas/transfer/*.xsd, declared by schemas/transfer/codec-manifest.yaml

A codec-manifest.yaml sits inside its own schemas/<codec>/ folder and declares the schema kind plus the formats the codec accepts:

# examples/money-transfer/.../schemas/transfer/codec-manifest.yaml
schemaKind: xsd
formats: [xml, json, yaml]

The reserved token sutra may not be used as a first-level subfolder name under any artifact folder (schemas/sutra/, bpmn/sutra/, …) — that would collide with the engine's own urn:sutra:* namespace. A deeper sutra (schemas/hr/sutra/) is fine.

Schema bundles — when a codec is a whole profile

schemaKind: xsd and schemaKind: json-schema are the generic case: a folder of schema files the engine validates a decoded document against. Some standards aren't that shape at all — they're a whole profile: an envelope grammar, a mapping from a wire-level message name to a schema file, and versioned editions the profile revs on its own release cadence, independent of Sutra releases. For those, schemaKind names a bundle kind a codec crate has registered (see Domain neutrality and the SPI model), and the folder's job shifts from "here are the schemas" to "here is the configuration that decides which schema backs which message, for this archive version."

A codec crate registers its own bundle kind this way whenever the standard it serves is a whole profile rather than a bare schema folder — say, a market venue that publishes a wrapper envelope around every message and revs its schema editions on its own release calendar. That codec crate is a proprietary extension, not part of this distribution — the mechanism below is generic; naming a concrete kind is only for illustration. Its manifest maps wrapper element names — not a bare schema namespace — onto schema files archived alongside it:

# schemas/<your-kind>/codec-manifest.yaml
schemaKind: <your-kind>
# schemas/<your-kind>/<your-kind>-manifest.yaml
appHdr: edition-2024/Header_v1.xsd   # optional
incoming:
  OrderConfirmation: edition-2026/OrderConfirmation_v3.xsd
outgoing:
  OrderConfirmation: edition-2026/OrderConfirmation_v3.xsd
  ShipmentNotice: edition-2024/ShipmentNotice_v2.xsd
schemas/<your-kind>/codec-manifest.yaml      # schemaKind: <your-kind>
schemas/<your-kind>/<your-kind>-manifest.yaml   # appHdr? / incoming{wrapper: relpath} / outgoing{wrapper: relpath}
schemas/<your-kind>/<edition-folder>/*.xsd   # free-form folder names — the manifest is the only truth

A few things fall out of that shape:

  • Edition folders sit side by side. Because the venue revs its schemas per release (the same wrapper can move from one schema version to the next between editions), archiving each release's files under its own folder and repointing the manifest is how a module adopts a new edition — a new version of the archive, no engine or codec change.
  • An unlisted wrapper falls back to the codec crate's own base schema for its pinned default version, so a bundle only needs to carry the wrappers it actually wants to validate more strictly than that default — the codec stays useful with zero configuration otherwise.
  • An enriched edition can be licensed material. A codec's base schemas may be freely redistributable while a fuller, usage-guideline edition is a licensed product participants obtain themselves and supply in their own archive — the engine never ships that tier, and neither does any artifact built from this repository.
  • Registration is deployment-scoped, exactly like every other artifact under the registration model: the bundle's registry key is urn:sutra:codec:<folder-path-with-'/'-folded-to-':'>:<deploymentId>. Naming the folder <your-kind> shadows the globally-registered urn:sutra:codec:<your-kind> for that deployment only — a second version of the same module with a different edition mapping registers under its own deploymentId and runs side by side, no collision.
  • Deploy-time errors are fail-closed: an unknown wrapper name, a wrapper listed under the wrong direction, a missing or uncompilable schema file, or a schema whose namespace doesn't match the expected one all reject the archive rather than deploy something that silently validates less than the manifest claims.

Store migrations, and evolving a projected structure

Every sql data store the package declares brings its own schema, under a folder named for the store:

migrations/<store-id>/V001__<description>.sql
migrations/<store-id>/V002__<description>.sql

<store-id> is the store's name in datastores.yaml, and the migrations: key of that store points at the folder (migrations: migrations/accounts). The scripts are yours: your dialect, your table names, your indexes and seed rows. The engine generates no DDL for a module store.

One store is the exception: the reserved coverage store, where path-coverage marks are persisted. Its declaration still picks the database, but the engine owns its schema — it ships that DDL per dialect and applies it to the connection on the same first-use path — so the store block carries no migrations: key and a package carries no migrations/coverage/ folder. See Coverage: declared routes as the compliance signal.

Three properties of how the package's own scripts run are worth designing around:

  • Applied in V<n>__ order, once per store instance, before the store serves its first operation — and serialized across replicas, so two engines booting at once don't race.
  • There is no migration ledger. The engine's own Flyway-style history table covers engine tables only; a module store's scripts are simply re-run on the next boot. Write them idempotentlyCREATE TABLE IF NOT EXISTS, INSERT … ON CONFLICT DO NOTHING, CREATE INDEX IF NOT EXISTS — because that is what makes the re-run a no-op rather than a failure.
  • A store's data carries across a version bump. There is no deployment_id on a business store's rows: deploying 1.0.1 beside 1.0.0 doesn't fork the data, and that is the feature, not an oversight.

When a projected structure changes

A store that declares a structure: block adds one obligation: a changed structure ships with the migration that makes the table match, in the same package. sutra lint derives the effective table shape from these very scripts, so it tells you at package time whether the pair is consistent — its job is to detect the mismatch, not to repair it.

Change to the declared typeWhat it costs
Add an optional scalar fieldAdditive. A new nullable column; rows written before it read the field as absent
Add a required scalar fieldLint error until the column exists and is nullable or has a DEFAULT — existing rows cannot satisfy a bare NOT NULL
Remove a fieldThe column becomes unmapped (a warning); existing data is untouched
Widen a facet (maxLength 35 → 70)Lint error until the ALTER ships in a new V… script; clean once it does
Rename a fieldModelled as remove + add. A columns: mapping can keep the physical column name instead
Scalar → nested or repeatedHard stop: STRUCTURE_NOT_FLAT. Either keep the field flat, or drop the structure block and go back to the opaque store — an explicit decision at package time, never a silent shape change

What follows from the table is a packaging rule: a type change and the ALTER that supports it belong in the same package. Lint replays every script in the folder in version order and compares the result against the declared type as it stands, so a package carrying both lands clean, while one carrying only the type change fails the gate rather than the deployment.

Building one by hand vs. scaffolding

sutra create app <name> (see Your first app) generates a package in this exact shape, verified through the engine's own loaders before anything is written. Growing it from there:

sutra create bpmn my-process --package packages/my-app --validation fatal
sutra create deployment my-app-eu --from packages/my-app   # explicit variant copy

sutra lint <package-dir> runs the full package-time validation suite (the same checks sutra package runs before sealing) with no output on success — the fast pre-flight to run before every package.

Next