Docs

Effect provenance

A route declares the effects it performs. Nifra works out what it can actually reach, and fails the check when the two disagree. That turns "this endpoint touches the database" from a comment into something CI enforces - and it is what lets a policy say "anything that writes must prove who asked" and have that be true.

The declaration

A route says what it does with capabilities, the same on a hand-written route or a server function:

TS
.post("/notes", { body: NoteInput, capabilities: ["db.write"] }, handler)

On its own that is an assertion. What makes it load-bearing is the other half: provenance.imports maps a module specifier to the capabilities that reaching it implies, so Nifra can compare what a route says against what it can do.

TS
import { server } from "@nifrajs/core/server"
import { defineAssuranceConfig, NIFRA_ASSURANCE } from "@nifrajs/core/assurance"

const app = server()

export default defineAssuranceConfig({
  source: app,
  capabilities: {
    definitions: [
      { id: "db.read", zone: "domain", access: "read" },
      { id: "db.write", zone: "domain", access: "write" },
    ],
    provenance: {
      // Reaching one of these implies holding the capabilities beside it.
      imports: [
        { specifier: "./read.ts", capabilities: ["db.read"] },
        { specifier: "./write.ts", capabilities: ["db.write"] },
        { specifier: "bun:sqlite", capabilities: ["db.read", "db.write"] },
      ],
      forbiddenImports: [],
    },
  },
  policy: {
    rules: [
      // Not a list of token names: anything whose DEFINITION is a domain write.
      {
        name: "authenticated-write",
        match: { access: "write", zone: "domain" },
        require: [NIFRA_ASSURANCE.AUTHENTICATED],
      },
      { name: "read", match: { methods: ["GET"] }, require: [] },
    ],
  },
})

Every create-nifra template ships this, armed. You do not have to write it to get the guarantee.

The chain, end to end

Write to the database and declare nothing, and the check says so:

BASH
$ nifra check

✗ effect/capability assurance: 1
    POST /notes evidence exceeds its declaration: db.write

Declare it, and the policy takes over:

BASH
$ nifra check
✓ effect/capability assurance: none

$ nifra assure
✖ POST /notes (authenticated-write) is missing nifra.authenticated

Only an authenticated write ships. Nobody had to remember anything - and note that the rule matches on access and zone rather than naming db.write, so a token introduced next year is covered the day it is declared instead of the day someone remembers to widen the rule.

Reach is per module

This is the rule everything else follows from, so it is worth stating plainly: a route's reach is computed from the module that REGISTERS it, following that module's imports, transitively.

Not from the handler body - static analysis cannot honestly tell you which closure touched which import. From the module. So a file that registers routes and imports a database gives every route in it database reach, whether or not it uses it. That is conservative, and it is true: the handler really does have the connection in lexical scope.

Which produces a specific dead end, and a specific finding for it:

BASH
$ nifra check

✗ effect/capability assurance: 1
    GET / can reach domain write capability db.write without declaring it, and a safe method
    may not declare one - move the route or the effect so the write is not in its module's reach

A GET route may not declare a domain write - that is an HTTP semantics rule. So a GET in a module that can reach a write has no declaration that is both legal and true, and no amount of editing the declaration fixes it. The fix is structural, and the message says so rather than bouncing you between two impossible demands.

What that means for your modules

Two habits follow, and the templates are shaped around both.

The app root composes; it does not register. If the root both merges route modules and declares routes of its own, those routes inherit the reach of everything merged there.

TS
// backend/app.ts - composition only. It merges route modules and registers none of its own.
import { server } from "@nifrajs/core/server"
import { routes } from "./routes.ts"

export const backend = server().merge(routes)

A feature is a module, owning its store, its adapters and the routes over them. The second feature with a database of its own gets its own file rather than another section of this one.

Splitting the seam by access

A raw driver cannot tell a read from a write at the import, so it grants both - which means a GET route in any module that can reach the driver is stuck. Putting your queries behind a seam split by access is what unsticks it, and it is what create-nifra --db scaffolds:

TEXT
db/
  index.ts        the connection - no route module imports this
  read.ts         reads, mapped to db.read
  write.ts        writes, mapped to db.write
  read-routes.ts  GET routes; imports ./read.ts only, so it can declare db.read and nothing more
  write-routes.ts POST routes; imports ./write.ts only

Because ./read.ts is mapped in provenance.imports, the walk stops there and grants exactly db.read - it never reaches the driver underneath. Keep the two halves import-disjoint, at the seam and at the route modules, and every route's declaration equals its reach.

The walk starts at the module that registers the route, which Nifra finds by reading the source without running it. It reads a path written as a string literal, or as a name bound to a module-level const string, with or without as const. The const can live in the same file or be exported from a project file it imports. The same holds for a WebSocket route's app.ws(path, ...). A path built at runtime, such as a template with ${...} in it, cannot be read this way. So when nifra.assurance.ts loads the app, Nifra also records, in development, the module whose call registered each route, and the walk starts there too: a computed path, or a route on a server a helper builds, is still covered. nifra check still names such an expression in an NF-C036 finding, since the route lints read the source alone. When the registering module is not where the route's code lives, such as a shared helper that registers other modules' handlers, list the right module in provenance.routeModules.

Runtime beacons

Static provenance answers what a module can reach, so its evidence is as broad as the module. A beacon answers which route did what, at the moment it does it. @nifrajs/cache, @nifrajs/jobs and @nifrajs/storage can emit one:

