can() or require() raises UnknownPermissionError at runtime and a type error at compile time. This section explains how to declare permissions correctly, name them consistently, and use wildcards safely.
Key shape
A permission key is a dotted string whose first segment is a namespace your application owns. That is the only structural requirement.defineCatalog rejects a key whose first segment is not in namespaces, and accepts any depth beyond it.
Most application catalogs settle on three segments because the middle one is a useful place to put the feature area:
- The first segment is the namespace, which is declared and enforced.
- The middle segments are feature areas. Use as many as you find useful, or none.
- The last segment is the leaf: the concrete thing someone may or may not do.
admin, admin.users) are inferred: every dotted prefix of a declared key is a group. You never declare them into existence, which is why depth costs nothing. Because a group is a folder and a permission is a leaf, one key can never be both. Declaring admin.users alongside admin.users.read is a boot-time error.
Depth is a house-style setting and not a rule. conventions.depth defaults to 3, and lintCatalog is the only thing that reads it. defineCatalog never checks it, and neither does any check at runtime. If your keys are shaped differently, say so in one line rather than renaming to match a default:
Declaring permissions: PermissionLeafInput
Permissions are declared by their full dotted key, the same string you check with, so a key at a call site greps straight back to its declaration. The value is either the literal true (defaults for everything) or a PermissionLeafInput object:
string
Short human-facing name shown in permission pickers and role-editor checkboxes, for example
"Publish file". Keeping the label here prevents UI copy from drifting into a side table. Falls back to the key’s leaf segment when omitted.string
Longer help text shown beside the permission in pickers and admin surfaces. Use this to explain the real-world consequence of the permission, not just restate the name.
"read" | "action"
The read-versus-action taxonomy. Inferred automatically when omitted: a leaf named
read or matching read_* is a "read"; everything else is an "action". Set explicitly only when the inference would be wrong.boolean
Marks the permission as destructive. Destructive actions pair with
can.fresh() at enforcement points to bypass caches, giving you a last-chance check before an irreversible operation. Inferred automatically for leaf names matching delete, delete_*, destroy, destroy_*, purge, or purge_*. Destructive actions should stand alone in their group, unbundled from related actions.readonly ScopeType[]
The scope types this permission is grantable at, beyond the global scope
*. Omitting this field inherits the default from the nearest enclosing group that declares scopes; if no enclosing group does, the permission is grantable at the global scope only. Pass an explicit empty array [] to override an inherited group default and make the permission global-only. See Scope Types for the full explanation.boolean
default:"false"
When
true, holding any grant of this permission at a scope implies it on the proper ancestors of that scope, though never on the global scope, so an unscoped can() is unaffected. Use this for the “shared doc shows its containing folder” pattern: a user who can read a specific document automatically sees the folder containing it. Off by default.boolean
default:"false"
The condition seam: holding this permission is necessary but not sufficient, so every gate must also pass
{ condition: () => … } evaluating an application predicate (“under the approver’s limit”, “still Draft”). A gate without one throws MissingConditionError at runtime and fails alfiz-verify in CI. Visibility shapes are unaffected. Off by default.The naming floor
The naming floor is Alfiz’s suggested house style, checked bylintCatalog and reported by alfiz-verify. Only the first rule is an error; the rest are warnings that do not fail a build. Adopt what suits you:
- Every tab needs at least one
readpermission. Name itreadfor general access to the section, orread_<thing>for a more specific read (e.g.read_history,read_exports). - Action permissions are named
<verb>_<noun>insnake_case, as ininvite_user,export_report,publish_file. A short list of common standalone verbs (create,update,delete,manage,approve,publish,archive,export,import,issue,revoke,view_as) are allowed without a noun. Anything else, such as a single-word action likeshare, is alintCatalogwarning and not an error, so it does not fail the build; rename itshare_fileif you want a clean run. - Destructive actions stand alone. Don’t bundle
deletewithupdatein the same leaf. Give them separate declarations so you can grant one without the other and so enforcement can applycan.fresh()selectively.
group() block. There is no
short form: the first segment must be a declared namespace, or defineCatalog
throws at boot naming the fix.
Organizing a large catalog: group()
A flat map is the right shape for a small catalog, since ten permissions in one object is complete and idiomatic, and groups are never required. Past a few dozen keys it becomes a wall of strings, and that is what group() is for: a named, foldable block carrying one group’s metadata and the keys under it.
permissions accepts a single map, a single block, or an array mixing both:
GroupInput
The metadata a block (or a groups entry) carries. Groups themselves are never declared, so this only decorates a path that keys already imply.
string
Short human-facing name for the group shown in role editors and permission pickers. Falls back to the path segment when omitted.
string
Longer description of what this group covers, shown in admin surfaces.
readonly ScopeType[]
Default scope types inherited by every permission under this group, including descendant groups, unless overridden closer to the leaf. This saves repeating
scopes: ["docs.doc"] on every sibling leaf when a whole tab is scoped to one resource type. The nearest enclosing declaration wins, and a leaf’s own scopes (including an explicit [] for global-only) overrides it.Labelling a group without a block
For paths you did not write a block for, typically the project level, use the optional top-levelgroups map:
Wildcards and forward-inclusive semantics
Alfiz supports three levels of wildcard pattern:
Wildcards are forward-inclusive: a stored grant of
docs.files.* will match any permission added under docs.files in the future, including ones that did not exist when the grant was created. That semantic is deliberate, and it is not configurable.
For roles and admin grants, the wildcard semantics mean:
- A role granted
docs.*today will also coverdocs.analytics.readthe day you add the analytics tab. - A user revoked
docs.files.deleteis revoked from that key even when a wildcarddocs.*grant would otherwise cover it, because personal revokes always win. alfiz-verifywarns when a wildcard pattern in a nav entry currently matches zero keys.