ADR 0021 — Endpoint meta passthrough (opaque per-endpoint metadata)
- Status: Accepted — extends ADR 0002
- Date: 2026-06-05
Context
EndpointDef has a fixed set of fields (method/path/desc/scope/
params/input/output/multipart/timeout + the tool fields toolName/
expose/ui/annotations). By ADR 0002 the core models no domain — so
there is no place to attach app-specific facts about an endpoint that the
framework should carry but not interpret: a feature gate (requiredFeature), a
rate tier, a cache-TTL hint, a doc/owner tag.
The only workaround in a consuming app is a side Map<toolName, X>, which is
brittle: HTTP-only endpoints have no toolName, and a hook’s MethodDef carries
no stable key to match on. Declaring the metadata next to the endpoint is
cleaner and reusable.
Decision
Add a generic, opaque metadata bag — the core gives it no meaning, exactly
as scope is a free string.
EndpointDef.meta?: Record<string, unknown>(on the shared base, so both HTTP-only and tool endpoints carry it).- It rides through
implement()toMethodDef.meta, unchanged. - Readable where the endpoint is already in scope: lifecycle hooks
(
beforeHandle/afterHandle/onError— second arg is theMethodDef) and tool mounts (collectTools→MountableTool.method.meta). - Never serialized into OpenAPI —
metais app-private, not part of the public HTTP contract; the generator reads only the contract fields, so it is excluded by construction. - Untyped in v1 — a plain
Record<string, unknown>; the consumer narrows when reading. No generic threading of ametatype throughdefineContractuntil there is real demand (avoids over-engineering).
Alternatives considered
- Side-map in the consumer (
Map<toolName, X>). Rejected: brittle — HTTP-only endpoints lack atoolName, no stable match key in hooks. - A fixed field per use case (
requiredFeature,rateTier, …). Rejected: it pulls the domain into the generic core — exactly what ADR 0002 forbids. - Generic
metatype ondefineContract. Deferred: real ergonomic cost now for speculative type-safety; revisit on demand.
Consequences
- Apps get a clean, declared escape-hatch for per-endpoint concerns the core deliberately does not model — consistent with ADR 0002 (generic core).
- Additive and optional: no behaviour change for endpoints without
meta(MethodDef.metaisundefined, never{}). - The core still models no domain — it transports the bag without reading it.
- If a typed
metais wanted later, it layers on without breaking this API. - Gotcha — declare meta as a
type/inline/satisfies, not aninterface. A TSinterfacehas no implicit index signature, so it is not assignable toRecord<string, unknown>; on the overloadeddefineContractthe failure misleadingly reports ascopemismatch. KeptRecord<string, unknown>over loosening toobject(which breaks the cast-freemeta?.xread in hooks and admits arrays/functions) or a genericmeta(whose typed read never reaches the lifecycle hooks, which see the baseMethodDef) — three independent reviews converged on documenting the idiom rather than changing the type. Documented indocs/guide/contracts.mdand theEndpointDef.metaJSDoc.