Skip to content

Commit 4d4040c

Browse files
docs(permissions): the hand-written pages describe the security catalog main serves, not the four retired row objects (#23322)
Fixes #22976 Clause-②: no (docs) ## What this changes After C3 stage 8 (#23222) and stage 7b (#23302), `main` serves positions, permission sets and capabilities as catalog definitions. The environment registry holds them and answers them by name (ADR-0131 D3), and assignments name them. Nine of the ten hand-written pages on the card still described the four retired row objects (`sys_position`, `sys_permission_set`, `sys_position_permission_set`, `sys_capability`) as stored tables. Some also described the ADR-0094 projector, the data-door write-through, binding rows, or the row-object Setup pages as live. This PR rewrites those passages to say what `main` does. Each rewrite was checked against source at `main` `404fc477e5`. The tenth page, `protocol/backward-compatibility.mdx`, has one mention, and that mention is correct release history, so it is unchanged. Docs only: 9 files, +149 / −136. There are no edits under `content/docs/releases/**`, `CHANGELOG.md`, ADRs (the `domain:skills` card #22978) or the generated reference pages. ## Re-measure on `main` `404fc477e5` The grep `sys_position|sys_permission_set|sys_capability` over `content/docs/**`, excluding `releases/**` and `references/**`, matches exactly the card's 10 pages: authorization 11, permission-sets 6, delegated-administration 5, capabilities 3, positions 3, system-context 2, and 1 each on the other four. A second grep for the removed mechanisms (projector, projection, write-through, backfill, walled catalog data door, `isRowActive`, `SystemObjectName.POSITION`, the data-door URLs) adds no page. Its hits outside these pages describe mechanisms that are still live: - `deployment/cli.mdx`: "mutation projectors". The datasource-admin plugin still registers one. - `kernel/cluster.mdx`: the registry write-through, `applyRegistryWriteThrough`. - The seed-tenancy, membership and summary-null backfills, which are unrelated to the catalog. The two generated reference pages that still mention the objects (`references/api/protocol.mdx` 3, `references/identity/eval-user.mdx` 2) render spec `.describe()` text. That text is residue for the 8-post stage, so those pages are not edited here. Each mention was judged either a mis-statement or a correct retired statement. A correct "no row is written" or "retired" sentence is kept, as the card allows. | Page | Line on `main` | Verdict | Done | | :--- | :--- | :--- | :--- | | `concepts/metadata-lifecycle` | 141 | Mis-statement. It names `POST /api/v1/data/sys_permission_set` as a clone path and says "the refusal does not name it yet". | The row now names what the refusal names: the Clone action or the clone door. | | `data-modeling/objects` | 336 | Mis-statement. It lists `sys_position_permission_set` as a member of the `system-data` bucket. | Only the two assignment tables are listed. | | `deployment/environment-variables` | 70 | Mis-statement. The grants-cache watched set lists three retired objects. | Five objects, as in the code. | | `permissions/authorization` | 36 | Mis-statement. Assignment is described as rows of `sys_permission_set` and `sys_position`. | Catalog definitions, plus the two assignment rows that name them. | | | 158, 169, 199, 207, 598 | Correct ("no row is written"). | Kept. | | | 233, 244 (Two doors) | Mis-statement. The projector, the data-door redirect, the data-layer provenance gate, and delete as reset on the data door. | Rewritten to the metadata door: `manage_metadata`, operator-only under a wall, `NOT_OVERRIDABLE` naming the clone. | | | 415–416 | Mis-statement. "Setup's Deactivate action writes" the row's `active` column. | Setup's catalog switch writes the activation ledger. | | | 607 | Mis-statement. The lint counts a `sys_capability` seed. | A seed row is not counted. | | `permissions/capabilities` | 71, 210 | Correct. | Kept. | | | 215 (callout) | First sentence correct. "Clearing its `active` flag" implies the row is still served. | The object is retired; the ceremony handles old rows. | | `permissions/delegated-administration` | 29, 37, 38, 53–54 | Mis-statement. The example, the governed-table list, the action-flag row, and the zoo's binding-write case. | Rewritten. | | `permissions/permission-sets` | 155 | Correct. | Kept. | | | 241, 247–248 | Junction row and data-layer anchor binding described as live. | The junction is retired; `everyone` is derived, and the gate runs on the derived definition. | | | 327 (adminScope example) | Mis-statement (`manageBindings`, `sys_position_permission_set`). | Example fixed. | | | 344–347 | "Data layer refuses forging", "position bindings" revoked on uninstall. | What uninstall does on `main`. | | | 366, 378 (the "record is a projection" section) | Mis-statement. | Rewritten as "One authoritative store — the definition". | | | 409, 444 | Data door and record described as live. | Removed. | | `permissions/positions` | 11, 62 | The junction and `sys_position.name` described as live. | Reworded. | | | 85 | Correct. | Kept. | | | Anchors section | Anchor binding described as an admin act and as a data-layer gate. | Derived definition; no caller writes either anchor. | | `permissions/system-context` | 111 (row 11) | The projector skip is described as live. | Annotated: the plugin no longer registers it; the module is still exported. | | | 130 (row 23b) | Correct (a name only a legacy row carries is refused). | Kept. | | | 187 (row 55b) | On `main` it still names the data door on the three retired objects. | **Left to #23231**, whose branch rewrites this row (see Shared pages). | | | 410–412 | "Resolves against the granting organization's own catalog row". | The read targets an unregistered object and gets no rows. | | `protocol/backward-compatibility` | 75 | Correct release history (`17.2.0` retired `sys_position.permissions`). | Kept. | ## Sources for the rewrites (`main` `404fc477e5`) - **Catalog read:** `packages/core/src/security/security-catalog.ts` (module doc). Positions, permission sets and capabilities are read by name from the engine registry first, then from the metadata service. No row is read. - **Assignments store names:** `sys-user-permission-set.object.ts:109` (`permission_set`, `Field.text`) and `:86` (the legacy `permission_set_id`, plain text, which grants nothing). `sys-user-position.object.ts:84` (`position`). The plugin registers three objects (`objects/index.ts:12-14`). - **Setup under `single` and under a wall:** - `metadata-core/src/meta-write-capability.ts:82` admits a `/meta` write for `manage_metadata` or `isSystem` only. - `:169-195` (`walledCatalogWriteVerdict`): under `group` or `isolated`, only the platform operator or `isSystem` may write the catalog. Under `single` the verdict is inert (`:182`). - Setup nav: `plugin-security/src/security-plugin.ts:1543-1545` (`/apps/setup/metadata/TYPE?scope=environment`). - Setup's active switch, at the objectui pin `1fb1ced3`: `app-shell/src/views/metadata-admin/catalog-activation.ts` and `ResourceListPage.tsx:148`. It writes `POST /security/_activation/:type/:name`. - **Packaged sets:** `metadata-protocol/src/packaged-base-regime.ts:194-199` (the clone routes the refusal names) and `managedItemSealedSentence` (`NOT_OVERRIDABLE` / 403). Delete: `protocol.ts:17772` (`refusePackagedBaseRemoval`). `permission` merges at read, so a delete removes only a stored row, and the package's definition stays served. - **Grants cache:** `core/src/security/resolve-user-grants-cache.ts:117-125` (watched set). `security-plugin.ts:1850-1866` bumps the `metadata` epoch on every metadata change. - **Lint:** `lint/src/validate-capability-references.ts:16-26`. A seed row is not a source. - **Delegated-admin gate:** - `delegated-admin-gate.ts:131-149` (the governed set still names the two retired objects) and `:396-405` (dispatch). - `:818`: `manageAssignments` also gates direct grants. - `:993-1048`: `manageBindings` and `authorEnvironmentSets` still take part in containment. - The zoo's binding cases are gone: `showcase-permission-zoo.dogfood.test.ts:173-177`. - **Uninstall:** `plugin-security/src/cleanup-package-permissions.ts:13-25` revokes the grants that name a declared set. - **Anchors:** `plugin-security/src/builtin-positions.ts:121` registers `guest` with no `permissionSets`. `:138` derives `everyone` from the baseline. - **Legacy reads:** `objectql/src/engine.ts:10276` (`resolveObjectName` throws `OBJECT_NOT_FOUND` for an unregistered object). `plugin-security/src/auto-org-admin-grant.ts:154-176` catches the error and answers `[]`. - **Old databases:** `cli/src/utils/organization-ownership-apply.ts:38-53` and `:399-405`. Under `single`, authored sets and positions are converted. Authored capability rows are reported. Seeded copies are deleted. ### Measured against ADR-0131 D2/D3 text The ADR's original reading was that Setup authoring under `single` needs no Studio capability (the organization admin's object grants). The ADR's own text withdraws that reading: the 2026-10-10 note in D2 and D3 (#22621) says Setup authoring under `single` requires `manage_metadata`. `main` does what the withdrawal says (`meta-write-capability.ts:82`), and the pages follow `main`. ## Shared pages - **#23303** (open, draft) edits `system-context.mdx` (it adds row 47c after row 47) and `backward-compatibility.mdx` (lines 117 and 195, the package count). This PR touches neither hunk. It does not edit `backward-compatibility.mdx` at all, and its `system-context.mdx` edits are rows 11 and the census paragraph near line 410. Whichever PR lands second merges `main`. - **#23231** has a branch and no PR yet (`claude/issue-23231-walled-environment-metadata-writes` at `e7fdcc48e2`). Its branch edits three of these pages: `capabilities.mdx` (the walled-refusal paragraph, lines 243–249), `permission-sets.mdx` (the walled `/packages` sentences, lines 354–360) and `system-context.mdx` (row 55b). This PR leaves those lines unchanged. On `permission-sets.mdx`, lines 351–353 sit unchanged between the two hunks. Row 55b on `main` still names the data door on the three retired objects. #23231's rewrite already scopes that clause to "while those objects are registered", so the row is left to it. ## Acceptance notes - **Not in this card (family residue, noted, not filed):** - 8-post: - The spec `.describe()` texts still cite the retired objects: `permission.zod.ts` `packageId` and `managedBy` ("persisted on `sys_permission_set` records", `bootstrapDeclaredPermissions`), `AdminScopeSchema.manageBindings`, and `sys-user-position.object.ts` `position` ("references `sys_position.name`"). Two generated reference pages render that text. - `adminScope.manageBindings` and `authorEnvironmentSets` are spec-declared keys whose primary gates target objects `main` no longer registers. They now take part only in strict containment. The showcase still declares both as `false`. The `permission` liveness row's `adminScope` evidence still cites the two retired objects. - 7a: `createPermissionSetWriteThrough` and `registerPermissionSetProjection` are still exported from `@objectstack/plugin-security` (`index.ts:79-80`), but nothing registers them. - C7b: the legacy-id reads of `sys_permission_set` and `sys_position` (`auto-org-admin-grant.ts:343-356`, `delegated-admin-gate.ts:1317`, `:1330`, and the grant-name backfill) throw `OBJECT_NOT_FOUND`. The errors are caught, so each read answers no rows. - `delegated-administration.mdx` says "`sys_member` is not one of the gate's governed objects" (the invitation role cap). `GOVERNED_OBJECTS` lists it (`delegated-admin-gate.ts:148`). This is unrelated to the catalog objects, so it is not changed here. - `permissions/administrator-guide.mdx` is not added to the page list. It names none of the four objects and none of the removed mechanisms. Its Setup walkthrough was not checked against the console at the pin. ## Verification Local runs on head `260137da82`. CI is not complete yet. - **Derived gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 44 commands; all 44 ran and exited 0. `--ran` reconciles: "44 derived famil(ies) accounted for — 44 run, 0 NOT-MEASURED (a DERIVED zero — all 44 recorded an exit code and none of them is 3)". Selected verdict lines: - `check:doc-anchors`: 499 internal fragment links resolve, including the new `permission-sets#package-suggestion--isdefault-adr-0090-d5`. - `check:system-context-census`: "OK — 133 elevation read sites … the page cites 126 symbol(s) against 126 required". - `check:doc-authoring`, `check:corpus-claim-drift`, `check:role-word`, `check:overlay-whitelist-table`, `check:docs-transcript-drift`, spec `check:docs` ("226 generated files in sync") and spec `check:skill-examples` (263 prose examples type-check): all green. - Two first runs exited 3 with `PREREQUISITE NOT MET`. Both were re-run after the lock-held builds of `@objectstack/lint...` and `@objectstack/client-react...` (`VERDICT command-exit 0`), and both then exited 0. - **Links.** `lychee 0.24.2` with `check-links.yml`'s argv (`--offline --root-dir content --fallback-extensions mdx,md --config lychee.toml` over the four globs): "2460 Total … 0 Errors". `node scripts/check-adr-links.mjs`: 730 links resolve. - **MDX compile.** `@mdx-js/mdx` 3.1.1 with `remark-gfm` compiled all nine edited pages (9/9). Control: an unclosed `Callout` fails with "Expected a closing tag". - **`skip-changeset`.** The diff is only `content/docs/**`. Of the 71 non-private `package.json` manifests, none has a `files[]` entry naming `content/`. Control: the same scan for `liveness` matches `packages/spec`. --- _Generated by [Claude Code](https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 87e902d commit 4d4040c

9 files changed

Lines changed: 149 additions & 136 deletions

File tree

‎content/docs/concepts/metadata-lifecycle.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/
138138
| :--- | :--- |
139139
| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. |
140140
| `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. |
141-
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or `POST /api/v1/data/sys_permission_set` with a new name. The clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label}`) copies the whole definition from the security catalog without reading a `sys_permission_set` record; the refusal does not name it yet. |
141+
| `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label}`) with a new name. The clone door copies the whole definition from the security catalog. |
142142

