Documentation menu

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:

PieceDecidesWhere it lives
LabelWho can decrypt content classified under itA protocol object, plus a grant addressed to each device
.nexapolicyWhich paths belong to which labelOne human-authored file, in the Development Root
Destination ruleWhich labels a workspace refuses to receiveA 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 Root layout
Development/
  .nexa/
  .nexapolicy        ← the one authoring file
  ProjectA/          ← user files only
  ProjectB/          ← user files only

The 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.

A standalone repository's .nexapolicy
# .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:

Command
nexa my-project policy validate
Expected output
error: 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
# 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.

Warning: An unregistered block or attribute is a parse error

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:

Path 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:

Precedence
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.

DirectiveEffective valueClass
label pathsglobal ∪ projectAdditive
sync workspace exclude_labelsglobal ∪ projectAdditive
ignore pathsglobal ∪ projectAdditive
Future scalar settingsproject value replaces global valueOverride

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

Development/.nexapolicy
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

Create a label
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.

CommandWhat it answers
nexa policy check [PATH]Does this file parse? Syntax only, so it needs no label definitions.
nexa policy validateDoes 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 showThe 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.
Syntax
nexa policy explain src/private/keys.ts

policy 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.

Note: Identical policies keep identical identifiers

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:

The two policy times
classification   the version's OWN pinned policy, fixed when it was recorded
destination      the CURRENT published rule for the workspace

The 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.

Syntax
nexa policy sync preview

policy 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.

Warning: The node is the boundary, not the client

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:

Reported by nexa record
2 path(s) under label secrets were not recorded into preview

Refusing 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:

PageShows
LabelsEvery 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.
WorkspacesEvery workspace with its kind, its branch, its base and selected versions, its candidate count, and whether it is archived or protected.
PolicyThe effective policy of a chosen version: labels resolved, rules in rank order, declared destination rules beside the published ones, and a path explainer.
Note: The website displays path policy; it does not author it

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:

KindWhich workspaces
primaryThe repository's default workspace, main.
branchAnother parentless workspace: a long-lived line of work.
normalA workspace inside a branch.
contributorOne 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:

Preview the upgrade
nexa policy upgrade --dry-run

Before 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.

Note: Holding a label is a cryptographic fact, not a permission

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

ActionPermissionRoles
View labels, workspaces and policyviewAnyone who can see the repository
Create, describe, archive a labellabels_manageOwner, admin
Grant and rotate label keysmanage_label_accessOwner, admin
Create and archive a workspaceworkspaces_manageOwner, admin, maintainer
Publish a workspace destination rulemanage_settingsOwner, admin
Select a candidatemergeOwner, 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.