Skip to content

ADR-0037 — policy collapses to a single predicate function, at three scopes

Status: accepted — amended by ADR-0043: a policy function reads args.context.app, not context.principal.

Context

An earlier design gave policy a small inspectable node-tree DSL (permission, role, owner, authenticated, filtered, composed with and/or/not, plus a when() escape hatch for an arbitrary predicate), resolved at three scopes: operations.<id>.policyEntityConfig.policy (one entity-wide default) → GlobalConfig.policy (createKavo({ policy })) → unrestricted, nearest scope wins wholesale, with operations.<id>.policy: false opting one operation out of an inherited default.

In practice almost every non-trivial policy ended up as a when() closure anyway — ordinary authorization logic (role bypasses ownership, a banned flag overrides everything, a check against a related record) reaches for when() and its and/or/not wrapping immediately. The DSL's constructor API, recursive evaluator, and bootstrap-time static-analysis passes bought less than they cost: a caller who wants composition is better served writing plain &&/||/!/early-return JavaScript against a single function than learning a parallel combinator vocabulary for the same thing. The three-scope resolution is independently valuable — the same rule applying to every write is a real, common case — and this ADR keeps it, re-expressed over functions instead of node trees.

Decision

policy is one function, not a node tree, at every scope:

ts
export type Policy<Entity = unknown> = (args: PolicyArgs<Entity>) => boolean | Promise<boolean>;

export interface PolicyArgs<Entity = unknown> {
  readonly context: KavoContext<Entity>;
  readonly entity?: Entity;
  readonly resource: string;
  readonly operation: OperationId;
  readonly params: WhenParams<Entity>; // Pick<KavoRequest<Entity>, "id">
}

true allows the request through; false (or a rejected/falsy promise resolution) denies it with ForbiddenException, exactly as before.

Three scopes, nearest-defined wins:operations.<id>.policy: Policy<Entity> | falseEntityConfig.policy: Policy<Entity> (one default for every operation on the entity) → GlobalConfig.policy: Policy (createKavo({ policy })) → unrestricted. operations.<id>.policy: false still opts one operation out of an inherited entity- or global-scope default, back to unrestricted — the only way to spell that, since omitting the key means "inherit," not "none." EntityConfig.policy/GlobalConfig.policy still do not accept false themselves, because there is nothing above global scope to opt out of. Resolution is still wholesale, not merged — the nearest scope that defines a policy replaces every operation it reaches, never combined field-by-field with a farther one.

GlobalConfig.policy stays untyped to any entity (Policy, i.e. Policy<unknown>) and structural rather than a KavoSettings field, because GlobalConfig.defaults is typed DeepPartial<KavoSettings>, and DeepPartial recurses into any property type that extends object — which a function type does — so DeepPartial<Policy> would produce an object type keyed by Function.prototype's own properties instead of a callable function, silently losing the one property that matters. EntityConfig.policy/ OperationConfig.policy sit beside DeepPartial<KavoSettings> for the same reason.

Removed: the node constructors (permission, role, owner, authenticated, filtered, and, or, not, when — a plain function is now what when() used to wrap, so there is nothing left to name separately), the PolicyNode and KavoPrincipal types, and the helpers evaluatePolicy (the engine calls the function directly), policyNeedsEntity, collectOwnerFields, and isPolicyNode. Composition — permission checks, ownership checks, role bypasses, ANDing several conditions — is ordinary JavaScript inside one function; there is no combinator API to learn.

Entity pre-fetch is no longer conditional. A plain function cannot be statically inspected for whether it reads entity, so policyNeedsEntity's optimization has no static signal to work from. Rather than approximate it (e.g. a second needsEntity flag the caller sets by hand, which drifts from the function body it describes), the engine always pre-fetches the row for every single-row operation (findOne, updateOne, patchOne, deleteOne, restoreOne, purgeOne) that has a resolved policy — from any of the three scopes. createOne/findMany still call the policy with entity: undefined — there is no single row for either, and this is now enforced by what the operation actually has to hand the function rather than by a bootstrap check walking the policy's shape. This removes resolveEntityConfig's two policy-shape validations (the entity-aware-on-createOne/findMany check and the owner()-field relation-crossing check) — there is no longer a shape to walk, at any scope.

A missing row always answers 404, ahead of the policy — now uniformly. This was already true for owner/when under the old DSL, since either always triggered the pre-fetch; a context-only policy (permission/role/ authenticated) never fetched at all and always answered 403 regardless of whether the row existed. The collapse to a plain function makes this uniform: the engine can no longer tell a context-only policy from a row-dependent one to special-case it, so every configured policy on a missing row now answers 404 first. findOne is unaffected in mechanism: it already loads its own result, so evaluating the policy there would fetch twice. checkPolicy always defers a configured findOne policy to checkFindOnePolicy, run against the row the handler already fetched — this was already true for entity-aware nodes; it is now true unconditionally. isCacheableRead's cache carve-out follows the same simplification: any resolved findOne policy refuses the cache-read shortcut, not only one policyNeedsEntity flagged.

Bootstrap validation is reduced to one check, applied at every scope:resolvePolicy rejects a policy value — at operation, entity, or global scope — that is not a function (ConfigurationException), whether it is a per-operation map, an array, or anything else a JS or dynamically-built config might produce that the type system cannot catch. EntityConfig/GlobalConfig declare no other shape for policy, so a TypeScript caller gets a compile error for a malformed value already.

Consequences

  • This is a breaking change to a public API with no back-compat shim: every permission(...)/role(...)/owner(...)/authenticated()/filtered(...)/ and(...)/or(...)/not(...)/when(...) call site becomes a hand-written Policy<Entity> function, at whichever scope it was configured. There is no runtime detection of the old node shape to produce a targeted error message beyond "not a function."
  • A single-row operation with a resolved policy now always costs one extra read, even for a policy that never touches entity (a bare permission check) and even when that policy came from an entity- or global-scope default rather than the operation itself. This trades a small, universal cost for removing the static-analysis machinery that used to avoid it selectively — judged worthwhile given how rarely a real policy stayed simple enough for the old optimization to fire in the first place.
  • A caller a context-only policy would have refused anyway — a failed permission check, say — now learns via 404 vs. 403 whether the row exists, where before it never triggered the pre-fetch and always saw 403 regardless. This extends a rule the framework already accepted for owner/when to every policy, rather than introducing a new hazard.
  • A policy that needs a value across a relation still loads it itself through context.repository, — the pre-fetch loads no relations either way, at any scope.
  • EntityConfig.policy/GlobalConfig.policy keep the shape they always had (one default, not a per-operation map) — only the function-vs-node question changes, so an application already on the three-scope form has no scope-placement decisions left to revisit, only a mechanical node-to-function rewrite at each site.