Extending Aperture
Audience: contributors adding a new extension point to the engine or a surface.
Each recipe below is a short, concrete how-to grounded in the real code: the files to touch, the interface to implement, and the test or gate you must satisfy. They assume you have read Architecture and Package layout. Every recipe leans on a package’s concept chapter for the why — follow those links rather than re-deriving the domain here.
Run make test (go test ./...), make vet, and make lint before you open a
PR. Some changes also trip the Update-Demand rule — a
surface change that lands without its skills/*.md doc is a CI failure.
Adding an error code
Concept: Error taxonomy. Files: errors/codes.go,
then make docs-gen.
-
Declare the constant in the
constblock inerrors/codes.go. Codes areSCREAMING_SNAKE,APERTURE_-prefixed, typedCode, with a doc comment explaining when it is raised:// APERTURE_WIDGET_JAMMED — the widget resolver could not advance. APERTURE_WIDGET_JAMMED Code = "APERTURE_WIDGET_JAMMED" -
Append it to
AllCodes— the slice every gate walks. -
Add a
Registryentry with aMessageand either at least oneFixuporFixupNotApplicable: true:APERTURE_WIDGET_JAMMED: { Message: "the widget resolver could not advance", Fixups: []string{"retry the request", "check the widget provider health"}, }, -
Regenerate the reference table:
make docs-genrerunsinternal/docsgen/errcodesovererrors.Registryand rewritesdocs/src/reference/error-codes.md(committed; no CI drift gate). Commit the regenerated file with your change.
Gates you must satisfy (in errors/codes_test.go):
TestCodesHaveFixups— every code has a Registry entry with a Message and a Fixup (orFixupNotApplicable).TestRegistryHasNoOrphans— the Registry contains nothing absent fromAllCodes.TestCodesAreScreamingSnakeNamespaced— every code isSCREAMING_SNAKEandAPERTURE_-prefixed.
Construct the error at the raise site with errors.New / Newf / Wrap /
Wrapf (or errors.WithContext for a details map); recover it with
errors.CodeOf. Any error already carrying an APERTURE_* code passes through
verbatim — the wrappers never re-stamp it.
Adding a scope strategy
Concept: Scopes & scope strategies. Files:
scope/ (a new resolver + factory), then register it on your scope.Registry.
A scope strategy decides a grant’s object membership. Implement
scope.ScopeResolver:
type ScopeResolver interface {
Contains(ctx context.Context, object identity.Identity) (bool, error)
Members(ctx context.Context, pattern identity.Pattern) ([]identity.Identity, error)
}
Containsanswers the hot-path question “is this concrete object a member?” and must never enumerate.Membersperforms a bounded enumeration (bounded byscope.DefaultMaxMembers) forEnumerate-style callers. If it needs to list “all objects of a type”, it consults the injectedscope.ObjectLister; when none is configured, returnAPERTURE_SCOPE_LISTER_UNCONFIGURED.
Provide a scope.Factory that validates the parsed scope.Spec for your
strategy and captures the GrantContext + Deps:
func newWidgetResolver(gc scope.GrantContext, deps scope.Deps) (scope.ScopeResolver, error) { … }
Register it under a key on a scope.Registry (Register/MustRegister). Reuse
the built-ins with scope.DefaultRegistry() and add yours, or start from
scope.NewRegistry(). A resolver never computes specificity — that stays the
pattern’s job in the engine. Cover Contains and Members with a table test
alongside scope/resolvers_test.go; an unregistered key surfaces
APERTURE_SCOPE_UNKNOWN_STRATEGY, a bad spec APERTURE_SCOPE_INVALID.
Adding an object provider
Concept: Providers. Files: a new package (see
csvprovider/ as the reference impl), then register it on a
provider.Registry.
A provider is the host’s pull source for one object-type. Implement
provider.ObjectProvider:
type ObjectProvider interface {
Fetch(ctx context.Context, id identity.Identity) (Metadata, error)
List(ctx context.Context) ([]Object, error)
Query(ctx context.Context, filter Filter) ([]Object, error)
}
Fetchreturns an object’sMetadata(amap[string]any); a missing object must return anAPERTURE_NOT_FOUNDcoded error so the Registry can distinguish “absent” from a fault. A plain error is wrapped asAPERTURE_PROVIDER_FETCH.- Return a fresh map per object — cached
Metadatais treated as read-only and is never copied on read, so a shared map would race readers. Listis the unfiltered enumeration;Queryhonours aprovider.Filter(Pattern,Fields,Limit). Aperture re-enforcesPatternandLimiton the results, so a provider that ignores them is still correct, only slower.
Register it under its object-type key on a provider.Registry
(provider.NewRegistry(...)), which pairs each provider with a per-type metadata
cache. A *provider.Registry also satisfies scope.ObjectLister, so it wires
directly into the scope resolvers above. Mirror csvprovider/csvprovider_test.go
for coverage.
Adding an auth method
Concept: Authentication. Files: auth/ (a new
adapter), then select it in the server wiring.
Authentication is always external — Aperture consumes credentials, it never
issues them. Implement auth.Authenticator:
type Authenticator interface {
Authenticate(ctx context.Context, bearer string) (principalID string, claims Claims, err error)
}
- Fail closed: a missing, malformed, or unverifiable credential returns
APERTURE_UNAUTHENTICATED(no principal derivable) orAPERTURE_INVALID_TOKEN(credential failed verification) — never a silently-empty principal. - Resolve the principal through the shared claim→principal mapping so “which
claim is the principal id” stays configuration, matching the
oidcandparsecadapters (thedevadapter is the one exception — the bearer is the principal). Followauth/oidc.go/auth/parsec.goas the models and add a*_test.gobeside them.
The middleware in internal/server extracts the bearer, calls Authenticate,
and attaches the resolved auth.Principal to the request context via
auth.WithPrincipal (recovered downstream with auth.PrincipalFromContext).
Adding an MCP tool
Concept: MCP surface. Files: mcp/toolmeta/meta.go,
mcp/contract.go, mcp/handlers.go, mcp/tools.go, mcp/schema.go.
The MCP core is SDK-free: it imports no MCP SDK. Adding a tool touches the pure-data identity table and the typed contract, and the go-sdk adapter picks it up automatically.
- Identity — add a name constant and a description constant in
mcp/toolmeta/meta.go, and a{Name, Description}row toMeta(). This is the single source of truth both the core and themcp/gosdkadapter read, so they never drift. - Contract — define the typed
In/Outstructs inmcp/contract.go(alias the facade’s surface-neutral query types where possible). Keep the types non-cyclic; a field that would introduce a Go-level cycle must be typedanyso the JSON-Schema reflector stays error-free. - Handler — add a
func(context.Context, *service.Service, In) (Out, error)inmcp/handlers.go. Read-only only: every tool calls a facade READ or DECISION method (Check/Enumerate/Explain/Simulate/Get*/List*); no handler may mutate. - Wire it — add
toolmeta.ToolYours: makeInvoke(handleYours)to the map inmcp/tools.go, and aregister(...)call inmcp/schema.go’sinitso its input/output schemas are reflected.
Gates (mcp/surface_test.go, mcp/firewall_test.go):
TestCatalogMatchesToolmeta— the catalog matchestoolmeta.TestNoMutatingTool— no tool name carries a mutating verb (put/delete/create/update/bestow/revoke/grant/set/remove/write).TestSchemaReflectionClean— every tool’s schema reflects without error.TestMCPCore_NoSDKImport— the core imports no MCP SDK (onlymcp/gosdkmay).
Adding a rule AST node
Concept: Rules engine. Files: rules/ast.go,
rules/compiler.go.
The rule AST is a small, closed node set that is both the engine’s input and the node editor’s serialization target — there is no second rule format, and its JSON form must round-trip byte-identically. To add a node type:
- Declare a
NodeTypeconstant inrules/ast.go(alongsideNodeAnd,NodeCompare,NodeVar, …). Add any fields it needs to theNodestruct. - Validate — add a
casetoNode.Validate()that checks the subtree is structurally well-formed. Validation runs before compilation and is what keeps the rendered expression injection-free, so be strict here. - Render — add a
casetoNode.render()(reached viaNode.Expr()) that emits the node’sexpr-lang/exprspelling. Rendering to an existing operator spelling means ASTs that render to the same expression share a compiled program in the cache.
The compiler (rules/compiler.go) validates, renders, and compiles once per
canonical hash. Keep evaluation pure: expose no wall-clock or random builtins.
If your node calls a function, it must be one of the curated pure functions or
one a host explicitly registers via the rules.Function(name, fn) compiler
option. Add round-trip and render tests beside rules/ast.go.
Adding an RPC
Concept / reference: RPC / HTTP overview,
RPC reference. Files:
internal/wire/rpc/service.proto, the committed generated *.pb.go /
*.twirp.go, internal/server/twirp.go, and the hand-authored RPC reference doc.
- Declare the method (and any new request/response messages) on
ApertureServiceininternal/wire/rpc/service.proto. - Regenerate the Twirp + protobuf code with
make proto(requiresprotocprotoc-gen-go+protoc-gen-twirp). The generatedservice.pb.go/service.twirp.goare committed — CI does not regenerate them — so commit the regenerated files with your change.
- Implement the method on
twirpHandlerininternal/server/twirp.go. The handler is a thin translator: decode the request, resolve the actor/principal from context, call theservice.Servicefacade, and encode the result. ReturnAPERTURE_*coded errors verbatim; never put policy logic here. - Document it — the RPC reference is hand-authored over
service.proto(there is no generator; drift is accepted). Add the new method todocs/src/surfaces/rpc-reference.md. - Cover it — add a smoke test beside the matching
internal/server/*_smoke_test.go.