Skip to content

Commit aa5eb2a

Browse files
committed
feat(spec)!: retire the overloaded managedBy: 'system' bucket → system-data (#3355)
ADR-0103 split `system` additively in v16: the 20 engine-owned objects moved to the new explicit `engine-owned`, the 8 admin/user-writable ones stayed behind. The surviving value therefore names the half that already moved out — "system" on precisely the objects a user writes — leaving an author choosing between `system` and `engine-owned` with nothing in the vocabulary to choose on. v17 finishes the split: the residue is renamed `system-data`, the bare value is retired from the load path with a prescription, and the affordance default flips from LOCKED to WRITABLE (so the 8 objects' `userActions` re-open blocks become redundant and are deleted). Stored metadata is converted by the ADR-0087 entry `object-managed-by-system-to-system-data`, not reinterpreted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5
1 parent 21676eb commit aa5eb2a

14 files changed

Lines changed: 315 additions & 94 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/platform-objects": minor
4+
"@objectstack/plugin-security": minor
5+
"@objectstack/plugin-approvals": minor
6+
"@objectstack/plugin-hono-server": minor
7+
"@objectstack/service-messaging": minor
8+
---
9+
10+
feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355)
11+
12+
**FROM → TO: `managedBy: 'system'``managedBy: 'system-data'`.** One-line fix:
13+
rename the value. Nothing else about the object changes. `os migrate meta --from 16`
14+
rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry
15+
`object-managed-by-system-to-system-data`, never silently reinterpreted.
16+
17+
ADR-0103 split the overloaded `system` bucket in v16, and it split it
18+
**additively**: the 20 engine-owned objects moved to the new explicit
19+
`engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables
20+
(`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`),
21+
`sys_user_preference`, `sys_approval_delegation`, and the three messaging config
22+
grids — stayed behind on `system`. That was the right move for a v16 that could
23+
not break authors, but it left the enum in a state where the surviving value
24+
names the half that had already moved out: `system` sitting on precisely the
25+
objects a user writes.
26+
27+
That is not a cosmetic complaint. An author choosing between `system` and
28+
`engine-owned` had nothing in the vocabulary to choose *on*, so the bucket was
29+
re-overloadable by anyone reading the name in good faith — a model author most
30+
of all, since "system table" reads as "the engine owns this" in every other
31+
codebase. `system-data` states both boundaries explicitly: the **schema** is the
32+
platform's (versus `platform`, which is tenant-modelled), the **data** is the
33+
admin's or the user's (versus `engine-owned`, where the engine owns both).
34+
35+
Because v16 already drained the engine side, the conversion is a **one-to-one
36+
mechanical value rename** with no judgement call — by construction every
37+
remaining `system` declaration is writable platform data.
38+
39+
**One deliberate consequence — the affordance default flips.** `system` defaulted
40+
LOCKED and each of the 8 objects re-opened its writes with a
41+
`userActions: { create: true, edit: true, delete: true }` block. `system-data`
42+
defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data
43+
is yours" should not make every member ask for it back. Those blocks are now
44+
redundant and have been deleted from the 8 platform objects; keep `userActions`
45+
only to **NARROW**. If you converted an object that carried no `userActions`, it
46+
gains the generic affordances — the honest reading of the bucket it moved into.
47+
48+
**No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS
49+
and permission sets all adjudicate off resolved affordances and the principal,
50+
never off the bucket name. `system-data` simply joins `platform` / `config` as a
51+
bucket the fail-closed guard does not cover, because a writable default has
52+
nothing to close on. The 8 objects passed that guard before (via `userActions`)
53+
and pass it now (via the bucket default), for the same resolved-affordance
54+
reason.
55+
56+
`'system'` is **retired from the load path**: the enum rejects it with a
57+
prescription naming `system-data` and the one-line fix. Absorbing it silently at
58+
load would leave every author still writing the name this rename exists to
59+
unteach.

packages/platform-objects/src/identity/sys-user-preference.object.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,12 @@ export const SysUserPreference = ObjectSchema.create({
2222
pluralLabel: 'User Preferences',
2323
icon: 'settings',
2424
isSystem: true,
25-
// managedBy: 'system' — preferences are per-user state authored from
26-
// the user's own settings page, never created by an admin. The list
27-
// surface in Setup is a support/diagnostic view only.
28-
managedBy: 'system',
29-
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
30-
// authors their own preferences (RLS self-grant). Affordance only — RLS is the
31-
// authz; opening it here keeps the system write guard from rejecting the
32-
// legitimate self-service write.
33-
userActions: { create: true, edit: true, delete: true },
25+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema:
26+
// preferences are per-user state authored from the user's own settings page
27+
// (RLS self-grant), never created by an admin — the list surface in Setup is a
28+
// support/diagnostic view only. The bucket default is full CRUD, so no
29+
// `userActions` block is needed; RLS remains the authz.
30+
managedBy: 'system-data',
3431
description: 'Per-user key-value preferences (theme, locale, etc.)',
3532
nameField: 'key', // [ADR-0079] canonical primary-title pointer (single-field titleFormat)
3633
titleFormat: '{key}',

packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,11 @@ export const SysApprovalDelegation = ObjectSchema.create({
3333
pluralLabel: 'Approval Delegations',
3434
icon: 'user-clock',
3535
isSystem: true,
36-
managedBy: 'system',
37-
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
38-
// authors their own out-of-office delegation. Affordance only (matches the
39-
// full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
40-
// keeps the system write guard from rejecting the self-service write.
41-
userActions: { create: true, edit: true, delete: true },
36+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a
37+
// user authors their own out-of-office delegation. The bucket default is full
38+
// CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is
39+
// needed — RLS/permission sets are the authz.
40+
managedBy: 'system-data',
4241
description:
4342
'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).',
4443
titleFormat: '{delegator_id} → {delegate_id}',

packages/plugins/plugin-hono-server/src/current-user-endpoints.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -316,28 +316,35 @@ function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null):
316316
/**
317317
* Buckets whose user-context generic writes are guarded fail-closed at the
318318
* engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2),
319-
* `engine-owned` / `system` / `append-only` by plugin-security's engine-owned
320-
* write guard (ADR-0103). `config` / `platform` have no such guard — their
319+
* `engine-owned` / `append-only` by plugin-security's engine-owned write guard
320+
* (ADR-0103). `config` / `platform` / `system-data` have no such guard — their
321321
* permission-set result stands.
322+
*
323+
* `system` was listed here until #3355 renamed it to the writable-default
324+
* `system-data`, which joins `config` / `platform` on the unclamped side. That
325+
* matters more here than it looks: this clamp reads `userActions` DIRECTLY rather
326+
* than the resolved affordances, so clamping a bucket whose members legitimately
327+
* dropped their now-redundant `userActions` block would report `allowEdit: false`
328+
* for tables the engine happily writes — the exact false-NEGATIVE this function
329+
* exists to avoid, merely inverted.
322330
*/
323-
const GUARDED_WRITE_BUCKETS: ReadonlySet<string> = new Set(['better-auth', 'system', 'engine-owned', 'append-only']);
331+
const GUARDED_WRITE_BUCKETS: ReadonlySet<string> = new Set(['better-auth', 'engine-owned', 'append-only']);
324332

325333
/**
326334
* Re-clamp a `/me/permissions` `objects` map by the SECOND server-side
327335
* enforcement layer that permission sets don't model: the engine write guards.
328336
* They fail-closed reject USER-CONTEXT insert/update/delete on every managed
329337
* object whose resolved affordances forbid the verb — `better-auth`
330-
* (ADR-0092 D2) and `system`/`append-only` (ADR-0103) — except where the object
331-
* opted the write affordance in via `userActions.{create,edit,delete}` (e.g.
332-
* sys_user opens `edit` for its profile fields; the RBAC link tables / prefs /
333-
* messaging config open their CRUD).
338+
* (ADR-0092 D2) and `engine-owned`/`append-only` (ADR-0103) — except where the
339+
* object opted the write affordance in via `userActions.{create,edit,delete}`
340+
* (e.g. sys_user opens `edit` for its profile fields).
334341
*
335342
* Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true`
336343
* for a platform admin on tables the guard actually blocks (sys_member,
337344
* sys_automation_run, …) — a false-POSITIVE that mirrors, inverted, the
338345
* false-negative the fold fixes. The real effective answer for a user-context
339346
* caller is `permission-set grant ∩ guard policy`, and the guard policy for a
340-
* guarded object is exactly its resolved CRUD affordance. `config`/`platform`
347+
* guarded object is exactly its resolved CRUD affordance. `config`/`platform`/`system-data`
341348
* objects are NOT clamped — no guard covers them, so their permission-set result
342349
* stands (an admin CAN write them via the data API, and the hint must not
343350
* under-report that).

packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,11 @@ export const SysPositionPermissionSet = ObjectSchema.create({
2424
pluralLabel: 'Position Permission Sets',
2525
icon: 'shield-plus',
2626
isSystem: true,
27-
managedBy: 'system',
28-
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema:
27+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema:
2928
// `suggested-audience-bindings.ts` inserts a binding with `context: callerCtx`
30-
// (deliberately not isSystem). Affordance only — the DelegatedAdminGate is the
31-
// authz.
32-
userActions: { create: true, edit: true, delete: true },
29+
// (deliberately not isSystem). The bucket default is full CRUD, so no
30+
// `userActions` block is needed — the DelegatedAdminGate is the authz.
31+
managedBy: 'system-data',
3332
description: 'Binds a permission set to a position.',
3433
titleFormat: '{position_id} → {permission_set_id}',
3534
highlightFields: ['position_id', 'permission_set_id'],

packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export const SysUserPermissionSet = ObjectSchema.create({
2323
pluralLabel: 'User Permission Sets',
2424
icon: 'user-check',
2525
isSystem: true,
26-
managedBy: 'system',
27-
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: delegated
28-
// `manageBindings` direct grants write this under the caller's context.
29-
// Affordance only — the DelegatedAdminGate is the authz.
30-
userActions: { create: true, edit: true, delete: true },
26+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema:
27+
// delegated `manageBindings` direct grants write this under the caller's
28+
// context. The bucket default is full CRUD, so no `userActions` block is
29+
// needed — the DelegatedAdminGate is the authz.
30+
managedBy: 'system-data',
3131
description: 'Direct assignment of a permission set to a user (optionally scoped to an organization).',
3232
titleFormat: '{user_id} → {permission_set_id}',
3333
highlightFields: ['user_id', 'permission_set_id', 'organization_id'],

packages/plugins/plugin-security/src/objects/sys-user-position.object.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,11 @@ export const SysUserPosition = ObjectSchema.create({
3434
pluralLabel: 'User Positions',
3535
icon: 'user-cog',
3636
isSystem: true,
37-
managedBy: 'system',
38-
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: delegated
39-
// "add position" writes this under the caller's context. Affordance only —
40-
// the DelegatedAdminGate is the authz; opening it here keeps the system write
41-
// guard from rejecting the legitimate write.
42-
userActions: { create: true, edit: true, delete: true },
37+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema:
38+
// delegated "add position" writes this under the caller's context. The bucket
39+
// default is full CRUD, so no `userActions` block is needed — the affordance is
40+
// a declaration only; the DelegatedAdminGate is the authz.
41+
managedBy: 'system-data',
4342
description: 'Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).',
4443
titleFormat: '{user_id} → {position}',
4544
highlightFields: ['user_id', 'position', 'business_unit_id', 'organization_id'],

packages/plugins/plugin-security/src/system-write-guard.ts

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* ADR-0103 — engine-owned write guard for the `engine-owned` / `system` /
5-
* `append-only` buckets.
4+
* ADR-0103 — engine-owned write guard for the `engine-owned` / `append-only`
5+
* buckets.
66
*
7-
* `managedBy: 'engine-owned'` (and the locked defaults of `system` /
8-
* `append-only`) mean *engine-owned*: rows a platform service owns end to end
7+
* `managedBy: 'engine-owned'` (and the locked default of `append-only`) mean
8+
* *engine-owned*: rows a platform service owns end to end
99
* (the approval engine, the sharing engine, the job runner, the messaging
1010
* pipeline, …), written only via
1111
* `isSystem` / a service `SYSTEM_CTX` / a context-less engine call. Until this
@@ -14,14 +14,16 @@
1414
* generic data API (ADR-0049 violation), exactly the hole ADR-0092's identity
1515
* write guard closed for `better-auth`.
1616
*
17-
* This is the `system`/`append-only` counterpart, keyed off the SAME contract
18-
* the UI and the `apiMethods` reconciliation use — {@link resolveCrudAffordances}
19-
* — rather than the raw bucket string. An object is engine-owned precisely when
20-
* its resolved affordances grant no write; the admin/user-writable members of
21-
* these buckets (the RBAC link tables, `sys_user_preference`, the messaging
22-
* config grids, …) declare `userActions` opening the verbs they legitimately
23-
* take, so they pass this guard and their real authz — the `DelegatedAdminGate`,
24-
* RLS self-grants, permission sets — adjudicates the principal, unchanged.
17+
* This is the `engine-owned`/`append-only` counterpart, keyed off the SAME
18+
* contract the UI and the `apiMethods` reconciliation use —
19+
* {@link resolveCrudAffordances} — rather than the raw bucket string. An object is
20+
* engine-owned precisely when its resolved affordances grant no write; a member
21+
* that opens a verb via `userActions` (e.g. an `append-only` table that permits
22+
* an amendment) passes this guard and its real authz — the `DelegatedAdminGate`,
23+
* RLS self-grants, permission sets — adjudicates the principal, unchanged. The
24+
* admin/user-writable platform tables (the RBAC link tables,
25+
* `sys_user_preference`, the messaging config grids) live in `system-data` since
26+
* #3355, a writable-default bucket this guard does not cover at all.
2527
*
2628
* A write is USER-CONTEXT when its context carries a real `userId` and is not
2729
* `isSystem`. `isSystem` and context-less engine/service writes bypass by
@@ -40,12 +42,21 @@ import { resolveCrudAffordances } from '@objectstack/spec/data';
4042
import { PermissionDeniedError } from './errors.js';
4143

4244
/**
43-
* Buckets whose DEFAULT affordance row is engine-owned (no user writes). The
44-
* explicit `engine-owned` bucket (ADR-0103) sits alongside `system` /
45-
* `append-only`, whose locked defaults are engine-owned too; all three are
46-
* guarded, and any member that opens a verb via `userActions` passes below.
45+
* Buckets whose DEFAULT affordance row is engine-owned (no user writes): the
46+
* explicit `engine-owned` bucket (ADR-0103) and `append-only`, whose locked
47+
* audit-log default is engine-owned too. Both are guarded, and any member that
48+
* opens a verb via `userActions` passes below.
49+
*
50+
* `system` used to sit here as well — its locked default made it engine-owned by
51+
* accident of the v16 additive split, while the 8 objects actually in it all
52+
* re-opened their writes via `userActions` and so passed this guard anyway. #3355
53+
* renamed that residue to `system-data` with a WRITABLE default, which puts it
54+
* with `platform` / `config`: buckets whose default grants the write have nothing
55+
* for a fail-closed guard to close on, and their authz is adjudicated by the
56+
* DelegatedAdminGate / RLS / permission sets. Net enforcement change: none — the
57+
* 8 objects passed before and pass now, for the same resolved-affordance reason.
4758
*/
48-
export const ENGINE_OWNED_BUCKETS: ReadonlySet<string> = new Set(['system', 'engine-owned', 'append-only']);
59+
export const ENGINE_OWNED_BUCKETS: ReadonlySet<string> = new Set(['engine-owned', 'append-only']);
4960

5061
/**
5162
* Engine write operation → the {@link resolveCrudAffordances} flag it needs.
@@ -87,8 +98,9 @@ function isUserContextWrite(context: any): boolean {
8798

8899
/**
89100
* Fail-closed on a user-context generic write to an engine-owned
90-
* `system`/`append-only` object. No-op for: reads, non-engine-owned buckets,
91-
* system/context-less writes, and objects whose `userActions` open the verb.
101+
* `engine-owned`/`append-only` object. No-op for: reads, non-engine-owned
102+
* buckets, system/context-less writes, and objects whose `userActions` open the
103+
* verb.
92104
*
93105
* @param schema the registered schema (or undefined — unknown objects pass)
94106
* @param operation the engine operation (`insert`/`update`/`delete`/…)

packages/services/service-messaging/src/objects/notification-preference.object.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,11 @@ export const NotificationPreference = ObjectSchema.create({
2525
pluralLabel: 'Notification Preferences',
2626
icon: 'bell-ring',
2727
isSystem: true,
28-
managedBy: 'system',
29-
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
30-
// authors their own mute/allow rows (and admins the `user_id = '*'` global
31-
// defaults) from the Setup "Notification Preferences" grid. Affordance only —
32-
// opening it keeps the system write guard from rejecting the self-service write.
33-
userActions: { create: true, edit: true, delete: true },
28+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a
29+
// user authors their own mute/allow rows (and admins the `user_id = '*'`
30+
// global defaults) from the Setup "Notification Preferences" grid. The bucket
31+
// default is full CRUD, so no `userActions` block is needed — RLS is the authz.
32+
managedBy: 'system-data',
3433
description: 'Per-user × topic × channel notification toggle (mute/allow), with admin-global defaults.',
3534
titleFormat: '{user_id} · {topic} · {channel}',
3635
highlightFields: ['user_id', 'topic', 'channel', 'enabled', 'digest'],

packages/services/service-messaging/src/objects/notification-subscription.object.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,10 @@ export const NotificationSubscription = ObjectSchema.create({
2323
pluralLabel: 'Notification Subscriptions',
2424
icon: 'rss',
2525
isSystem: true,
26-
managedBy: 'system',
27-
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: authored
28-
// from the Setup "Notification Subscriptions" grid. Affordance only — opening
29-
// it keeps the system write guard from rejecting the write.
30-
userActions: { create: true, edit: true, delete: true },
26+
// [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema:
27+
// authored from the Setup "Notification Subscriptions" grid. The bucket
28+
// default is full CRUD, so no `userActions` block is needed.
29+
managedBy: 'system-data',
3130
description: 'Standing subscription of a principal (role/team/user) to a notification topic.',
3231
titleFormat: '{principal} · {topic}',
3332
highlightFields: ['topic', 'principal', 'enabled', 'created_at'],

0 commit comments

Comments
 (0)