Developer Annotations

Xema replaces handwritten route and capability manifests with decorators that are the source of truth. A developer writes a controller; convention inference classifies every route and derives its capability surface at boot, so the description of what a service exposes lives next to the code that exposes it.

Annotations describe what exists. They are not the access check — access is decided by capability grants and the policy decision point, documented on Permissions & Access. Keep that distinction in mind while reading: a decorator classifies a route, a guard fences it, and a grant authorizes the caller.

This page documents the decorator set, the convention-inference rules, and the escape hatches.


The decorator set

All decorators live in @xemahq/xema-decorators. Import what you use; the rest stay out of your code.

@XemaResource(...)

Declares the resource this controller serves. One per controller.

ts
1@XemaResource('invoice')
2@Controller('orgs/:orgId/projects/:projectId/invoices')
3export class InvoiceController { /* ... */ }

Long form when you need to be explicit:

ts
1@XemaResource({
2  resource: 'invoice',
3  spaceFrom: ProjectSpace.fromParam('projectId'),
4  tags: ['finance'],
5})
6@Controller('orgs/:orgId/projects/:projectId/invoices')
7export class InvoiceController { /* ... */ }

The resource name flows into every operationId and capability ref derived under this controller.

@XemaRoute(...)

Declares that a method is a Xema-managed route. Empty by default — the inference engine fills in the action and the operationId from the method name and the HTTP verb.

ts
1@XemaResource('invoice')
2@Controller('orgs/:orgId/projects/:projectId/invoices')
3export class InvoiceController {
4  @XemaRoute()
5  @Get(':id')
6  read(@Param('id') id: string) { /* ... */ }
7  // → action = 'read', operationId = 'invoice.read', label = 'invoice.read'
8
9  @XemaRoute()
10  @Get()
11  list() { /* ... */ }
12  // → action = 'list', operationId = 'invoice.list', label = 'invoice.list'
13
14  @XemaRoute()
15  @Post()
16  create(@Body() body: CreateInvoiceDto) { /* ... */ }
17  // → action = 'create', operationId = 'invoice.create'
18}

Override when the convention doesn't fit:

ts
1@XemaRoute({ action: 'export', operationId: 'invoice.exportPdf' })
2@Get(':id/pdf')
3exportPdf() { /* ... */ }

@XemaPublicRoute(...)

Declares the route is intentionally reachable without an authenticated caller. You must say why, and reason is a closed set (PublicRouteReason) rather than free text — an unrecognised value throws at startup:

ts
1import { PublicRouteReason, XemaPublicRoute } from '@xemahq/xema-decorators';
2
3@XemaPublicRoute({
4  reason: PublicRouteReason.OpenApiSpec,
5  rateLimit: { perMinute: 60 },
6})
7@Get('openapi.json')
8getOpenapi() { /* ... */ }

Use PublicRouteReason.Unspecified only as a deliberate fallback — it requires a non-empty notes string. The reason travels with the route's classification, so "which routes are open, and on what grounds" is answerable from the service's own generated description rather than from memory.

@XemaInternalRoute(...)

Classifies a route as service-to-service — an internal audience rather than a browser-facing one. audience is a single value from a closed set: service, admin, or worker.

ts
1import {
2  InternalRouteAudience,
3  XemaInternalRoute,
4} from '@xemahq/xema-decorators';
5import { ServiceActorGuard } from '@xemahq/platform-common';
6
7@UseGuards(ServiceActorGuard)
8@Controller('hooks')
9export class RunHooksController {
10  @XemaInternalRoute({ audience: InternalRouteAudience.Service })
11  @Post('run-finished')
12  handleRunFinished() { /* ... */ }
13}

The decorator classifies; the guard enforces. This is the single most important thing to get right on this page. @XemaInternalRoute records an audience — it keeps the route out of the public client bundle and states who it is meant for. It installs no runtime check of its own, and nothing derives an admission decision from the audience value. A route that must refuse external callers says so with an explicit guard (ServiceActorGuard, or ServiceTokenGuard for a shared-secret caller), exactly as above.

