diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js
index 849c878e9c..cb285b022f 100644
--- a/.claude/workflows/docs-accuracy-audit.js
+++ b/.claude/workflows/docs-accuracy-audit.js
@@ -145,6 +145,7 @@ const ALL_HANDWRITTEN = [
"content/docs/permissions/attachments-access.mdx",
"content/docs/permissions/authentication.mdx",
"content/docs/permissions/authorization.mdx",
+ "content/docs/permissions/capabilities.mdx",
"content/docs/permissions/delegated-administration.mdx",
"content/docs/permissions/explain.mdx",
"content/docs/permissions/field-level-security.mdx",
diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx
new file mode 100644
index 0000000000..9c6ce5c6fc
--- /dev/null
+++ b/content/docs/permissions/capabilities.mdx
@@ -0,0 +1,240 @@
+---
+title: "Declaring Capabilities"
+description: "How a package DEFINES an authorization capability with defineCapability — the declaration half of ADR-0066 D1 — and how that name travels from source to the sys_capability catalogue to a permission-set grant to a requiredPermissions check."
+---
+
+# Declaring Capabilities
+
+Every other page in this module is about **consuming** a capability: granting
+one through a permission set, requiring one on an object or an action, reading
+one back in an access matrix. This page is about the other end — how a package
+**defines** the capability in the first place, and what happens to that name
+afterwards.
+
+
+**Two arrays, similar words, unrelated vocabularies.** A stack can carry both
+`capabilities:` and `requires:`. They have nothing to do with each other, and
+picking the wrong one produces metadata that validates and then does nothing.
+Read the next section before you write either.
+
+
+## `capabilities:` is not `requires:`
+
+| | `capabilities: [...]` | `requires: [...]` |
+|:--|:--|:--|
+| **Declares** | An **authorization** capability this package *offers* | A platform **service** this package *needs* |
+| **Answers** | "What new privilege can an administrator now grant?" | "What must be installed for this app to boot?" |
+| **Vocabulary** | Author-chosen names, `^[a-z][a-z0-9_.]*$` — `export_data`, `billing.refund` | A **closed** vocabulary: canonical kebab-case tokens from `PLATFORM_CAPABILITY_TOKENS` — `ai`, `automation`, `hierarchy-security` |
+| **Entry shape** | `defineCapability({ name, label, description, scope })` (`CapabilityDeclarationSchema`) | A plain `string` |
+| **Unknown value** | There is no "unknown" — you are minting the name | A `defineStack` **error** at authoring time (a typo, or a token no runtime provides) |
+| **Consumed by** | `systemPermissions` (grant) and `requiredPermissions` (requirement), by name string | The runtime capability loader, which resolves each token to a service plugin |
+| **When it bites** | Never at boot — an ungranted capability is simply held by nobody | **Fail-fast at startup**: a declared-but-missing provider aborts boot instead of degrading silently |
+| **Spec** | ADR-0066 D1 | Platform service vocabulary — see the [CLI reference](/docs/deployment/cli) |
+
+The word "capability" is doing double duty across two protocols, which is why
+this trap is easy to fall into and expensive to leave in place. The one-line
+test: **`capabilities:` is about people, `requires:` is about packages.** If the
+sentence you are trying to write ends in "…may do this", it belongs in
+`capabilities:`. If it ends in "…must be installed", it belongs in `requires:`.
+
+## The whole loop
+
+Each of the other pages in this module shows one segment of this. End to end, a
+capability name travels through five stations:
+
+```
+ ① DECLARE capabilities: [defineCapability({ name: 'export_data' })]
+ │ packages//src
+ ▼
+ ② REGISTER AppPlugin → metadata.registerInMemory('capability', name)
+ │ (also: **/*.capability.ts files)
+ ▼
+ ③ SEED bootstrapDeclaredCapabilities → sys_capability row
+ │ managed_by: 'package' + package_id provenance
+ ▼
+ ④ GRANT permission set: systemPermissions: ['export_data']
+ │ assigned to positions / users
+ ▼
+ ⑤ CHECK resource: requiredPermissions: ['export_data']
+ AND-gated ahead of any CRUD grant
+```
+
+1. **Declare** — `defineCapability(...)` on the stack's `capabilities` array,
+ or a `*.capability.ts` / `*.capability.yml` file the filesystem loader globs.
+2. **Register** — at boot, `AppPlugin` puts each stack-declared capability into
+ the metadata registry under the `capability` kind, so boot seeders and
+ runtime resolvers can list it.
+3. **Seed** — `bootstrapDeclaredCapabilities` (in `@objectstack/plugin-security`)
+ reads them back and upserts each one into `sys_capability` with
+ `managed_by: 'package'` and `package_id` provenance. This is what makes the
+ capability *attributable* and package uninstall well-defined.
+4. **Grant** — an administrator (or a permission set your package ships) lists
+ the name in [`systemPermissions`](/docs/permissions/permission-metadata#system-permissions).
+5. **Check** — a resource lists the name in `requiredPermissions`, and the
+ evaluator AND-gates it against the union of `systemPermissions` across the
+ caller's resolved permission sets.
+
+Stations ④ and ⑤ are covered in depth by
+[Permission Sets](/docs/permissions/permission-sets) and
+[Authorization Architecture](/docs/permissions/authorization). Everything below
+is about ① – ③.
+
+## The declaration shape
+
+{/* os:check */}
+```typescript
+import { defineCapability } from '@objectstack/spec';
+
+export const ExportDataCapability = defineCapability({
+ name: 'export_data',
+ label: 'Export Data',
+ description: 'Bulk-export records to CSV/XLSX.',
+ scope: 'org',
+});
+```
+
+Collect the declarations on the stack, next to the permission set that grants
+them:
+
+```typescript
+import { defineStack } from '@objectstack/spec';
+import { ExportDataCapability } from './capabilities/export-data.capability';
+
+export default defineStack({
+ manifest: { name: 'billing', namespace: 'billing', version: '1.0.0' },
+ capabilities: [ExportDataCapability], // ← ① DEFINE
+ permissions: [
+ { name: 'billing_admin', systemPermissions: ['export_data'] }, // ← ④ GRANT
+ ],
+ // and on a resource: requiredPermissions: ['export_data'] // ← ⑤ REQUIRE
+});
+```
+
+### Fields
+
+| Field | Type | Required | Notes |
+|:--|:--|:--|:--|
+| `name` | `string` | ✅ | The contract. `^[a-z][a-z0-9_.]*$` — lowercase, digits, `_` and `.` |
+| `label` | `string` | optional | Shown in Setup. Defaults to a humanized `name` |
+| `description` | `string` | optional | What holding the capability permits |
+| `scope` | `'platform' \| 'org'` | optional | Defaults to `'platform'`. `org` = scoped to the caller's organization |
+| `packageId` | `string` | optional | Author-declared fallback provenance (ADR-0086 D3). Normally the registry stamps this for you |
+
+The shape is **strict**: an unrecognised key is a parse error at authoring
+time, not a silently dropped field. Three near-miss keys get a named refusal
+rather than a generic one, because each is a real inversion of the three-way
+separation — `permissionSets` (a capability never names its own holders),
+`requiredPermissions` (that is the requirement side, authored on the resource)
+and `inputs` (a capability is a name, not a contract). The full generated field
+reference lives in [Security schemas](/docs/references/security).
+
+## The name is the contract
+
+Resolution is **by string, everywhere**. `systemPermissions` and
+`requiredPermissions` both carry plain names, and the evaluator compares them as
+plain names. Three consequences worth internalising before you pick one:
+
+- **Your name lands in the same flat namespace as the platform's own.** There
+ is no per-package prefixing applied for you. Namespace it yourself —
+ `billing.refund` reads unambiguously; `refund` will collide with somebody.
+- **A typo fails closed, and quietly.** `mange_users` is a perfectly valid
+ capability name; it is simply held by nobody, so the caller is denied. That is
+ the safe direction, but nothing about the denial says the name exists nowhere.
+ The authoring lint `validateCapabilityReferences` (ADR-0066 ⑨) closes part of
+ this gap: it resolves every `requiredPermissions` reference against the
+ capabilities known at author time and **warns** on the unresolved ones. It is
+ a warning, not an error, because a single package's lint cannot see
+ capabilities declared by *other* installed packages. `systemPermissions` is
+ deliberately not flagged — that is the declaration side.
+- **Renaming a shipped capability is a breaking change** for every permission
+ set and every resource that references the old string, including ones in other
+ packages you cannot see.
+
+You cannot take a platform name. A declaration whose `name` collides with a
+curated platform capability (`manage_users`, `manage_metadata`,
+`setup.access`, …) is refused loudly at boot — those are platform-owned.
+
+## What the seeder does, and what it refuses
+
+`bootstrapDeclaredCapabilities` runs on `kernel:ready` and is idempotent: it
+re-seeds on every boot, so the row always reflects the shipped declaration. Four
+outcomes are refusals rather than writes, and each one leaves the capability
+behaving differently:
+
+| Situation | Outcome |
+|:--|:--|
+| No resolvable owning package (`_packageId` and `packageId` both absent) | **Refused — no row is written.** The declaration is inert |
+| Name collides with a curated platform capability | Refused; the platform keeps its own definition |
+| A row already exists owned by a **different** package | Skipped loudly; a package never writes into a foreign record |
+| A row was authored by an administrator (`managed_by: 'admin'`) | Never clobbered |
+
+A pre-existing `managed_by: 'platform'` row for a *non-curated* name is a
+different case: that is the untitled placeholder the old implicit back-door
+derived from whatever a permission set happened to reference, and an explicit
+declaration **claims** it — upgrading the row to package provenance with your
+authored label, description and scope. The provenance and composition rules
+behind that behaviour are set out in
+[Package capability declaration](/docs/permissions/authorization#package-capability-declaration-adr-0066-d1).
+
+
+**The `sys_capability` row is a catalogue, not a gate.** It carries the label,
+the scope and the provenance that make a capability reviewable, attributable and
+uninstallable — but no authorization check reads it. Permission-set grants and
+resource `requiredPermissions` match capability names **as strings**; the only
+production readers of the table are the two boot seeders. The row's `active`
+flag is a catalogue/visibility flag with no authorization effect: clearing it
+revokes nothing. Withdraw a capability by withdrawing the **grant**.
+
+
+## `capability` is code-only
+
+`capability` is a registered metadata kind, and its registry entry declares
+`allowRuntimeCreate: false` **and** `allowOrgOverride: false`. Together those
+are the code-only declaration, so:
+
+```
+PUT /api/v1/meta/capability/:name
+ → 403 NOT_CREATABLE
+ "Metadata type 'capability' is code-only: the metadata-type registry
+ declares allowRuntimeCreate=false and allowOrgOverride=false, so it
+ cannot be created through the runtime metadata API … on any kernel.
+ Declare it in source (**/*.capability.ts) and redeploy."
+```
+
+The refusal fires **before** the body is validated, on every kernel, in draft
+mode as well as active. This is deliberate and follows straight from the
+three-way separation: an administrator minting a brand-new capability at runtime
+has no counterpart in it — nothing in code would ever *require* the name, so the
+result is an unreferenced grant target sitting in the live authorization
+namespace. `job` and `agent` carry the same pair for the same reason.
+
+Two things this does **not** close:
+
+- **The package-declaration channel is untouched.** `AppPlugin` registers stack
+ `capabilities[]` through the in-memory registry and the filesystem loader
+ globs `filePatterns`; neither goes through the runtime write door.
+- **`supportsOverlay: false`** — a capability is a name, a label and a scope;
+ there is no merge semantic, and a per-organization overlay of a
+ package-shipped declaration could re-scope `org` → `platform`.
+
+`OS_METADATA_WRITABLE=capability` remains the one documented operator escape
+hatch (ADR-0005). Behind it the write is judged by `CapabilityDeclarationSchema`
+and a malformed body is rejected with `422 invalid_metadata` — it is not a way
+to store arbitrary JSON on the authorization surface.
+
+## Checklist
+
+- [ ] Name is namespaced and lowercase, and does not shadow a platform capability
+- [ ] `label` and `description` are written for the administrator who will grant it in Setup
+- [ ] `scope` is `org` unless the privilege really is platform-wide
+- [ ] The declaration is reachable from the stack (`capabilities: [...]`) or a `*.capability.ts` file
+- [ ] The package resolves an owning package id — otherwise the seeder writes no row
+- [ ] Something actually **requires** the name (`requiredPermissions`), and something **grants** it (`systemPermissions`)
+
+## See also
+
+- [Authorization Architecture](/docs/permissions/authorization#the-three-way-separation-adr-0066) — capability / assignment / requirement, and the enforcement chain
+- [Permission Sets](/docs/permissions/permission-sets) — the granting half
+- [Permission Metadata](/docs/permissions/permission-metadata#system-permissions) — `systemPermissions` in a permission-set body
+- [Access Recipes](/docs/permissions/access-recipes) — worked end-to-end scenarios
+- [Security schemas](/docs/references/security) — generated field reference
diff --git a/content/docs/permissions/index.mdx b/content/docs/permissions/index.mdx
index b59263b1b7..11f358089b 100644
--- a/content/docs/permissions/index.mdx
+++ b/content/docs/permissions/index.mdx
@@ -50,6 +50,12 @@ grants nothing and is refused at boot. That is an authoring-time build error
Because AI agents act through the same permission-aware surface, these rules bound
agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as-tools)).
+Most of this module is written for the administrator who **consumes** a
+capability — granting it through a permission set, requiring it on a resource.
+The package author's half — how a capability name is DEFINED in the first
+place, and why `capabilities:` and `requires:` are unrelated arrays that read
+alike — is [Declaring Capabilities](/docs/permissions/capabilities).
+
> **Implementation status — Permission Model v2 (ADR-0090) is live.** REST → ObjectQL propagates a populated `ExecutionContext` (userId, tenantId, positions, permissions, principalKind) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks fire on every authenticated request; authenticated principals implicitly hold the `everyone` position and anonymous principals hold `guest` (D9). The `member_default` baseline is **additive** (D5 — no fallback cliff) and its owner-write policies are domained to `org_member` holders. Tenant isolation is a **Layer 0 tenant wall** (`plugin-security/tenant-layer.ts`, ADR-0095 D1) that AND-composes `organization_id == current_user.organization_id` ahead of and independently of business RLS — the earlier wildcard `tenant_isolation` RLS policy on `member_default` was retired, because as an OR-merged business policy it could be widened. `member_default` still ships the per-object `sys_organization_self` / `sys_user_self` overrides for the global tables that carry no `organization_id` column. SecurityPlugin remains the sole authority for tenant isolation, and analytics auto-bridges to `security.getReadFilter`. **Anonymous traffic is denied by default** (ADR-0056 D2), and public forms self-authorize via a declaration-derived `publicFormGrant`. **An unset OWD fails closed to `private`** (D1) and the D7 publish linter makes it a build error. Criteria sharing rules (with `position` / `unit_and_subordinates` recipients) are live and dogfood-proven; v17 reconciled the authoring surface with the enforced runtime (#1878) — `group` was renamed to the enforced `team`, `business_unit` joined the enum, and owner-type rules and `guest` recipients were pruned rather than left declared-but-skipped ([Sharing Rules](/docs/permissions/sharing-rules#recipient-types)). RBAC-table writes are governed by the delegated-admin gate (D12), and the `security` service answers `explain(request)` per evaluation layer (D6). The Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See [Implementation Status](/docs/releases/implementation-status) for the latest matrix.
## What's in this module
@@ -64,6 +70,7 @@ agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as
- [Sharing Rules](/docs/permissions/sharing-rules)
- [Field-Level Security](/docs/permissions/field-level-security)
- [Permission Metadata](/docs/permissions/permission-metadata)
+- [Declaring Capabilities](/docs/permissions/capabilities) - the package author's half: `defineCapability`, and how `capabilities:` differs from `requires:`
- [Security Permissions Matrix](/docs/permissions/permissions-matrix)
- [Record-View Auditing](/docs/permissions/record-view-auditing) - who opened which record, and when
- [Access Recipes](/docs/permissions/access-recipes)
diff --git a/content/docs/permissions/meta.json b/content/docs/permissions/meta.json
index 964435bbd8..9a09fc241f 100644
--- a/content/docs/permissions/meta.json
+++ b/content/docs/permissions/meta.json
@@ -16,6 +16,7 @@
"field-level-security",
"attachments-access",
"permission-metadata",
+ "capabilities",
"permissions-matrix",
"access-matrix",
"explain",