The profile format¶
A profile is the document that defines conformance. This file describes the format
the runner consumes; the normative definitions of the fields are the JSON Schemas in
estamora-conformance-spec/schema/,
and the authoring guide is that repository's docs/profile-authoring.md. Where
this file and a schema disagree, the schema is right.
A bundle, not a file¶
A profile is a directory:
profiles/<id>/<version>/
├── profile.yaml identity, status, provenance, and the manifest
├── methods.yaml the interface
├── authorization.yaml who must authorize what
├── events.yaml which events must, may or must not be emitted
├── behavior.yaml preconditions and postconditions
├── invariants.yaml properties that must survive an operation
├── failures.yaml the ways an operation is allowed to fail
└── vectors/ the corpus, one directory per operation
The reason it is a directory is that its parts have different audiences. A behaviour rule and a failure rule about the same method are read by different people, and a reviewer asked to read one 1,500-line document reviews none of it. It also makes the diff of a requirement change name the requirement's kind — the difference between a review that catches a semantic change and one that does not.
Every document is required, including the empty ones. A bundle must contain a
failures.yaml, and a profile that declares no failure mode writes failures: [].
The alternative makes this profile states that nothing may fail and somebody
forgot to declare the failures the same document to a reader and to the loader, and
the second is the failure mode worth refusing: a requirement that went missing by
accident is indistinguishable, in a report, from one that was never made.
Identity and the manifest¶
estamora_spec_version: "1.0"
profile:
id: sep-41
version: "1.0"
title: Soroban Token Interface
status: stable
...
includes:
methods: methods.yaml
authorization: authorization.yaml
events: events.yaml
behavior: behavior.yaml
invariants: invariants.yaml
failures: failures.yaml
vectors:
- transfer
- approve
shared_vectors:
- common
estamora_spec_version is the version of the format, and it is the first thing
the loader reads. It is independent of the profile version and of any runner
version, and it is bumped only when the document grammar changes in a way a consumer
must know about. A bundle declaring a format this runner does not implement is
refused by name, because reading it with the wrong rules would be worse than not
reading it.
status is one of four lifecycle stages, and it is a claim the profile has to be
able to defend:
| Status | Means |
|---|---|
draft |
May change without notice. Not something to measure a deployed contract against. |
experimental |
Executable and unreviewed. The case for a profile under development. |
stable |
A behavioural change requires a version bump. |
deprecated |
Must name a successor in superseded_by. |
compatibility.interface is full or partial, and compatibility.notes is
required to be non-empty. provenance records the class of authority the
requirements came from, the exact revision they were read from, and — required to
be non-empty — the ambiguities the profile resolved and how. Estamora forbids
inventing requirements, so a departure from the literal upstream text has to be
recorded rather than absorbed. A bundle whose requirements have no stated origin
cannot be reviewed.
shared_vectors names vector sets under the repository-level vectors/
directory, which is how a requirement stated once — a transfer beyond the balance
fails, an unknown account's balance is zero — is consumed by every profile that
needs it. A profile that had to restate them would drift from the others, and the
drift would be invisible.
Methods¶
methods:
- id: transfer
name: transfer
requirement: required
summary: Move an amount from one account to another.
description: >-
...
args:
- name: from
type: { kind: prim, name: address }
semantics: The account the tokens are drawn from.
authorization:
required: true
semantics: The holder must sign for the amount.
returns:
type: { kind: prim, name: void }
semantics: Nothing; the effect is the balance change.
mutability: mutates
invocation: read_write
authorization: [transfer-requires-holder-authorization]
events: [transfer]
failures: [insufficient-balance, unauthorized-caller]
behaviors: [transfer-moves-exact-amount]
A method declares its own signature, and then references the rules that concern it. Every one of those references is resolved by the loader, which fails on a name that does not exist.
That is not a stylistic choice. A typo in one of those lists would otherwise produce
a requirement that is declared, appears in estamora profile output, and is never
evaluated against anything — a check that silently does not happen. The runner
refuses to execute a bundle with a broken reference rather than executing it
partially, and estamora validate reports all of them at once rather than the first.
requirement is required, optional or forbidden. A method marked optional
is measured and reported; a vector that fails against it is recorded and, by itself,
never decides a verdict. This is how a profile says a conforming implementation may
not have this.
Authorization¶
Each rule states an actor, the coverage the actor's signature must have, and what happens without it:
authorization_rules:
- id: transfer-requires-holder-authorization
methods: [transfer, transfer_from]
actor:
kind: argument
name: from
coverage:
mode: exact
arguments: [from, amount]
unauthorized:
kind: fail
failure: unauthorized-caller
wrong_actor:
kind: fail
failure: unauthorized-caller
replay_sensitive: false
There are three actors: argument (a principal named by an argument value),
none (no principal may be asked to sign, which is what a read-only surface
declares), and the caller. coverage.mode is exact or at_most. exact names
what the signature must cover and is rejected by the schema when the set is empty;
at_most with an empty set is the encoding of "must not demand authorization over
any argument".
unauthorized and wrong_actor are separate fields because they are separate
requirements. A contract that accepts an unsigned call and a contract that accepts a
signature from the wrong account are wrong in different ways, and a profile that
could not tell them apart would report one of them as conformant.
Events¶
events:
- id: transfer
name: transfer
requirement: required
occurrence: on_success
topics:
- index: 0
type: { kind: prim, name: symbol }
binding: { kind: literal, value: transfer }
semantics: The event discriminator.
data:
format: either
fields:
- name: amount
type: { kind: prim, name: i128 }
binding: { kind: input, name: amount }
semantics: The quantity moved.
optional: false
cardinality: { min: 1, max: 1 }
ordering: []
correlations: [balances-conserved-by-transfer]
requirement here is what makes the event document worth writing even for a
read-only surface: forbidden declares an event the profile recognises and requires
to be absent, which is what lets a vector name it in expected.events.forbidden and
have the prohibition checked. An empty events: would make that vector a corpus
defect — a reference to an event the profile never described.
binding is the mechanism that keeps a requirement from being a hard-coded
literal. An event field can be bound to an operation input, to a fixture actor, to a
state value read after the call, to another read, or left unconstrained when only
its presence and shape matter. A profile that bound the amount to 250 would pass a
contract that emitted a stale constant.
format: either exists because SEP-41 explicitly permits both a scalar/vec form and
a map form for several events. Forcing one reading would fail conforming contracts,
which is a worse outcome than accepting both.
Matching is on the declared name and nothing else, so a requirement is not a test
of one implementation language.
Behaviour, state, invariants and failures¶
Behaviour states postconditions as expressions over the before and after worlds:
behaviors:
- id: transfer-moves-exact-amount
method: transfer
kind: success
preconditions:
- kind: greater_or_equal
left: { kind: input, name: amount }
right: { kind: literal, value: "0" }
postconditions:
- kind: decrease
left: { kind: state_before, resource: balance, target: from }
right: { kind: input, name: amount }
Because the left side names the world before the call and the amount names the operation's own input, the rule holds for every starting balance rather than for the amounts a vector happens to use. A rule is also scoped by the outcome the vector expects: a rule that describes the success path is reported as inapplicable — with the reason — on a vector that expects a refusal, rather than being applied and failing a conforming contract.
Invariants are declared once with a scope (the methods and outcomes they range over) and referenced by id. Outside their scope they are reported as inapplicable, because an invariant evaluated over an empty set has been skipped, not verified.
Failures name a semantic category rather than an implementation's error string,
so a profile is not brittle against an implementation that numbers its errors
differently. Where a standard fixes a category name, the profile uses it; where it
does not, the profile states the category and documents the interpretation in
provenance.interpretation_notes.
Vectors¶
A vector is a concrete situation and the outcome it expects:
id: transfer-basic-001
profile: sep-41
profile_version: "1.0"
kind: positive
method: transfer
tags: [transfer, positive]
fixtures:
actors:
- { name: alice, kind: account }
balances: { alice: "1000", bob: "500" }
total_supply: "1500"
ledger:
sequence: 1000
timestamp: "2026-01-15T12:00:00Z"
inputs:
from: { kind: actor, ref: alice }
to: { kind: actor, ref: bob }
amount: "250"
authorization:
actors: [alice]
expected: accepted
expected:
outcome: success
state_assertions:
- resource: { kind: balance, account: alice }
predicate:
kind: equal
left: { kind: read, method: balance, args: [{ kind: actor, ref: alice }] }
right: { kind: literal, value: "750" }
events:
required:
- event: transfer
inputs are parsed according to the type the profile declares, not guessed from
the document: a vector writes an amount as "250" and the profile says the argument
is an i128, so the runner parses it as one. A vector cannot widen an argument's
type by writing a value of another shape.
Fixture actors are the only addresses a vector may name. There is no way to write a literal address into an expression, which is what stops a document from making the address parser perform work on its behalf.
Negative vectors are mandatory in practice. A corpus of successes establishes that a
contract can do the right thing, not that it refuses to do the wrong one, and the
whole reason Estamora separates expected failure from unexpected success is that
the second is a conformance failure while the first is the point.
Checking a profile before you propose it¶
must pass, and then the same bundle has to pass the specification repository's own
validation, because that repository is where profiles are normative and this runner
is not a second authority over them. CONTRIBUTING.md describes the proposal
process.
What the runner refuses to do with a bundle¶
| Situation | Result |
|---|---|
| A format version this runner does not implement | PROFILE_ERROR, naming the version. |
| A manifest naming a document that is absent | PROFILE_ERROR, naming the file. |
| A requirement referring to something undeclared | An error, not a warning. |
| A vector with no expected outcome, or referring to an undeclared event | VECTOR_ERROR. |
| A malformed document, or one that does not parse | PROFILE_ERROR; nothing is described from a document that was partly understood. |
Never a verdict. A runner that tolerates an unreadable profile evaluates a contract against requirements it never read, and then reports a result — which is the exact failure mode Estamora exists to prevent.