Omitting the guard leaves the route mounted and reachable by any caller the service's ambient authentication already admits. Classification is not a fence; treat the two as separate obligations and satisfy both.

@XemaIgnoreRoute(...)

Declares the route is not Xema-managed. You must say why — this is the escape hatch for legacy endpoints during migration:

ts
1@XemaIgnoreRoute({ reason: 'Legacy health endpoint — to be deleted after Phase X' })
2@Get('healthz')
3health() { /* ... */ }

@XemaCapability(...) / @XemaCapabilities(...)

Declares that a method exposes a capability — the invocation surface other biomes and agents call through the capability router.

ts
1@XemaCapabilities({ resource: 'invoice', version: '1', spaceFrom: ProjectSpace.fromParam('projectId') })
2@Controller(...)
3export class InvoiceCapabilityController {
4  @XemaCapability()         // ref defaults to 'invoice.extract@1'
5  @Post('extract')
6  extract(@Body() input: ExtractInput) { /* ... */ }
7
8  @XemaCapability('invoice.send-reminder@1')   // explicit ref
9  @Post('send-reminder')
10  sendReminder() { /* ... */ }
11}

Space-from helpers

Every protected resource is anchored to a Space. The spaceFrom field of @XemaResource and @XemaCapability says how to compute the SpaceRef from the inbound request:

ts
1import {
2  InputSpace,
3  ProjectSpace,
4  RequestSpace,
5  SpaceFromKind,
6} from '@xemahq/xema-decorators';
7
8ProjectSpace.fromParam('projectId')      // URL param
9ProjectSpace.fromStatic('xema://org/acme')  // fixed SpaceRef
10RequestSpace.fromHeader('x-xema-org-id') // request header
11InputSpace.fromField('projectId')        // body field

These helpers are convenience constructors over one closed union, SpaceFromSpec, whose kind is param, field, header, or static. Adding a fifth kind is a platform change, because the runtime resolver has to know how to interpret it — so declare the spec, rather than writing a resolver of your own.


The convention dictionary — action inference

When @XemaRoute() is empty, the inference engine picks an action from a closed dictionary matched against the method name:

Method name patternInferred action
get, read, fetch, show, find (single)read
list, index, search, findAlllist
create, add, post, newcreate
update, patch, edit, modifyupdate
replace, put, setreplace
delete, remove, destroydelete
export, downloadexport
approve / rejectapprove / reject
send, notify, dispatchsend
run, execute, triggerrun
extract, parse, analyzeextract

If none match, startup fails with a clear diagnostic:

text
1[XemaRuntime] Controller InvoiceController method 'crunchNumbers':
2  Unable to infer action from method name.
3  Fix options:
4    • Rename the method to one of: read, list, create, ...
5    • Add an explicit action: @XemaRoute({ action: 'crunch' })
6    • Mark as ignored: @XemaIgnoreRoute({ reason: '...' })

The dictionary is intentionally small and closed. It grows only through a kernel PR — never by org or biome opt-in — so the meaning of a verb stays uniform across the entire platform.


OperationId inference

By default the OpenAPI operationId is <resource>.<action> — e.g. invoice.read — falling back to <resource>.<methodName> when action inference does not yield a verb.

operationId identifies the operation. It names the method on the generated client, so it has to be unique and it is stable across regenerations. Treat it as part of your API contract.

Override it when the inferred name is not the one you want on the client:

ts
1@XemaRoute({ operationId: 'invoice.exportPdf' })
2@Get(':id/pdf')
3exportPdf() {}

There is no longer a permission label

Earlier versions of this decorator also derived a permission label (<resource>.<action>), emitted it onto the generated specification as x-xema-permission, and let you override it with @XemaRoute({ permission }).

That is removed as of @xemahq/xema-decorators 0.15.0, and the reason is the one the old text admitted itself: the label authorized nothing. Nothing in the request path read it, and — measured across every Xema repository — nothing anywhere read it off a generated specification either. It was produced and never consumed, so an author writing permission: 'invoice.export' was writing a field that no code could observe.

