Skip to content

Commit 260137d

Browse files
committed
docs(permissions): the hand-written pages describe the security catalog main serves, not the four retired row objects
The catalog-row docs sweep after stage 8. Positions, permission sets and capabilities are definitions the environment registry serves by name (ADR-0131 D3); assignments name them. The pages no longer describe the sys_permission_set projection, the data-door write-through, the binding rows or the row-object Setup pages as live. Claude-Session: https://claude.ai/code/session_012zwsLMxQBwHUYJgPyrpEvx Co-authored-by: Claude <noreply@anthropic.com>
1 parent 404fc47 commit 260137d

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)