> ## Documentation Index
> Fetch the complete documentation index at: https://docs.alfiz.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Declaring Alfiz Permissions: Keys, Kinds, and Wildcards

> Learn Alfiz permission declarations: dotted keys, read vs. action kinds, destructive flags, scope wiring, and forward-inclusive wildcards.

Every permission your application enforces must be declared in the catalog before any check can reference it. Alfiz will not evaluate a key it has never seen. Passing an unknown key to `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:

```
docs.files.read
docs.files.update_file
docs.files.delete

billing.invoices.read
billing.invoices.export

admin.users.read
admin.users.invite_user
admin.users.delete
```

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

The group levels (`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:

```ts theme={null}
conventions: { depth: 2 }      // an integration catalog: zoom.host
conventions: { depth: 4 }      // a deeper feature tree
conventions: { depth: "any" }  // mixed depths, so turn the check off
```

<Tip>
  Adopting Alfiz in an existing codebase? Set `depth` to whatever your keys already are, or `"any"`, and move on. Renaming 400 call sites to satisfy a lint default is the wrong first day.
</Tip>

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

```ts theme={null}
defineCatalog({
  namespaces: ["admin"],
  permissions: {
    "admin.users.read": { kind: "read" },
    "admin.users.invite_user": true,
    "admin.users.delete": { destructive: true },
  },
});
```

```ts theme={null}
export interface PermissionLeafInput {
  label?:              string;
  description?:        string;
  kind?:               "read" | "action";
  destructive?:        boolean;
  scopes?:             readonly ScopeType[];
  impliedOnAncestors?: boolean;
  requiresCondition?:  boolean;
}
```

<ParamField body="label" type="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.
</ParamField>

<ParamField body="description" type="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.
</ParamField>

<ParamField body="kind" type="&#x22;read&#x22; | &#x22;action&#x22;">
  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.
</ParamField>

<ParamField body="destructive" type="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.
</ParamField>

<ParamField body="scopes" type="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](/catalog/scope-types) for the full explanation.
</ParamField>

<ParamField body="impliedOnAncestors" type="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.
</ParamField>

<ParamField body="requiresCondition" type="boolean" default="false">
  The [condition seam](/enforcement/conditions): 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.
</ParamField>

## The naming floor

The naming floor is Alfiz's suggested house style, checked by `lintCatalog` 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:

1. **Every tab needs at least one `read` permission.** Name it `read` for general access to the section, or `read_<thing>` for a more specific read (e.g. `read_history`, `read_exports`).
2. **Action permissions are named `<verb>_<noun>`** in `snake_case`, as in `invite_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 like `share`, is a `lintCatalog` **warning** and not an error, so it does not fail the build; rename it `share_file` if you want a clean run.
3. **Destructive actions stand alone.** Don't bundle `delete` with `update` in the same leaf. Give them separate declarations so you can grant one without the other and so enforcement can apply `can.fresh()` selectively.

Keys are always written **in full**, even inside a `group()` block. There is no
short form: the first segment must be a declared namespace, or `defineCatalog`
throws at boot naming the fix.

```ts theme={null}
// ✅ Well-formed tab
permissions: {
  "docs.files.read":               { kind: "read", description: "View the file list" },
  "docs.files.read_collaborators": { kind: "read", description: "View who a file is shared with" },
  "docs.files.create":             { description: "Create a new file" },
  "docs.files.publish":            { description: "Publish a draft file" },
  "docs.files.archive":            { description: "Archive a file" },
  "docs.files.delete":             { destructive: true, description: "Permanently delete a file" },
}

// ❌ Below the naming floor: no read permission under docs.files
permissions: {
  "docs.files.create":  { description: "Create a file" },
  "docs.files.publish": { description: "Publish a file" },
}

// ❌ Fails at boot: bare leaf names are not keys
permissions: {
  read:   { kind: "read" },   // "read" is not under a declared namespace
  create: {},
}
```

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

```ts theme={null}
import { defineCatalog, group } from "@alfiz/core";

