Capabilities, Constraints & Attributes
The three composition kinds. A capability says what a product does, a constraint says what it may not do, and an attribute says what it is. Products are composed from these, not forked from product types.
A capability is an opt-in, composable trait a product enables in its productConfig. It is a reusable behavior - a sentiment rating, a reader note, detachable storage, a one-time deploy fee - that predates being welded into any one product type. Rather than forking a new product type every time a product needs a new behavior, you compose a product from traits.
This is the thesis: the recipe DESCRIBES behavior. A sentiment rating was once hard-coded into the open-letter contract. As a capability, it is a trait any product can turn on - an open letter, a bottle-drop message, a guestbook. The product type says what KIND of thing this is; capabilities say what it DOES.
Capabilities are declared in SmartProduct.productConfig:
productConfig: {
version: 1,
capabilities: [
{ id: "sentiment" },
{ id: "detachable" },
],
constraints: [
{ id: "author-cannot-mint" },
{ id: "mint-window", config: { opensAt: 812000, closesAt: 815000 } },
],
}See Smart Products for the productConfig shape and the SDK create surface.
The three kinds
Three registries, three jobs. A product is described by what it composes, and that description is the vocabulary you build with: an Event with a Location of type PublicLink, a Feedback capability, and a mint-window Constraint.
- A capability says what a product DOES. It can contribute mint parameters, per-instance data, storage, events, read-only getters, and standalone public functions. Enabling
sentimentgives a mint asentimentparameter, stores it per edition, emits a capability event, and exposes aget-edition-sentimentgetter. - A constraint says what a product MAY NOT do. Guards and error codes - plus, since it may need to state its own terms, a getter and the state that terms live in. Enabling
author-cannot-mintinjects anasserts!that rejects a mint by the author. - An attribute says what a product IS. Descriptive state with a getter and an owner-gated setter. It is the only kind that is per-product rather than per-mint, and the only kind an owner can change after deploy.
| Capability | Constraint | Attribute | |
|---|---|---|---|
| answers | what it does | what it may not do | what it is |
| cardinality | per edition | per mint attempt | per product |
| touches the mint | yes - params, guards, storage, events | yes - guards | no |
| mutable after deploy | no (edition data is written once) | n/a | yes, via its setter |
Picking a kind is mechanical rather than a judgement call. If the fact is per-edition it is a capability; if it blocks an action with an asserts! it is a constraint; if it describes the product and an owner can change it, it is an attribute. The type system enforces the boundary rather than trusting the convention: an attribute's contribution type has only three fields, so it cannot express a mint contribution.
Constraint guards emit BEFORE capability guards, so a violating mint fails fast and cheap before any capability logic runs.
Touching the mint means contributing to it, not reading it
An attribute may be READ by the mint - the price is the worked example, since make-reservation checks the fee that product-info declares - but it may never add a parameter, a guard, or stored data. Without that distinction the fact a buyer cares about most, what a thing costs, would sit outside the vocabulary that exists to describe products.
Attribute, Type, Value
Only attributes have a third level, and it is what lets one attribute describe genuinely different things.
- The Attribute is the slot a product declares:
location. - The Type is the shape the value takes:
PhysicalAddressorPublicLink. It owns the on-chain tuple. - The Value is the instance on chain: a specific venue, or a specific link.
Every Type publishes the SAME getter. get-location returns a different tuple depending on the Type, discriminated by a kind field the reader checks first. That is what lets an off-chain reader parse a location from a contract it did not generate, without knowing in advance which Types exist. A reader that does not recognise a kind can still read the fields every Type shares.
The Type is frozen at deploy. Clarity gives a define-data-var exactly one tuple shape for the life of the contract, so a Type can never be changed later by a setter - only its value can. This is why there is no Unplaced type: a placeholder could never become a real address. Announce early by deploying a PhysicalAddress with blank fields and filling them in afterwards with set-location.
Most attributes have exactly one Type, and that is fine - the model degrades gracefully.
Constraints can state their own terms
A constraint could once enforce a rule that nothing on chain could read, so a buyer had no way to ask when a mint window closes. Every constraint may now publish its own terms: mint-window exposes get-mint-window and is-mint-window-open, both built from the same expression as the guard, so disclosure cannot drift from enforcement.
The invariant
Behavior is client-expressible; economics are server-forced.
A product's behavior - which traits it composes - is expressed by the client in productConfig. But anything with economic consequence is resolved by the server, never trusted from the config. The clearest case is deploy-fee: the client names only the fee AMOUNT (feeSats). The recipient, and any protocol launchpad cut (protocolRecipient / protocolSats), are derived server-side from the App record before the contract is generated - a client-supplied recipient is ignored and overwritten. Fees and splits cannot be gamed by config.
How composition works
Enabled capabilities, constraints and attributes contribute static (or value-derived) Clarity snippets into named SLOTS in the shared contract fragments. The composer concatenates every enabled entry's snippet per slot, in a deterministic order, and the template processor substitutes each slot into the generated contract.
The slots:
| Slot | Filled by | Purpose |
|---|---|---|
MINT_PARAMS | capabilities | extra publish-edition parameters |
MINT_GUARDS | constraints, then capabilities | asserts! that run first / fail-fast |
MINT_STORAGE | capabilities | extra edition-data tuple fields written at mint |
MINT_EVENTS | capabilities | extra print emits after the universal edition event |
EDITION_DATA_FIELDS | capabilities | extra columns in the edition-data map definition |
CAP_READ_ONLY | all three kinds | read-only getters - a capability's, a constraint's terms, an attribute's value |
CAP_PUBLIC_FUNCTIONS | all three kinds | standalone define-public functions - an attribute's setter lives here |
CAP_ERROR_CODES | capabilities, then constraints, then attributes | extra define-constant err-... lines |
DATA_VARS | all three kinds | top-level state a composed part owns - define-data-var / define-map |
ON_DEPLOY | capabilities | top-level expressions that run once at publish (deploy-fee's transfer) |
USE_GUARDS | constraints | asserts! that reject a violating use of an already-minted instance, such as a ticket validated at a door (max-uses). The mint is not the only guarded action. Only a template that declares the placeholder carries it - today the ticket - so composing max-uses onto a page is refused with a 400 naming the slot |
Ordering is deterministic. Each entry carries an order; snippets sort by it within a slot. This is the dependency axis - a capability that reads another's mint-time write emits after it (sentiment-snapshot orders 110, after sentiment's 100). Guards additionally group constraints-before-capabilities. A product with no capabilities yields a plain mint - every slot defaults to empty, so composing nothing is byte-identical to a base contract.
Note that ON_DEPLOY is the one deploy-time slot: deploy-fee's snippet is a top-level expression that runs once in the deploy transaction (tx-sender is the deployer), not at mint. It is ordered above the deployed-event print, so an aborted fee never emits a phantom "deployed" event.
The shape descriptor
The shape descriptor is the server-derived, queryable snapshot of which capabilities a product has. Stamped on SmartProduct.shapeDescriptor at create from the same validated capability set that becomes productConfig.capabilities - so the two agree by construction, never from raw client free-text. It holds the sorted, registry-resolved capability ids (exactly the set the composer builds the contract from) plus a monotonic shapeVersion. It answers "what is this product" without re-parsing config; rows without a descriptor are implicitly the base shape.
Descriptive-only capabilities
Not every capability contributes Clarity. Some are descriptive-only or off-chain - they make a pre-existing feature a first-class, recognized element of a product's shape without generating any contract code:
sentiment-snapshotis a read model - a server-side aggregate rollup ofsentimentsignals, maintained off the ProtocolEvent stream. It contributes no Clarity.stickersis an off-chain SVG render. Placements persist asProductStickerrows, and the sticker artist's cut settles through the generic role-agnosticcreator-infomap, never a sticker-named contract slot.auto-mintis a generation-time template behavior - a template block stripped at generation unless a recipient is set. The capability recognizes the feature; it does not (yet) generate it from a slot.private-noteis off chain by design: a note disclosed to one person must not become a permanent public record.bracket-entrynames the picks a bracket contract already writes; wiring them through slots is not done.
For these, enabling the capability composes byte-identically to composing nothing on-chain. They still matter: they put the feature in the product's shape descriptor and its config, so the protocol can name and reason about it.
Validation
Two layers gate a config. The Zod schema checks STRUCTURE (an array of { id, config }, or { id, type, value } for an attribute). The policy layer enforces registry SEMANTICS against the product's domain:
- unknown id rejected
- per-entry
configvalidated against the def'sconfigSchema domainScope- a domain-bound entry on the wrong domain is refused (this is what keeps "compose onto any product" honest). Most entries areany;ticket-holderandmax-usesare EVENT-only,bracket-entryis PREDICTION-only andbombis GAME-onlyrequires- every required capability is enabled (e.g.sentiment-snapshotrequiressentiment)conflictsWith- no enabled pair conflicts- for an attribute, the named
typemust exist and itsvaluemust satisfy that Type's schema
A third check runs at composition rather than validation: two composed parts may not define the same on-chain name. Slot concatenation cannot collide, but definitions can, and two attributes both claiming location would produce Clarity that does not compile. The composer refuses, naming both culprits.
An attribute's value is also re-validated at composition, because it is the only caller-supplied text that reaches a Clarity string literal - the Type's schema is what stands between a request body and generated source.
The current catalog
Capabilities
| Capability | What it does | Per-instance data |
|---|---|---|
sentiment | Signer attaches a 1-10 rating plus an optional note (up to 128 chars) when minting an edition. Stores it, emits a capability event, exposes get-edition-sentiment. | { sentiment, feedbackNote? } at mint |
sentiment-snapshot | Off-chain read model: an aggregated rollup of a product's sentiment signals (count / mean / median / distribution). No Clarity; requires sentiment. | none (aggregates sentiment's) |
feedback | Sentiment minus the grade - a reader attaches a free-text note (1-128 chars) at mint. Reusable by any product wanting a plain note. | { note } at mint |
detachable | The current edition OWNER pins the edition's token URI to permanent storage and can one-way lock it, so the product survives the protocol disappearing. First capability to add a standalone public function. | none |
deploy-fee | Charges a one-time fee atomically at contract publish (deploy-time, not per mint). Underfunded, the publish aborts - no paid-but-no-product. Client sets only the amount, and optionally currency (SBTC by default, where feeSats is sats; STX, where it is read as microSTX). | none |
auto-mint | On deploy, mint edition #1 to a named recipient (the "gift" detachable references). Descriptive-only: the real behavior is a generation-time template strip. | none |
ticket-holder | EVENT only. The name on the ticket: a required holder name (1-128 characters) at mint, stored per edition, rendered on the artifact, recorded in a capability event, and read back through get-ticket-holder. Public by design - it is read at a door. | { holderName } at mint |
private-note | What a purchaser tells the product owner, and nobody else - up to 256 characters. Generates no Clarity and never reaches the chain: it is staged off chain against the mint transaction (productInstances.stageNote), redacted from every public read and readable only by the owner. | { note }, owner-only |
bracket-entry | PREDICTION only. The picks and tiebreaker a player commits at mint. Descriptive-only today: the bracket contract's own submit function writes the picks, so the capability names them in the product's shape without generating Clarity. | { picks, tiebreaker? } at mint |
bomb | An item: something used during a live match rather than at mint. A spectator clears one occupied square of a Tic-Tac-Boom board. Its price is a share of the buy-in (priceOfBuyInPct, 0 to 100, frozen at deploy): a priced bomb splits like a buy-in, so its pool share grows the kitty, and a bomb priced at 0 moves nothing but gas. Tic-Tac-Boom opens its tables at 0. Its audience is spectator, and the generated contract refuses either seated player - see Game Lobbies. | the uses, per match |
stickers | Owner places one or more artist-designed SVG stickers on the product render. Off-chain; placements persist as ProductSticker rows and the artist's cut rides the generic revenue pipeline. | none |
Constraints
| Constraint | What it does |
|---|---|
author-cannot-mint | The product owner (author) may not mint their own editions. A guard-only rule lifted from the open-letter product. |
mint-window | Editions can only be minted within an inclusive burn-block-height window [opensAt, closesAt]. Config-derived - the bounds inline into the guard. Publishes its own window through get-mint-window. |
supply | Caps how many instances may exist. 0 means unlimited. Owns its own max-supply var and the single writer for it, set-max-supply - a one-way ratchet toward MORE availability, so an owner can never reduce what buyers were already told. |
max-uses | EVENT only. Bounds how many times one instance may be used after it is minted - admissions per ticket. Fills USE_GUARDS rather than the mint guards. 0 means unlimited, the default. Owns its max-uses var, a set-max-uses setter that can only loosen the cap, and the getters get-max-uses and get-uses-remaining. |
Attributes
| Attribute | Types | What it describes |
|---|---|---|
product-info | Standard | Name, description, thumbnail, price in both currencies, preferred currency, whether instances may transfer, and whether offers are allowed. Read through get-product-info, which returns the same eight field names on every product family. |
location | PhysicalAddress, PublicLink | Where the product is. A venue with a postal address, or a public link. Both read through one get-location. |
A PublicLink is public. It lives in a world-readable data var, so anyone who can read the contract can open it - a ticket to a linked event records attendance, it does not grant entry. A gated variant would be a separate Type with an authenticated read path, and it does not exist yet.
See also
- Smart Products - the
productConfigshape and SDK create surface - Contract Templates - how the composed slots become a deployed Clarity contract
- Revenue Composition - the server-forced economics side of the invariant
Contract Templates
A contract template is the Clarity source behind a product type. What the protocol generates, what gets frozen into it, and how capabilities compose in.
Game Lobbies
The GAME domain: a lobby is a smart product that hosts a series of two-player matches for a pot. What it is made of, and what it guarantees a caller.