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.
Define an Annotation
Section titled “Define an Annotation”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@nameafterfrom M use { @name }(see below). - Annotation parameters (
tagabove) are in scope inside every hook body.
Apply Annotations
Section titled “Apply Annotations”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:70 —
annotations? ~ "mod" ~ ident ~ "{" ~ ... ~ "}"):
@registered("payments")mod payments { fn charge(amount: number) -> bool { true }}Target Kinds
Section titled “Target Kinds”The compile-time validator recognizes seven target kinds, defined by the
AnnotationTargetKind enum at
crates/shape-ast/src/ast/functions.rs:264-279:
targets: value | Where it applies |
|---|---|
function | fn name(...) { ... } |
type | type Name { ... } / enums / structs |
module | mod name { ... } |
expression | arbitrary expression, e.g. @x compute() |
block | block expression { ... } |
await_expr | await @x rhs |
binding | let / 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 Hooks Are Restricted
Section titled “Module-Target Hooks Are Restricted”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.
Definition-Time Hook Target Restriction
Section titled “Definition-Time Hook Target Restriction”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.
Lifecycle Hooks
Section titled “Lifecycle Hooks”| Hook | Phase | Purpose |
|---|---|---|
before(args, ctx) | runtime | pre-execution wrapper |
after(args, result, ctx) | runtime | post-execution wrapper |
comptime pre(target, ctx, ...) | compile time | early transform/validation |
comptime post(target, ctx, ...) | compile time | late transform/validation |
on_define(target, ctx) | definition time | side effects at definition emission |
metadata(target, ctx) | definition time | metadata emission at definition emission |
Runtime Hooks: Signature and Contracts
Section titled “Runtime Hooks: Signature and Contracts”Runtime signatures are:
before(args, ctx)after(args, result, ctx)
Do not manually add self; wrapper internals handle that.
Runtime ctx
Section titled “Runtime ctx”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:
| Target | Schema | Source |
|---|---|---|
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 atfunctions_annotations.rs:1420-1452fromConstant::Function(impl_idx). The canonical use is short-circuit redirection (see@remotebelow).ctx.state— initialized to an empty typed object ({}). A per-annotation persistent state slot. May be replaced by thebeforereturn 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.
before Return Contract
Section titled “before Return Contract”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:
- Array (checked with
BuiltinFunction::IsArray) — treated as rewrittenargs. The wrapper stores the array into the wrapper’s__argslocal before the impl call (functions_annotations.rs:1517-1527). - Object (checked with
BuiltinFunction::IsObject) — interpreted against a typed schema{ args: Any, result: Any, state: Any }registered atfunctions_annotations.rs:1551-1556/expressions/mod.rs:308-312. Each field is read withGetFieldTyped:resultnon-null → short-circuit: the wrapper storesresultinto__resultand jumps past the impl call (functions_annotations.rs:1625-1643). The impl is not invoked.argsnon-null → replace the wrapper’s__argslocal (functions_annotations.rs:1647-1663).statenon-null → rebuildctxwith the new state field (functions_annotations.rs:1665-1689); the rebuiltctxcarries the newstateand a fresh emptyevent_log.
- 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.
after Return Contract
Section titled “after Return Contract”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.
Special Case: Void Impl Returns
Section titled “Special Case: Void Impl Returns”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 Annotation Semantics
Section titled “Await Annotation Semantics”Await annotations have first-class runtime mechanics:
beforeexecutes beforeawaitbeforecan replaceargs[0](the awaited subject) via the array form or via the object form’sargs:fieldafterexecutes after await resolutionaftersees 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.
Compile-Time Hooks (comptime pre/post)
Section titled “Compile-Time Hooks (comptime pre/post)”These hooks run in the same comptime engine as comptime { ... }.
Parameter binding is positional:
targetctx- 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:
- A
targetdescriptor — typed object{ name: String, kind: String, id: I64 }built atfunctions_annotations.rs:247-296. Forfunctiontargets,idis the function index; formoduletargets,idis the module binding index; fortype,idisnull. - The annotation’s source arguments (
@name(arg1, arg2)). - Parameters past the base (target + args) are filled by name:
fnortarget→ another target descriptor;ctx→ a fresh runtime ctx (built byemit_annotation_runtime_ctx,functions_annotations.rs:210-245); any other name →null.
Annotation Resolution at Use Sites
Section titled “Annotation Resolution at Use Sites”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:
- Bare name in
compiled_annotations— direct hit on the canonical compiled-annotation table. - Qualified
local::nameform — splits on::. The local prefix is resolved to a canonical module path via either the graph-drivengraph_namespace_map(the canonical map for graph-based compiles) or the legacymodule_scope_sourcesmap. The resolvedcanonical::nameis then checked againstcompiled_annotations. - Module-scope walk — for bare names, walks
module_scope_stackin reverse and triesmodule::nameincompiled_annotations. - Named-import alias — for bare names, checks
imported_annotations[name]and resolves tohidden_module_name::original_name.
imported_annotations is populated by two paths:
- Legacy / non-graph compile —
register_import_namesatcrates/shape-vm/src/compiler/statements.rs:1104-1122registers eachfrom M use { @name }spec (parsed withis_annotation: true, grammar atshape-ast/src/shape.pest:74—annotation_import_item = { "@" ~ ident }). - Graph-driven compile —
register_graph_imports_for_moduleatcrates/shape-vm/src/compiler/statements.rs:1240-1357registers both named-import specs (line 1338-1356) and namespace-import re-exports (line 1315-1328: every annotation export from the imported module is mapped ontoimported_annotationsunder 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 worksfrom std::core::remote use { @remote }
@remote("worker:9527")fn process_a(data) { data }// Form B — namespace import: bare @remote AND qualified @remote::remote both workuse 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).
Order of Execution
Section titled “Order of Execution”For @a @b fn f(...):
- comptime pre order:
a, thenb - comptime post order:
a, thenb - runtime before order: outer to inner (
a, thenb) - runtime after order: inner to outer (
b, thena)
Treat stacked annotations as nested wrappers.
Serde-Style Derive Pattern
Section titled “Serde-Style Derive Pattern”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.
Concrete Policy Examples
Section titled “Concrete Policy Examples”Remote Execution (@remote)
Section titled “Remote Execution (@remote)”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:9527Key mechanics:
ctx.__implretrieves the original function reference (the implementation) — the function-target-only field onctx.- The stdlib
@remotehandler serializes the function + args and sends via the wire protocol, then returns{ result: value }— the short-circuit object form of thebeforereturn 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.
Await Host Routing (@host)
Section titled “Await Host Routing (@host)”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.
ctx vs self
Section titled “ctx vs self”selfis internal wrapper plumbing.- User-facing policy state flows through explicit
args,result, and the threectxfields (__implon function targets,state,event_log), plus arbitrary user-keyed slots viactx[key].
This keeps annotation code predictable and LSP-friendly.
Native C Interop
Section titled “Native C Interop”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.
Guidance
Section titled “Guidance”- 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
ctxas the three documented fields plus user-keyed slots — do not invent parallel state stores inside the annotation body.