Platform Capabilities
A capability is infrastructure a plugin needs but does not provision: somewhere to put an uploaded image, something that turns an address into coordinates, something that puts a mail in front of a customer. The plugin declares what it needs in its own vocabulary; the platform provisions it; the framework carries the declaration between the two stacks and refuses a deploy where the two disagree.
Three capabilities are supported today. The mechanism is deliberately open-ended — the list below is where it has been taken so far, not the limit of what it models.
The reason for the ceremony is the split-stack ordering: the platform deploys
first and cannot read the plugins' schemas. So the platform's list is written
before anything can check it against what the plugins actually want, and every
symptom of getting it wrong is silent — an upload input that finds no endpoint and
writes to the wrong bucket, a geocode that answers Unavailable until the retries
run out, a confirmation mail nobody notices was never sent. The declaration, the
manifest and the deploy gate exist to turn each of those into a refusal with a name
on it.
The capabilities supported today
Three, and the set is expected to grow. What follows is the roster this release provisions, not a closed model of what a plugin may ever need — a queue, a scheduler, a payment processor and a search index are all the same shape of thing, and each would arrive as another arm here. Treat the list as a fact about the version you are building against, and see When the set grows for what a new capability does and does not disturb.
The roster is ReventlessInfra.Platform.capability — a real variant, so the
compiler finds every consumer when the set grows:
type capability =
| ObjectStore({plugin: string, store: string})
| Geocoding
| Messaging
| ObjectStore | Geocoding | Messaging | |
|---|---|---|---|
| What it is | A bucket for values events reference | Address → ranked candidates | Sending a message to a person |
| Declared by | a field | a slice | a slice |
| Identity | {plugin}.{store} | the capability | the capability |
| Travels as | requiredStores | requiredCapabilities | requiredCapabilities |
| Reached at runtime | client presign (see below) | capabilities.geocode | capabilities.messaging |
| Provisioned on AWS | Capability_ObjectStore_S3 | Capability_Geocoding_AwsLocation | Capability_Messaging (SES or log) |
| Locally | in-memory / filesystem store | not provisioned — answers Unavailable | log transport, prints and sends nothing |
| Deploy gate | Util_StoreLayout.coverageFor | CapabilityNeed.unmet | CapabilityNeed.unmet |
ObjectStore is the one carrying an identity beyond its own name, and the reason
is that a deployment has several: two plugins that pick the same store name get two
stores. That mirrors how a field's declaration reads — an unqualified
@storageRef("productImages") means this plugin's store. A deployment has one
geocoder and one mail sender, so those need no key.
When the set grows
Adding a capability is a framework change, not something a plugin can do locally — but almost none of the cost lands on application code, and that is by design rather than by luck:
Capabilities.tis a record, not a widening argument list. A new field breaks the handful of places that construct it — the platforms, which is exactly where a new capability has to be wired anyway — and leaves everytranslatereadingcapabilities.geocodeuntouched.- The roster and the need are real variants, so when the set grows the compiler names every consumer that has to be revisited instead of leaving a string comparison to fail at runtime.
- The wire carries strings.
pluginStructureandcapabilities.jsonpersist capability names as text, so a plugin built against a newer framework still decodes in an older reader. A name the reader does not recognise is reported as unrecognised, never folded into a known arm. - An older platform does not refuse a newer plugin's unknown need. The deploy
gate skips a capability this build cannot evaluate, because refusing over one it
cannot reason about would be a guess dressed as a check. The capability then
degrades the way an unprovisioned one always does —
Unavailable, retried, and visible in the sweep.
The practical consequence: pin the framework version you declare against, and expect a capability that is missing rather than mis-declared to surface as queued work, not as a crash.
Two declaration rails
Fields declare object stores
A field says its value lives in a store, and that is the declaration — there is no separate list to keep in step with it:
| UpdateImage({@storageRef("productImages") imageUrl: string})
The plugin build walks the command, event and state schemas, collects every such
field into pluginStructure.requiredStores (qualified) with
requiredStoreDeclarations as provenance, and the platform provisions from the
union across its plugins. The semantic types UploadableImage.t and
UploadableFile.t declare the same thing through the shared Semantic.StoredIn
marker, as does @offload for a large value carried inline-or-by-reference.
Two consequences worth knowing before you rename a field:
- A declaration can destroy infrastructure. A hand-written bucket could only be
removed by editing Pulumi; a declared one disappears when the last
@storageRefnaming it does. Hence~protect, on by default and turned off only for stacks that are routinely torn down. - Guessing is a lint, never a mechanism.
Capability_Inferencere-runs the UI's field-name heuristics (imageUrl,photo,*storageRef, …) over the same schema walk and warns about a field that looks like a stored ref but declares no store. It never provisions:imageUrlis genuinely ambiguous between an uploaded object and an external URL, and only its author can say which.
Slices declare geocoding and messaging
An OutboundTranslationSlice — the component that reaches outside the deployment, and the only one that declares capability needs — exports a value:
let capabilityNeeds: array<Reventless.CapabilityNeed.t> = [Messaging]
A trait exports its own, so a host names it rather than repeating it:
let capabilityNeeds = TraitAddressGeocoding.AddressGeocoding.capabilityNeeds
CapabilityNeed.t has two constructors, Geocoding and Messaging. Object
stores are deliberately not among them — a store need is already declared by the
field and identified by (plugin, store), which a bare capability name cannot
carry. This type is for the needs no field can express.
It is also one need however many channels the platform provisions. Which
channels those are is a runtime answer the provider publishes, not a second
declaration, because a plugin that declared Sms would fail a deploy it could
have run on email.
Both spellings persist as strings, not enum members, in pluginStructure and
in capabilities.json — so a plugin built against a newer framework still decodes
in an older reader, and a capability name this build does not recognise is reported
as unrecognised rather than silently dropped into a known arm.
From declaration to provisioning
-
The plugin build writes
capabilities.jsonbeside the generatedPlugin.res— one entry per capability, keyed by identity, with the declaring sites as provenance. Keys are taken verbatim frompluginStructure, so the manifest is a rendering of the structure and never a second scan of the sources; the two cannot spell one fact differently. A plugin that declares nothing yields an empty list, not an absent file — "declares nothing" is a statement, distinguishable from "was never built". -
pnpm run generate:platformunions the manifests the deploy manifest names and writes the platform's list:// AUTO-GENERATED — do not edit.
let capabilities: array<ReventlessInfra.Platform.capability> = [
// catalog: Products.productImage → productImages
ObjectStore({plugin: "Catalog", store: "productImages"}),
// ordering: GeocodeCustomerAddress
Geocoding,
// ordering: SendNotification
Messaging,
]The provenance comments are the point: when a capability disappears from this list, the diff says which change removed it.
-
The platform root calls the
Capability_*helper for each and passes the handles todeployPlatform. After a@storageRefchange: rebuild the plugin, regenerate, review the diff.
What is injected, and what is not
A slice's translate is handed a record with two of the three:
type t = {
geocode: Geocoding.search,
messaging: Messaging.provider,
}
Injected rather than looked up, and required rather than optional, because the alternative is a slot filled at cold start that nothing enforces: ES modules evaluate imports before the importing module's body, so "the entry point runs first" is an assumption a bundling change can quietly break. A missing argument does not compile; an unfilled slot fails on the first real address, in production.
A record rather than a widening argument list, so adding a capability breaks only
the places that construct it — the platforms, which is where a new capability has
to be wired anyway — and leaves every translate reading capabilities.geocode
untouched.
The object store is the exception, and it is an open seam rather than a
boundary. Its runtime half today is client-side: the browser gets a per-store
presigned PUT and the minted ref is what the field carries. Plugin code that wants
to read an offloaded value back calls Offload.resolve(~fetch), which has no
injected caller yet — the accessor for it belongs in this record and is not there.
So Capabilities.t is where two of three capabilities are reached, not the
definition of what a capability is.
Unavailable is a modelled outcome
Capabilities.none is the capability set of a platform that provisions nothing.
Every call answers Unavailable, translate maps that to Error, the item is
retried and the sweep surfaces it. That is the wanted behaviour for a deployment
that simply has no geocoder: the work stays queued and visible rather than
being written off as a verdict on the data — which is what a NoMatch would mean.
It is named so that a platform passing it is making a statement rather than filling
in a blank.
The two deploy gates
Slice-declared needs — CapabilityNeed.unmet compares what the plugins
declared against what the platform provisioned, and a mismatch refuses the deploy
naming the capability, the component that asked, and what happens if it ships:
every call answers Unavailable, the slice exhausts its retries, and a permanent
verdict is recorded against data that is fine, with no error anywhere. Only what
was declared is checked — a plugin that declares nothing is unaffected.
Stores — Util_StoreLayout.coverageFor is the same set difference with
three outcomes rather than two:
| Outcome | Meaning |
|---|---|
Covered | Every declared store is provisioned. |
NotAdopted | The platform provisions no stores — it has not adopted capability provisioning at all. |
Missing | The platform provisions some stores but not this one: a missing or misspelled entry. |
Collapsing the first two would force a choice between breaking every deployment that predates the mechanism and not helping the ones that have adopted it. Being strict here is worth it because the symptom is silent: the upload input finds no per-store endpoint, falls back to the legacy single service, and writes to whatever bucket that serves — a 2xx, a plausible ref, and the wrong destination.
Messaging channels
The messaging capability is the one with a vocabulary inside it. Three channels are defined; one has a transport today:
| Channel | Address | Status |
|---|---|---|
Email | Email.t | Provisioned — SES, or a log transport that prints and sends nothing |
Sms | Phone.t | No transport. messagingSmsSender is carried so a stack can state the number, but nothing reads it |
Push | Messaging.pushAddress | Defined only |
A channel appears when a transport does, not when its config key exists — claiming one ahead of its backend would collect preferences that silently deliver nothing. So the vocabulary here is also a statement of intent: SMS and push are modelled, addressed and routed, and what is missing in each case is the provider binding.
A (channel, address) pair can be built wrong — Sms beside an email address
compiles and fails at the provider — so recipient fuses them and the channel is
read back off the value that carries it. A send answers a receipt or one of four
failures, and the split is the retry rule: Unavailable retries, while
UnsupportedChannel, UnsupportedPushService and Refused do not. Everything
that sweeps a failed send derives from Messaging.retriable rather than re-reading
the constructors.
Push is one channel and three provisionings
Push is the one channel whose address is a variant rather than a branded scalar,
because the shapes do not agree:
| Arm | Shape | Issued by |
|---|---|---|
Apns | {deviceToken: string} | Apple, against a signing key, team id and bundle id |
Fcm | {registrationToken: string} | Google, against a service-account credential |
WebPush | {endpoint, p256dh, auth} | the browser, sealed with a VAPID keypair |
Email needs no such split: one sender provisioning reaches every mailbox, because SMTP routes off the address. Push has no routing layer. Which service can reach a device is fixed by which credential the deployment holds, and there is nothing in the address to route on — so provisioning one gives you none of the others, and they fail independently.
The channel deliberately stays one arm anyway. Splitting it three ways would offer a person a choice between notification services, which is not a choice anyone has: an app knows its own token, and nobody prefers APNs. So the selector keeps three arms and the discrimination lives one level down, on the address and on what a provider publishes.
Read provider.channels before offering a choice
The provider publishes what it can attempt — twice, at two granularities:
type provider = {
channels: array<Messaging.channel>,
pushServices: array<Messaging.pushService>,
send: send,
}
A capability publishes what it can reach, at the granularity a caller must choose
at — and when those two granularities differ, it publishes both. That is the
general rule, not a fact about messaging: it recurs anywhere one selector is served
by several independently provisioned providers. Push is the worked example. A person
picks a channel, so channels is what a preference centre renders; a token is
reachable only through its own service, so pushServices is what
Messaging.supports checks a ToPush against. Answering push off channels would
call an APNs token supported on a deployment holding only an FCM credential, and
leave the caller to find out by spending a real message.
The two cannot disagree, because channels is derived: build a provider through
Messaging.makeProvider, which appends Push exactly when pushServices is
non-empty. Stating that as an invariant beside two independently written fields is
how it stops being true.
A preference centre must render channels, not the three above. Offering a
channel nothing can deliver on collects a subscription that never arrives, and
discovering a channel by failing on it costs a real message. Messaging.supports is
the check, and it applies the same rule the provider applies internally, so the two
cannot disagree. An empty list means no channel at all — which is the shape
Capabilities.none takes, and what the deploy gate exists to catch before it ships.
A deployment that provisions some push but not this address's service gets
UnsupportedPushService, which names the service; UnsupportedChannel(Push) keeps
its narrower meaning of no push at all.
This matters because the domain side has its own copy. The notification trait's
Notification_Rules.channel mirrors all three — it is the domain's vocabulary,
carrying a host's schema and travelling on the wire, where the platform's is the
capability's — and its subscription matrix is kind × channel over the full set. So
a recipient can be recorded as subscribed on a channel this deployment will then
answer UnsupportedChannel for. Nothing is wrong with that: the record is a
preference, and the guard is reading provider.channels at the point a choice is
offered.
The trait's recipient directory also shows what un-fusing a recipient costs. It
stores a channel beside one flat address: string and re-fuses them on send, which
round-trips for email and SMS and cannot round-trip push at all — one string names
neither the issuing service nor a Web Push subscription's keys. So the generated
recipientFor refuses Push and records a delivery failure saying why, rather than
guessing a service. A push transport arrives with the stored shape it needs, in the
commit that justifies the columns.
Configuration
Which transport, which sender, which place index — all of it is per-deployment
configuration rather than code, read off the usual ladder (env var,
Pulumi.local.yaml, then platform:<key>). See the
deployment guide for the keys, their defaults,
and what each does when unset.
See also
- Domain Traits — traits that export a
capabilityNeedsfor a host to name. - Plugin System — where
pluginStructurecomes from. - Deployment guide — the config keys and the deploy-time refusals.