Go-native annotation SDK
Spice annotation extensions are ordinary versioned Go source. The application
selects descriptor packages with file-scoped comment imports, and the
application module will authorize their executable handlers with Go tool
directives. Spice does not define a plugin manifest, dependency resolver,
binary registry, or runtime annotation container.
File-scoped imports
Annotation imports remain valid Go comments:
// @import { Application } from "github.com/spice-framework/spice/annotation/core"// @import { Controller, Get as GET } from "github.com/spice-framework/spice/annotation/web"// @import * as web from "github.com/spice-framework/spice/annotation/web"
// @Application// @Controller// @web.Get(path="/orders")// @GET(path="/orders/{id}")func main() {}Named imports make framework-wide concepts concise. Aliases let an application choose a local spelling. Namespace imports retain visible provenance where two starters expose similar concepts.
Imports apply to the complete file regardless of their textual order. A file is fail-closed: every annotation must resolve through a named or namespace binding declared in that same file. Duplicate local names, malformed paths, private descriptor symbols, missing imports, and missing descriptor source are source-positioned errors. No built-in annotation registry or name-based semantic fallback participates in product analysis.
The physical source always contains // . GoLand concealment is presentation
only, so gofmt, Go Run, debuggers, Git, and copied text operate on valid Go.
The retired @spice.import spelling fails closed and is never folded or
resolved. The shared compiler emits an exact source diagnostic and a
document-version-checked @import replacement, so migration cannot silently
reinterpret or broadly rewrite a file.
Descriptor contract
Every annotation is one exported, documented function in its own .go file:
import ( "context"
"github.com/spice-framework/spice/annotation/sdk" annotationtool "example.com/starter/annotationtool")
// Controller marks a type whose methods are exposed through generated// net/http adapters.func Controller() sdk.Definition { return sdk.Definition{ Name: "web.Controller", Summary: "Marks an HTTP controller.", Targets: []sdk.Target{sdk.TargetType}, Arguments: []sdk.Argument{{ Name: "prefix", Kinds: []sdk.Kind{sdk.KindString}, Description: "Optional route prefix.", }}, Examples: []sdk.Example{{ Title: "Controller", Code: "// @Controller", }}, Compatibility: sdk.Compatibility{ Since: "0.1.0", MinimumSpice: "0.1.0", }, Implementation: sdk.Implementation{ Tool: annotationtool.Path, Handler: ControllerHandler, Protocol: sdk.ProtocolV1Alpha2, }, }}
// ControllerHandler is the actual generic contribution implementation.func ControllerHandler( _ context.Context, invocation sdk.Invocation,) (sdk.Result, error) { if err := invocation.RequireDescriptor( "example.com/starter/annotation/web", "Controller", ); err != nil { return sdk.Result{}, err } arguments, err := sdk.BindArguments(invocation, "", "prefix") if err != nil { return sdk.Result{}, err } prefix, err := arguments.String("prefix", false) if err != nil { return sdk.Result{}, err } return sdk.OneContribution(sdk.Contribution{ Kind: sdk.ContributionController, Controller: &sdk.ControllerContribution{ Prefix: prefix, }, })}The compiler loads application and selected descriptor packages in one
go/packages operation and statically decodes the returned composite literal.
It does not execute the function, package initialization, or any provider.
Descriptor bodies may contain only one return statement with a keyed
sdk.Definition composite literal. Nested metadata must also be keyed static
composite literals. Scalar values must be literals or exported SDK constants;
local constants, calls, control flow, computed expressions, and mutable
initialization fail.
Definitions require a summary, target, compatibility range, documented
example, documented arguments, supported value kinds, an exact protocol, a
fully qualified tool package, and a typed package-level sdk.Handler.
The descriptor and handler must be exported declarations in the same .go
file. The handler’s exact signature is
func(context.Context, sdk.Invocation) (sdk.Result, error). The compiler
derives its package and symbol from Go type information; descriptor authors do
not repeat a handler ID or source-symbol string. This metadata is the common
compiler, LSP, and GoLand contract for completion, documentation, descriptor
navigation, and direct implementation navigation.
An argument may additionally declare a semantic ValueDomain. The initial
domain, sdk.ValueDomainGoInterface, says that an identifier argument denotes
a named runtime Go interface. The SDK metadata, not an annotation-name switch,
causes the shared compiler service to expose its typed interface catalog and
the LSP to offer exact package/type completions. Constraint-only, anonymous,
pointer-to-interface, inaccessible, and unresolved types remain invalid.
Module and offline behavior
The typed compiler lexically discovers only import comments before its single semantic load so descriptor packages can be included in that same Go type universe. Semantic annotation resolution still examines only files selected by the active Go build.
Normal analysis forces GOPROXY=off. It uses -mod=vendor when
vendor/modules.txt exists and -mod=readonly otherwise. Missing module-cache
or vendor content is therefore an actionable load diagnostic and never an
editor-triggered download.
Before a file imports a descriptor, editor completion can discover its public
function from the target module graph, active workspace modules, local
replacements, vendor source, and already-populated module cache. This is a
bounded lexical catalog only: it recognizes exported exact
func() sdk.Definition declarations and literal identity/provenance fields,
but the existing typed-program decoder remains authoritative after insertion.
The catalog runs offline, does not execute descriptors or tools, and marks
whether the target application’s own go.mod declares the exact tool path.
Selecting a candidate always inserts a visible @import; discovery never
creates an implicit compiler binding.
The public SDK defines bounded Content-Length JSON-RPC framing and typed
initialize, describe, analyze, and shutdown messages. protocol.Serve
provides the matching panic-contained server loop so extension authors do not
reimplement framing or method dispatch.
The compiler parses the target root’s exact go.mod and rejects tools not
listed by an exact tool directive; a parent module or another go.work
member cannot authorize the process. It resolves package and module provenance
with offline go list, preserving selected versions and local or versioned
replacement identity. Descriptor and executable packages must resolve from the
same module version and replacement.
Authorized tools launch through the fixed command shape:
go tool <full-package-path> --spice-stdioThe descriptor cannot supply a binary path, shell, command-line fragment, or
environment mutation. The host negotiates exact v1alpha2
protocol/tool/module identity, requires describe to enumerate every public
descriptor package and descriptor-to-capability registration, rejects a
descriptor whose package or symbol is absent from that declaration, and
serializes calls over one persistent process per workspace and tool. Analyze
dispatch uses the descriptor’s Go symbol identity; arbitrary handler-name
strings are not part of the protocol.
Calls have bounded startup and request deadlines. Framing corruption, stdout contamination, a crash, a timeout, or cancellation fails the operation and terminates the complete process tree without replay. Windows processes are contained in kill-on-close Job Objects; Unix processes use dedicated process groups. Stderr is bounded and diagnostic-only.
Typed contributions
Every explicitly imported occurrence is sent to its descriptor’s declared handler after static descriptor validation. The invocation contains the canonical descriptor identity, normalized typed literal arguments, declaration target, stable Go symbol ID, package path, exact type identity when available, and non-executable declaration facts. It never contains a provider value or an instruction to execute application code.
Function annotations can inspect generic, compiler-produced result metadata
without parsing the declaration’s complete signature or importing compiler
packages. Invocation.FunctionResultFacts reads the optional reserved
go.function.results.* entries from the existing v1alpha2 Facts string map.
Each ordered result exposes:
- its original readable import-path-qualified type ID, preserving a source alias;
- its canonical type ID after top-level Go alias removal;
- its effective Go kind after reducing a named type to its underlying kind,
such as
interface,struct,pointer, ortype-parameter; and - for a canonical named type, the origin declaration’s package path and name.
An instantiated generic named type retains its concrete type arguments in the
canonical type ID, reports the underlying kind such as interface, and uses
its named origin to identify the generic declaration.
The predeclared error origin has name error and an empty package path.
Pointers and other non-named top-level types have no named origin. The public
FunctionResultFact, EncodeFunctionResultFacts, and
DecodeFunctionResultFacts contracts are annotation-neutral; they do not name
or special-case any Spice or third-party interface.
The fact set has an explicit canonical decimal count, stable zero-based keys,
a 64-result bound, and bounded UTF-8 type identities. A partial set, unknown
key inside the reserved namespace, unsupported kind, noncanonical index,
missing named origin, or malformed value fails closed when a handler elects to
decode it. Invocations from earlier v1alpha2 hosts have no reserved result
facts and are reported as absent, while unrelated future fact namespaces are
ignored. This evolution adds no JSON field: older v1alpha2 tools continue to
decode the same facts map and may ignore its new entries.
Handlers return the public sdk.Contribution discriminated union. The current
typed capabilities cover application roots, service stereotypes, providers,
explicit interface bindings, configuration, controllers, routes, modules,
named interfaces, lifecycle,
bootstrap features, scheduling, async execution, transactions, event topics
and listeners, caching, authorization, and guarded generated files. Both the
tool-side encoder and compiler-side decoder validate the selected kind and its
one matching payload. Unknown fields, unknown kinds, ambiguous payloads,
trailing JSON, malformed values, and duplicate contribution kinds fail before
the immutable application IR is built.
The compiler consumes contribution kinds and typed payloads, not annotation names. Any authorized third-party descriptor can contribute a supported capability without a compiler switch for that descriptor’s package or name. Official descriptors use exactly the same protocol path through:
github.com/spice-framework/toolchain/cmd/spice-annotation-coreAll 30 official descriptors have one public descriptor file, one declared
handler, a real implementation source symbol, rich GoDoc, compatibility
metadata, and examples. @Service, @Controller, and @Repository contribute
constructible stereotypes with deterministic ordinary Go constructor
selection. @Implements contributes typed named-interface expressions; the
compiler verifies exact method sets and plans a source-owned generated Go
assertion before adding an interface candidate. Its public descriptor marks
the positional argument with ValueDomainGoInterface, so a third-party
annotation can request the same compiler-owned authoring behavior without
being named Implements. Third-party architectural
stereotypes remain non-constructing unless their typed contribution explicitly
sets the construction contract.
Bean-selection annotations return the generic bean-metadata contribution:
@Qualifier("name")is repeatable on beans and exact constructor parameters.@Primaryand@Fallbackcontrol deterministic single-value selection.@Order(value)controls collection order.@Singleton,@Prototype,@RequestScope, and@SessionScopedeclare cleanup ownership.
The SDK contribution contains typed fields; handlers do not resolve candidates or construct values. Parameter invocations carry their exact parameter index, name, type identity, and physical source position through the same protocol. The compiler applies all selection and ownership rules generically, so a third-party annotation may contribute equivalent metadata without adding an annotation-name switch.
Use the read-only inspection commands:
go tool github.com/spice-framework/toolchain/cmd/spice annotations list ./...go tool github.com/spice-framework/toolchain/cmd/spice annotations doctor ./...list reports explicit descriptors and whether their tools are authorized.
doctor launches authorized tools, negotiates provenance, checks handlers and
source symbols, shuts them down, and reports every problem. Neither command
installs dependencies or changes module files.
spice verify, generate, build, run, dev, and lsp now share this
tool-aware compiler service. Verification uses validation mode, so it includes
committed generated files and does not require an application target;
generation mode excludes generated files while producing the guarded plan.
Author a third-party module
Keep each descriptor beside its real handler while leaving the executable tool command visibly separate:
example.com/acme/spice-mail├── annotation/mail/send.go├── internal/annotationtool/tool.go└── cmd/spice-annotations/main.goThe public descriptor package imports only
github.com/spice-framework/spice/annotation/sdk. The tool command may
also import github.com/spice-framework/spice/annotation/sdk/protocol; they do
not import compiler, internal/cli, or an official handler package. A
descriptor’s Implementation.Handler is a typed reference to the real
package-level handler in the same file, and the tool’s describe response
must report that descriptor registration.
Implement protocol.Tool as an instance-owned value. initialize checks the
exact tool and protocol identities, describe returns stable handler metadata,
analyze decodes normalized invocation facts and returns typed contributions,
and shutdown releases owned resources. protocol.Serve owns framing and
panic containment. Handlers must honor the caller context; they must not retain
an invocation, write generated files, print to stdout, scan ambient packages,
or execute application declarations.
The committed independent proof is split between
testdata/annotationfixture and
testdata/annotationapp. It demonstrates a named
core import, an aliased third-party provider import, a namespace-qualified
policy import, plugin-owned diagnostics, real descriptor and handler source
navigation, provider contribution, deterministic generated Go, ownership
checking, build, and execution. The fixture plugin imports only the public SDK
and protocol.
Publish and select a version
Tag the descriptor packages and tool command in the same Go module. Consumers select that one version with standard Go commands:
go get -tool example.com/acme/spice-mail/cmd/spice-annotations@v1.4.0go mod tidygo tool github.com/spice-framework/toolchain/cmd/spice annotations doctor ./...go get -tool adds the executable package to the application module’s tool
block and selects its module in the ordinary build list. The descriptor import
comment selects symbols from that same resolved module. Spice rejects a
descriptor and tool that differ in module path, version, or replacement
identity. Removing the integration uses the standard command:
go get -tool example.com/acme/spice-mail/cmd/spice-annotations@nonego mod tidyBecause tool dependencies participate in minimal-version selection, extension authors should keep their dependency surface small and publish compatibility ranges honestly. Do not hide a second dependency solver or download path in the annotation process.
In an LSP client such as GoLand, importing an annotation whose exact tool is
not declared offers a two-step quick fix. Preview runs the displayed
go get -tool command against a temporary sibling modfile, shows the exact
go.mod/go.sum unified diff, and changes no application file. A separate
Apply previewed action is the confirmation. It accepts only the
content-derived preview token and only while both original module-file hashes
still match; guarded staged replacement rolls back on failure. Re-previewing
replaces an older plan. Editor completion and ordinary analysis remain offline
and never invoke this path implicitly.
Local development and workspaces
Use a normal replacement while developing an application and extension together:
require example.com/acme/spice-mail v0.0.0
replace example.com/acme/spice-mail => ../spice-mail
tool example.com/acme/spice-mail/cmd/spice-annotationsgo.work can make both modules convenient to edit, but it does not authorize
the tool. The application module’s own go.mod must retain the tool
directive. Editor documentation labels a local replacement explicitly and
shows its source directory; it never presents local source as checksum-verified
published content.
Vendor and offline operation
Run the ordinary Go workflow:
go mod tidygo mod vendorgo test -mod=vendor ./...go tool github.com/spice-framework/toolchain/cmd/spice annotations doctor ./...go tool github.com/spice-framework/toolchain/cmd/spice generate --check ./...When vendor/modules.txt exists, Spice uses -mod=vendor; otherwise it uses
-mod=readonly. It always sets GOPROXY=off for analysis and tool launch.
Therefore editor completion, hover, diagnostics, navigation, generation, and
verification cannot download missing code. Install or vendor the dependency
deliberately when the diagnostic says its source is unavailable.
Trust and review
An annotation tool is a native executable with the developer’s permissions. It is not sandboxed. Before authorizing one, review its maintenance, license, release provenance, dependencies, cancellation behavior, network/file access, diagnostic quality, and generated-output requests. Capability declarations are inspectable compatibility metadata, not a security boundary.
Spice narrows the execution surface by requiring an exact application-owned
tool directive, an exact full package path, offline Go resolution, protocol
and module identity negotiation, bounded framing and stderr, deadlines,
process-tree cancellation, no replay, and guarded generation. Those controls
do not make an untrusted native process safe.
GoLand authoring loop
With the Spice plugin installed, type @ or edit an @import. Completion
shows the descriptor package, selected version or replacement, tool, and
handler. Accepting a completion adds a visible named or namespace import when
needed. Modifier-click opens the one-file descriptor; Go to Implementation
opens the handler; Quick Documentation renders its GoDoc, arguments, examples,
compatibility, provenance, and protocol metadata.
Inside a Go-interface value domain, completion is generated by the same
go/packages type universe used by verification and generation. GoLand does
not enumerate its own index to decide what Spice may inject. Accepting an
external interface adds a namespace @import for its Go package when needed.
The Spice generator—not the editor—emits the mandatory compile-time assertion
in a source-owned generated shard. Native Implement Methods remains a useful
authoring and offline fallback, but it cannot register or select a bean; the
next Spice analysis is authoritative.
The editor does not install a missing tool silently. Use go get -tool
directly until the preview-and-confirm module edit action is available. Then
run spice annotations doctor to validate the executable identity and every
selected handler before generation.