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
// src/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 app = 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.

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 without a configured beacon 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.

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.