Skip to content

Annotations

Annotations are Shape’s policy/composition layer.

They can run in two phases:

  • compile time: transform signatures, bodies, generated methods
  • runtime: wrap behavior around function/expression/await execution

This is intentionally unified with comptime, not a separate language model.

annotation traced(tag) {
targets: [function]
before(args, ctx) {
print("[" + tag + "] before")
args
}
after(args, result, ctx) {
print("[" + tag + "] after")
result
}
}

Rules:

  • Definition form always uses parentheses: annotation name(...) { ... }.
  • Use-site supports local @name / @name(...), qualified @ns::name(...), and named-import @name after from M use { @name } (see below).
  • Annotation parameters (tag above) are in scope inside every hook body.

Function target:

@traced("math")
fn add(a: int, b: int) -> int {
a + b
}

Expression target:

let value = @traced("expr") compute()

Await target:

let value = await @traced("io") fetch_user()

Type target:

@serializable()
type Point { x: number, y: number }

Module target (annotation goes immediately before mod keyword, per the grammar at crates/shape-ast/src/shape.pest:70annotations? ~ "mod" ~ ident ~ "{" ~ ... ~ "}"):

@registered("payments")
mod payments {
fn charge(amount: number) -> bool { true }
}

The compile-time validator recognizes seven target kinds, defined by the AnnotationTargetKind enum at crates/shape-ast/src/ast/functions.rs:264-279:

targets: valueWhere it applies
functionfn name(...) { ... }
typetype Name { ... } / enums / structs
modulemod name { ... }
expressionarbitrary expression, e.g. @x compute()
blockblock expression { ... }
await_exprawait @x rhs
bindinglet / var / const

The string label emitted into the runtime target descriptor (the kind field of the target descriptor inside handlers) is the lowercase form ("function", "type", "module", …) — see crates/shape-vm/src/compiler/functions_annotations.rs:199-207.

Set targets: [...] explicitly for clarity and better tooling. When you omit targets:, the validator allows any target; when you include it, the compiler enforces it at every use-site via validate_annotation_target_usage at crates/shape-vm/src/compiler/compiler_impl_reference_model.rs:1015-1062.

Module-target annotations only fire definition-time hooks. The lifecycle emitter for module targets is emit_annotation_lifecycle_calls_for_module at crates/shape-vm/src/compiler/functions_annotations.rs:59-74, which routes through emit_annotation_lifecycle_calls_for_target at lines 76-109 — and that path only invokes on_define_handler and metadata_handler. There is no runtime wrapper compilation for mod. Putting before / after / comptime pre / comptime post on a module annotation is silently a no-op on that handler kind at module targets.

is_definition_annotation_target at crates/shape-vm/src/compiler/compiler_impl_reference_model.rs:1003-1012 restricts on_define / metadata hooks to function, type, or module targets only. The validator at lines 1033-1049 raises a SemanticError (“Annotation defines definition-time lifecycle hooks (on_define/metadata) and cannot be applied to a <target>”) when these hooks land on expression, await_expr, binding, or block.

HookPhasePurpose
before(args, ctx)runtimepre-execution wrapper
after(args, result, ctx)runtimepost-execution wrapper
comptime pre(target, ctx, ...)compile timeearly transform/validation
comptime post(target, ctx, ...)compile timelate transform/validation
on_define(target, ctx)definition timeside effects at definition emission
metadata(target, ctx)definition timemetadata emission at definition emission

Runtime signatures are:

  • before(args, ctx)
  • after(args, result, ctx)

Do not manually add self; wrapper internals handle that.

The ctx parameter is a typed object with a compile-time-fixed schema. The schema is built by the wrapper bytecode emitter at three sites that differ only in field count:

TargetSchemaSource
function{ __impl: Any, state: Any, event_log: Array<Any> }functions_annotations.rs:1441-1452
expression / block / binding{ state: Any, event_log: Array<Any> }expressions/mod.rs:489-499
await_expr{ state: Any, event_log: Array<Any> }expressions/mod.rs:665-667
module (on_define / metadata){ state: Any, event_log: Array<Any> }functions_annotations.rs:210-245