143143
A permission set and a position also have a switch, outside the refusal: `POST /api/v1/security/_activation/:type/:name` (`:type` = `permission` or `position`), body `{enabled: false}`, switches one off for the deployment. The refusal does not name it: a packaged position's refusal keeps the managed seal's edit remedy. The audience anchors `everyone` and `guest` are not switchable.
144144

‎content/docs/data-modeling/objects.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -332,9 +332,9 @@ export const SysHolidayCalendar = ObjectSchema.create({
332332
```
333333

334334
The reason is leverage, not authorization. The bucket's charter members are the
335-
RBAC link tables — `sys_user_position` and `sys_user_permission_set`, the grant
336-
surface of the entire permission model, and `sys_position_permission_set`,
337-
whose rows no longer grant (a position's sets are its `permissionSets`). Every row a CSV import writes still passes the delegated-admin
335+
RBAC assignment tables — `sys_user_position` and `sys_user_permission_set`, the
336+
grant surface of the entire permission model. (A position's own sets are its
337+
definition's `permissionSets`, not rows.) Every row a CSV import writes still passes the delegated-admin
338338
gate, RLS and permission-set adjudication one at a time, so an admin who cannot
339339
grant a permission set by hand cannot grant it by file either. What differs is
340340
blast radius: row by row, one misclick affects one person; one wrong CSV is a

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
6767
| `OS_DEV_CRYPTO_KEY` | string | — | Development convenience crypto key, consulted after `OS_SECRET_KEY`. Do not use in production. |
6868
| `OS_CLUSTER_DRIVER` | string | `memory` | Cluster coordination driver id. When set to anything other than `memory`, the runtime treats the deployment as multi-node (and requires `OS_SECRET_KEY`). Non-memory drivers are opt-in sibling packages (e.g. `redis` via `@objectstack/service-cluster-redis`) — see [Cluster](/docs/kernel/cluster). |
6969
| `OS_REDIS_URL` | url | — | Connection URL passed to a non-memory cluster driver (e.g. `OS_CLUSTER_DRIVER=redis`). |
70-
| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_position`, `sys_position_permission_set`, `sys_permission_set`, `sys_user`, `sys_metadata_activation`) and by any `metadata.changed` (a permission set can be declared in metadata); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. |
70+
| `OS_AUTHZ_GRANTS_CACHE_TTL_MS` | number | `0` | Staleness bound, in milliseconds, for the cross-request authorization grants cache (#11633). `0` (the default) means **off** — a real path, not a degenerate TTL. A non-zero value caches the resolved authorization grants envelope (`resolveUserAuthzGrants` — positions, permission sets, posture, RLS peer ids) per `(user, organization, seed)` on each node. On the writing node the cache is retired **immediately** by any write to a watched authorization object (`sys_member`, `sys_user_position`, `sys_user_permission_set`, `sys_user`, `sys_metadata_activation`) and by any `metadata.changed` (positions and permission sets are catalog definitions, so a change to one is a metadata change); `sys_session` is deliberately not watched, so session-activity writes cannot flush it. Entries additionally expire at the earliest upcoming ADR-0091 validity boundary — `min(ttl, nextBoundary)` — because a `valid_until` passing is a permission change with **no write anywhere**. The permission explainer and `runAs:'user'` automation runs always read uncached. When a value is set with no cross-node invalidation bus — no cluster service, or the in-process `memory` driver, which fans out to nobody — the boot says so loudly, every time: the TTL is then the whole bound on how long this replica may honour a grant another replica revoked. A malformed value is treated as `0` and warned about rather than silently read as "disabled". Deployment config only; it is deliberately not a settings row, because a cached path must not serve the knob that bounds the cache. |
7171

7272
---
7373

‎content/docs/permissions/authorization.mdx‎

Lines changed: 51 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,14 @@ Authorization splits into three concerns that stay decoupled:
3232
via [`defineCapability`](#package-capability-declaration-adr-0066-d1); extended
3333
by admins in Setup. A capability is **not** a contract and has no `inputs` — a
3434
resource merely *references* one by name (see Requirement, below).
35-
2. **Assignment** — *who holds it* — permission sets / positions / user
36-
bindings (`sys_permission_set`, `sys_position`, `sys_user_permission_set`,
37-
`sys_user_position`, and the sets a position names in its `permissionSets`). Runtime records,
38-
maintained by admins in Setup — and, since ADR-0090 D12, a **governed**
39-
surface: writing them requires tenant-level administration or a covering
40-
delegated `adminScope`, never just CRUD on the tables.
35+
2. **Assignment** — *who holds it* — permission sets and positions, which are
36+
catalog definitions served by name from the environment registry (a
37+
position names the sets it distributes in its `permissionSets`; ADR-0131
38+
D3), handed to users by the assignment rows `sys_user_permission_set` and
39+
`sys_user_position`, which name the set or position. The assignment rows are
40+
runtime records, maintained by admins in Setup — and, since ADR-0090 D12, a
41+
**governed** surface: writing them requires tenant-level administration or a
42+
covering delegated `adminScope`, never just CRUD on the tables.
4143
3. **Requirement** — *what a resource needs* — an object / field / action
4244
references a capability as a contract. A resource never bakes in "who",
4345
only "what is required".
@@ -217,63 +219,53 @@ Remember the three-way separation: a capability is **not** a contract. You
217219

218220
### Two doors, one metadata (ADR-0086 D6/D7)
219221

220-
The `managedBy` provenance axis is not just descriptive — the platform
221-
**populates and gates on it**, so editing a permission set flows through exactly
222-
one of two doors, each writing only what it owns:
222+
Package provenance is not just descriptive — the registry records the owning
223+
package on every item it serves and the metadata door **gates on it**, so
224+
editing a permission set flows through exactly one of two doors, each writing
225+
only what it owns:
223226

224227
- **Package door** (studio `/studio/:packageId/access`) — a package's own set is
225228
**metadata**, so edits are saved as a **draft** stamped with the `packageId`
226229
(`saveMetaItem` `mode:'draft'`) and go live with the package's atomic
227230
**Publish**, exactly like Data and Interfaces. On publish, the security
228231
catalog serves the published body from the registry, owned by the package
229-
(ADR-0131 D3). The landed publish fires `onMetadataMutation`, which runs the
230-
audience-binding suggestion reconcile and re-derives the `everyone` anchor's
231-
accepted package defaults. Until stage 7 retires the ADR-0094 projector, the
232-
publish (a package-door draft publish, or a writable base's direct publish)
233-
also has the projector write a `sys_permission_set` projection row,
234-
`managedBy:'admin'` with no `packageId`, and the boot reconcile re-creates it
235-
if it is missing. No authorization path reads that row, and uninstalling the
236-
package removes it. Enforcement is unaffected while a set is still a draft
237-
(drafts never enter the active resolve).
232+
(ADR-0131 D3); no row is written for it. The landed publish fires
233+
`onMetadataMutation`, which runs the audience-binding suggestion reconcile
234+
and re-derives the `everyone` anchor's accepted package defaults.
235+
Enforcement is unaffected while a set is still a draft (drafts never enter
236+
the active resolve).
238237
- **Environment-admin door** (`metadata-admin`) — the cross-package all-objects
239238
matrix plus subject **assignment** (`sys_user_permission_set`), edited
240239
**live** (config); the sets a position distributes are its definition's
241-
`permissionSets`. It owns env-authored
242-
sets (`managedBy` `platform`/`user`) and assignments — not package sets.
243-
Since ADR-0094 the set **definition** itself has one authoritative store —
244-
the metadata layer: an env-door write to `sys_permission_set` (the Setup
245-
CRUD) is transparently redirected into an env-scope metadata save
246-
(`saveMetaItem`), and the data record is a **pure projection** the platform
247-
re-derives on every metadata mutation (awaited — no staleness window) and at
248-
boot. A package's published set gets such a row too, until stage 7 — the
249-
transitional `managedBy:'admin'` row described in the Package-door bullet
250-
above. Under a `group` or `isolated` tenancy posture that Setup write is the
251-
platform operator's: anyone else is refused `403 PERMISSION_DENIED` before the
252-
redirect (ADR-0131 D3). Deleting an artifact-backed set through this door **resets** it to its
253-
declared body rather than removing it; renaming through the data door is
254-
rejected (the name is the metadata identity — clone instead). Subject
255-
assignments remain plain config rows, unchanged.
256-
- **Data-layer gate (evolved by ADR-0094)** — the security middleware still
257-
**refuses** any payload that forges package provenance (insert or update,
258-
single or array) and the lifecycle ops with no overlay translation
259-
(`transfer`/`restore`/`purge`) on package rows, failing closed ahead of the
260-
CRUD check — even a `modifyAllRecords` super-user is blocked. An ordinary
261-
admin-door **edit of a set whose definition ships as a code artifact is
262-
refused with `403 not_overridable`**, loudly, at the moment of the write:
263-
the ADR-0094 write-through still translates the edit into a metadata save,
264-
but `permission` declares `allowEnvironmentOverlay: false` (ADR-0005's security
265-
row — overlays of the authorization surface would create silent privilege
266-
drift), so the tier gate refuses it and no env-scope overlay is minted. The
267-
supported channel is the one ADR-0086 two-doors always named: **edit the
240+
`permissionSets`. It owns environment-authored sets and assignments — not
241+
package sets. The set **definition** has one authoritative store, the
242+
metadata layer (ADR-0094 D1, ADR-0131 D3): Setup → Access Control →
243+
Permission Sets opens the catalog list
244+
(`/apps/setup/metadata/permission?scope=environment`), and a create or edit
245+
there is a metadata save (`PUT /api/v1/meta/permission/:name`) into the
246+
environment's definition ledger (`sys_metadata`), served from the registry
247+
beside the packages' sets. No row mirrors it: the `sys_permission_set`
248+
object is no longer registered, so no data route serves one. The save
249+
requires `manage_metadata`; under a `group` or `isolated` tenancy posture it
250+
is the platform operator's, and anyone else is refused `403` (ADR-0131 D3).
251+
The name is the item's identity, so there is no rename — clone instead. A
252+
delete cannot remove a set a package ships: its definition stays served from
253+
the package. Subject assignments remain plain config rows, unchanged.
254+
- **Packaged sets are sealed** — an **edit of a set whose definition ships as a
255+
code artifact is refused with `403 NOT_OVERRIDABLE`**, loudly, at the moment
256+
of the write: `permission` declares `allowEnvironmentOverlay: false`
257+
(ADR-0005's security row — overlays of the authorization surface would
258+
create silent privilege drift), so no environment-scope overlay is minted,
259+
and the refusal names the clone (the "Clone" action on the set, or
260+
`POST /api/v1/security/permission-sets/:name/clone`). Narrowing the packaged
261+
set itself takes the channel ADR-0086 two-doors always named: **edit the
268262
package and re-publish**. (ADR-0094 D5's 2026-07-14 direction — translate
269263
such an edit into a first-class ADR-0005 env-scope overlay — was **retired
270-
on 2026-08-09**; see ADR-0094 D5-R.) A set authored through the data door,
264+
on 2026-08-09**; see ADR-0094 D5-R.) A set authored in the environment,
271265
whose definition lives only in `sys_metadata`, rides the still-open
272-
`allowRuntimeCreate` tier and stays editable. A "delete" of a packaged set
273-
through this door still degrades to a **reset** to the shipped declaration —
274-
the admin door can never remove a packaged definition. System /
275-
boot writes carry `isSystem` and bypass it (the ADR-0094 projector's among
276-
them), so the platform's own writes are never self-blocked. That bypass is not local to this gate — the full set
266+
`allowRuntimeCreate` tier and stays editable. System / boot writes carry
267+
`isSystem` and pass the door's capability gate, so the platform's own writes
268+
are never self-blocked. That bypass is not local to this gate — the full set
277269
of behaviours the flag changes is catalogued in
278270
[System Context (`isSystem`)](/docs/permissions/system-context).
279271

@@ -412,10 +404,11 @@ unwinding the assignments" — and it is enforced in the same place, by the same
412404
discipline: **resolution-time filtering, fail-closed**, in
413405
`resolveAuthzContext`, with no cleanup job involved.
414406

415-
A catalogue row's own `active` column (`sys_permission_set.active`,
416-
`sys_position.active`) no longer decides a grant. That column is what Setup's
417-
Deactivate action writes today, so a Deactivate in Setup does not switch the
418-
grant off.
407+
No definition carries the switch, and no row does: the `active` column of the
408+
retired catalog row objects (`sys_permission_set.active`,
409+
`sys_position.active`) decides nothing, and neither object is registered any
410+
more (ADR-0131 D3). Setup's active switch on the Positions and Permission Sets
411+
catalog lists writes the ledger row, through the door below.
419412

420413
The ledger row is written through
421414
`POST /api/v1/security/_activation/:type/:name`, with `:type` either
@@ -604,7 +597,9 @@ The complete, prioritized gap map lives in issue **#2561** (the production
604597
The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences`
605598
(`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a
606599
`requiredPermissions` names a capability registered nowhere — no built-in, no
607-
permission set grants it via `systemPermissions`, no `sys_capability` seed.
600+
`defineCapability` declaration, no permission set grants it via
601+
`systemPermissions`. A `sys_capability` seed row is not counted: no runtime
602+
reader consults one.
608603
- **Per-operation `requiredPermissions`** (ADR-0066 ⑤) — **landed**: an
609604
object's `requiredPermissions` may be a `string[]` (gates all CRUD) or a
610605
`{ read, create, update, delete }` map (read-open / write-gated), enforced

‎content/docs/permissions/capabilities.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,11 @@ read and the metadata door serve.
215215
**No authorization check reads a `sys_capability` row.** Permission-set grants
216216
and resource `requiredPermissions` match capability names **as strings**. The
217217
boot writes no row for a capability, declared or curated: both are served by
218-
the registry. A row an older database still holds is a catalogue only, and
219-
clearing its `active` flag revokes nothing. Withdraw a capability by
220-
withdrawing the **grant**.
218+
the registry. The `sys_capability` object itself is retired and no longer
219+
registered (ADR-0131 D3): a row an older database still holds is served by no
220+
route and read by nothing, and `os migrate organization-ownership --apply`
221+
reports the authored rows and deletes the seeded copies (ADR-0131 D10).
222+
Withdraw a capability by withdrawing the **grant**.
221223
</Callout>
222224

223225
## `capability` is code-only

0 commit comments

Comments
 (0)