@XemaRoute({ permission }) no longer exists. Remove it; the action and operationId options are unaffected.

What decides whether a caller may proceed has not changed, and never involved this label: it is a capability grant evaluated by the policy decision point — see How access is actually decided below.


Boot-time discovery — XemaRuntimeModule.forService

The decorator surface needs one wiring point per service:

ts
1import { XemaRuntimeModule } from '@xemahq/xema-decorators';
2
3@Module({
4  imports: [
5    XemaRuntimeModule.forService({
6      serviceName: 'invoice-api',
7      serviceKind:  'biome-api',       // 'platform-service' | 'biome-api' | 'cli'
8      discovery:    'decorators',      // or 'manual' where decorator scanning is unavailable
9      policy:       'fail-closed',     // or 'fail-open' (dev only)
10    }),
11  ],
12})
13export class AppModule {}

At boot the module:

  1. Scans every controller for the decorators above.
  2. Fail-closed startup: any unclassified route is a startup error. The error message lists fix options.
  3. Writes routes, capabilities, and service manifests to a temporary directory as startup diagnostics — a snapshot of what this process classified, useful when a startup error needs explaining. They are not part of the image, they are not read back, and nothing downstream consumes them. Override the location with manifestOutputDir if the default is not writable.
  4. Optionally publishes the service's fully-described capabilities to the capability registry, when the service opts in with registerCapabilities: true. That publish is the authoritative one, and it is complete-set: a capability the service stops declaring is withdrawn on the next boot.

The durable description of a service's routes is its generated OpenAPI document, which carries the classification for every operation. That is what clients and tooling read.

fail-open exists only for local development. XemaRuntimeModule.forService refuses it outright under a production environment and throws at construction, so a service cannot start in a cluster with unclassified routes tolerated.


How access is actually decided

Developers define what exists. Admins decide who can do it — and the unit they work with is the capability ref.

A capability ref (billing:invoice.read@1) names one specific action. Everything an admin does is expressed against those refs:

Admin actionWhat it is
Grant"This subject may use this capability, in this environment." The one thing the verdict checks.
RoleA reusable bundle of grants. Grant the bundle once, hand it to people.
TeamA group of subjects, with nesting. Assign a role to a team and every member — and every sub-team — inherits it.

Each grant carries its own posture: the environment it applies in, optional constraints (call-rate and cost ceilings), and whether an invocation needs a named human approver before it proceeds. Those are properties of the grant, so the same capability can be routine in one environment and approval-gated in another without the developer changing anything.

At request time a single policy decision composes the verdict — grant, delegation clamp, agent reach tier, resource ownership, credential binding — and returns allow, deny, or needs_approval, fail-closed. The full contract is on the Policy page; the model as a whole, including how an agent's ceiling interacts with its owner's authority, is on Permissions & Access.

The admin never edits the developer's annotations. The developer never edits the admin's policy.


What this replaces

Before annotations, a service described itself in hand-maintained side files — a route list, a capability list — kept in step with the controllers by whoever remembered. They drifted, and the drift was invisible until something downstream broke.

After annotations:

  • The description is derived from the code, on every boot. There is no side file to update and none to forget.
  • A new endpoint is a one-line addition (@XemaRoute() @Get(':id') read() {}), and its action, operationId, and space resolver all follow from it.
  • An unclassified route is a startup error rather than a silent omission, so a surface cannot ship undescribed.
  • The single source of truth lives next to the code it describes.

What annotations do not replace is the access decision. Classifying a route says what it is; a guard says who may reach it, and a grant says who may act. Those remain explicit.


  • Permissions & Access — how access is actually decided, end to end.
  • Capabilities — the invocation surface @XemaCapability produces, and the unit every grant names.
  • Policy — the verdict shape, obligations, and approval flow.
  • Environments — the trust profile a grant is scoped to.
  • Spaces — spaceFrom resolves the SpaceRef for every request.
  • Service registry — how a service announces itself to its peers.

Previous: ← MCP and Capabilities Next: CLI →