WIT has three @ attribute productions today — gate (@since/@unstable/@deprecated), @implements, and @external-id
Each with its own placement rules, and two of them landed this year.
Several more are queued in other issues: user-defined gate strings (#388), @non-exhaustive (#454), @get/@set (#235), and shared/async/reentrant as function attributes. So: should the next one be general?
An annotation is a package-scope type, and applying it is a WAVE value of that type.
Declaring:
package tooling:rust@1.0.0;
@annotation
type derive = list<string>;
@annotation
type rename = string;
Applying:
@annotate(tooling:rust/derive, ["Hash", "Eq"])
record object-id {
@annotate(tooling:rust/rename, "objectID")
id: string,
}
Why a type instead of a bespoke parameter list? The obvious alternative is to give @annotation a signature like a function's — @annotation derive(names: list<string>) — but that's a second, weaker type system that you then have to grow. As soon as an annotation needs "exactly one of these six", a parameter list can only offer six optional fields and a comment asking you to set one. That's a variant, and a variant is a type. Make the payload an ordinary WIT type and annotations inherit the whole type system, including whatever gets added to it later.
Why package-qualified? Bare @word stays the platform's. Users never take a bare name, so everything in the queue above can still land as a built-in later without colliding with anything anyone wrote. Collisions between user annotations become impossible, and versioning falls out of ordinary package deps rather than a registry.
This leans on two things in flight: #639 for WAVE literals, and #694 for a place to put package-scope types. String-only payloads would work with today's grammar, since @external-id already takes a string literal.
What would help most is use cases from people shipping WIT: which annotations you'd declare, where you'd attach them, and whether anything downstream needs to read them at runtime rather than at build time.
Why now: the built-in attribute queue
This picks up #58, which ends with an invitation to bring something concrete.
What's changed since is that the same surface has grown one hardcoded attribute at a time. gate came in #332, implements in #613, external-id in #672, with the last two merged this year and each carrying its own position rules. Waiting behind them: #388 wants a fourth production for user-defined gate strings, #454 proposes @non-exhaustive(u16), #235 floats @get/@set, and in that same thread @lukewagner anticipates shared, async, and reentrant as function attributes.
Back in 2024, #332's read was that a generic annotation syntax "opens a can of worms that require a lot more thought and discussion". Fair at the time. But the attributes aren't released yet, so this seems like the moment to ask the question before there are five of them.
The shape here isn't new either — it's the first reply on #58, where @lukewagner floated "defining syntax for literal values of all the interface value types" alongside "CapnProto ... explicit declarations and validation". Both halves exist now. WAVE is moving into this repo in #639, and Cap'n Proto style declarations just need somewhere to live, which is #694.
On typed versus untyped, I'll take the answer the #58 thread already reached: @Pauan documented Rust's token-soup rule and concluded "that flexibility is probably overkill for Wasm".
Why a payload type, in more detail
A parameter list would cover most annotations. record for named arguments is what you'd reach for anyway, and list<T> and option<T> handle multiplicity and optionality fine.
Where it runs out is closed choice. Nothing in @annotation http(get: option<string>, put: option<string>, ...) says "exactly one of these". A variant says it, and a variant has to be a type.
The most widely deployed annotation I know of needs exactly that. google.api.http is how every Google API declares its REST mapping:
message HttpRule {
string selector = 1;
oneof pattern {
string get = 2;
string put = 3;
string post = 4;
string delete = 5;
string patch = 6;
CustomHttpPattern custom = 8;
}
string body = 7;
repeated HttpRule additional_bindings = 11;
}
A oneof is a variant, so that half ports directly.
The other half doesn't, and I'd rather point at it than skip past it. additional_bindings is a list of the message it sits inside, and WIT forbids recursive types — WIT.md is explicit ("record cannot refer to itself"), and Explainer.md notes there's no rectype analogue in the type grammar. So HttpRule isn't expressible in WIT today, as a payload type or as anything else.
I think that cuts in favor of the type-based shape rather than against it. Whether WIT gets recursive types is a question about the type system, not about annotations. If it's ever answered, annotations that take types inherit the answer for nothing, whereas annotations with bespoke parameter lists would need the grammar extended a second time.
One more thing worth taking from additional_bindings: Google gets several URL mappings per method from a repeated field inside the payload rather than from a repeatable option. That's why I don't think a repeatability flag is needed either. @annotate(tooling:rust/derive, ["Hash", "Eq"]) beats applying the annotation twice and then defining a merge policy.
Two smaller ones from my own protos, both about placement rather than payload shape: a UUID-derivation option attaches both to an enum and to individual enum values, and a NATS one declares two annotations in one package with a different payload type per position.
Placement rules, and the one default I'd settle early
The original sketch had a declarations field naming the positions an annotation may attach to:
@annotation(declarations = [record, variant, enum])
type derive = list<string>;
Protobuf makes this mandatory in practice — you have to pick something to extend. Java's @Target and C#'s AttributeUsage make it optional.
This can be a follow-up. The argument list on @annotation is the extension point, so fields can arrive later without touching the grammar again. Deferring it also defers declaration-kind, which is the largest piece of new machinery in the whole idea.
The part I'd separate out is that adding a field later is free, but deciding what its absence means is not. Java and C# both left omission meaning effectively everywhere, and neither can narrow that now without breaking existing code. While annotations are gated and unreleased, either answer is still available. Once a bare @annotation ships meaning "anywhere", that's the answer permanently. So I'd rather declarations become required before the feature stabilizes, even if it isn't in the first cut.
If it does land, list<declaration-kind> with no universal case seems cleanest, since any and [record, enum] can't both be values of one type, and protobuf gets by without one.
That leaves declaration-kind itself: a closed enum of WIT declaration forms (record, func, record-field, variant-case, and so on). Nothing unified exists today — wit-parser has TypeDefKind, WorldItem and FunctionKind as fragments. Is that worth defining as general reflection rather than something annotation-private?
Retention, and why I don't think it's a new axis
#58 drew this line on 2022-12-21, separating annotations "only meant to be meaningful to a particular language or host", which are fine "as long as we say you can always strip these sorts of annotations", from ones that "logically extend the URL, adding a data payload that is passed to the host". It ended with "perhaps this use case should be considered separately", and the thread went quiet.
A per-annotation retention field is that same line, drawn by the annotation author instead of the spec picking a side and shipping two features:
@annotation(retention = binary)
type alias-of = list<string>;
#307 wants the same thing for an instance-reuse hint — a defined section rather than a custom one, so it "couldn't be indiscriminately stripped". Smithy is the deployed version: @stevelr noted in that thread that @sensitive is consumed at runtime by logging libraries while @required is codegen-only.
Not-stripped-by-default is a reasonable starting policy and I'm happy for the field to wait for a use case that needs the other setting.
Binary encoding and round-tripping
I'd start from what exists rather than #58's custom-section-versus-name fork. attribute ::= versionsuffix | implements | externid is already defined on both import and export, and attributes there are ignored by type checking. That satisfies #58's round-trip requirement — "Wit should be co-expressive with component types so that we can render an arbitrary component's type as Wit and also do a rough roundtrip" — and #672 shipped with round-tripping in wasm-tools.
@lukewagner's reply lands in the same place, via typeidx and valueidx.
The caveat I'd want help with is @alexcrichton's point on #613: attribute work has already pushed bindings generators off the WIT AST and onto a nominal post-processed form, so a retained annotation has to survive that too.
Positions, and where @ is rejected today
I'd want attachment allowed everywhere from the start, including the places @ is rejected now.
Verified against wasm-tools 1.255.0: gates work on interface and world items, on resource declarations, and on resource methods, but they're a parse error on record fields, variant cases, and enum cases. That's exactly where #58's motivating examples live (deprecate a field, gate a new case), where #312 wants parameters, and where #454 wants non-exhaustiveness.
An unrelated bug I noticed while checking: WIT.md's resource-method production has no gate prefix, yet wasm-tools accepts gates there and WASI interfaces depend on it. Happy to send that one-line fix separately.
Things I'm deliberately not proposing
Happy to write this up properly if the shape holds — syntax in WIT.md, encoding in Binary.md, semantics in a new Annotations.md, alongside a wasm-tools PR the way #672 was done.
WIT has three
@attribute productions today —gate(@since/@unstable/@deprecated),@implements, and@external-idEach with its own placement rules, and two of them landed this year.
Several more are queued in other issues: user-defined gate strings (#388),
@non-exhaustive(#454),@get/@set(#235), andshared/async/reentrantas function attributes. So: should the next one be general?An annotation is a package-scope type, and applying it is a WAVE value of that type.
Declaring:
Applying:
@annotate(tooling:rust/derive, ["Hash", "Eq"]) record object-id { @annotate(tooling:rust/rename, "objectID") id: string, }Why a type instead of a bespoke parameter list? The obvious alternative is to give
@annotationa signature like a function's —@annotation derive(names: list<string>)— but that's a second, weaker type system that you then have to grow. As soon as an annotation needs "exactly one of these six", a parameter list can only offer six optional fields and a comment asking you to set one. That's avariant, and avariantis a type. Make the payload an ordinary WIT type and annotations inherit the whole type system, including whatever gets added to it later.Why package-qualified? Bare
@wordstays the platform's. Users never take a bare name, so everything in the queue above can still land as a built-in later without colliding with anything anyone wrote. Collisions between user annotations become impossible, and versioning falls out of ordinary package deps rather than a registry.This leans on two things in flight: #639 for WAVE literals, and #694 for a place to put package-scope types. String-only payloads would work with today's grammar, since
@external-idalready takes a string literal.What would help most is use cases from people shipping WIT: which annotations you'd declare, where you'd attach them, and whether anything downstream needs to read them at runtime rather than at build time.
Why now: the built-in attribute queue
This picks up #58, which ends with an invitation to bring something concrete.
What's changed since is that the same surface has grown one hardcoded attribute at a time.
gatecame in #332,implementsin #613,external-idin #672, with the last two merged this year and each carrying its own position rules. Waiting behind them: #388 wants a fourth production for user-defined gate strings, #454 proposes@non-exhaustive(u16), #235 floats@get/@set, and in that same thread @lukewagner anticipatesshared,async, andreentrantas function attributes.Back in 2024, #332's read was that a generic annotation syntax "opens a can of worms that require a lot more thought and discussion". Fair at the time. But the attributes aren't released yet, so this seems like the moment to ask the question before there are five of them.
The shape here isn't new either — it's the first reply on #58, where @lukewagner floated "defining syntax for literal values of all the interface value types" alongside "CapnProto ... explicit declarations and validation". Both halves exist now. WAVE is moving into this repo in #639, and Cap'n Proto style declarations just need somewhere to live, which is #694.
On typed versus untyped, I'll take the answer the #58 thread already reached: @Pauan documented Rust's token-soup rule and concluded "that flexibility is probably overkill for Wasm".
Why a payload type, in more detail
A parameter list would cover most annotations.
recordfor named arguments is what you'd reach for anyway, andlist<T>andoption<T>handle multiplicity and optionality fine.Where it runs out is closed choice. Nothing in
@annotation http(get: option<string>, put: option<string>, ...)says "exactly one of these". Avariantsays it, and avarianthas to be a type.The most widely deployed annotation I know of needs exactly that.
google.api.httpis how every Google API declares its REST mapping:A
oneofis avariant, so that half ports directly.The other half doesn't, and I'd rather point at it than skip past it.
additional_bindingsis a list of the message it sits inside, and WIT forbids recursive types — WIT.md is explicit ("record cannot refer to itself"), and Explainer.md notes there's norectypeanalogue in the type grammar. SoHttpRuleisn't expressible in WIT today, as a payload type or as anything else.I think that cuts in favor of the type-based shape rather than against it. Whether WIT gets recursive types is a question about the type system, not about annotations. If it's ever answered, annotations that take types inherit the answer for nothing, whereas annotations with bespoke parameter lists would need the grammar extended a second time.
One more thing worth taking from
additional_bindings: Google gets several URL mappings per method from a repeated field inside the payload rather than from a repeatable option. That's why I don't think a repeatability flag is needed either.@annotate(tooling:rust/derive, ["Hash", "Eq"])beats applying the annotation twice and then defining a merge policy.Two smaller ones from my own protos, both about placement rather than payload shape: a UUID-derivation option attaches both to an enum and to individual enum values, and a NATS one declares two annotations in one package with a different payload type per position.
Placement rules, and the one default I'd settle early
The original sketch had a
declarationsfield naming the positions an annotation may attach to:Protobuf makes this mandatory in practice — you have to pick something to
extend. Java's@Targetand C#'sAttributeUsagemake it optional.This can be a follow-up. The argument list on
@annotationis the extension point, so fields can arrive later without touching the grammar again. Deferring it also defersdeclaration-kind, which is the largest piece of new machinery in the whole idea.The part I'd separate out is that adding a field later is free, but deciding what its absence means is not. Java and C# both left omission meaning effectively everywhere, and neither can narrow that now without breaking existing code. While annotations are gated and unreleased, either answer is still available. Once a bare
@annotationships meaning "anywhere", that's the answer permanently. So I'd ratherdeclarationsbecome required before the feature stabilizes, even if it isn't in the first cut.If it does land,
list<declaration-kind>with no universal case seems cleanest, sinceanyand[record, enum]can't both be values of one type, and protobuf gets by without one.That leaves
declaration-kinditself: a closed enum of WIT declaration forms (record,func,record-field,variant-case, and so on). Nothing unified exists today — wit-parser hasTypeDefKind,WorldItemandFunctionKindas fragments. Is that worth defining as general reflection rather than something annotation-private?Retention, and why I don't think it's a new axis
#58 drew this line on 2022-12-21, separating annotations "only meant to be meaningful to a particular language or host", which are fine "as long as we say you can always strip these sorts of annotations", from ones that "logically extend the URL, adding a data payload that is passed to the host". It ended with "perhaps this use case should be considered separately", and the thread went quiet.
A per-annotation
retentionfield is that same line, drawn by the annotation author instead of the spec picking a side and shipping two features:#307 wants the same thing for an instance-reuse hint — a defined section rather than a custom one, so it "couldn't be indiscriminately stripped". Smithy is the deployed version: @stevelr noted in that thread that
@sensitiveis consumed at runtime by logging libraries while@requiredis codegen-only.Not-stripped-by-default is a reasonable starting policy and I'm happy for the field to wait for a use case that needs the other setting.
Binary encoding and round-tripping
I'd start from what exists rather than #58's custom-section-versus-name fork.
attribute ::= versionsuffix | implements | externidis already defined on bothimportandexport, and attributes there are ignored by type checking. That satisfies #58's round-trip requirement — "Wit should be co-expressive with component types so that we can render an arbitrary component's type as Wit and also do a rough roundtrip" — and #672 shipped with round-tripping in wasm-tools.@lukewagner's reply lands in the same place, via
typeidxandvalueidx.The caveat I'd want help with is @alexcrichton's point on #613: attribute work has already pushed bindings generators off the WIT AST and onto a nominal post-processed form, so a retained annotation has to survive that too.
Positions, and where
@is rejected todayI'd want attachment allowed everywhere from the start, including the places
@is rejected now.Verified against wasm-tools 1.255.0: gates work on interface and world items, on
resourcedeclarations, and on resource methods, but they're a parse error on record fields, variant cases, and enum cases. That's exactly where #58's motivating examples live (deprecate a field, gate a new case), where #312 wants parameters, and where #454 wants non-exhaustiveness.An unrelated bug I noticed while checking: WIT.md's
resource-methodproduction has nogateprefix, yet wasm-tools accepts gates there and WASI interfaces depend on it. Happy to send that one-line fix separately.Things I'm deliberately not proposing
@sinceon an interface to reach its items, and @lukewagner raised the same during Add @feature and @since gates to WIT #332. But that debate is about what silence means for an ungated item, which is gate semantics rather than metadata inheritance. Better settled in wit: Propagate feature gate annotations to inner items #559.list<T>andoption<T>cover multiplicity and optionality without needing a merge policy. On defaults, @badeend's argument in Default values #15 applies: a default that participates in subtyping has to be known to glue code, which makes it a type-system feature.@get/@set. That stays Resource properties #235's call; this would only be the mechanism it could ride.@production with "if it takes us too long to make a real proposal, we could go with your soft reservation idea". @bbb651's point in Function parameter documentation #533 is the reason to prefer the grammar: comment-level annotations aren't syntax errors when malformed, so every tool ends up with its own dialect.Happy to write this up properly if the shape holds — syntax in WIT.md, encoding in Binary.md, semantics in a new Annotations.md, alongside a wasm-tools PR the way #672 was done.