Skip to main content

Upgrade to Ninka 0.6.0

0.6.0 changes eleven things a project can observe. Every project has work to do, and the work has an order: the workspace has to move before it can be rebuilt, and it has to be rebuilt before the generated consumer code means anything.

Work down this page with a terminal open. Each step says what to run, what you should see, and what you will see if you have not done the step before it.

Before you start, put the upgrade on its own branch — step 2 rewrites every generated file in the workspace, and you want that diff readable.

git switch -c upgrade-ninka-0.6
npm install ninka-authz@0.6.0 # or: npm install -D ninka-authz@0.6.0

.NET projects also update the Ninka.Authz package reference to 0.6.0. The runtime and the artifacts move together — a 0.6.0 runtime refuses artifacts a 0.5.x CLI built, and a 0.5.x runtime refuses artifacts a 0.6.0 CLI builds. There is no window in which a half-upgraded deployment works.


Step 1 — Rename the workspace to ninka/

mv authz ninka
git add -A

There is no fallback and no deprecation period. Until you do this, every command stops before it reads anything:

error: Ninka workspace moved from ./authz to ./ninka.

Rename it with:

mv authz ninka

Or keep the current location with: --workspace authz

If you genuinely cannot move the directory, pass --workspace authz on every invocation. Be clear about what that buys you: it changes where Ninka looks, and nothing else. A workspace kept at authz/ still fails everything below until you have done step 2.

Check:

npx ninka verify

You should now get past the workspace error and into artifact findings — that is the next step. Expect something like:

✔ ninka/invoice-access.tegata.json → invoice-access (tegata_hash c2b55b92fb53…)
✘ bundle-0001: no execution bundle is committed (ninka/out/bundles/bundle-0001.build.json is
missing) and this toolchain can build one — run `ninka build` and commit ninka/out/bundles/
✘ the committed artifacts are not the set ninka/ compiles to — 7 artifacts nothing in this
workspace accounts for: …

Step 2 — Rebuild the artifacts

This is where Artifact Contract v2 and the new Rego entrypoints are absorbed. One command:

npx ninka build

build requires the pinned opa toolchain and fails if it is unavailable — that is the difference between build and compile, and it is what you want here.

✔ ninka/invoice-access.tegata.json → invoice-access (tegata_hash c2b55b92fb53…)
wrote ninka/out/
✔ ninka/out/bundles/bundle-0001.wasm (1 entrypoints)
✔ validation: invoice-access (51 vectors, wasm agrees with the reference evaluator)
− removed 5 v1 artifacts (superseded by policies/ and bundles/): invoice-access.build.json,
invoice-access.manifest.json, invoice-access.rego, invoice-access.validation.json,
invoice-access.wasm
✔ lib/generated/ninka.ts (1 policy)

The layout has changed under ninka/out/:

before (0.5.x) after (0.6.0)
authz/out/invoice-access.rego ninka/out/policies/invoice-access.rego
authz/out/invoice-access.manifest.json ninka/out/policies/invoice-access.manifest.json
authz/out/invoice-access.validation.json ninka/out/policies/invoice-access.validation.json
authz/out/invoice-access.build.json ninka/out/bundles/bundle-0001.build.json
authz/out/invoice-access.wasm ninka/out/bundles/bundle-0001.wasm
authz/out/input-contract.json ninka/out/input-contract.json

The Rego moved inside as well: package ninka.invoice_access became package ninka.p_<sha256(policy.id)>, and the manifest entrypoint became ninka/p_5bb69feb…/allow. Nothing in your application names these, but an external opa eval, a bundle inspection script or a dashboard keyed on the package path does — update those with the values in the rebuilt manifests.

Three things to know about this step.

build removes the superseded v1 files for you, but not the generated type files

types.gen.ts and types.gen.cs survive step 2 and must be deleted by hand — that is step 3. Until you do, verify stays red even though the build succeeded.

A number the compiler cannot carry exactly now stops the build

