Authoring a profile¶
This is the practical guide. CONTRIBUTING.md covers process,
GOVERNANCE.md covers status and review, and
VERSIONING.md covers when a change needs a new version.
Before you start¶
Answer three questions, in this order. A profile that cannot answer them is not ready to be written.
- What is the authority? Which document states these requirements, at which exact
revision? If the answer is "my implementation", the provenance source is
upstream-implementationand you must say so. If the answer is "nothing", the profile is a composite of choices, which is allowed but must be recorded. - What can a contract do to satisfy the interface and still be wrong? Name at least three things. Those become your negative vectors, and they are the profile's purpose. A profile whose author cannot name any is a type signature with extra steps.
- What is out of scope, and why? An empty dimension is a decision. Record it in the compatibility notes rather than leaving a reader to guess whether it was considered.
The shape of a bundle¶
profiles/<id>/<MAJOR.MINOR>/
├── profile.yaml identity, upstream citation, compatibility, provenance, manifest
├── methods.yaml interface requirements
├── authorization.yaml who must authorize what, and which arguments their authorization covers
├── events.yaml what must be emitted, with what structure
├── behavior.yaml what each call must leave behind
├── invariants.yaml what must survive
├── failures.yaml which calls must be rejected, and what a rejection leaves
└── vectors/<operation>/*.yaml
Start from profiles/sep-41/1.0/ for a real standard, or from a bundle under
profiles/examples/ if you want something small enough to read in one sitting.
Step 1 — the manifest and its provenance¶
estamora_spec_version: "1.0"
profile:
id: my-interface
version: "0.1"
title: My Interface
status: draft
summary: One sentence. A reviewer reads this before anything else.
description: >-
What the interface is for, who implements it, and what the profile adds beyond the
interface's shape.
license: Apache-2.0
specification:
name: SEP-0041
title: Soroban Token Interface
version: "0.5.1"
status: draft
url: https://…
updated: "2026-08-03"
maintainers: [your-github-handle]
compatibility:
interface: full # or `partial`, with the omissions named in `notes`
notes: […] # required non-empty: what you cover and what you deliberately do not
provenance:
source: upstream-specification
derived_from: stellar-protocol@master ecosystem/sep-0041.md v0.5.1 (updated 2026-08-03)
interpretation_notes: […] # required non-empty
includes:
methods: methods.yaml
authorization: authorization.yaml
events: events.yaml
behavior: behavior.yaml
invariants: invariants.yaml
failures: failures.yaml
vectors: [my_operation] # directory names under this bundle's vectors/
shared_vectors: [common] # sets under the repository's vectors/
interpretation_notes being required is not bureaucracy. A profile that had to interpret
an ambiguous upstream sentence and does not say so is presenting a decision as a
transcription, and a reviewer has no way to tell the difference.
If your interface has no upstream document, source: composite, a derived_from that
says so plainly, and an interpretation note explaining that every requirement is a choice.
That is a legitimate profile; a profile that hides it is not.
Step 2 — methods, with their requirement lists¶
Every method declares what governs it. The lists are identifiers, resolved by the cross-reference validator, and a dangling reference is an error rather than a warning:
- id: transfer
name: transfer # the on-chain name, snake_case
requirement: required # required | optional | forbidden
summary: … # one sentence
description: >- # what a conforming contract must do, and the mistakes worth naming
args:
- name: from
type: { kind: prim, name: address } # or muxed_address, i128, vec, option, map, tuple, result, custom
semantics: … # what the argument means, not just its type
authorization:
required: true # must the caller's authorization cover this argument?
semantics: …
returns:
type: { kind: prim, name: void }
semantics: …
mutability: mutating # readonly | mutating
invocation: invoke # invoke | read_only | simulate
authorization: [holder-authorizes-transfer]
events: [transfer]
failures: [transfer-beyond-balance]
behaviors: [transfer-moves-value]
notes: […] # anything a reader must know that has no other home
Two conventions worth following:
idis kebab-case,nameis the on-chain name.transfer-fromas an identifier,transfer_fromas the name. Value expressions that call a method use the name.- A read-only method still needs an authorization rule. Say
actor: { kind: none }and explain why reading is public. Omitting the rule is what the tooling rejects.
Step 3 — authorization, as three outcomes¶
- id: holder-authorizes-transfer
methods: [transfer, burn]
actor:
kind: argument # argument | invoker | none
argument: from
on_behalf_of: false
coverage:
mode: exact # exact | at_least | at_most
arguments: [from]
unauthorized: { kind: fail, failure: missing-transfer-authorization }
wrong_actor: { kind: fail, failure: wrong-transfer-actor }
replay_sensitive: false
Coverage is not decoration. mode: exact over [from] states that the caller's
authorization must cover precisely the account being debited — which is a different claim
from "the caller presented a signature", and the one that catches a contract checking for
presence rather than for identity.
The schema enforces two consistency rules. A rule whose actor is none or invoker may
not name coverage arguments, because there are none to name. A rule with mode: exact
must name at least one, because "exactly these arguments" over an empty set is not a
statement.
Step 4 — events, tied to state¶
- id: transfer
name: transfer
requirement: required
occurrence: on_success
topics:
- index: 0
type: { kind: prim, name: symbol }
binding: { kind: literal, value: transfer }
- index: 1
type: { kind: prim, name: address }
binding: { kind: input, name: from }
data:
format: either # scalar | vec | map | either
fields:
- name: amount
type: { kind: prim, name: i128 }
binding: { kind: input, name: amount }
optional: false
cardinality: { min: 1, max: 1 }
ordering: []
correlations: [balances-conserved-by-transfer]
Bindings are how the event is tied to the call: literal for the discriminator, input
for an argument, actor for a fixture account, read for a value fetched back,
state_after for a resource after the call, unconstrained when the interface gives you
no way to know the value. Prefer input and read over literals that repeat a fixture
value: a requirement expressed against an argument survives a change of fixture.
format: either accepts both documented encodings. Use it when the upstream specification
permits both and the difference is not observable to a consumer; pin the format when it
is.
correlations is what makes an event checkable rather than merely well-formed. Without
one, a contract can emit a correctly shaped event that reports something the contract did
not do.
Step 5 — behaviour, as relative postconditions¶
- id: transfer-moves-value
method: transfer
kind: success # success | failure
preconditions:
- kind: greater_than
left: { kind: input, name: amount }
right: { kind: literal, value: "0" }
postconditions:
- kind: delta
target: { kind: read, method: balance, args: [{ kind: input, name: from }] }
direction: decrease
by: { kind: input, name: amount }
expect_failures: []
expect_events: [transfer]
forbid_events: []
invariants: [balances-conserved-by-transfer]
State the relative change. equal against a literal repeats a fixture value and stops
holding the moment the fixture changes; delta and unchanged hold for every fixture,
which is what a requirement is supposed to do.
For a failure behaviour, postconditions must be unchanged clauses and forbid_events
must name the event the operation would have emitted. A contract that emits its success
event before validating its arguments is invisible to a state assertion alone.
Step 6 — invariants, scoped¶
- id: balances-conserved-by-transfer
title: Transfers conserve the total of all balances
kind: conservation # conservation | state_unchanged | authorization_blocks_mutation | monotonic | bounds | predicate
severity: error # error | warning
summary: …
description: …
scope:
methods: [transfer, transfer-from] # or [*] for every method
outcomes: [success]
resource: balances # required for conservation/monotonic/bounds, forbidden otherwise
rationale: … # required: why it holds, and what breaks if it does not
Scope is mandatory because "balances are conserved" is true of a transfer and false of a mint. An unscoped invariant is wrong for at least one operation in the profile.
The kind determines what else is required, and the schema enforces it in both
directions: a bounds invariant needs a predicate and a resource; a state_unchanged
invariant may have neither.
Step 7 — failures, semantically¶
- id: transfer-beyond-balance
category: insufficient_balance
methods: [transfer]
trigger: The amount exceeds the sender's balance.
expected:
outcome: failure
signal: either # trap | panic | host_error | either
state_effect: unchanged # reverted | unchanged | unspecified
events_emitted: none # none | unspecified
error_codes:
policy: semantic_only # semantic_only | tolerated | exact_required
allowed: []
rationale: …
Use semantic_only unless the upstream specification actually fixes codes. Requiring an
exact payload that the standard does not define marks conformant contracts as
non-conformant, which is a false negative and worse than a missing requirement.
state_effect: unchanged is the field that catches clamping and partial application, and
events_emitted: none is the field that catches events emitted before validation.
Step 8 — vectors¶
At least one negative vector is mandatory. Represent every other dimension in which the
profile has something to check. Put a case in this bundle when it depends on this profile's
decisions; put it in the shared library when every profile of a family needs it, and in
vectors/common/ when it holds for any fungible token.
Name the file after the id it declares, write the rationale, and make the failure
vector assert unchanged state and the absence of the event.
Step 9 — validate, then propose¶
npm run validate # schemas, cross-references, corpus, release metadata
npm run docs:generate # refresh docs/generated/
npm test # the suite, including the layout and corpus contracts
Then open a pull request. If the profile is new, open the profile proposal issue first — it asks for the information a reviewer needs, which is cheaper than a review round trip.
Common mistakes¶
| Symptom the validator prints | What it means |
|---|---|
Cross-reference checks were skipped because at least one document failed schema validation |
Fix the schema errors first; every reference error below them is derived noise. |
Required method "x" is not governed by any authorization rule |
Add a rule, including a none actor one for a public read. |
Vector file must be named … to match its declared id |
Rename the file. The name is what a reviewer navigates by. |
Profile has no "negative" vector |
Add one. Without it the profile cannot establish conformance. |
Vector supplies N data entries for event X, which accepts between … |
The event's payload is declared with a different arity than the vector expects. |
Profile declares version "0.1" but its directory is named … |
A released profile's directory name is its version. An example profile has no version directory, which is why it lives under profiles/examples/. |