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, aset +eregion, 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.wasmis still on disk, byte-identical to the one committed before the run. A downstream step gated ontest -f ninka/out/bundles/*.wasmsees 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 initscaffolded a hand-writtenlib/authz.tsthat wired the runtime up. That stub is no longer generated and is no longer needed — the projection is the integration point. If you kept alib/authz.tswith your own logging, request-context or error handling in it, keep it: make it a thin wrapper that callscreateNinka()once and re-exports, and maintain it as your code. - 0.6.0 addressed policy ids exactly.
policies.invoiceAccessbecamepolicies["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()replacesAuthz.Load(...). It callsAuthz.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 generatedPolicies.<Name>container — see Upgrade to 0.8.0 if you are upgrading past 0.6.0. - Input contract types are named
i_<hash>, with aglobal usingPascalCase alias emitted whenever the name does not collide — soInvoiceAccessInputkeeps working, and a colliding group of policies gets no alias and is addressed byi_<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/outinstead ofauthz/out, so a bareload()looks somewhere new; diris any filesystem path you deploy to.ninka/outis 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:
--outis not remembered. It applies to the one invocation it is given to. If you compile to a non-default path, a bareverifyafterwards resolves the path again from scratch — so it either checks the default path or refuses as above; it does not find the file the earliercompilewrote. Whatevercompilegets,verifygets.- A C# projection is checked only when you pass
--out-csharp. Bareverifyin a C#-only workspace exits 0 and never looks atGenerated/Ninka.g.cs— it reports− no TypeScript Consumer Projection to checkand stops there. A green bareverifyis not evidence that your C# projection is current. Put--out-csharpin 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:
- attributes listed in
decision_log.unmaskedinvocabulary.json; and action.nameandresource.type, which are always raw — they are literals at the call site, not runtime data. Listing them indecision_log.unmaskedis 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— matchbundles/*.wasminstead; - a deep link to a particular policy's module — there is no longer one module per policy;
- code that reads
data/reference.json—wasmHrefmoved frompolicies[].wasmHreftobundles[].wasmHref, and there is a new top-levelbundlesarray.
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
propertiesneeds 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 verifychecks, and where application-boundary tests belong.