TS
import { useCapability } from "@nifrajs/core/capabilities"
import { createCache } from "@nifrajs/cache"

const cache = createCache({ beacon: useCapability })

// Announces cache.write against THIS route before the operation, and throws if it was not declared.
export const write = async (c: object): Promise<void> => {
  await cache.for(c).set("k", 1)
}

useCapability is passed in rather than imported by those packages, so all three keep their zero dependencies - a cache should not pull the server into a bundle that only wanted a cache. Nothing changes for existing code: only the for(context) path announces anything, and asking for it with neither a beacon nor a tracing observer configured throws rather than handing back something that silently proves nothing.

The two are complements. Static provenance is total and runs in CI; a beacon is exact but only speaks for code that actually executed.

Retry safety

A capability can require that its effect be safe to repeat. The definition says which, and nifra check fails a route that declares the capability without the matching evidence:

TS
// A capability can require that an effect be safe to retry.
{ id: "billing.charge", zone: "domain", access: "write", idempotency: "durable" }

request is satisfied by schema.idempotency: a retry carrying the same Idempotency-Key replays the stored response instead of re-running the handler. durable asks for more, and is satisfied by the durableCommand adapter:

TS
import { server } from "@nifrajs/core"
import { executeCapability } from "@nifrajs/core/capabilities"
import { createDurableEffectJournal } from "@nifrajs/core/durable-execution"
import { durableCommand } from "@nifrajs/middleware"

declare const store: import("@nifrajs/core/durable-execution").DurableEffectStore
declare const gateway: { charge(): Promise<{ id: string }> }

const commands = durableCommand({ journal: createDurableEffectJournal({ store }) })

// Every executeCapability below this records intent before the effect and one outcome after.
const app = server()
  .use(commands)
  .post("/charge", { capabilities: ["billing.charge"] }, (c: object) =>
    executeCapability(c, "billing.charge", {}, () => gateway.charge()),
  )

The two are different guarantees, which is why one does not stand in for the other. Response replay needs a stored response to replay; if the process dies between charging the card and storing it, there is nothing to replay and the retry charges again. The journal is what survives that, so it is what clears the durable tier. The adapter is order-scoped like the auth plugins - routes registered before .use(...) are not covered, and nifra check says so rather than assuming.

A process that dies while its handler runs also leaves the key reserved, and retries get 409. A store that implements renew(), as MemoryIdempotencyStore does, holds that reservation as a lease of pendingTtlMs (60 seconds by default) that the server renews every third of it while the handler runs, so the key frees once the lease lapses rather than after ttlMs. A store without renew() keeps the key reserved for ttlMs, and declaring pendingTtlMs on it is a registration error.

Mounts nifra cannot read

mount() and mountFetch() hand requests to code that route reflection does not walk, so the effects behind a mount are unproven. A server() you own belongs in merge() instead, where every route is analyzed like your own. For a handler you cannot analyze, such as a closure exported by an auth library, state why:

TS
import { server } from "@nifrajs/core"

// Stands in for a handler from another package (better-auth's): nifra cannot see what it reaches.
const authHandler = { fetch: (request: Request) => new Response(request.url) }

export const app = server().mount({
  path: "/api/auth",
  app: authHandler,
  opaque: "better-auth's own handler; its effects are its own",
})

A mount with a reason is a known gap: nifra check and nifra capabilities check list it on every run, and it neither fails assurance nor lowers a level. A mount without one fails, naming both fixes: mount /api/auth/* is not analyzed. The capability lockfile and the trust manifest record routes, not mounts, so review a new gap where it is listed: in the check output. The same opaque field works on a createWebApp mounts entry.

BASH
$ nifra check
• known gap, not capability-analyzed: /api/auth/* - better-auth's own handler; its effects are its own

A mount can also state the routes it serves and the capabilities each one exercises. Pass an object with the reason and a routes list. Each path is the full request path and must sit under the mount:

TS
export const app = server().mount({
  path: "/api/auth",
  app: authHandler,
  opaque: {
    reason: "better-auth's own handler; its effects are its own",
    routes: [
      { method: "POST", path: "/api/auth/sign-in/email", capabilities: ["session.write"] },
      { method: "GET", path: "/api/auth/get-session", capabilities: ["session.read"] },
    ],
  },
})

Assurance lists these routes with analysis: "declared", apart from the routes it analyzed. The mount stays a known gap with its reason. A declared capability the policy does not define fails, as it does on a route, and so does a domain write declared on GET. Nothing else is checked, because there is no evidence to read. The routes also appear in the capability lockfile, so a changed declaration shows up in review. They appear in the nifra context route index too. An L4 run lists them as having no contract to test. A malformed entry, or a path outside the mount, stops the analysis with an error that names it.

The lockfile

Once assurance passes, snapshot the result. The lockfile is a review artifact: a route that starts touching something new shows up as a diff in the pull request rather than as nothing at all.

BASH
$ nifra capabilities snapshot   # writes capabilities.lock.json
$ nifra levels
✓ L0 typed contract
✓ L1 route assurance
✓ L2 capability lockfile

That is L2 on the verification ladder. See also server functions, which are public POST endpoints and are covered by exactly the same policy.

Turning it down

The firewall is provenance.imports in nifra.assurance.ts, and it is yours. Emptying it disarms the reach comparison while leaving the declarations and the policy in place; removing a driver entry stops that driver implying anything. Nothing here is load-bearing for the framework - it is load-bearing for the guarantee, and the guarantee is opt-out.