Labels, workspaces and .nexapolicy
The version 1 .nexapolicy grammar in full: one authoring file per Development Root with repository-specific project scopes, fixed precedence, destination rules for workspaces, the two policy times, and the Rinko Hub pages that display them.
Three things decide what a file is, who can open it, and where it may travel. A label decides who. A .nexapolicy file decides which paths belong to it. A workspace destination rule decides where work under a label may not go. This guide is the developer-facing account of
all three, and the Rinko Hub pages that show them.
If what you want is the encryption story — what a peer without the key sees, what sealing does and does not hide — read Privacy and file control first. This page assumes it.
The mental model
The three pieces are deliberately separate, and each one is useless as a lever on the others:
| Piece | Decides | Where it lives |
|---|---|---|
| Label | Who can decrypt content classified under it | A protocol object, plus a grant addressed to each device |
.nexapolicy | Which paths belong to which label | One human-authored file, in the Development Root |
| Destination rule | Which labels a workspace refuses to receive | A published protocol object, enforced by the node |
Editing the policy file grants nothing. It names labels and never carries keys, so a contributor may declare whatever they like and gain no access — the bytes are encrypted under a key no document can confer. That is precisely what makes the file safe to keep in the repository where everyone can review changes to it.
One authoring source
Under a Development Root there is exactly one authoring file, beside .nexa, covering every project in the
root. Project directories stay pure working trees: they never acquire a .nexapolicy, never acquire a .nexa, and never acquire a cache, a lock
or a binding file.
Development/
.nexa/
.nexapolicy ← the one authoring file
ProjectA/ ← user files only
ProjectB/ ← user files onlyThe exception is a standalone repository — one created outside any Development
Root. It keeps its own .nexapolicy at its own root, exactly as before, and a project block there is an error: a standalone repository has no projects.
# .nexapolicy
policy 1
global {
label "secrets" { paths = ["secrets/**", ".env"] }
}Labels are repository-specific
A label is a protocol object in one repository: a random identifier, a key, and
the devices that hold it. Two repositories never share a label, even when both call theirs secrets. Under a Development Root every project is its own repository, so a rule
naming a label belongs in the block of the project whose label it is, and is resolved against
that project's labels only.
That is why a root's global block may not name a label. It would reach every
project, and each would resolve the key against its own labels — sealing a sibling's files under
an unrelated label that shares the name, or refusing every project that has none. Rather than
pick one of those silently, check, validate, explain and record refuse it and say where the rule belongs:
nexa my-project policy validateerror: object error
caused by: invalid policy on line 3: labels are repository-specific: `label "secrets"` in `global` would apply to every project in this Development Root and resolve against each repository's own labels; scope it under the project instead: `project "my-project" { label "secrets" { paths = [...] } }`Earlier releases accepted that form. A root file still using it is refused until each rule is
copied into the project block of every project that should have it — after nexa <project> label create <key> in any project that lacks the label.
Versions already recorded keep the policy pinned to them. An empty global { } remains legal; it holds no other settings today. In a standalone repository, global is that repository's own scope and labels in it are accepted.
The version 1 grammar
A file whose first non-comment statement is policy 1 uses the version 1 block
grammar. A file with no header is version 0 and is parsed by the unchanged version 0 parser —
nothing about an existing file, an existing pinned policy or an existing version identifier
changes. policy N for any other N is refused by name rather than misread.
# Development/.nexapolicy
policy 1
project "ProjectA" {
label "secrets" { paths = ["**/.env*", "src/private/**"] }
sync workspace "preview" { # where this work may not go
exclude_labels = ["secrets"]
}
}
project "ProjectB" {
label "restricted" { paths = ["secrets/**"] }
}Two scopes — global and project "Name" — and three blocks inside them
(under a Development Root, label and sync rules go in project blocks only): label "<key>" with a paths list, sync workspace "<name>" with an exclude_labels list, and ignore with a paths list (see Ignoring build output). Nothing else. Strings are double-quoted with only \" and \\ as escapes; lists take an optional trailing comma; # starts a comment
outside a quoted string and is ordinary text inside one.
Every block, attribute and selector name is in a table the parser consults, and an unregistered one fails the whole document, naming the scope, the block and the offending token. There is no permissive mode: a security document whose unknown lines are skipped is a document whose protections can be removed by a typo.
Path semantics
Paths are relative to the project root and take three shapes:
exact a/b.txt
subtree dir/**
glob **/.env* src/**/*.key *.pem* matches within one component and never crosses /; ** matches zero or more whole components. Matching is byte-exact on component
boundaries and nothing is case-folded or normalised, so secrets/** covers secrets/a and never secrets-public. Refused outright: a leading /, a . or .. component, an empty component, \, a trailing / (write /**), a control character, and ** mixed into a component (a**b), which has no reading that does not
contradict another.
Precedence is fixed, not positional. The order patterns are compared in is decided at parse time, independent of declaration order and of scope:
rank = (kind, literal bytes, fewer wildcards)
kind: exact (3) > subtree (2) > glob (1)So reordering the file cannot change what it means, and where two scopes both apply — a version 0 root file, which still has a shared scope — a rule beats another only by being more specific, never by being the more local of the two. Two rules of equal rank naming different labels fail closed rather than racing: statically when the patterns are identical, and at decide time for a concrete path otherwise. Equal rank under the same label is fine.
Merge semantics, per directive
Merging depends on what the directive is for, and the grammar table marks each one. Security directives are additive; that is a requirement, not a default.
| Directive | Effective value | Class |
|---|---|---|
label paths | global ∪ project | Additive |
sync workspace exclude_labels | global ∪ project | Additive |
ignore paths | global ∪ project | Additive |
| Future scalar settings | project value replaces global value | Override |
Under a Development Root the global side of both security rows is empty — labels are
repository-specific — so a project's effective rules are its own block. The union matters for a version
0 root file's shared scope, and for the scalar settings the table reserves.
A project can only add protections; there is no spelling that subtracts one the
root declared, because removing one is how a path gets unprotected by an edit that reads like a
narrowing. Override exists in the table for future scalar configuration — typed blocks
such as validation or build settings, each of which must declare its merge class when it is added
— and is never available to a security directive. No such directive exists today.
Ignoring build output
policy 1
global {
ignore { paths = ["**/target", "**/node_modules"] } # every project
}
project "my-project" {
ignore { paths = ["dist"] } # this project alone
}An ignore block keeps paths out of every version: build output, dependency caches,
anything regenerated rather than written. An ignored path is never recorded, never reported by status, and never removed when a version is written over the working tree. A
pattern covers the directory it names and everything beneath it, so target, target/** and **/target all ignore a top-level target/ —
and an ignored directory is not even walked. Unlike a label, ignore is allowed in global under a Development Root, where it applies to every project; a project block can
ignore more, never less.
Ignoring is not protecting. An ignored file is simply absent from the version
and still readable on disk. To keep a file in the repository but restricted, label it. Ignore
rules are read from the file when the working tree is read and are never pinned into a version,
so collaborators keep their own. nexa <project> policy show lists the rules
in force, and nexa <project> policy explain <path> says when a path is ignored.
Requires Nexa 0.1.7 or later.
Labels are named, not bound — and resolution fails closed
A version 0 file bound a nickname to an identifier on a label line. A version 1
file names a label by its key and carries no identifier at all: the key resolves
against the repository's label definitions.
The key is immutable, because rules bind to it — changing it would silently unbind every rule naming it. Display name and description are free to change. Resolution fails closed for an absent, archived or ambiguous key, each with a message naming the key and the remedy, because a rule missing from a sealing policy is a path written in the clear.
Commands
nexa label create secrets --name "Secrets" --description "Credentials"label create accepts paths as trailing arguments, or none at all: a label may be created
and given paths later. It writes the declaration where the policy is read from — the root's file,
scoped to this project, or the repository's own — and says which. Hand-written files are preserved:
comments and blank lines survive.
| Command | What it answers |
|---|---|
nexa policy check [PATH] | Does this file parse? Syntax only, so it needs no label definitions. |
nexa policy validate | Does every key of this project resolve against its own repository's labels, with no
equal-specificity conflict — and does every exact path it protects name a file? Catches
what would otherwise surface at nexa record. A sibling project's block is
listed but resolved only from that project. |
nexa policy show | The effective policy: labels resolved, rules ranked, destination rules listed. |
nexa policy explain <PATH> | Why this path is classified the way it is, and every rule that matched. |
nexa policy sync <WORKSPACE> | What is excluded — separately, what is declared and what is published. |
nexa policy upgrade [--dry-run] | Version 0 → version 1, proving equivalence first. |
nexa policy migrate [--dry-run] | Fold a legacy project-local file into the root's, within version 0. |
nexa policy explain src/private/keys.tspolicy explain reports every rule that matched, in rank order, with
the scope each one came from — not only the winner, because the rule someone suspects is usually the
one that lost. It then names the effective label, the file the rules were read from, and whether this
device holds the key.
Version pinning: editing the file never reinterprets an older version
Recording a project associates that project's effective policy — the shared rules plus its own, and nothing belonging to a sibling — with the version produced. It is associated at the moment of recording, so editing the root policy later never reinterprets an older version. Old versions keep the policy they were recorded under, which is what content sealed months ago depends on.
That association is also what makes sealing travel: a collaborator receives the declaration through ordinary object exchange, with no policy file in the project's working tree.
A pinned effective policy is written in version 0 text whenever version 0 can express it —
exact and subtree rules with no destination rules. Writing a v0-expressible policy in the v1
format would give identical protections different identifiers, so old versions would stop
deduplicating against new ones. Only a policy containing a glob or a destination rule is
written in the version 1 effective format, which is the one place a label block carries
an identifier: a clone reads it holding no definitions of its own, so a key alone would resolve
to nothing.
Destination rules and the two policy times
sync workspace "<name>" { exclude_labels = […] } means no version that
is a candidate or the selection of that workspace may contain content under those labels. Sealed entries
are public metadata, so this is checkable by any peer, with keys or without.
Two policies are in force at two different moments, and conflating them is the common error:
classification the version's OWN pinned policy, fixed when it was recorded
destination the CURRENT published rule for the workspaceThe authoring file is the source; the published object is the rule. Otherwise
any contributor could relax a destination rule by editing a file in their own checkout.
Publishing the rule needs a manage-capable grant, and the CLI publishes it on nexa push, nexa sync and nexa publish — every command
that carries work to a node — for the workspace that run actually touches. Without manage it publishes nothing and says so in one line; the push continues.
nexa policy sync previewpolicy sync reports the two separately: what your file declares, and what
the repository currently publishes — which is what a node actually enforces. A difference
between them is the useful signal, and collapsing them into one list would hide it.
A hosted node refuses a reference advertisement naming a workspace whose new candidates or selection violate that workspace's published rule, and refuses the batch as a whole rather than applying the admissible half — a partial application would report a success the client reads as "my push landed". It refuses the equivalent operations over the API too. Client-side checks exist so a refusal arrives early and legibly; they are a convenience, and the node's answer is the boundary.
Sealed objects themselves are still stored and forwarded to peers holding no key. Exclusion governs what a workspace's versions reference, never which ciphertext a peer may hold.
What happens at record time
Recording in a context bound to an excluding workspace omits the excluded paths from the tree rather than refusing the whole recording, and says which:
2 path(s) under label secrets were not recorded into previewRefusing would make the rule unusable in practice — a developer working in that workspace with a .env.local on disk could record nothing at all, and the obvious workaround, deleting the file, is worse than
the problem. Classification uses the version's own effective policy, read in the same invocation.
The Rinko Hub pages
A repository has three pages for this, and they read rather than author:
| Page | Shows |
|---|---|
| Labels | Every label: key, display name, how many people hold it, how many paths it classifies in the selected version, and whether it is archived. Each label's own page adds its access list and the workspaces that exclude it. |
| Workspaces | Every workspace with its kind, its branch, its base and selected versions, its candidate count, and whether it is archived or protected. |
| Policy | The effective policy of a chosen version: labels resolved, rules in rank order, declared destination rules beside the published ones, and a path explainer. |
There is no form anywhere on Rinko Hub that writes a path rule. Paths are authored in the
Development Root's .nexapolicy and pinned into each recorded version, and the
pages show you what the protocol resolved. What Rinko Hub does author is the label itself,
the grants that hand out its key, the workspaces, and a workspace's published destination rule.
Workspace kinds, archiving and deletion
A workspace's kind is derived, never stored — from its name and its parent — so it cannot disagree with the facts that determine it:
| Kind | Which workspaces |
|---|---|
primary | The repository's default workspace, main. |
branch | Another parentless workspace: a long-lived line of work. |
normal | A workspace inside a branch. |
contributor | One the contribution system derived for a single contributor. |
Archiving is not deleting. An archived workspace is hidden from default lists and refuses new candidates and new selections, and that is all: every version stays recorded and reachable, the candidates already offered stay listed, and the history stays readable. There is no unarchive today, and Rinko Hub offers no delete at all.
Deletion exists only in the CLI, and only with guards: it refuses the primary workspace, refuses a contributor's workspace, refuses a branch that still has children, refuses one with an outstanding merge, and otherwise requires you to acknowledge what remains reachable. Versions survive a deletion regardless — what goes is the workspace's own reference to them.
Version 0 files, and upgrading
Version 0 files continue to parse and mean exactly what they meant. Legacy project-local files, pinned version 0 policy objects, version identifiers and sealing are untouched, and upgrading is something you do deliberately:
nexa policy upgrade --dry-runBefore writing anything, upgrade proves that every declared pattern, in every
project, is classified exactly as before — by comparing decisions, not by arguing the
translation is faithful. It creates a label definition for each version 0 binding, for the
identifier the file already names rather than a fresh one, and refuses a nickname that is not a
valid key by name rather than transliterating it, because a name silently
rewritten leaves its author looking for one the file no longer has. The original is kept at .nexapolicy.migrated-backup.
Until a label has a definition, a version 1 file cannot reference it — which is the reason such labels are still listed, with no key, everywhere they appear. An empty listing would say "this repository has no labels" about a repository whose content is sealed under one.
Managing labels and workspaces from Rinko Hub
This section is for maintainers. Everything here happens on the repository's own pages, and each action is checked on the server: a control a viewer may not use is absent rather than disabled, and the permission is re-checked when the request arrives.
Labels
Create. A label is created with a key — the immutable slug .nexapolicy names it by — plus a display name and a description you may change later.
Creating one mints its identifier and its first key, and grants that key to your own hosted device,
so you can read what you classify. The key must be unique in the repository, and uniqueness is checked
at creation.
Describe. Changing the display name or the description mints a new revision of the definition and nothing else. It is cosmetic by construction: a rename cannot unlabel anything, because rules bind to the key and the key never changes. Renaming the key is not offered — it would mean creating a second label and migrating the rules to it explicitly, which is a decision rather than an edit.
Archive. Archiving retires a label so no policy may reference it from now on. It removes no grant and re-encrypts nothing: every device that could read its content still can, and its history stays readable. Archiving a label the selected version's policy still names is refused, with the rules named — an archived label a policy still references makes every recording fail, so the refusal happens where you can act on it.
Grants and rotation live on the Label access page, which is the grants sub-page of Labels. Granting wraps the label's current key to one device; expanding across a team grants to each member's device; rotating mints a new epoch and carries forward only the devices you name, which is how access is withdrawn — revocation is the absence of a new grant, not the deletion of an old one. See Managing label access.
An account with every platform permission and no grant can neither re-grant a label nor rotate it. The pages report whether your own device holds a label separately from what your role allows, because they are different questions with different answers.
Workspaces
Create. A workspace is created within a branch, optionally derived from another — deriving pins the base version it started from. The name must be one the protocol accepts, and the reserved contribution prefix is refused: those workspaces belong to the contribution flow, which owns their lifecycle.
Archive. Archiving hides a workspace and stops new work arriving, retaining
everything described above. main cannot be archived, and neither can a contributor's
workspace.
Protected workspaces. main and every contribution workspace are
protected against the generic controls. A contributor's workspace is reachable only through the
contribution use-cases, so a generic "add a candidate here" cannot reach inside one; choosing
what main holds is a selection, which is a merge decision rather than workspace administration.
Destination rules. A workspace's published exclusions can be republished from its page. This is the rule the node enforces on arrival — it is not where the rule is written, which is still the Development Root's file. Publishing an empty list is a deliberate withdrawal of every rule and is recorded as a revision like any other, which is why a workspace that never published one shows no rule rather than an empty one.
Who may do what
| Action | Permission | Roles |
|---|---|---|
| View labels, workspaces and policy | view | Anyone who can see the repository |
| Create, describe, archive a label | labels_manage | Owner, admin |
| Grant and rotate label keys | manage_label_access | Owner, admin |
| Create and archive a workspace | workspaces_manage | Owner, admin, maintainer |
| Publish a workspace destination rule | manage_settings | Owner, admin |
| Select a candidate | merge | Owner, admin, maintainer |
The two label permissions are deliberately distinct: one decides the vocabulary of protection, the other decides who can read what is protected under it. A repository can reasonably want a maintainer who creates workspaces and merges but cannot hand out a key. Publishing a destination rule sits with settings rather than with workspace management because it decides what content may reach a workspace, which is an administrative statement about the repository rather than ordinary workspace work.
Every one of these actions is recorded with its actor and time, and what is recorded names the label's key and identifier — both public — and never a key, a device's wrapped material or a path list.