export const files = group("docs.files", {
  label: "Files",
  description: "File authoring and publication",
  scopes: ["docs.folder"],           // the group-wide default
}, {
  "docs.files.read":               { kind: "read", description: "View the file list" },
  "docs.files.read_collaborators": { kind: "read", description: "View who a file is shared with" },
  "docs.files.create":             { description: "Create a new file", scopes: [] },
  "docs.files.publish":            { description: "Publish a draft file" },
  "docs.files.archive":            { description: "Archive a file" },
  "docs.files.delete":             { destructive: true, description: "Permanently delete a file" },
});

export const sharing = group("docs.sharing", {
  label: "Sharing",
  scopes: ["docs.folder"],
}, {
  "docs.sharing.read":                { kind: "read", description: "View sharing records" },
  "docs.sharing.add_collaborator":    { description: "Add a collaborator to a file" },
  "docs.sharing.remove_collaborator": { description: "Remove a collaborator from a file" },
});
```

`permissions` accepts a single map, a single block, or an array mixing both:

```ts theme={null}
defineCatalog({
  namespaces: ["docs"],
  permissions: [files, sharing, { "docs.reports.read": { kind: "read" } }],
  scopeTypes: { "docs.folder": { parent: null } },
});
```

Keys stay absolute inside a block, so nothing about grepping changes. Every key **must** start with the block's path, and a key that does not is a compile error naming the fix:

```ts theme={null}
group("docs.files", {
  "docs.sharing.read": true,  // ❌ Type error: this key must start with "docs.files."
});
```

<Tip>
  Because keys are absolute, blocks compose by concatenation, with no tree merging. That makes one file per feature a supported layout: put `export const files = group("docs.files", …)` next to the code it gates, and have your root catalog import and list them. Adding a surface then touches one small file, and the diff is exactly the permissions you added.
</Tip>

### `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.

```ts theme={null}
export interface GroupInput {
  label?:       string;
  description?: string;
  scopes?:      readonly ScopeType[];
}
```

<ParamField body="label" type="string">
  Short human-facing name for the group shown in role editors and permission pickers. Falls back to the path segment when omitted.
</ParamField>

<ParamField body="description" type="string">
  Longer description of what this group covers, shown in admin surfaces.
</ParamField>

<ParamField body="scopes" type="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.
</ParamField>

### Labelling a group without a block

For paths you did not write a block for, typically the project level, use the optional top-level `groups` map:

```ts theme={null}
defineCatalog({
  namespaces: ["docs"],
  groups: { docs: { label: "Documents", description: "The shared document workspace" } },
  permissions: [files, sharing],
});
```

## Wildcards and forward-inclusive semantics

Alfiz supports three levels of wildcard pattern:

| Pattern        | Matches                                     |
| -------------- | ------------------------------------------- |
| `*`            | Every permission in the catalog             |
| `docs.*`       | Every permission under the `docs` project   |
| `docs.files.*` | Every permission under the `docs.files` tab |

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.

<Warning>
  Forward-inclusive wildcards mean that a role or grant covering `admin.*` will silently acquire every permission you add under `admin` later, including destructive ones. Grant broad wildcards to roles only when "everything under this group, including future capabilities" is the correct intent. Scope specific grants to narrower patterns like `admin.users.*` when you need to limit reach.
</Warning>

<Tip>
  Wildcards select only leaf permissions, and never group paths. Passing the group path `"docs.files"` where a pattern is expected names nothing. The pattern selecting everything under a group is `"docs.files.*"`. `alfiz-verify` catches this and suggests the corrected pattern.
</Tip>

For roles and admin grants, the wildcard semantics mean:

* A role granted `docs.*` today will also cover `docs.analytics.read` the day you add the analytics tab.
* A user revoked `docs.files.delete` is revoked from that key even when a wildcard `docs.*` grant would otherwise cover it, because personal revokes always win.
* `alfiz-verify` warns when a wildcard pattern in a nav entry currently matches zero keys.