That is the complete ctx contract at this release. The fields are:

  • ctx.__impl (function targets only) — a function reference to the original (un-wrapped) implementation. Constructed at functions_annotations.rs:1420-1452 from Constant::Function(impl_idx). The canonical use is short-circuit redirection (see @remote below).
  • ctx.state — initialized to an empty typed object ({}). A per-annotation persistent state slot. May be replaced by the before return contract (see below).
  • ctx.event_log — initialized to an empty array []. A scratch array for handlers that want to record events.

Handler code may stash arbitrary keyed values under ctx[key]. The function-target __impl field is the canonical pre-allocated example.

The before return value is interpreted by apply_before_result_contract at crates/shape-vm/src/compiler/expressions/mod.rs:296-445 (function targets use the verbatim same shape inline at functions_annotations.rs:1502-1693).

The bytecode emitter classifies the return at runtime, in order:

  1. Array (checked with BuiltinFunction::IsArray) — treated as rewritten args. The wrapper stores the array into the wrapper’s __args local before the impl call (functions_annotations.rs:1517-1527).
  2. Object (checked with BuiltinFunction::IsObject) — interpreted against a typed schema { args: Any, result: Any, state: Any } registered at functions_annotations.rs:1551-1556 / expressions/mod.rs:308-312. Each field is read with GetFieldTyped:
    • result non-null → short-circuit: the wrapper stores result into __result and jumps past the impl call (functions_annotations.rs:1625-1643). The impl is not invoked.
    • args non-null → replace the wrapper’s __args local (functions_annotations.rs:1647-1663).
    • state non-null → rebuild ctx with the new state field (functions_annotations.rs:1665-1689); the rebuilt ctx carries the new state and a fresh empty event_log.
  3. Any other value — ignored. Original args remain, no short-circuit.

This contract is shared by function wrappers, expression wrappers, and await-expression wrappers. Returning the original args (the typical identity tail of before) falls into case (3) — wrapper proceeds with the unchanged args.

The after return value becomes the final value seen by the caller, stored into the wrapper’s __result local at functions_annotations.rs:1818-1821 and returned at line 1825-1829.

There is no special destructuring of the after return — whatever it returns is the wrapper’s return.

When the impl function has explicit -> Void, the wrapper replaces the impl’s null return sentinel with a Unit value before invoking after (functions_annotations.rs:1743-1764). This avoids tripping the “missing required argument” guard on the result parameter when the underlying function returns nothing.

Await annotations have first-class runtime mechanics:

  • before executes before await
  • before can replace args[0] (the awaited subject) via the array form or via the object form’s args: field
  • after executes after await resolution
  • after sees the resolved result and can transform it

This is the core mechanic for remote dispatch wrappers, retries, timeout guards, and similar policies implemented by extensions.

These hooks run in the same comptime engine as comptime { ... }.

Parameter binding is positional:

  1. target
  2. ctx
  3. annotation arguments in order

Variadic final parameter is supported.

annotation set_return(type_name) {
targets: [function]
comptime post(target, ctx, name) {
set return (name)
}
}

The comptime target argument is a ComptimeTarget value built at crates/shape-vm/src/compiler/comptime_target.rs::ComptimeTarget::from_function for function targets. The comptime ctx argument is the same { state, event_log } 2-field typed object as the runtime expression / module ctx.

Definition-Time on_define / metadata Hooks

Section titled “Definition-Time on_define / metadata Hooks”

For function / type / module targets, the compiler emits a call to each on_define_handler / metadata_handler at definition emission via emit_annotation_handler_call at crates/shape-vm/src/compiler/functions_annotations.rs:111-194. The handler receives, in order:

  1. A target descriptor — typed object { name: String, kind: String, id: I64 } built at functions_annotations.rs:247-296. For function targets, id is the function index; for module targets, id is the module binding index; for type, id is null.
  2. The annotation’s source arguments (@name(arg1, arg2)).
  3. Parameters past the base (target + args) are filled by name: fn or target → another target descriptor; ctx → a fresh runtime ctx (built by emit_annotation_runtime_ctx, functions_annotations.rs:210-245); any other name → null.

When you write @name(...) or @ns::name(...), the compiler resolves the annotation via resolve_compiled_annotation_name_str at crates/shape-vm/src/compiler/compiler_impl_reference_model.rs:926-968.