If a Tegata contains an integer beyond ±2^53, build stops. 0.5.x compiled it at exit 0 and shipped a different number into the Rego:

error: ninka/invoice-access.tegata.json states a number the compiler cannot carry losslessly:
line 72, column 59: the Tegata states 9007199254740993, but the compiler holds 9007199254740992
and would ship 9007199254740992 into the Rego
The value you reviewed and the value that would execute are different numbers. State a value the
compiler carries exactly — every integer up to 9007199254740992 in magnitude is carried exactly —
or express the quantity in a coarser unit.

Fix the Tegata: state a value the compiler carries exactly, or express the quantity in a coarser unit (yen → thousands of yen, bytes → KiB). Then re-run build.

A policy set opa refuses is now a failed build

When the pinned opa toolchain runs and refuses the policy set, build (and compile) now exit 1 and print opa's own words:

error: opa v0.65.0 refused this policy set:
error: load error: 3 errors occurred during loading:
invoice-access.rego:191: rego_parse_error: var cannot be used for rule name
invoice-access.rego:191: rego_parse_error: var cannot be used for rule name
invoice-access.rego:191: rego_parse_error: negated expressions cannot be used for rule head
the Rego above is generated from your Tegata, so a refusal here is normally a Ninka defect — report it with the message above.
ninka/out/ still holds the Rego, the manifests and the types; no execution bundle was published.

In 0.5.x the same refusal produced a single line at exit 0:

note: WASM build skipped — Command failed: <the opa command line>

This is breaking: a project that changed nothing can go from a green pipeline to a red one, on nothing but a Ninka upgrade. It is also not a regression. The old exit 0 was reporting success for a build that produced no bundle at all — the workspace was left with no .wasm and no build.json, and CI, the pre-commit hook and the agent that wrote the policy were all told the build had worked. 0.6.0 stops folding "the toolchain refused what Ninka generated" into "the toolchain is not installed". Only the second is recoverable, and only the second still takes the soft path: compile on a machine with no opa still exits 0 with note: WASM build skipped after writing Sekisho checks, Rego and manifests, and build on that machine still exits 1 — exactly as it did in 0.5.x.

Because the Rego is generated from your Tegata, a refusal here is normally a Ninka defect rather than something to fix in your policy. Report it with the message above.

Check the scripts that call build. Two habits that 0.5.x tolerated now decide whether you find out:

