Specificaton
About
This specification provides a complete description of all elements of the AOP Definition Language (ADL). However, the focus is on formally correct representation, not on didactic explanation.
In this respect, the specification should be viewed primarily as a reference for the exact behavior of the language.
For an introduction and background information, please refer to the Overview section.
File Format
- File extension:
.adl - Encoding: UTF-8
- One skill per file (enforced); a skill may span any number of files (see 4.3)
- Skill declaration must be the first non-comment statement
- A new compatibility line (major bump; minor bump of a 0.x skill) → new file (convention, see 4.3)
- Members inside braced bodies are whitespace-separated; an optional comma may additionally separate them (enabling compact one-line declarations such as
object Tag { id guid, name string })
Identifiers
The IDL uses two identifier forms throughout. These are structural building blocks referenced by all other sections.
Ident (ident)
A single identifier: a letter followed by letters, digits, or underscores.
ident = [A-Za-z][A-Za-z0-9_]*
Used for: type names, enum option names, property names, parameter names, service names, tag names, union variant names, import type references, import aliases.
Note: The grammar rule ident defines what is valid as an identifier in IDL source code. This is distinct from the AOP built-in types symbol and symbolpath (section 6.1), which describe valid wire values and have a broader value space (e.g., symbol permits the @ prefix as part of the name).
Ident Path (identpath)
A dot-separated sequence of idents, representing a hierarchical qualified name.
identpath = ident ("." ident)*
An identpath with no dots is a valid ident — every ident is also a valid identpath.
Used for: skill names, function and notification names, error identifiers, import skill references.
Keyword Escaping
Backticks force identifier interpretation of a keyword spelling. They are legal in every identifier position, but only required in the strict positions listed in section 2.4 — everywhere else keywords are ordinary identifiers:
object SensorReading {
type string # "type" is a keyword, valid bare as a property name
`error` string # escaping remains legal — equivalent to bare "error"
float float # name matches a built-in type — still fine, position disambiguates
normal int
}
Backticks are stripped by the parser — the wire name is the unescaped identifier (e.g., type, not `type`). Bare and backticked spellings of the same name are the same identifier.
Context-Sensitive Keywords
The following words are keywords of the language:
skill, use, as, type, enum, object, union, tag, error, service, func, notify, concept, is, within, property, command, state, event, since, until, optional, view, of, bool, int, float, string, guid, time, data, symbol, symbolpath, strid, value, select, void, true, false
Keywords are context-sensitive: they have special meaning only where the grammar expects them. In name positions a bare keyword is an ordinary identifier — property, parameter, enum option, union variant and shared-member names, union tag names, service names, error names, annotation names, constraint keys, skill names, and every segment of an identpath (including the first) may use keyword spellings without escaping. service event, func event.Get(type: symbolpath), and concept event.reset are all valid.
The exception is any position that introduces a name into the type-reference namespace. In these strict positions a bare keyword is an error (IDL1012) and backtick escaping is required:
- Declaration names after
type,enum,object,union, andview(including theirsince-versioned block forms; views have none), and the generated-enum name afterasin a union tag declaration (section 7.4.3). - The parent reference of an object (after
:). - The target of a
viewdeclaration (afterof). - The local name a
usedeclaration introduces: theasalias if present, otherwise — when the import resolves to a type — the final segment of the import path. Unaliased error imports introduce error names, which are identpath positions; bare keywords stay legal there.
This keeps type references unambiguous: a type-reference position (section 6) never contains a bare keyword, so built-in type names and structural keywords cannot be shadowed silently. A keyword-named type declared with backticks (e.g. object `state` {}) must also be referenced with backticks.
Three member positions are disambiguated by a single token of lookahead:
since(anduntil) at a member or parameter start is a lifecycle prefix (section 12) exactly when followed directly by a string literal — version literals are always quoted. Otherwise it is a name: insince string,sinceis a property named “since” of typestring.tagin a union body declares the discriminant only as the first member, where section 7.4.1 requires it. At any later member start,tagis an ordinary name. Corner case: a union version block (sections 7.4.5, 12.1) contains no tag declaration, so a first member namedtagthere must be escaped or reordered.asafter a union tag’s type names the generated enum (section 7.4.3) exactly when it starts on the same line as the tag type (the token before it). On a following line,asis an ordinary shared-property name: intag Kind stringfollowed byas string,asis a property named “as” of typestring. (The canonical formatter writes a tag declaration on one line, so in formatted source “the tag type’s line” and “the tag’s line” coincide.)
Known-key names inside a map’s known-keys block (section 6.3.1) are wire values of the key type, not identifiers; keywords such as state were always permitted there.
Identifier Usage Summary
| Context | Form | Bare keywords? | Examples |
|---|---|---|---|
Type names (type, enum, object, union, view) |
ident |
no — strict (§2.4) | Person, ZipCode |
Object parent references (after :) |
ident |
no — strict (§2.4) | Person |
View target references (after of) |
ident |
no — strict (§2.4) | Datapoint |
| Enum option names | ident |
yes | low, high, object |
| Property names | ident |
yes | name, age, type |
| Parameter names | ident |
yes | id, query, data |
| Service names | ident |
yes | PersonService, event |
Tag names (in union) |
ident |
yes | ActionType |
| Union variant names | ident |
yes | BackendOperation |
| Import type references | ident |
no when unaliased — strict (§2.4) | PhoneNumber |
Import aliases (as) |
ident |
no — strict (§2.4) | VideoDatapoint |
Union tag enum names (as in tag) |
ident |
no — strict (§2.4) | LayoutItemType |
| Skill names | identpath |
yes | wg.core, event.stream |
| Function / notification names | identpath |
yes | GetPerson, event.Get |
| Error identifiers | identpath |
yes | Data.NotFound, Call.Param.Missing |
| Import skill references | identpath |
yes | wg.core, CommonSkill |
Case Sensitivity
Declared names become case-insensitive symbols on the wire, so declaration uniqueness is case-insensitive: two declarations in the same scope whose names differ only in case are a build error. This applies wherever a duplicate-name rule holds — top-level types, errors, and concepts; function and notification wire names (section 4.1); object, error, union, and concept members; enum option names; union variants; parameters.
object CaseTestObject {
foobar int
fooBar int # build error: duplicate property (differs only in case)
}
service a {
func f() -> void
func F() -> void # build error: wire names "a.f" and "a.F" are the same function
}
References resolve by exact spelling. The declared spelling is canonical: a reference to type Foo must be written Foo, not foo. Case-insensitivity governs uniqueness, never lookup.
Enum option values and union wire values are values of the base/tag type, not identifiers; their duplicate rule follows that type’s case semantics (sections 7.2 and 7.4.2).
Comments
Regular Comments
Regular comments are ignored by all tooling (parser, code generator, doc generator). They use # prefix:
# This is a regular comment
# The parser and doc generator ignore these
Doc Comments
Doc comments are extracted by the documentation generator and attached to the immediately following definition. They use /// prefix:
/// This is a doc comment.
/// It can span multiple lines.
/// @param id The unique identifier
/// @return The person object, or null if not found
Supported Doc Tags
| Tag | Context | Description |
|---|---|---|
@param <n> |
Functions, notifications | Documents a parameter |
@return |
Functions | Documents the return value |
@error <id> |
Functions | Documents when/why a specific error is returned |
@name <name> |
Concepts | Provides a human-friendly name |
@see <identifier> |
Any | Cross-reference to another definition |
@example <text> |
Any | Usage example |
@deprecated <message> |
Any | Marks as deprecated with reason |
@since <version> |
Any | Documents introduction version (informational, no structural effect) |
Tooling cross-checks the structured tags against the documented signature and warns on mismatch (documentation never breaks a build): a @param must name a declared parameter, and an @error must name something the function can actually return — an entry of its | list (as written, aliases included), a skill @global error, or a platform error (section 9.4). @error on a notification always warns (notifications have no error contract).
Multi-line Tag Content
Doc tag content can span multiple /// lines. A continuation line is any /// line that does not start a new @tag. All continuation lines are appended to the preceding tag’s content.
/// Retrieves a person by ID.
/// @param id The unique identifier of the person.
/// Must be a valid GUID that was returned by CreatePerson.
/// @error Data.NotFound The person does not exist.
/// This can happen if the person was deleted between
/// the time the ID was obtained and this call.
/// @example GetPerson("550e8400-e29b-41d4-a716-446655440000")
/// Returns: { "id": "550e...", "name": "Jane Doe" }
/// @return The person object, or null if not found
In this example, @param id spans two lines, @error Data.NotFound spans three lines, and @example spans two lines. The leading whitespace on continuation lines is preserved as-is (the doc generator may trim or reformat).
This is a doc generator concern, not a parser concern — the parser captures the raw text content of each /// line. The doc generator is responsible for grouping lines into tags and their continuations.
The canonical formatter reorders @param blocks — each tag line plus its continuations — to match the declaration order of the parameters; all other doc lines keep their positions.
func GetPerson(
/// The unique identifier of the person
id: guid,
) -> Person?
When both @param on the function doc and an inline /// on the parameter exist, @param takes precedence.
Skills and Namespaces
Skills as Namespace Boundaries
Skills are the primary namespace and versioning boundary in AOP. All types, errors, functions, and notifications defined within a skill belong to that skill’s namespace.
Key implications:
- Type namespacing: A type’s fully qualified name is
SkillName.TypeName. This is howuseimports reference types. - Function uniqueness: Function names must be unique within a skill, not within a service — case-insensitively (section 2.6):
a.fanda.Fare the same wire name. Functions and notifications share this namespace. Services are organizational grouping only. - Services have no protocol-level significance: AOP sees flat function names. A function
PersonService.GetPersonin skillMySkillis just a function namedPersonService.GetPerson— the service prefix is part of the function name, not a separate namespace level. - Non-breaking reorganization: Splitting a service into two (or merging two into one) is a non-breaking change, since the wire-level function names remain the same.
Protocol negotiation operates at the skill level — clients negotiate skill support, not service support.
Codegen and Import Namespacing
At the model level, imported types are copied in — every imported (or transitively referenced) foreign type is cloned into the importing skill’s bundle, versioned relative to the importing skill. Within the compiled model each skill therefore remains a self-contained bundle.
Generated code deduplicates those copies: each imported declaration is emitted once per (origin skill, compatibility line), by the owning line, at that line’s effective version — importers reference the owner’s emitted artifact instead of receiving their own copy. If skill C imports A.B "1.2.0" and skill D imports A.B "1.3.0", code output contains a single B (the 1.x line’s current definition, which satisfies both constraints). Different lines stay distinct: a skill E importing A.B "2.0.0" makes the output contain the 2.x B as well.
When several compatibility lines of one skill are emitted together, the newest line keeps plain names and every older (superseded) line’s artifacts carry a line discriminator — a V<line> name suffix in the code generators, where <line> is the line key with . written as _ (e.g. BV1 next to B; BV0_4 for the 0.4.x line of a 0.x skill), applied consistently to all of that line’s artifacts (types, enums, objects, unions and their variants, service interfaces, notification parameter classes); references follow the suffixed names. Documentation generators render per-skill views instead and may repeat imported types under each consuming skill; superseded lines are emitted under a <skill>-v<line> path there (-v1, -v0.4).
Skill Declaration
Every IDL file declares exactly one skill. All definitions in the file belong to this skill.
skill <identpath> "<version>"
Examples:
skill PersonSkill "1.0.0"
skill wg.core "0.0.13"
Rules:
- Exactly one
skilldeclaration per file (required, enforced). - Must be the first non-comment statement in the file.
<version>is a SemVer 2.0 version core: exactlyMAJOR.MINOR.PATCH, three non-negative integers without leading zeros. The patch is mandatory ("1.0"is an error). Pre-release and build-metadata suffixes ("1.0.0-beta","1.0.0+build") are not allowed: version points name wire-contract states, and their ordering is the plain numeric one (ruling 2026-09-04).- All definitions in the file belong to this skill.
- Convention: a new compatibility line → create a new file.
Compatibility lines. Versions of one skill that are compatible with each other form a compatibility line (short: line). Following SemVer, the line is the major version for 1.0.0 and above (1.0.0 … 1.9.9 are one line, 2.0.0 starts another). For initial-development versions SemVer makes no compatibility promise (SemVer §4), so ADL adopts the established convention that the minor is the breaking unit there: the line of a 0.y.z version is 0.y (0.4.0 … 0.4.9 are one line, 0.5.0 starts another; ruling 2026-09-04). Everything this specification says about lines — grouping files (4.3.1), the bounds of since/until (12.4), import constraints (5.2), and code-generation deduplication (4.2) — uses this definition. A line is written 1.x or 0.4.x in diagnostics and keyed 1 or 0.4 in generated artifacts.
Skills Across Multiple Files
A skill may be split across any number of files — how definitions are
distributed is up to the author (e.g. one type per file). The header scopes
everything in the file to the declared skill and version: the header
version is the default since for all definitions in the file, individually
overridable with since/until. Adding a type in a later version is
therefore just a new file with a newer header version — no since wrapper
needed.
Files group by (skill name, compatibility line) into skill lines:
- Files of the same line merge into one namespace: cross-file
references need no
usedeclaration,sinceblocks may extend definitions living in sibling files, and duplicate names across files are build errors.since/untilversions in a file must be within the file’s line and not lower than the file’s header version. - Files of different lines form independent skill lines — incompatible
models that coexist in one compilation (that is the point of semver:
skill a 1.x and skill a 2.x, or skill b 0.4.x and skill b 0.5.x).
useversion constraints select the line an import binds to (section 5.2). - A line’s base version is its lowest file header; its effective version is the highest version point any of its files mention.
use imports and their aliases remain file-local (section 5.3). Tooling
always compiles the complete set of input files as one model; generation may
emit selectively — a specific version view of a skill, or one output per
line.
Imports (use)
The use statement brings types and errors from other skills into the current skill’s scope. Imports are resolved at build time — the output for each skill is a self-contained bundle.
use <identpath> "<version>" [as <ident>]
The import path is a single identpath followed by the required version constraint. The skill/member split of the path is resolved eagerly at build time: the longest leading prefix that names a skill declared in the compilation wins, with no backtracking; the remainder names the imported member — a type (always a single segment) or an error (an identpath, dots included). A path whose every segment matches a skill name (no remainder) is a build error, even when a shorter split would have resolved. All imports are explicit — wildcard imports are not supported, ensuring that every file clearly declares which types it depends on. Only type and error declarations can be imported (section 5.6).
Basic Imports
use AddressSkill.Address "1.0.0" # Single type, >=1.0.0 <2.0.0
use wg.core.PhoneNumber "1.0.0" # Single type from dotted skill name
use AlarmSkill.Busy "1.0.0" # An error
use AlarmSkill.Data.NotFound "1.0.0" # A dotted error name (skill AlarmSkill, error Data.NotFound)
An import without a version string is an error. An unaliased import introduces the full remainder as its local name — for types the final segment, for errors the whole (possibly dotted) error identpath. When the import resolves to a type, that unaliased local name enters the type-reference namespace and is a strict position (section 2.4): a bare keyword there is an error. Error names are identpath positions, so bare keywords remain legal in unaliased error imports. Skill-reference segments may use keyword spellings freely — use state.core.Snapshot "1.0.0" is valid.
Because errors and types live in separate namespaces (section 5.4), a single-segment remainder matching both a type and an error in the source skill is ambiguous — a build error with a dedicated diagnostic; rename one of them in the source skill.
Version Constraint Semantics
- The version means “at least this version, within the same compatibility line” (section 4.3).
"1.0.0"→>=1.0.0, <2.0.0"1.4.0"→>=1.4.0, <2.0.0"0.4.9"→>=0.4.9, <0.5.0(a 0.x line is one minor)- When the compilation contains several lines of the imported skill (section 4.3.1), the constraint’s line selects the skill line.
- The constraint is checked against the selected line’s effective version (its highest version point), not its base version.
- An unsatisfiable constraint is a build error (ruling 2026-07-12; this reverses an earlier warning-level ruling): binding a different line would silently swap the wire contract, and binding a line whose effective version is below the requested minimum would promise members that do not exist. Both the requested-line-absent case and the minimum-unmet case reject the compilation.
Aliased Imports
When an imported type name collides with an existing type in the current scope, use as to alias the import:
use VideoSkill.Datapoint "1.0.0" as VideoDatapoint
use AlarmSkill.Datapoint "1.0.0" as AlarmDatapoint
Rules:
- The alias is file-local — the wire format and other files are unaffected.
- The alias can be used anywhere the original type name would be used within the file.
- The alias is the local name the import introduces, so it is the strict position (section 2.4) — a bare keyword alias is an error (for error imports too, by uniformity). With an alias present, the final path segment is just a reference and may be a bare keyword:
use state.core.until "1.0.0" as UntilRefimports a (backtick-declared) type nameduntil. - Error imports alias the same way:
use AlarmSkill.Data.NotFound "1.0.0" as AlarmMissingmakes the error referenceable asAlarmMissingin this file’s error lists. The wire identity stays the source error’s name.
Collision Handling
If two types with the same name — compared case-insensitively (section 2.6) — end up in scope, it is a build error. This can happen when two explicit imports bring in the same type name from different skills. Types and errors occupy separate namespaces: a type import and an error import may share a local name without colliding; error imports collide only with the skill’s own errors and with each other.
Resolution: use as aliases to disambiguate.
# BUILD ERROR: both imports bring in "Datapoint"
use VideoSkill.Datapoint "1.0.0"
use AlarmSkill.Datapoint "1.0.0"
# RESOLVED: aliases
use VideoSkill.Datapoint "1.0.0" as VideoDatapoint
use AlarmSkill.Datapoint "1.0.0" as AlarmDatapoint
Resolution Rules
- Explicit
use: Named types and errors are pulled into the current skill’s bundle. - Transitive auto-resolution: If an imported type or error references other types, those are automatically pulled in. Explicit
useis not required for transitives.
There is no implicit resolution beyond transitives and the designated platform skills (section 9.4): every other direct cross-skill reference — in properties and in func/notify signatures alike, types and errors both — requires an explicit use. Platform skills are exempt because they are explicitly named per compilation, never discovered — resolving against them does not make a file’s meaning depend on which unrelated skills share the compilation. (An earlier draft auto-resolved signature type references against any skill in the compilation; that rule was removed in 0.14 for exactly that reason.)
Imported errors are cloned into the consumer’s bundle like imported types: the clone records its origin, its lifecycle resets to the consumer’s base version, and — because @global is skill-scoped (section 9.3) — the clone is never implicitly global in the consumer; list it explicitly in the | lists that can return it. Hierarchical prefix matching (section 9) applies to the error’s wire name, which the clone keeps.
Importable Declarations
The type namespace and the error namespace are importable: type aliases, enums (including the enums generated from union tags, section 7.4.3), objects, unions, and errors. Services, functions, notifications, and concepts cannot be imported — naming one in a use statement is a build error with a dedicated diagnostic. There is nothing to import for those kinds: functions and notifications are invoked by wire name, not referenced as types, and the types their signatures use are imported like any other (section 5.5).
Views (section 7.5) live in the type namespace but are not importable either: a view is a client-local projection, written by and for the skill that declares it. Naming a view in a use statement is a build error.
Built-in Types
The following types are built into the language and must not be redeclared.
Scalar Types
| Type | Description |
|---|---|
bool |
Boolean value |
int |
64-bit signed integer |
float |
64-bit IEEE 754 double-precision float |
string |
UTF-8 string |
guid |
128-bit identifier (RFC 4122 / Windows GUID format) |
time |
ISO 8601 UTC timestamp (YYYY-MM-DDThh:mm:ss.sssZ) |
data |
Binary data (base64-encoded in transport) |
symbol |
Identifier matching @?[A-Za-z][A-Za-z0-9_]*, case-insensitive. The @ prefix is part of the allowed name space for AOP symbol values; it is not related to IDL annotation syntax. |
symbolpath |
Dot-separated sequence of symbols: symbol("."symbol)*, case-insensitive |
strid |
String identifier: dotted, case-insensitive identifier matching [A-Za-z0-9_\-\/:\#]+(\.[A-Za-z0-9_\-\/:\#]+)* |
Special Types
| Type | Description |
|---|---|
value |
Generic AOP value (any type) |
select |
Property-selection parameter (valid only as a parameter type) |
void |
No return value (valid only as function return type) |
void means “no result”: it is valid only as a bare function return type and takes no container or nullable suffix (void[], void?, and void{} are errors). The grammars express this with a dedicated return_type production.
select declares the AOP property-selection parameter: a list of property names the caller wants included in the function’s result objects, letting the server omit everything else. On the wire the value is a string array; the entries are symbol-shaped property names (never dotted paths), and AOP additionally reserves the special values [all] and [default]. select is valid only as a bare parameter type in func and notify parameter lists and takes no container or nullable suffix (its wire form is already an array); the grammars express this with a dedicated param_type production. It composes with the optional modifier — select: optional select is the conventional spelling. A function has at most one select-typed parameter. Views (section 7.5) build on this type: generated clients can fill a select parameter automatically from a declared view.
Container Suffixes
Container suffixes can be applied to any type (built-in or user-defined, excluding void and select):
| Suffix | Description | Example |
|---|---|---|
[] |
Array (sequential, 0-based) | string[], Person[] |
[#] |
Indexed array (sparse, int-keyed) | Person[#] |
{} |
Map (string-keyed, default) | value{}, Person{} |
{KeyType} |
Map with typed key | Person{symbol}, int{strid} |
Map key types:
Bare T{} is shorthand for T{string}. The key type can be specified explicitly inside the braces. The key type must resolve to a string-based type:
- Built-in string-based types:
string,symbol,symbolpath,strid - User-defined types whose base value type is string-based (e.g., a
type ShortName : string { maxLen = 8 }or atype Sym : symbol)
Examples:
metadata value{} # string-keyed (default)
lookup Person{symbol} # symbol-keyed map
config int{strid} # strid-keyed map
aliases Person{ShortName} # custom string type as key
Known Keys
Maps can declare a set of known keys with per-key types. This is useful when a map has a known set of commonly used keys with specific types, but remains open for additional unknown keys typed by the map’s value type.
<ident> <valueType>{<keyType>} {
<key> <type>
...
}
The block after the map type declaration lists known keys and their specific types. The map’s value type serves as the default type for any key not listed.
Known key name syntax: Key names are written as bare identpaths where the key type permits, or as string literals for values containing characters outside the identpath charset. Tooling validates that each key name is a valid value of the map’s declared key type:
symbolkeys: must be a valid symbol (single ident, no dots).@-prefixed values require string literals (e.g.,"@system").symbolpathkeys: bare identpaths (dots allowed) or string literals.stridkeys: bare identpaths (subset) or string literals for values with special characters (-,/,:,#).stringkeys: anything goes, but non-ident characters require string literals.- User-defined key types: validated against the base type’s constraints (regex, length, etc.).
Known key type constraint: Each known key’s type must be assignable to the map’s value type. If the map value type is value, any type is valid. If the map value type is string, all known keys must be typed with a string-based type — string, symbol, symbolpath, strid, or an alias resolving to one of them (they are strings on the wire). Violations are build errors.
Examples:
object Datapoint {
/// Datapoint properties with known keys
props value{symbolpath} {
state int
statetext string
statevalue float
}
}
object DeviceConfig {
/// Symbol-keyed flags
flags value{symbol} {
active bool
visible bool
}
}
object LocalizedLabels {
/// String-keyed labels for locales
labels string{string} {
"en-US" string
"de-DE" string
}
}
object SystemConfig {
/// Strid-keyed configuration entries
config value{strid} {
"app/settings:theme" string
"log:level" int
}
}
Known keys are always open — additional keys beyond those listed are permitted and typed according to the map’s value type. If a fixed, closed set of named fields is needed, use an object instead.
Known keys may also be declared on a named map type (section 7.1), giving the keyed map shape a reusable name.
Nullability
The ? suffix marks a type’s value as nullable — null is a valid value wherever the type appears:
email string? # the value may be null
func GetPerson(id: guid) -> Person? # may return null
? can be combined with container suffixes: string[]? (nullable array), string?[] (array of nullable strings).
Value nullability (?) is distinct from parameter omittability: the optional modifier (section 10.3), valid only on function and notification parameters, marks an argument the caller may leave out entirely. The two compose — cursor: optional string? declares a parameter that may be omitted and whose value, when present, may be null.
Type Definitions
Basic Types (type)
A type declaration names one of two shapes: a scalar alias with optional constraints, or a named map type with optional known keys. The base’s shape decides which body the braces hold: constraint assignments pair with scalar bases, known-key entries with map bases.
Scalar alias — aliases a scalar type with optional constraints:
type <ident> : <valueType> {
<constraint> = <value>
...
}
Example:
/// 5-digit US zip code
type ZipCode : string {
minLen = 5
maxLen = 5
regex = "^[0-9]{5}$"
}
If no constraints are needed, the braces can be omitted:
type Identifier : string
Named map type — names a map type, optionally declaring known keys with the same syntax and semantics as a property’s known-keys block (section 6.3.1: doc comments on keys, string-literal keys for values outside the identpath charset, always open, key names validated against the key type, key types assignable to the value type):
type MyCollection : value{string} {
primary string
secondary string
}
type Tags : string{} # plain named map type, no known keys
Rules for named map types:
- The base must carry a map suffix; arrays cannot be named, and the base itself cannot be nullable (
type X : string{}?is a build error — mark the usage sites nullable instead). - The map’s value type may be anything a property’s map value type may be, including
value, user-defined types, and nullable elements (int?{}). - A reference to a named map type behaves as that map type at its use sites and may itself take container and nullable suffixes (
Tags?,Tags[]). - A named map type is not a valid map key type, enum base, or union tag, and a property referencing one cannot attach another known-keys block — the keys live on the
typedeclaration. - The value type must not reach the declaring type again through named map types (
type A : A{}, or mutually recursive named maps) — a build error. Use anobjectfor recursive shapes. - Like scalar aliases, named map types are importable (section 5.6); their known keys travel with the import.
- Known keys are not individually versionable, and
typedeclarations remain non-versionable insinceblocks.
Available Constraints
| Constraint | Applies to | Description |
|---|---|---|
minLen |
string, symbol, symbolpath, strid |
Minimum string length |
maxLen |
string, symbol, symbolpath, strid |
Maximum string length |
regex |
string, symbol, symbolpath, strid |
Regular expression pattern |
caseInsensitive |
string |
Values are compared case-insensitively (boolean: true/false) |
from |
int, float |
Minimum value (inclusive) |
to |
int, float |
Maximum value (inclusive) |
Note: caseInsensitive is only valid on string-based types. symbol, symbolpath, and strid are already case-insensitive by definition; specifying caseInsensitive on them is a build error. Constraints apply to scalar aliases only — on a named map type the body declares known keys, and constraint assignments are a build error.
Enum Types (enum)
An enum defines a fixed set of named values:
enum <ident> : <baseType> {
<ident> = <value>
...
}
Base type: The base type must be int, a string-based type (string, symbol, symbolpath, strid), or a type alias that ultimately resolves to one of them. Other scalar types (bool, float) are not valid enum bases. Option values of a symbol-, symbolpath-, or strid-based enum are string literals and must be valid values of that type (section 6.1); violations are build errors.
Examples:
enum Priority : int {
low = 0
medium = 1
high = 2
}
/// Supported UI theme colors
enum Color : string {
/// Corporate blue
blue = "blue"
/// Alert/error red
red = "red"
/// Success green
green = "green"
}
type ShortCode : string {
maxLen = 8
}
enum Region : ShortCode {
eu = "eu"
us = "us"
apac = "apac"
}
Enum options can have doc comments. The option identifier (left side) is the programmatic name; the value (right side) is the wire/storage value.
Uniqueness: Option names must be unique within the enum, case-insensitively (section 2.6). Option values must be unique too — two options mapping to the same wire value make the value-to-name direction ambiguous. Value comparison follows the base type’s case semantics: string-based values compare exactly, while values of symbol/symbolpath/strid bases and of string aliases constrained caseInsensitive = true compare case-insensitively (enum C : symbol { a = "a" A = "A" } is thus doubly in error). Options with disjoint lifecycles (section 12) may reuse names and values.
Object Types (object)
An object defines a complex type with named, typed properties:
[<annotation>]
object <ident> [: <ident>] {
<ident> <type>
...
}
Example:
/// A person in the system
object Person {
/// Full legal name
name string
/// Age in years
age int
id guid
created time
avatar data?
tags string[]
metadata value{}
zip ZipCode
addresses Address[#]
}
Property syntax: <ident> <type> — no colons, no wrapping objects. Nullable properties use the ? suffix on the type. Property names must be unique within the object, case-insensitively (section 2.6); properties with disjoint lifecycles (section 12) may reuse a name.
Object Inheritance
An object can extend another object using : Parent syntax. This is limited, structural inheritance — it exists to reduce clutter in IDL definitions for structurally related types. AOP has no concept of inheritance; the build tooling expands inheritance at compile time, producing flat, self-contained objects in the output bundle.
object Entity {
id guid @readonly
name string
}
object Person : Entity {
age int
email string?
}
# Wire output for Person: { id, name, age, email }
# No inheritance marker in the bundle
Rules:
- Single inheritance only. Multiple inheritance is not supported.
- Parent reference is an
ident— a type name resolved through normal import rules. - Transitive inheritance is supported: if
C : BandB : A, then C inherits all properties from both B and A. - Wire expansion: The tooling merges all inherited properties into the child. The wire format contains no trace of the inheritance relationship.
Property Override
If a child object redeclares a property from its parent, the child’s version replaces the parent’s entirely — type, annotations, and doc comment are all overwritten.
object Employee : Person {
/// Override: employee names are readonly
name string @readonly
role string
}
# Wire output: { id, name(@readonly), age, email, role }
There is no restriction on what can change in an override — the property is fully replaced. This enables narrowing types (value → string), adding annotations, or changing documentation.
Annotation Inheritance
Definition-level annotations (e.g., @sparse) are inherited from the parent. If the child redeclares the same annotation, it is redundant. A child cannot remove an annotation inherited from its parent.
If a parent is @sparse, all children are implicitly @sparse. Property-level annotations (e.g., @readonly) are inherited with their properties and can be overridden via property override (section 7.3.2).
Versioning and Inheritance
- A
sinceblock can add properties to a child object, following normal versioning rules. - The parent reference is fixed at definition time. A
sinceblock cannot change which parent an object extends. - If the parent gains new properties via
since, the child inherits them automatically — no redeclaration needed.
Union Types (union)
A union defines a discriminated (tagged) type — an object whose structure varies depending on a discriminant property. Unions replace inheritance-based patterns at the protocol level and enable language-appropriate code generation (class hierarchies in C#, interfaces + structs in Go, discriminated unions in TypeScript).
[<annotation>]
union <ident> {
tag <ident> <tagType> [as <ident>]
<shared properties>
<ident> [= <wireValue>] {
<variant properties>
}
...
}
Structure
A union consists of:
tagdeclaration (required): Names the discriminant property and its type, using property syntax (no colon). The tag type is one of the terminalsbool,int,float,string,symbol,symbolpath,strid— type aliases are not valid tag types. Typically it isstringorint. The tag declaration must be the first member of the union body; this is also what makestagusable as an ordinary member name afterwards (section 2.4). It may carry a doc comment, which documents the discriminant property and becomes the doc comment of the generated enum (section 7.4.3). An optionalas <ident>starting on the same line as the tag type names the generated enum explicitly (section 7.4.3); the name is a strict position (section 2.4).- Shared properties (optional): Properties common to all variants, using standard object property syntax (including annotations).
- Variants: Named blocks, each defining the additional properties for that variant. An empty variant is written as
VariantName {}.
The tag, the shared properties, and every variant’s properties share one wire-object namespace, and variant names must be unique within the union; all of these comparisons are case-insensitive (section 2.6).
Variant Wire Values
Each variant maps to a discriminant value. If the variant name matches the wire value, only the name is needed. If they differ, use = <wireValue>. Because an implicit wire value is the variant name — an identifier, and thus a valid value of every string-based tag type — variants of unions whose tag type is not string-based (string, symbol, symbolpath, strid) must always declare explicit wire values. Explicit string wire values on a symbol, symbolpath, or strid tag must be valid values of that type (section 6.1):
union AppAction {
tag ActionType string
# Wire value is "BackendOperation" (implicit, matches name)
BackendOperation {
Data string
}
# Wire value differs from variant name
SelectDatapoints = "SelectDatapointInExplorer" {
DatapointIds guid[]
}
# Empty variant, wire value is "CompleteEvent"
CompleteEvent {}
}
Wire values must be unique within the union (two variants mapping to the same discriminant value are indistinguishable). Comparison follows the tag type’s case semantics: string values compare exactly, symbol/symbolpath/strid values case-insensitively — mirroring enum option values (section 7.2).
Generated Enum
The tooling automatically generates an enum from the union’s variants. The enum:
- Is named after the tag property (e.g.,
ActionType), unless the tag declaration names it explicitly withas <ident>(see below). - Has the tag’s type as its value type.
- Contains one option per variant, using the variant name as the option id and the wire value as the option value.
- Inherits doc comments from each variant.
- Takes its own doc comment from the tag declaration’s doc comment, falling back to the union’s doc comment when the tag has none.
For the example above, the generated enum is equivalent to:
enum ActionType : string {
BackendOperation = "BackendOperation"
SelectDatapoints = "SelectDatapointInExplorer"
CompleteEvent = "CompleteEvent"
}
The generated enum is an ordinary enum declaration of the skill. It enters the declaration namespace like any declaration — its name must not collide (case-insensitively, section 2.6) with another declaration of the skill, including the union itself (IDL3043) — and it can be referenced as a type and imported with use (section 5.6), alone or alongside its union. Importing the union brings the enum along.
Explicit enum name. The tag declaration may name the generated enum with as <ident>, starting on the same line as the tag type (section 2.4). The name is a strict position (section 2.4): a bare keyword requires backticks. This decouples the enum’s name from the wire property’s name — the discriminant property is still ItemType on the wire — and lets several unions in one skill share a tag name:
union LayoutItem {
tag ItemType string as LayoutItemType
Pane = "pane" {}
Split = "split" {}
}
union TextItem {
tag ItemType string as TextItemType
Label {}
Button {}
}
Without as, both unions would generate an enum named ItemType and the second would be rejected (IDL3043). An explicit name equal to the tag name (tag Kind string as Kind) is permitted and equivalent to omitting it.
Full Example
/// Action to perform in the current context.
union AppAction {
tag ActionType string
# Shared properties across all variants
/// Execution priority
Priority int
/// Delay before execution in milliseconds; null means immediate
Delay int?
/// Source context
Context string @readonly
/// Server-side backend operation.
/// Executed via core.ExecuteBackendOperation.
BackendOperation {
/// The bstream of the action, handled as opaque
Data string
}
/// Call a specific number or person
CallNumber {
/// Id of person
Person guid
/// Person field Id, e.g., Fax, Mobile, Pager
Field int
/// Phone number, can contain textparams
Phone string
}
/// Launch a specified application
StartApplication {
/// Application name or identifier, can contain textparams
Application string
/// Command line parameters, can contain textparams
Parameters string
/// Execution folder, can contain textparams
Path string
}
/// Complete the event in the current context
CompleteEvent {}
/// @deprecated Use BackendOperation instead
LegacyOperation = "legacy_op" {
Data string
}
}
Code generation targets:
| Language | Generated structure |
|---|---|
| C# | Abstract base class + derived classes, or record hierarchy |
| Go | Interface + concrete structs with factory function |
| TypeScript | Discriminated union type |
Union Lifecycle
Unions support since / until for the union itself and for individual variants:
# Adding a new variant in 1.1
since "1.1.0" union AppAction {
/// Play the specified sound
PlaySound {
File string
}
}
# Removing a variant
since "1.0.0" until "1.3.0" union AppAction {
/// @deprecated Use BackendOperation instead
LegacyOperation = "legacy_op" {
Data string
}
}
Views (view)
A view is a client-declared projection of an object: it names the subset of the target object’s properties the client cares about. Views exist so generated clients can request exactly those properties through a function’s select parameter (section 6.2) and receive a correspondingly smaller result type — without redeclaring the service.
view <ident> of <ident> {
<ident> # pick: the target's property, unchanged
<ident> <ViewType> # narrowing: the property retyped to a view of its type
}
The view name and the target reference are strict positions (section 2.4). The target is resolved through normal import rules — like an object’s parent reference — and must be an object (local or imported; inheritance is flattened first, so picks may name inherited properties).
Entries. Each entry names a property of the target:
- A pick is a bare property name. The property keeps its type, annotations, and documentation from the target; a doc comment on the entry overrides the documentation.
- A narrowing is a property name followed by a type. The type must be a view whose target is the property’s type, and the container/nullability shape must match the property’s exactly (
Datapoint[]narrows toSmallDatapoint[], not toSmallDatapointorSmallDatapoint[]?).
A narrowing’s type must start on the same line as the entry name; an entry name followed by a line break, ,, or } is a pick. (Member separators are optional, so this rule is what makes a + b on separate lines two picks rather than one narrowing. The canonical formatter emits one entry per line.)
Rules:
- Every entry must name an existing property of the target. Duplicate entries are errors. A view cannot add properties.
- All narrowing entries of a view must narrow to the same leaf view. A select list is flat — symbol-shaped property names, no paths — so one call can trim exactly one object type; two properties of the same element type may both be narrowed to that type’s view (
Local SmallCMIandRemote SmallCMI[]), but narrowings to different views are an error. - The target of a view must be an
object— views of views are not permitted. - Views are not versionable: no
since/untilblock may wrap or appear inside a view. A view is checked against the version of the target its skill imports (or declares); if a picked property disappears in a later version, the view’s build breaks. - Views are not importable (section 5.6).
- Views share the type namespace and are usable as ordinary types anywhere in the declaring skill (properties, parameters, return types).
Wire semantics. A view is not a wire type: nothing about it enters the compiled bundle, and no protocol-level construct corresponds to it. A view value is simply a partial instance of the target object — the picked properties, transmitted under their original wire names.
Select derivation and callability. For a function that declares a select-typed parameter, a view is callable when its target is the function’s success return type — either directly (return type T, T?, T[], T[#] with view target T) or through its narrowings (return type R, view of R narrowing properties to a view of T — all narrowings share that one leaf view). The derived select value is the list of property names of the leaf view — the shared narrowed view, or the view itself for direct returns. So the leaf view must consist of picks only; a narrowing inside it would have no flat-select equivalent and is an error in that position.
Code generators emit, for every callable view, a client call variant that fills the select parameter from the view and types the result as the view (e.g. a C# method GetDatapointsAsSmallGetResultAsync returning the view class). How the variant is spelled is a generator concern; the derivation above is not.
Example:
skill monitoring.app "1.0.0"
use monitoring.Datapoint "1.0.0"
use monitoring.DatapointGetResult "1.0.0"
/// Just enough of a datapoint for the overview list
view SmallDatapoint of Datapoint {
id
name
}
view SmallGetResult of DatapointGetResult {
objects SmallDatapoint[] # the narrowing (leaf view)
total
}
Given func GetDatapoints(filter: string, select: optional select) -> DatapointGetResult in the imported skill, SmallGetResult is callable and derives the select value ["id", "name"] from SmallDatapoint.
Annotations
Annotations modify the behavior of definitions, properties, and parameters using the @ syntax. They can appear on type definitions (before the keyword) and on properties, known-key entries, and parameters (after the type).
Syntax
@<flag>
@<flag>(<value>)
@<flag1>,<flag2>
@<flag1>,<flag2>(<value>)
On definitions:
/// Transmitted with default-value omission
@sparse
object Measurement {
value float
label string
source string @readonly
}
On properties:
object Person {
id guid @readonly
created time @readonly
name string
details EventDetails @sparse
}
On parameters:
func UpdatePerson(
id: guid @readonly,
name: string,
) -> void
Definition-Level Annotations
Annotations on definitions appear between the doc comment and the keyword:
/// Doc comment
@annotation
object Name { ... }
/// Doc comment
@annotation
union Name { ... }
Built-in Annotations
| Annotation | Applies to | Constraint | Description |
|---|---|---|---|
@readonly |
Property, parameter | None | Cannot be set via API |
@sparse |
object definition |
Object types only | All properties subject to default-value omission in transmission |
@sparse |
Property | Object-typed properties only | The referenced type behaves as @sparse in this context |
@localizable |
Property, known-key entry, parameter | string/strid-rooted types only | Value is translatable by the translation system (section 8.5) |
Additional annotations may be defined as the IDL evolves. The grammar supports arbitrary annotation names to allow forward-compatible extension.
Sparse Semantics
In AOP, objects can be transmitted with default-value omission: properties holding their default value (0, "", false, null, empty array, empty map) are not included in the wire representation. This behavior is called sparse transmission.
@sparse can be applied at two levels:
On an object definition: All properties of the object are subject to default-value omission. Any instance of this type is transmitted sparsely.
@sparse
object Address {
street string
city string
zip string
}
On an object-typed property: The referenced type behaves as if it were @sparse in this specific context, regardless of whether the type itself is declared @sparse. This does not affect other usages of the same type.
object Event {
id guid
name string
details EventDetails @sparse # sparse in this context only
}
@sparse on scalar-typed properties is a build error — sparseness applies to objects (which contain multiple fields that can individually be omitted), not to individual scalar values.
Localizable Semantics
Human-readable text carried through the API — display names, captions, hints — is translated by the platform’s translation system. The @localizable annotation marks a value as translatable. It is a wire-neutral marker like @readonly: codegen does not change the transported shape; the annotation feeds the translation pipeline and documentation.
@localizable can be applied in exactly three positions: on an object property, on a known-key entry (in a known-keys block on a map-typed property or in a named map type’s body), and on a function or notification parameter.
The annotated value’s type must root to string or strid: following type-alias chains (including named map types) and stripping one container level per step — the array element or map value is the translated text — the scalar at the root must be string or strid. Nullable markers (?) are transparent.
type Title : string { maxLen = 120 }
type Texts : string{} {
heading string @localizable # known-key entry in a named map type
}
object Card {
name string @localizable # plain string
title Title @localizable # alias chain roots to string
lines string[] @localizable # array of translated texts
labels string{} @localizable # map of translated texts
captions string{} {
plain Title @localizable # known-key entry on a property
}
}
service Cards {
func Rename(id: guid, name: string @readonly,localizable) -> void
}
The other string-based types do not qualify: symbol and symbolpath are machine identifiers, not text. Enums never qualify either, even with a string base — enum values are wire-contract symbols; translating them would break the contract. @localizable on a type whose root is anything but string or strid is a build error.
@localizable anywhere else is a build error: on definitions (type, enum, object, union, error, service), on union shared or variant properties, and on error properties.
Error Declarations
Errors represent structured failure responses in the AOP protocol. A function returns either its declared result type or an error. Errors are declared as named types with an identpath identifier and optional data properties.
Error Declaration (error)
error <identpath>
error <identpath> {
<ident> <type>
...
}
The error identifier is an identpath, enabling hierarchical grouping. Consumers can match errors at any level of the hierarchy (e.g., matching Call.Param catches Call.Param.Missing, Call.Param.Type, and Call.Param.Value).
Examples:
# Error with no additional data
error Call.Failure
# Error with data properties
error Call.Param.Missing {
/// Name of the missing parameter
Param string
}
error Call.Param.Type {
/// Name of the incorrect typed parameter
Param string
}
error Data.NotFound {
/// Data type, e.g. 'datapoint'
Type string
/// ID of the requested data
Id string
}
error Data.Validation {
Type string
Id string
ValidationResult ErrorDataValidationResult[]
}
Every error implicitly carries an Id (identpath) and Message (string) as defined by the AOP protocol. The properties declared in the error body define the shape of the error’s Data field.
Exhaustive Error Contracts
Error declarations on functions are exhaustive — the | error list on a function declares the complete set of application-level errors that function can return. Codegen produces closed error types (e.g., Rust enum, Zig error set, TypeScript discriminated union) that enable exhaustive matching in consuming code. Imported errors (section 5) participate in | lists under their local name (the remainder identpath, or the as alias).
This applies only to application-level errors — errors declared in IDL files. Protocol-level errors (transport failures, version mismatch, malformed messages) are a separate runtime layer handled by the SDK, not declared in IDL. The SDK wraps all calls such that both application errors and protocol errors are representable, e.g.:
Result<T, AppError> | ProtocolError
Global Errors (@global)
Some application errors can occur on any function within a skill — authorization failures, license checks, audit requirements. Rather than listing these on every function signature, they can be declared with the @global annotation. @global is skill-scoped: it makes the error implicit only on the declaring skill’s functions, and it does not travel with an import (section 5.5) — an imported @global error must be listed explicitly in the consumer’s | lists.
@global
error Auth.NoPermission {
Resource string
Action string
}
@global
error License.Expired
A @global error is implicitly part of every function’s error contract within the skill where it is declared. It does not need to appear in any function’s | error list. Codegen includes global errors in the generated error type for every function in that skill.
@global is skill-scoped — a @global error in skill A does not affect skill B’s function contracts. Skills are the namespace and error boundary.
Platform Skill
Declarations that apply across all skills — cross-cutting authorization or licensing errors, ubiquitous data shapes — live in a well-known platform skill. The platform skill is a regular .adl file using standard syntax, but the build toolchain treats it specially (it is designated, not discovered — named per compilation, e.g. via adlc --platform aop.platform): its @global errors and its type namespace are implicitly available to every skill.
# aop.platform.adl
skill aop.platform "1.0.0"
# Implicitly usable in every skill of the compilation, no `use` needed.
type Ident : string
object RequestHeader {
trace guid
}
@global
error Auth.NoPermission {
Resource string
Action string
}
@global
error License.Expired
Platform types. Every declaration of the platform skill’s type namespace (type aliases, enums, objects, unions) is implicitly resolvable in every other skill of the compilation, in both property and signature positions. The resolution order is: local declaration → file-local import (section 5) → platform skill. A local declaration or an import silently shadows a platform name. When several platform skills are designated and more than one provides a referenced name, that reference is a build error — disambiguate with an explicit use, which always remains possible (platform types are importable like any type, and an explicit use of a platform type unifies with the implicit resolution). Only the platform skill’s own declarations are provided; declarations it imported from elsewhere do not leak through, and a platform skill never resolves implicitly against itself. When several compatibility lines of a platform skill are in the compilation, the newest line provides both the errors and the types.
Implicitly resolved platform types are copied into the consumer’s bundle exactly like imports (section 5.5): the clone records its origin and behaves as if it had been imported.
Platform errors. As before, @global errors of the platform skill are implicit in every function’s error contract. Platform errors are not implicitly nameable in | error lists — referencing one explicitly still requires a use (section 5.6).
This separation keeps protocol errors (SDK/runtime) and platform errors (application-wide, but declared in IDL) distinct. The codegen output for any skill includes three error layers:
- Function errors — declared per function via
|, exhaustive for that function - Skill-global errors —
@globalerrors declared in the same skill - Platform errors —
@globalerrors from the platform skill
The platform skill versions independently. Adding a new platform error is a potentially breaking change for consumers relying on exhaustive matching.
Error Lifecycle
Errors support since / until for versioning, just like other definitions:
since "1.1.0" error Data.Conflict {
Type string
Id string
}
Service Definitions
A service groups related functions and notifications. Services are organizational — they have no protocol-level significance in AOP (see section 4.1).
Named Services
A named service prefixes all its function and notification names with the service name:
service <ident> {
func ...
notify ...
}
Example:
service PersonService {
# Wire name: "PersonService.GetPerson"
func GetPerson(id: guid) -> Person?
# Wire name: "PersonService.PersonChanged"
notify PersonChanged(person: Person)
}
Anonymous Services
An anonymous service (no name) contains functions and notifications whose names are used as-is on the wire, with no prefix:
service {
func ...
notify ...
}
Example:
service {
# Wire name: "core.system.Ping" (exact, no prefix)
func core.system.Ping() -> bool
# Wire name: "GetStatus" (exact, no prefix)
func GetStatus() -> string
# Wire name: "system.Heartbeat" (exact, no prefix)
notify system.Heartbeat(timestamp: time)
}
Use anonymous services when you need absolute control over function names — e.g., for functions with pre-existing wire names, system-level functions, or cross-cutting concerns that don’t belong to any particular service grouping.
Functions (func)
A function is a callable operation with parameters and a return type. It may optionally declare known error returns:
func <identpath>(<params>) -> <returnType>
func <identpath>(<params>) -> <returnType> | <e>, <e>, ...
Function names are identpaths. In named services, the service name is prepended to form the wire name (e.g., GetPerson in PersonService becomes PersonService.GetPerson). In anonymous services, the name is used as-is.
The | separates the success return type from declared error types. Everything after | must reference a declared error. The error list is exhaustive — it declares the complete set of function-specific errors that this function can return (see section 9.2). @global errors (section 9.3) and platform errors (section 9.4) are implicit and do not need to appear in the | list.
Parameter syntax: <ident>: [optional] <type> — note the colon (distinguishes from property syntax in objects). Parameter names must be unique within the signature, case-insensitively (section 2.6).
The optional modifier marks the parameter as omittable: the caller may leave the argument out entirely. It is valid only in parameter position (functions and notifications; section 10.4 shares this rule) and may appear at any position in the parameter list — parameters are transmitted by name, so trailing placement is not required. (The canonical formatter nevertheless places optional parameters after the required ones, preserving the relative order within each group — a pure style ruling with no wire effect.) optional is distinct from the ? type suffix (section 6.4), which marks the value as nullable; the two compose:
func Search(
query: string,
limit: optional int, # the argument may be omitted
cursor: optional string?, # omittable, and null is a valid value
) -> Station[]
An inline since prefix precedes the parameter name as usual: since "1.2.0" id: optional guid. Versioned redeclarations of a function (see below) must preserve each parameter’s optional modifier — changing it is a signature mismatch.
A parameter of the built-in type select (section 6.2) declares the function’s property-selection parameter; at most one per function. Views (section 7.5) let generated clients fill it automatically.
Examples:
# No declared errors
func DeletePerson(id: guid) -> void
# With declared errors
func GetPerson(id: guid) -> Person? | Data.NotFound, Data.Forbidden
# Multi-line with full documentation
/// Retrieves a person by ID
/// @param id Unique person identifier
/// @return The person, or null if not found
/// @error Data.NotFound No person exists with the given ID
/// @error Data.Forbidden Caller lacks read permission for this person
func GetPerson(
/// Unique person identifier
id: guid,
) -> Person? | Data.NotFound, Data.Forbidden
The @error doc tag complements the structural | declaration. The | declares which errors are possible; @error documents when and why they occur. The documentation generator can cross-reference them and warn if they are out of sync.
Versioned error additions:
When a function gains new error returns in a later version, redeclare the full function signature in a since block:
# Base version
service PersonService {
func GetPerson(id: guid) -> Person? | Data.NotFound, Data.Forbidden
}
# 1.2 adds Data.Validation
since "1.2.0" service PersonService {
func GetPerson(id: guid) -> Person? | Data.NotFound, Data.Forbidden, Data.Validation
}
Tooling merges these — the 1.2 version includes all three errors. The return type and parameters must match across versions; only the error list may differ.
Notifications (notify)
A notification is an event that clients can subscribe to. It has parameters but never a return value or error list:
notify <identpath>(<params>)
Examples:
notify PersonChanged(person: Person)
notify PersonDeleted(id: guid)
Parameter syntax is identical to functions. In named services, notification names get the service prefix; in anonymous services, names are used as-is.
Concept Definitions
A concept defines an ontological contract — a named set of capabilities, properties, states, and events that a logical entity in the system can conform to. Concepts describe what something is and can do at an abstract level, independent of how it is implemented in services or functions.
Concepts are used by the modeling team to define the system ontology. They live in skill files with normal versioning. There is currently no formal binding between concepts and services/functions, but this may be added in the future (e.g., service A implements concept B).
Concept Declaration
concept <identpath> {
...members...
}
The concept name is an identpath, providing hierarchical naming (e.g., pids.sensor, pms.barrier).
Example:
/// A physical perimeter intrusion detection sensor
concept pids.sensor {
is ias.reset
property direction int
property usesLpr bool
command On
command Off
command Bypass
command Unbypass
state IsOff bool
state IsBypassed bool
event Alarm {
distance int
direction int
}
event Fault
}
Conformance (is)
The is declaration expresses that a concept conforms to, specializes, or inherits from another concept. The target is an identpath referencing another concept. Multiple is declarations are allowed.
concept pids.zone {
/// Resets all active alarms of all sensors of this zone
is pids.reset
/// Arms this zone and enables alarm generation
is pids.arm
/// Sets all sensors in this zone into test mode
is pids.testable
}
Doc comments on is describe the relationship in this specific context — why this concept conforms to the target, not what the target means in general.
Additional relationship keywords (e.g., has) may be introduced in future versions as the modeling requirements evolve.
Domain Membership (within)
The within declaration expresses that a concept exists within the context or domain of another concept — e.g. a fire panel exists within the domain of fire detection. The target is an identpath referencing another concept. Multiple within declarations are allowed.
/// A fire alarm control panel
concept firepanel {
/// Fire panels belong to the fire detection domain
within fire
}
Doc comments on within describe the relationship in this specific context — why this concept belongs to the target domain, not what the target means in general.
Properties (property)
Properties are static configuration or attributes of a concept — values that describe what the entity is, not its current runtime state.
property <identpath> <type>
Examples:
/// The direction that is blocked by this barrier
/// 0: bi-directional, 1: only entry, 2: only exit
property direction int
/// Indicates if the barrier uses license plate recognition
property usesLpr bool
Property names are identpaths, allowing dotted names for edge cases where hierarchical naming is needed.
Commands (command)
Commands declare abstract operations that can be performed on the concept. They represent capabilities, not concrete function signatures.
command <identpath> # void, no parameters
command <identpath> -> <type> # with return type, no parameters
command <identpath> { # void, with parameters
<ident> <type>
...
}
command <identpath> -> <type> { # with return type and parameters
<ident> <type>
...
}
The default return type is void. The -> clause is optional; -> void is legal but redundant.
Examples:
/// Opens the barrier
command Open
/// Closes the barrier
command Close
/// Blocks the barrier — it doesn't open for any reason until unblocked
command Block
/// Change the barrier's direction
command SetDirection {
/// 1: only entry, 2: only exit
direction int
}
/// Allow a specific license plate to pass through freely
command AllowLicensePlate {
licenseplate string
}
/// Check sensor health, returns diagnostic result
command Diagnose -> DiagnosticResult
Parameters inside the command body use property syntax (name type, no colon). Command names are identpaths.
States (state)
States are dynamic runtime conditions of a concept — values that describe the entity’s current status.
state <identpath> <type>
Examples:
/// No one can open the barrier
state IsBlocked bool
/// The barrier remains open
state IsUnlocked bool
/// The barrier is physically open
state IsOpen bool
State names are identpaths.
Events (event)
Events declare observable occurrences that the concept can emit. Events may optionally carry data properties.
event <identpath> # event with no data
event <identpath> { # event with data properties
<ident> <type>
...
}
Examples:
/// Intrusion detection alarm
event Alarm {
distance int
direction int
}
event Fault
event ForcedOpen
event OpenTooLong
event ExitWithoutEntry
Parameters inside the event body use property syntax. Event names are identpaths.
Member Ordering
The ordering of members within a concept block is not enforced. Convention is: is declarations first, then within, then property, command, state, event — but the parser accepts any order.
Full Example
/// A physical barrier for controlling vehicle or pedestrian access
concept pms.barrier {
/// The direction that is blocked by this barrier
/// 0: bi-directional, 1: only entry, 2: only exit
property direction int
/// Indicates if the barrier uses license plate recognition
property usesLpr bool
/// Opens the barrier
command Open
/// Closes the barrier
command Close
/// Blocks the barrier entirely
command Block
/// Unblocks the barrier
command Unblock
/// Set barrier to remain open
command ActivateKeepOpen
/// Change the barrier's direction
command SetDirection {
/// 1: only entry, 2: only exit
direction int
}
/// Allow a specific license plate to pass through freely
command AllowLicensePlate {
licenseplate string
}
/// No one can open the barrier
state IsBlocked bool
/// The barrier remains open
state IsUnlocked bool
/// The barrier is physically open
state IsOpen bool
event ForcedOpen
event OpenTooLong
event Tamper
event LockedInOpenPosition
event LockedInClosedPosition
event Breakage
event Fault
event ExitWithoutEntry
}
concept pms.reset {
is general.reset
}
/// A zone grouping multiple sensors
concept pids.zone {
/// Resets all active alarms of all sensors of this zone
is pids.reset
/// Arms this zone and enables alarm generation
is pids.arm
/// Sets all sensors in this zone into test mode
is pids.testable
/// Enable the zone
command Enable
/// Disable the zone
command Disable
/// The zone is intentionally disabled
state Disabled bool
event Alarm
event Fault
}
Versioned Additions (since / until)
Definitions can be versioned to express lifecycle within a skill. This enables capability negotiation: a client declaring support for “PersonSkill >= 1.1” will receive all definitions with since ≤ 1.1.
since
Adds definitions starting from a specific version:
since "<version>" <definition> {
...
}
Example — adding properties to an existing object:
since "1.1.0" object Person {
email string?
phone PhoneNumber?
}
Example — adding functions to an existing service:
since "1.1.0" service PersonService {
func GetPersonByEmail(email: string) -> Person
}
Example — adding errors:
since "1.1.0" error Data.Conflict {
Type string
Id string
}
Example — adding union variants:
since "1.1.0" union AppAction {
PlaySound {
File string
}
}
Example — adding enum options:
since "1.1.0" enum Color {
yellow = "yellow"
}
Example — adding concept members:
since "1.1.0" concept pids.sensor {
command Diagnose -> DiagnosticResult
state DiagnosticsAvailable bool
}
until
Removes definitions at a specific version (exclusive — the definition exists in versions [since, until)):
since "<from>" until "<to>" <definition> {
...
}
Example:
/// @deprecated Use GetPerson instead
since "1.0.0" until "1.3.0" service PersonService {
func GetPersonLegacy(id: int) -> Person
}
GetPersonLegacy exists in versions 1.0.0, 1.1.0, 1.2.0 but is removed in 1.3.0.
Inline since on Parameters and Properties
Individual parameters or properties can be versioned inline:
since "1.1.0" service PersonService {
func GetPersonByEmail(
email: string,
since "1.2.0" includeHistory: bool?,
) -> Person
}
Here, GetPersonByEmail exists since 1.1, but the includeHistory parameter was added in 1.2.
Version Rules
- Versions in
since/untilmust be on the same compatibility line (section 4.3) as the file-level skill declaration: the same major, or for a 0.x skill the same0.MINOR. sinceversion must be ≥ the file-level base version.untilversion must be >sinceversion.- Lifecycle coverage: a member must not reference a type (or inherit from an object, or list an error) whose lifecycle does not cover the member’s own availability window — the referenced declaration must exist at least as early and be removed no earlier. This keeps every negotiated version view closed under its references: a client on version v never receives a definition pointing at one it cannot see.
- Convention: a new compatibility line (major bump; minor bump of a 0.x skill) starts a new file rather than using
untilfor mass removal.