The lookup order is:

  1. Bare name in compiled_annotations — direct hit on the canonical compiled-annotation table.
  2. Qualified local::name form — splits on ::. The local prefix is resolved to a canonical module path via either the graph-driven graph_namespace_map (the canonical map for graph-based compiles) or the legacy module_scope_sources map. The resolved canonical::name is then checked against compiled_annotations.
  3. Module-scope walk — for bare names, walks module_scope_stack in reverse and tries module::name in compiled_annotations.
  4. Named-import alias — for bare names, checks imported_annotations[name] and resolves to hidden_module_name::original_name.

imported_annotations is populated by two paths:

  • Legacy / non-graph compileregister_import_names at crates/shape-vm/src/compiler/statements.rs:1104-1122 registers each from M use { @name } spec (parsed with is_annotation: true, grammar at shape-ast/src/shape.pest:74annotation_import_item = { "@" ~ ident }).
  • Graph-driven compileregister_graph_imports_for_module at crates/shape-vm/src/compiler/statements.rs:1240-1357 registers both named-import specs (line 1338-1356) and namespace-import re-exports (line 1315-1328: every annotation export from the imported module is mapped onto imported_annotations under its bare name).

Named-Import vs Namespace Resolution at HEAD

Section titled “Named-Import vs Namespace Resolution at HEAD”

Both forms resolve at HEAD under the graph-driven path:

// Form A — named import: bare @remote works
from std::core::remote use { @remote }
@remote("worker:9527")
fn process_a(data) { data }
// Form B — namespace import: bare @remote AND qualified @remote::remote both work
use std::core::remote
@remote("worker:9527")
fn process_b(data) { data }
@remote::remote("worker:9527")
fn process_c(data) { data }

For Form A, lookup hits step 4 (named-import alias). For Form B bare-name use, lookup hits step 4 via the graph-driven re-exports. For Form B qualified @remote::remote, lookup hits step 2 (qualified resolution against graph_namespace_map).

For @a @b fn f(...):

  • comptime pre order: a, then b
  • comptime post order: a, then b
  • runtime before order: outer to inner (a, then b)
  • runtime after order: inner to outer (b, then a)

Treat stacked annotations as nested wrappers.

annotation serializable() {
targets: [type]
comptime pre(target, ctx) {
comptime for field in target.fields {
if field.type == "function" {
error("Serializable type cannot include function fields")
}
}
}
comptime post(target, ctx) {
extend target {
method to_json() {
// generated serializer
}
method from_json(payload) {
// generated deserializer
}
}
}
}

This gives Rust-like derive ergonomics using pure Shape + comptime.

The stdlib defines a @remote annotation in std::core::remote that uses a before hook with short-circuit return to transparently ship function execution to a remote shape serve node:

from std::core::remote use { @remote }
@remote("worker:9527")
fn process(data) {
data
}
let result = process([1, 2, 3])
// result executed on worker:9527

Key mechanics:

  • ctx.__impl retrieves the original function reference (the implementation) — the function-target-only field on ctx.
  • The stdlib @remote handler serializes the function + args and sends via the wire protocol, then returns { result: value } — the short-circuit object form of the before return contract.
  • Works with foreign functions (Python/TypeScript) — the remote server executes the foreign code using its loaded language extensions.

See Standard Library: Remote for the full transport API.

use std::core::remote
annotation host(name) {
targets: [await_expr]
before(args, ctx) {
// extension-provided wrapper: returns a new awaitable routed to `name`
{ args: [remote::route(name, args[0])], state: { host: name } }
}
after(args, result, ctx) {
result
}
}
let user = await @host("eu-west") fetch_user(id)

remote::route is extension code. Annotation mechanics are language-level. The before return uses the object form: the args field replaces the wrapper’s __args local (so the new awaitable becomes args[0]), and the state field rebuilds ctx with { host: "eu-west" } carrying the new state and a fresh empty event_log.

  • self is internal wrapper plumbing.
  • User-facing policy state flows through explicit args, result, and the three ctx fields (__impl on function targets, state, event_log), plus arbitrary user-keyed slots via ctx[key].

This keeps annotation code predictable and LSP-friendly.

Native interop details are defined in the canonical Native C Interop chapter.

This chapter only covers annotation behavior; avoid duplicating native ABI syntax or marshalling rules here.

  • Keep annotations narrow and composable.
  • Keep compile-time logic in comptime pre/post.
  • Keep runtime wrappers explicit (args, result, ctx.state).
  • Use targets: [...] explicitly for correctness and editor support.
  • Treat ctx as the three documented fields plus user-keyed slots — do not invent parallel state stores inside the annotation body.