grep -rn "ninka build\|ninka compile" package.json Makefile .github/workflows/ scripts/ 2>/dev/null
  • An ignored exit status. Anything of the shape npx ninka build || true, a set +e region, or a wrapper that reads only the last line of output, will now discard a real failure. The exit status is the result.
  • A check that tests for the bundle instead of the status. A failed build does not remove what is already committed: after the refusal above, ninka/out/bundles/bundle-0001.wasm is still on disk, byte-identical to the one committed before the run. A downstream step gated on test -f ninka/out/bundles/*.wasm sees a bundle and proceeds — with the previous build's module. Gate on the exit status of the command that was supposed to produce it.

The same rule now covers an opa that cannot be run at all. build has always exited 1 when the toolchain could not be resolved — an unsupported platform, a failed download, a NINKA_OPA_PATH naming a file that is not there. What it did not cover is resolution SUCCEEDING and the binary still not running: a NINKA_OPA_PATH pointing at a file that exists and cannot be executed (wrong mode, a directory, something that is not a program) walked past that check, took compile's soft path, and left build printing note: WASM build skipped at exit 0 with no bundle. In 0.6.0 build exits 1 there too:

error: the opa NINKA_OPA_PATH names (/opt/toolchain/opa) could not be run: spawnSync /opt/toolchain/opa EACCES
`build` publishes an execution bundle, so a toolchain it cannot run is a failed build — `compile` is the command that writes everything that does not need opa.
ninka/out/ still holds the Rego, the manifests and the types; no execution bundle was published.

compile is unchanged. Nothing ran, so this is still "the toolchain is unavailable" and still the recoverable half: compile writes Sekisho checks, Rego and manifests and exits 0 with the note:. What changed is that build's promise — it does not exit 0 without publishing a bundle — is now kept on the result rather than on a pre-flight check of the path.

If your CI sets NINKA_OPA_PATH, this is the change that will tell you the value is wrong. A container image that moved the binary, a mount that arrives read-only, a cache restored without the execute bit: each of those used to produce a green build and no .wasm. Errors here name the binary the way it was chosen — the opa NINKA_OPA_PATH names (…) when you supplied it, opa v0.65.0 when the pinned one was used — because past that flag the binary is yours, and Ninka neither probes nor checksums it.


Step 3 — Delete the old generated type files, and move the TypeScript consumer code

3a. Delete types.gen.*

rm -f ninka/out/types.gen.ts ninka/out/types.gen.cs

Nothing writes these any more, and step 2 did not remove them for you. ninka/out/ is now a strict set: anything in it that a compile does not produce is a finding. Leave them and verify stays red even though build succeeded:

✘ the committed artifacts are not the set ninka/ compiles to — 2 artifacts nothing in this
workspace accounts for:
ninka/out/types.gen.ts is not something a compile of ninka/ writes — everything under
ninka/out is generated, so nothing accounts for it. Delete it, or keep it outside the
generated tree

3b. Find what depended on them

grep -rn "types\.gen" --include='*.ts' --include='*.tsx' . | grep -v node_modules
grep -rnE 'policies\.[a-zA-Z]' --include='*.ts' --include='*.tsx' . | grep -v node_modules

On a 0.5.x project that turns up the two shapes you have to change:

lib/authz.ts:5:import type { PolicyRef } from "../authz/out/types.gen";
lib/authz.ts:28:export { policies } from "../authz/out/types.gen";
app/api/invoices/route.ts:13: const allowed = await check(policies.invoiceAccess, {

3c. Rewrite the imports and the call sites

Step 2 already wrote the Consumer Projection — one file, by default <source root>/generated/ninka.ts, exporting exactly two names: createNinka and policies. It carries the execution bundle inside it, so importing it is the entire integration.

// before — 0.5.x
import { check, policies } from "@/lib/authz";
const allowed = await check(policies.invoiceAccess, input);

// after — 0.6.0
import { createNinka, policies } from "@/generated/ninka";
const authz = await createNinka();
const allowed = authz.check(policies["invoice-access"], input);

Two changes, and they are independent:

  • The composition root is createNinka(). In 0.5.x, ninka init scaffolded a hand-written lib/authz.ts that wired the runtime up. That stub is no longer generated and is no longer needed — the projection is the integration point. If you kept a lib/authz.ts with your own logging, request-context or error handling in it, keep it: make it a thin wrapper that calls createNinka() once and re-exports, and maintain it as your code.
  • 0.6.0 addressed policy ids exactly. policies.invoiceAccess became policies["invoice-access"]. A policy id was data and was not turned into an identifier in 0.6.0 — there was no camelCase form in that release. This changed again in 0.8.0, which reintroduces a derived reference name (collision-checked this time, unlike the pre-0.6.0 form). If you are upgrading straight to the current release, finish this step as written, then continue with Upgrade to 0.8.0 — do not stop at this intermediate, 0.6.0-shaped state.

A ref you build by hand instead of taking from policies is refused:

ninka: policy ref for "invoice-access" carries no tegata_hash — it cannot be bound to the loaded
bundle. Pass the policies.<name> reference your generated Ninka projection exposes for
"invoice-access"

3d. Commit the projection

git add lib/generated/ninka.ts # or wherever your source root is

It is generated, but it is your source: it is what your application imports and what verify compares against. Commit it like any other file in the repository.

If Ninka wrote the projection somewhere you did not expect, or refused to choose, go to step 6 now — the path resolution rule is the same for compile, build and verify, and it is easier to settle once.


Step 4 — C#: regenerate with --out-csharp

--lang csharp is gone, and types.gen.cs is no longer part of the artifact set. The C# Consumer Projection is written where you name it, because C# has no source-root convention to resolve from:

npx ninka build --out-csharp Generated/Ninka.g.cs
✔ ninka/out/bundles/bundle-0001.wasm (1 entrypoints)
✔ validation: invoice-access (51 vectors, wasm agrees with the reference evaluator)
✔ lib/generated/ninka.ts (1 policy)
✔ Generated/Ninka.g.cs (1 policy)

The old flag is rejected outright:

$ npx ninka build --lang csharp
error: build does not accept "--lang" (usage: ninka build [--out <file>] [--out-csharp <file>])

Then update the C# call sites:

// before — 0.5.x
var authz = await Authz.Load("authz/out");
var allowed = authz.Check(Policies.InvoiceAccess, new InvoiceAccessInput { … });

// after — 0.6.0
var authz = NinkaProjection.Create();
var allowed = authz.Check(InvoiceAccessInput.Policy, new InvoiceAccessInput { … });
  • NinkaProjection.Create() replaces Authz.Load(...). It calls Authz.FromEmbedded(...) with the bundle the projection carries, so there is no directory to deploy.
  • 0.6.0 removed Policies.<Name>. The policy ref moved onto its input contract: <Name>Input.Policy. 0.8.0 moves it again, onto a generated Policies.<Name> container — see Upgrade to 0.8.0 if you are upgrading past 0.6.0.
  • Input contract types are named i_<hash>, with a global using PascalCase alias emitted whenever the name does not collide — so InvoiceAccessInput keeps working, and a colliding group of policies gets no alias and is addressed by i_<hash> directly. The alias is sugar; the hash is the name.

Commit Generated/Ninka.g.cs alongside the TypeScript projection.


Step 5 — Check your runtime load path

There are two ways to get the bundle into the runtime, and 0.6.0 changes which one is the default route:

Embedded (the normal path). createNinka() / NinkaProjection.Create() use the bundle compiled into the projection file. Nothing is read from disk, there is no directory to deploy, and there is no path to configure. If you moved to the projection in steps 3 and 4, you are done — skip to step 6.

Directory (still supported, now optional). Ninka.load(dir) / Authz.Load(dir) read a deployed artifact directory. Two things changed:

  • the default value is now ninka/out instead of authz/out, so a bare load() looks somewhere new;
  • dir is any filesystem path you deploy to. ninka/out is a default, not a location the runtime requires — if your deployment puts the artifacts at /srv/app/authz, pass that.

If you call load() with no argument, or with "authz/out", decide explicitly which path your deployment actually produces and pass it.

Check — point the runtime at your deployed artifacts and load them. A v1 artifact set is refused, and says so:

ninka: <dir> holds artifact v1 build files (invoice-access.build.json) but no execution bundle in
<dir>/bundles. Recompile the artifacts with the current Ninka (`npx ninka build`)

If you see that after step 2, your deployment is shipping stale artifacts — find the copy step that is still picking up the old directory.


Step 6 — Give CI the same arguments the project compiles with

This is the step most likely to turn a green pipeline red without anything in your project having changed, so do it deliberately.

ninka verify now regenerates each Consumer Projection from the canonical artifacts and compares it byte for byte. To do that it has to know which file to compare — and it does not remember what compile did. Where it can resolve the path on its own it does; where it cannot, it refuses:

✘ cannot tell where the Consumer Projection belongs: /path/to/project has src/ and lib/, and no
tsconfig.json "rootDir" to choose between them. — pass the same --out this project compiles with

src/ alongside lib/ with no rootDir is an ordinary TypeScript layout. A project that changed nothing can hit this on upgrade, and a project that never hit it can start hitting it the day someone adds a second top-level directory.

To be clear about what this is: 0.5.x verify did not check any projection at all — on the same project it exits 0 with no projection line in its output. Nothing that used to be verified has stopped being verified. 0.6.0 extended verification to cover the generated file your application actually imports, and the cost of covering it is that an invocation which does not say which file to check can no longer be counted as a pass.

The rule: pass verify the same --out / --out-csharp you pass compile.

# CI
- run: npx ninka build --out src/generated/ninka.ts --out-csharp Generated/Ninka.g.cs
- run: npx ninka verify --out src/generated/ninka.ts --out-csharp Generated/Ninka.g.cs

Green looks like this:

✔ invoice-access: match (tegata_hash c2b55b92fb53…)
✔ src/generated/ninka.ts: Consumer Projection matches (1 policy)
✔ Generated/Ninka.g.cs: Consumer Projection matches (1 policy)
verify: all artifacts match

If your project has an unambiguous source root, bare verify resolves it and you need no flags:

✔ lib/generated/ninka.ts: Consumer Projection matches (1 policy)
verify: all artifacts match

Two traps worth checking for by hand:

  • --out is not remembered. It applies to the one invocation it is given to. If you compile to a non-default path, a bare verify afterwards resolves the path again from scratch — so it either checks the default path or refuses as above; it does not find the file the earlier compile wrote. Whatever compile gets, verify gets.
  • A C# projection is checked only when you pass --out-csharp. Bare verify in a C#-only workspace exits 0 and never looks at Generated/Ninka.g.cs — it reports − no TypeScript Consumer Projection to check and stops there. A green bare verify is not evidence that your C# projection is current. Put --out-csharp in the CI line.

Step 7 — Remove unknown arguments from wrapper scripts

Unknown arguments used to be ignored. They are now rejected with exit 2:

$ npx ninka verify foo
error: verify does not accept "foo" (usage: ninka verify [--out <file>] [--out-csharp <file>])

This catches typos, flags left over from an older version, and arguments meant for a different command — all of which silently did nothing in 0.5.x and passed CI.

Check — run each Ninka invocation in your repository once and read the output:

grep -rn "ninka " package.json Makefile .github/workflows/ scripts/ 2>/dev/null

Anything using --lang csharp is covered by step 4.


Step 8 — Make Decision Log consumers forward-compatible

If you consume the Decision Log — shipping entries to a SIEM, matching on them in tests, validating them against a schema — read this step. If you do not, skip it.

The Decision Log contract is now normative, and one clause asks something new of consumers:

C8 — a consumer MUST tolerate fields the contract does not name, and MUST NOT reject or discard an entry because it carries a field the consumer does not know. The field set of an entry is not closed.

Nothing in 0.6.0 emits a new field, and the wire format has not changed. This is not a migration you can observe failing today; it is a contract that lets a future release add a field without breaking you. Do the work now, while the release notes are in front of you.

Check your consumer for:

  • a closed/strict JSON schema (additionalProperties: false, .strict() in Zod, [JsonExtensionData] absent with strict deserialization in .NET);
  • exhaustive matching over the field set;
  • anything that treats an unrecognized key as an error rather than as data to carry along.

Relax those. Ignore what you do not recognize; do not reject the entry.

While you are there: masking

The rule has not changed, but it is easy to have written it down by halves. Values in the Decision Log are masked unless they are raw, and raw has two sources:

  1. attributes listed in decision_log.unmasked in vocabulary.json; and
  2. action.name and resource.type, which are always raw — they are literals at the call site, not runtime data. Listing them in decision_log.unmasked is an error.

One behaviour did change, and it will change your log text: when subject.properties or resource.properties held a scalar (a string, number or boolean) instead of an attribute map, 0.5.x wrote it out unmasked. It is masked now:

0.5.x {"subject":{"properties":"ssn-123-45-6789"}, …}
0.6.0 {"subject":{"properties":"***"}, …}

If you have log assertions or downstream parsers that pinned the old text for these input shapes, update them. Decisions are unaffected.


Step 9 — Update ninka docs -o path assumptions

Skip this step unless you publish the static Authorization Reference and something in your pipeline knows the export's internal paths.

The export no longer writes one module per policy; it writes one per bundle:

0.5.x 0.6.0
policies/document-access.a9741ad21c7a.wasm bundles/bundle-0001.2d5ba8a5d58e.wasm
policies/member-management.dda2e807906f.wasm
policies/project-access.446fa6cfe3bb.wasm

If you copy the exported directory to a static host, nothing changes. index.html is identical between the two versions, every href in it is relative, and the snapshot directory is still named YYYYMMDD-HHmmss. Verify with:

npx ninka docs -o ./reference
find ./reference -type f
./reference/20260818-061146/index.html
./reference/20260818-061146/data/reference.json
./reference/20260818-061146/assets/app.css
./reference/20260818-061146/assets/app.js
./reference/20260818-061146/bundles/bundle-0001.726e7d8b5870.wasm

Change something only if you have one of these:

  • a CDN rule, cache header rule, upload allowlist or path rewrite matching policies/*.wasm — match bundles/*.wasm instead;
  • a deep link to a particular policy's module — there is no longer one module per policy;
  • code that reads data/reference.jsonwasmHref moved from policies[].wasmHref to bundles[].wasmHref, and there is a new top-level bundles array.

An empty -o is refused

-o "" used to be accepted, and what it meant was not "no directory": the empty string resolved against the current directory, so the snapshot was written into the project root — beside your source, inside the repository. The same intention spelled --output= or as a bare -o was refused. In 0.6.0 every empty spelling is refused alike, with exit 2:

error: -o requires a directory to export into (`-o .` for the project root)

Nothing is lost, because the project root is a path and can be named: ninka docs -o . writes the same YYYYMMDD-HHmmss directory in the same place.

Check any script that passes the directory through a variable. npx ninka docs -o "$REFERENCE_DIR" with REFERENCE_DIR unset exported into the repository and reported success; it now stops with a usage error, which is the point.

The snapshot's version field was renamed, and its value did not change

In the snapshot metadata, artifact_format_version is now snapshot_format_version, and the value is still 1.

This is a rename of a key, not a version bump. The old name was the name of a different contract — the artifact format, which is separately at version 2 in 0.6.0 — reused for the export snapshot's own format. The two are independent version series, and the snapshot format has not changed, so its version stays 1 under its own name.

If you parse data/reference.json, read snapshot.snapshot_format_version. If you were asserting artifact_format_version === 1 there, that assertion was never about the artifact format.


Step 10 — Rebuild, verify, and run your tests

npx ninka build --out <your projection path> [--out-csharp <your C# projection path>]
npx ninka verify --out <your projection path> [--out-csharp <your C# projection path>]
✔ invoice-access: match (tegata_hash c2b55b92fb53…)
✔ lib/generated/ninka.ts: Consumer Projection matches (1 policy)
✔ Generated/Ninka.g.cs: Consumer Projection matches (1 policy)
verify: all artifacts match

Then run your application's own tests, and pay attention to two categories that this upgrade moves:

  • authorization outcomes. If step 2 stopped on a numeric literal, a threshold in your policy was not the number you thought it was, and correcting it changes decisions. Re-run whatever covers that policy.
  • Decision Log assertions. Anything pinning the logged text of a scalar properties needs updating (step 8).

Finally, commit everything Ninka generated:

git add ninka/ lib/generated/ninka.ts Generated/Ninka.g.cs
git status # nothing generated should be left untracked or modified

A clean git status after a build is the same property verify enforces in CI. If they disagree, the CI command and the local command are not being given the same arguments — go back to step 6.

See also

  • Upgrade to 0.8.0 — the next upgrade, which moves the reference syntax this page just walked you into.
  • CLI — flags, exit codes, and where the Consumer Projection goes.
  • Generated Files — what each file under ninka/out/ is.
  • Test Authorization — what ninka-authz verify checks, and where application-boundary tests belong.