ADR-0043 — KavoContext.app (an app-defined context) replaces principal
Status: accepted — amends ADR-0032 §217 and ADR-0037
Context
KavoContext.principal: unknown was a single fixed, untyped slot. Core never populated or inspected it (doc 01 §8); it was whatever KavoCallOptions.principal carried, null otherwise, and four consumers read it — custom operation handlers, computed-field resolvers, the read result-cache key, and a policy function. ADR-0032 §217 pinned it as unknown on purpose: "nothing about this ADR narrows the context contract itself."
That shape forced every application into one opaque box named for one use (authentication), with no typing. Applications want to carry their own request-scoped context — the authenticated user, a tenant id, a locale, a request id, feature flags — as a typed object they define, and have Kavo thread it through unchanged.
A generic type parameter (KavoContext<Entity, Ctx>) was rejected: @Kavo is a decorator and cannot infer a generic from a module-level option, so the type would erase at the Nest controller boundary. A process-wide augmented interface — the Express.Request pattern — types every context.app.* read for free and matches how request-scoped context actually works: one shape per application, not per entity.
Decision
KavoContext.principal is removed entirely. In its place:
KavoContext.app: KavoAppContext— a per-request object Kavo carries but never populates, inspects, or shapes.KavoAppContextis an empty interface exported from the@kavo/corebarrel (ADR-0010), widened by the application through declaration merging:tsdeclare module "@kavo/core" { interface KavoAppContext { userId: string; roles: string[]; } }Unaugmented it is
{}, and every field read is a compile error — the signal to declare the fields the application uses. Coresrcstays zero-import (ADR-0005):KavoAppContextis a bareinterface, no runtime.KavoCallOptions.app?: KavoAppContext— the one channel a programmatic caller fills it through.createKavoContextdefaults it to a shared frozen{}.@kavo/nest'sappmodule option —(request: KavoAppContextRequest) => KavoAppContext, a bare function bound onto each controller instance by the discovery pass and run per request in the generated handler. There is notrueshorthand: a plain object cannot imply which fields to pull. Unset → generated routes sendoptions: nullandcontext.appstays{}.boundKavoAppContextis the counterpart for methods Kavo does not generate; it throws on an object the binder never visited rather than answering{}(issue #142).Result cache — the read cache key canonicalizes
context.appwhere it canonicalizedprincipal.KavoAppContextmust therefore be JSON-canonicalizable — no reference cycles — whenever the result cache is enabled (ADR-0031). No escape hatch; the constraint is documented.Policy — a
policyfunction readsargs.context.app. ADR-0037 already collapsedpolicyto a single predicate, so there is no built-in helper set orKavoPrincipaltype to retarget; only the field name changes.
The word "principal" does not appear anywhere in packages/** after this change. Kavo is pre-1.0; there is no deprecated alias, migration shim, or compatibility error code.
Consequences
- One
KavoAppContextshape per process. A per-entity or per-operation context type is explicitly not provided (YAGNI); the decorator-inference problem above is why, and the door stays open if a real case appears. - An application that never augments
KavoAppContextgets a compile error on anycontext.app.<field>read. That is the intended upgrade signal for code that used to castcontext.principal. - ADR-0032 §217's "
KavoContext.principalstaysunknown" no longer holds; ADR-0037's reference to aKavoPrincipalcast target is void. - The type says more than the runtime guarantees.
createKavoContextand@kavo/nest'sboundKavoAppContexthand out{}typed asKavoAppContext, so a non-optional augmented field (userId: string) isundefinedat run time on any request with no or a partialappextractor — every GraphQL/MCP call included.wiring-your-own-auth.mdtells integrators to declare fields optional unless an extractor is guaranteed to fill them. KavoAppContextmust be plain, shallow data when the result cache is on. The cache key canonicalizes it (§Result cache); a class instance with prototype getters canonicalizes identically for every caller and collapses the cache bucket, and a cyclic value throws aRangeErrorfromcanonicalize's 512-level depth guard rather than serving a wrong hit. Documented as an integrator constraint (ADR-0031, the result-cache and auth guides), not otherwise enforced.