From 705248b3cba6ecedeaff7b3b4c6d7e5af7bdc8ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 14:36:02 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(spec)!:=20retire=20the=20overloaded=20?= =?UTF-8?q?`managedBy:=20'system'`=20bucket=20=E2=86=92=20`system-data`=20?= =?UTF-8?q?(#3355)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --- .changeset/retire-managed-by-system-bucket.md | 59 +++++++++++++ .../identity/sys-user-preference.object.ts | 15 ++-- .../src/sys-approval-delegation.object.ts | 11 ++- .../src/current-user-endpoints.ts | 23 +++-- .../sys-position-permission-set.object.ts | 9 +- .../objects/sys-user-permission-set.object.ts | 10 +-- .../src/objects/sys-user-position.object.ts | 11 ++- .../plugin-security/src/system-write-guard.ts | 50 ++++++----- .../objects/notification-preference.object.ts | 11 ++- .../notification-subscription.object.ts | 9 +- .../objects/notification-template.object.ts | 10 +-- packages/spec/src/conversions/registry.ts | 80 ++++++++++++++++++ packages/spec/src/data/object.zod.ts | 83 ++++++++++++++----- packages/spec/src/migrations/registry.ts | 28 ++++++- 14 files changed, 315 insertions(+), 94 deletions(-) create mode 100644 .changeset/retire-managed-by-system-bucket.md diff --git a/.changeset/retire-managed-by-system-bucket.md b/.changeset/retire-managed-by-system-bucket.md new file mode 100644 index 0000000000..efe4467404 --- /dev/null +++ b/.changeset/retire-managed-by-system-bucket.md @@ -0,0 +1,59 @@ +--- +"@objectstack/spec": minor +"@objectstack/platform-objects": minor +"@objectstack/plugin-security": minor +"@objectstack/plugin-approvals": minor +"@objectstack/plugin-hono-server": minor +"@objectstack/service-messaging": minor +--- + +feat(spec)!: retire the overloaded `managedBy: 'system'` bucket — the residue becomes `system-data` (#3355) + +**FROM → TO: `managedBy: 'system'` → `managedBy: 'system-data'`.** One-line fix: +rename the value. Nothing else about the object changes. `os migrate meta --from 16` +rewrites it for you; stored metadata is CONVERTED by the ADR-0087 entry +`object-managed-by-system-to-system-data`, never silently reinterpreted. + +ADR-0103 split the overloaded `system` bucket in v16, and it split it +**additively**: the 20 engine-owned objects moved to the new explicit +`engine-owned`, while the 8 admin/user-writable ones — the RBAC link tables +(`sys_user_position`, `sys_user_permission_set`, `sys_position_permission_set`), +`sys_user_preference`, `sys_approval_delegation`, and the three messaging config +grids — stayed behind on `system`. That was the right move for a v16 that could +not break authors, but it left the enum in a state where the surviving value +names the half that had already moved out: `system` sitting on precisely the +objects a user writes. + +That is not a cosmetic complaint. An author choosing between `system` and +`engine-owned` had nothing in the vocabulary to choose *on*, so the bucket was +re-overloadable by anyone reading the name in good faith — a model author most +of all, since "system table" reads as "the engine owns this" in every other +codebase. `system-data` states both boundaries explicitly: the **schema** is the +platform's (versus `platform`, which is tenant-modelled), the **data** is the +admin's or the user's (versus `engine-owned`, where the engine owns both). + +Because v16 already drained the engine side, the conversion is a **one-to-one +mechanical value rename** with no judgement call — by construction every +remaining `system` declaration is writable platform data. + +**One deliberate consequence — the affordance default flips.** `system` defaulted +LOCKED and each of the 8 objects re-opened its writes with a +`userActions: { create: true, edit: true, delete: true }` block. `system-data` +defaults **WRITABLE** (full CRUD), because a bucket that exists to say "the data +is yours" should not make every member ask for it back. Those blocks are now +redundant and have been deleted from the 8 platform objects; keep `userActions` +only to **NARROW**. If you converted an object that carried no `userActions`, it +gains the generic affordances — the honest reading of the bucket it moved into. + +**No enforcement moves.** The engine write guard, the `DelegatedAdminGate`, RLS +and permission sets all adjudicate off resolved affordances and the principal, +never off the bucket name. `system-data` simply joins `platform` / `config` as a +bucket the fail-closed guard does not cover, because a writable default has +nothing to close on. The 8 objects passed that guard before (via `userActions`) +and pass it now (via the bucket default), for the same resolved-affordance +reason. + +`'system'` is **retired from the load path**: the enum rejects it with a +prescription naming `system-data` and the one-line fix. Absorbing it silently at +load would leave every author still writing the name this rename exists to +unteach. diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index 302558b36f..7b75a1979f 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -22,15 +22,12 @@ export const SysUserPreference = ObjectSchema.create({ pluralLabel: 'User Preferences', icon: 'settings', isSystem: true, - // managedBy: 'system' — preferences are per-user state authored from - // the user's own settings page, never created by an admin. The list - // surface in Setup is a support/diagnostic view only. - managedBy: 'system', - // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user - // authors their own preferences (RLS self-grant). Affordance only — RLS is the - // authz; opening it here keeps the system write guard from rejecting the - // legitimate self-service write. - userActions: { create: true, edit: true, delete: true }, + // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: + // preferences are per-user state authored from the user's own settings page + // (RLS self-grant), never created by an admin — the list surface in Setup is a + // support/diagnostic view only. The bucket default is full CRUD, so no + // `userActions` block is needed; RLS remains the authz. + managedBy: 'system-data', description: 'Per-user key-value preferences (theme, locale, etc.)', nameField: 'key', // [ADR-0079] canonical primary-title pointer (single-field titleFormat) titleFormat: '{key}', diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts index cae11ff270..a0faafbaf9 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.ts @@ -33,12 +33,11 @@ export const SysApprovalDelegation = ObjectSchema.create({ pluralLabel: 'Approval Delegations', icon: 'user-clock', isSystem: true, - managedBy: 'system', - // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user - // authors their own out-of-office delegation. Affordance only (matches the - // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it - // keeps the system write guard from rejecting the self-service write. - userActions: { create: true, edit: true, delete: true }, + // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a + // user authors their own out-of-office delegation. The bucket default is full + // CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is + // needed — RLS/permission sets are the authz. + managedBy: 'system-data', description: 'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).', titleFormat: '{delegator_id} → {delegate_id}', diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 10003e9746..e566fe111b 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -316,28 +316,35 @@ function isWriteOptedIn(v: boolean | { enabled?: boolean } | undefined | null): /** * Buckets whose user-context generic writes are guarded fail-closed at the * engine: `better-auth` by plugin-auth's identity write guard (ADR-0092 D2), - * `engine-owned` / `system` / `append-only` by plugin-security's engine-owned - * write guard (ADR-0103). `config` / `platform` have no such guard — their + * `engine-owned` / `append-only` by plugin-security's engine-owned write guard + * (ADR-0103). `config` / `platform` / `system-data` have no such guard — their * permission-set result stands. + * + * `system` was listed here until #3355 renamed it to the writable-default + * `system-data`, which joins `config` / `platform` on the unclamped side. That + * matters more here than it looks: this clamp reads `userActions` DIRECTLY rather + * than the resolved affordances, so clamping a bucket whose members legitimately + * dropped their now-redundant `userActions` block would report `allowEdit: false` + * for tables the engine happily writes — the exact false-NEGATIVE this function + * exists to avoid, merely inverted. */ -const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'system', 'engine-owned', 'append-only']); +const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'engine-owned', 'append-only']); /** * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side * enforcement layer that permission sets don't model: the engine write guards. * They fail-closed reject USER-CONTEXT insert/update/delete on every managed * object whose resolved affordances forbid the verb — `better-auth` - * (ADR-0092 D2) and `system`/`append-only` (ADR-0103) — except where the object - * opted the write affordance in via `userActions.{create,edit,delete}` (e.g. - * sys_user opens `edit` for its profile fields; the RBAC link tables / prefs / - * messaging config open their CRUD). + * (ADR-0092 D2) and `engine-owned`/`append-only` (ADR-0103) — except where the + * object opted the write affordance in via `userActions.{create,edit,delete}` + * (e.g. sys_user opens `edit` for its profile fields). * * Without this clamp, {@link foldWildcardSuperUser} would report `allowEdit:true` * for a platform admin on tables the guard actually blocks (sys_member, * sys_automation_run, …) — a false-POSITIVE that mirrors, inverted, the * false-negative the fold fixes. The real effective answer for a user-context * caller is `permission-set grant ∩ guard policy`, and the guard policy for a - * guarded object is exactly its resolved CRUD affordance. `config`/`platform` + * guarded object is exactly its resolved CRUD affordance. `config`/`platform`/`system-data` * objects are NOT clamped — no guard covers them, so their permission-set result * stands (an admin CAN write them via the data API, and the hint must not * under-report that). diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index ff06a622de..22a3b78191 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts @@ -24,12 +24,11 @@ export const SysPositionPermissionSet = ObjectSchema.create({ pluralLabel: 'Position Permission Sets', icon: 'shield-plus', isSystem: true, - managedBy: 'system', - // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: + // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: // `suggested-audience-bindings.ts` inserts a binding with `context: callerCtx` - // (deliberately not isSystem). Affordance only — the DelegatedAdminGate is the - // authz. - userActions: { create: true, edit: true, delete: true }, + // (deliberately not isSystem). The bucket default is full CRUD, so no + // `userActions` block is needed — the DelegatedAdminGate is the authz. + managedBy: 'system-data', description: 'Binds a permission set to a position.', titleFormat: '{position_id} → {permission_set_id}', highlightFields: ['position_id', 'permission_set_id'], diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index 03278faa55..684c2cef38 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -23,11 +23,11 @@ export const SysUserPermissionSet = ObjectSchema.create({ pluralLabel: 'User Permission Sets', icon: 'user-check', isSystem: true, - managedBy: 'system', - // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: delegated - // `manageBindings` direct grants write this under the caller's context. - // Affordance only — the DelegatedAdminGate is the authz. - userActions: { create: true, edit: true, delete: true }, + // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: + // delegated `manageBindings` direct grants write this under the caller's + // context. The bucket default is full CRUD, so no `userActions` block is + // needed — the DelegatedAdminGate is the authz. + managedBy: 'system-data', description: 'Direct assignment of a permission set to a user (optionally scoped to an organization).', titleFormat: '{user_id} → {permission_set_id}', highlightFields: ['user_id', 'permission_set_id', 'organization_id'], diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index cb6e130ac9..e3ec418793 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -34,12 +34,11 @@ export const SysUserPosition = ObjectSchema.create({ pluralLabel: 'User Positions', icon: 'user-cog', isSystem: true, - managedBy: 'system', - // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: delegated - // "add position" writes this under the caller's context. Affordance only — - // the DelegatedAdminGate is the authz; opening it here keeps the system write - // guard from rejecting the legitimate write. - userActions: { create: true, edit: true, delete: true }, + // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: + // delegated "add position" writes this under the caller's context. The bucket + // default is full CRUD, so no `userActions` block is needed — the affordance is + // a declaration only; the DelegatedAdminGate is the authz. + managedBy: 'system-data', description: 'Assigns a position (sys_position.name) to a user. Platform-owned (ADR-0057 D4, ADR-0090 D3).', titleFormat: '{user_id} → {position}', highlightFields: ['user_id', 'position', 'business_unit_id', 'organization_id'], diff --git a/packages/plugins/plugin-security/src/system-write-guard.ts b/packages/plugins/plugin-security/src/system-write-guard.ts index 15316c107a..f2a2fe099b 100644 --- a/packages/plugins/plugin-security/src/system-write-guard.ts +++ b/packages/plugins/plugin-security/src/system-write-guard.ts @@ -1,11 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * ADR-0103 — engine-owned write guard for the `engine-owned` / `system` / - * `append-only` buckets. + * ADR-0103 — engine-owned write guard for the `engine-owned` / `append-only` + * buckets. * - * `managedBy: 'engine-owned'` (and the locked defaults of `system` / - * `append-only`) mean *engine-owned*: rows a platform service owns end to end + * `managedBy: 'engine-owned'` (and the locked default of `append-only`) mean + * *engine-owned*: rows a platform service owns end to end * (the approval engine, the sharing engine, the job runner, the messaging * pipeline, …), written only via * `isSystem` / a service `SYSTEM_CTX` / a context-less engine call. Until this @@ -14,14 +14,16 @@ * generic data API (ADR-0049 violation), exactly the hole ADR-0092's identity * write guard closed for `better-auth`. * - * This is the `system`/`append-only` counterpart, keyed off the SAME contract - * the UI and the `apiMethods` reconciliation use — {@link resolveCrudAffordances} - * — rather than the raw bucket string. An object is engine-owned precisely when - * its resolved affordances grant no write; the admin/user-writable members of - * these buckets (the RBAC link tables, `sys_user_preference`, the messaging - * config grids, …) declare `userActions` opening the verbs they legitimately - * take, so they pass this guard and their real authz — the `DelegatedAdminGate`, - * RLS self-grants, permission sets — adjudicates the principal, unchanged. + * This is the `engine-owned`/`append-only` counterpart, keyed off the SAME + * contract the UI and the `apiMethods` reconciliation use — + * {@link resolveCrudAffordances} — rather than the raw bucket string. An object is + * engine-owned precisely when its resolved affordances grant no write; a member + * that opens a verb via `userActions` (e.g. an `append-only` table that permits + * an amendment) passes this guard and its real authz — the `DelegatedAdminGate`, + * RLS self-grants, permission sets — adjudicates the principal, unchanged. The + * admin/user-writable platform tables (the RBAC link tables, + * `sys_user_preference`, the messaging config grids) live in `system-data` since + * #3355, a writable-default bucket this guard does not cover at all. * * A write is USER-CONTEXT when its context carries a real `userId` and is not * `isSystem`. `isSystem` and context-less engine/service writes bypass by @@ -40,12 +42,21 @@ import { resolveCrudAffordances } from '@objectstack/spec/data'; import { PermissionDeniedError } from './errors.js'; /** - * Buckets whose DEFAULT affordance row is engine-owned (no user writes). The - * explicit `engine-owned` bucket (ADR-0103) sits alongside `system` / - * `append-only`, whose locked defaults are engine-owned too; all three are - * guarded, and any member that opens a verb via `userActions` passes below. + * Buckets whose DEFAULT affordance row is engine-owned (no user writes): the + * explicit `engine-owned` bucket (ADR-0103) and `append-only`, whose locked + * audit-log default is engine-owned too. Both are guarded, and any member that + * opens a verb via `userActions` passes below. + * + * `system` used to sit here as well — its locked default made it engine-owned by + * accident of the v16 additive split, while the 8 objects actually in it all + * re-opened their writes via `userActions` and so passed this guard anyway. #3355 + * renamed that residue to `system-data` with a WRITABLE default, which puts it + * with `platform` / `config`: buckets whose default grants the write have nothing + * for a fail-closed guard to close on, and their authz is adjudicated by the + * DelegatedAdminGate / RLS / permission sets. Net enforcement change: none — the + * 8 objects passed before and pass now, for the same resolved-affordance reason. */ -export const ENGINE_OWNED_BUCKETS: ReadonlySet = new Set(['system', 'engine-owned', 'append-only']); +export const ENGINE_OWNED_BUCKETS: ReadonlySet = new Set(['engine-owned', 'append-only']); /** * Engine write operation → the {@link resolveCrudAffordances} flag it needs. @@ -87,8 +98,9 @@ function isUserContextWrite(context: any): boolean { /** * Fail-closed on a user-context generic write to an engine-owned - * `system`/`append-only` object. No-op for: reads, non-engine-owned buckets, - * system/context-less writes, and objects whose `userActions` open the verb. + * `engine-owned`/`append-only` object. No-op for: reads, non-engine-owned + * buckets, system/context-less writes, and objects whose `userActions` open the + * verb. * * @param schema the registered schema (or undefined — unknown objects pass) * @param operation the engine operation (`insert`/`update`/`delete`/…) diff --git a/packages/services/service-messaging/src/objects/notification-preference.object.ts b/packages/services/service-messaging/src/objects/notification-preference.object.ts index 9a7833620e..2029d55910 100644 --- a/packages/services/service-messaging/src/objects/notification-preference.object.ts +++ b/packages/services/service-messaging/src/objects/notification-preference.object.ts @@ -25,12 +25,11 @@ export const NotificationPreference = ObjectSchema.create({ pluralLabel: 'Notification Preferences', icon: 'bell-ring', isSystem: true, - managedBy: 'system', - // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user - // authors their own mute/allow rows (and admins the `user_id = '*'` global - // defaults) from the Setup "Notification Preferences" grid. Affordance only — - // opening it keeps the system write guard from rejecting the self-service write. - userActions: { create: true, edit: true, delete: true }, + // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a + // user authors their own mute/allow rows (and admins the `user_id = '*'` + // global defaults) from the Setup "Notification Preferences" grid. The bucket + // default is full CRUD, so no `userActions` block is needed — RLS is the authz. + managedBy: 'system-data', description: 'Per-user × topic × channel notification toggle (mute/allow), with admin-global defaults.', titleFormat: '{user_id} · {topic} · {channel}', highlightFields: ['user_id', 'topic', 'channel', 'enabled', 'digest'], diff --git a/packages/services/service-messaging/src/objects/notification-subscription.object.ts b/packages/services/service-messaging/src/objects/notification-subscription.object.ts index e4b0cfb1cf..acb3a982e8 100644 --- a/packages/services/service-messaging/src/objects/notification-subscription.object.ts +++ b/packages/services/service-messaging/src/objects/notification-subscription.object.ts @@ -23,11 +23,10 @@ export const NotificationSubscription = ObjectSchema.create({ pluralLabel: 'Notification Subscriptions', icon: 'rss', isSystem: true, - managedBy: 'system', - // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: authored - // from the Setup "Notification Subscriptions" grid. Affordance only — opening - // it keeps the system write guard from rejecting the write. - userActions: { create: true, edit: true, delete: true }, + // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: + // authored from the Setup "Notification Subscriptions" grid. The bucket + // default is full CRUD, so no `userActions` block is needed. + managedBy: 'system-data', description: 'Standing subscription of a principal (role/team/user) to a notification topic.', titleFormat: '{principal} · {topic}', highlightFields: ['topic', 'principal', 'enabled', 'created_at'], diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index fa23593735..02e217342d 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -22,11 +22,11 @@ export const NotificationTemplate = ObjectSchema.create({ pluralLabel: 'Notification Templates', icon: 'file-text', isSystem: true, - managedBy: 'system', - // [ADR-0103] Admin-writable DATA on a platform-defined schema: authored from - // the Setup "Notification Templates" grid. Affordance only — opening it keeps - // the system write guard from rejecting the admin authoring write. - userActions: { create: true, edit: true, delete: true }, + // [ADR-0103, #3355] Admin-writable DATA on a platform-defined schema: authored + // from the Setup "Notification Templates" grid. The bucket default is full + // CRUD, so no `userActions` block is needed — affordance is a declaration + // only; permission sets remain the authz. + managedBy: 'system-data', description: 'Per (topic × channel × locale) render template for notifications.', titleFormat: '{topic} · {channel} · {locale}', highlightFields: ['topic', 'channel', 'locale', 'is_active'], diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index ca85bfcc00..0e9e7a8a9b 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -2710,6 +2710,85 @@ const flowNodeScriptBranchKeysRemoved: MetadataConversion = { }, }; +/** + * `object.managedBy: 'system'` → `'system-data'` (protocol 17, #3355 — the v17 + * close-out of ADR-0103's v16 enum split). + * + * v16 split the overloaded `system` bucket ADDITIVELY: the 20 engine-owned + * objects moved to the new explicit `engine-owned`, and the 8 admin/user-writable + * ones stayed on `system`. The value that remained therefore labelled the exact + * opposite of what its name said — writable platform DATA under the word + * "system" — and left an author choosing between `system` and `engine-owned` with + * nothing in the vocabulary to choose on. v17 renames the residue to + * `system-data` and retires the bare value. + * + * Because v16 already drained the engine side, this is a ONE-TO-ONE mechanical + * replacement with no judgement call: every remaining `system` declaration is, + * by construction, writable platform data. + * + * **Retired from the load path** — the enum rejects `'system'` with the + * {@link MANAGED_BY_SYSTEM_RETIRED} prescription, and that rejection is the + * whole point: a live-window entry at `normalizeStackInput` would run BEFORE the + * enum and silently absorb the value, so an author (or a model) would keep + * writing the name the rename exists to unteach. Stored `sys_metadata` rows and + * `os migrate meta --from 16` are exactly the `includeRetired` seams, so data at + * rest is CONVERTED rather than reinterpreted. + * + * Note the affordance side-effect, which is deliberate and is why this is a + * major-window change rather than a docs fix: `system` defaulted LOCKED and each + * object opened its writes via `userActions`, while `system-data` defaults + * WRITABLE. A converted row that carried no `userActions` therefore gains the + * generic affordances — which is the honest reading of the bucket it is being + * moved into, and changes no enforcement: the write guard, the delegated-admin + * gate, RLS and permission sets all adjudicate independently of the bucket name. + */ +const objectManagedBySystemToSystemData: MetadataConversion = { + id: 'object-managed-by-system-to-system-data', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'object.managedBy', + summary: + "object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the " + + 'engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the ' + + 'name describing what the bucket actually holds: admin/user-writable platform data)', + apply(stack, emit) { + return mapCollection(stack, 'objects', (obj, path) => { + if (obj.managedBy !== 'system') return obj; + emit({ from: 'system', to: 'system-data', path: `${path}.managedBy` }); + return { ...obj, managedBy: 'system-data' }; + }); + }, + fixture: { + before: { + objects: [ + { + name: 'sys_user_position', + label: 'User Position', + managedBy: 'system', + userActions: { create: true, edit: true, delete: true }, + }, + // every other bucket passes through untouched — including `engine-owned`, + // the value v16 already moved the engine side to + { name: 'sys_automation_run', label: 'Automation Run', managedBy: 'engine-owned' }, + { name: 'crm_deal', label: 'Deal' }, + ], + }, + after: { + objects: [ + { + name: 'sys_user_position', + label: 'User Position', + managedBy: 'system-data', + userActions: { create: true, edit: true, delete: true }, + }, + { name: 'sys_automation_run', label: 'Automation Run', managedBy: 'engine-owned' }, + { name: 'crm_deal', label: 'Deal' }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -2746,6 +2825,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly * across `platform-objects` and the three example apps uses only declared * top-level keys. */ +/** + * Prescription for the retired `managedBy: 'system'` bucket (#3355, the v17 + * close-out of ADR-0103's v16 split). + * + * ADR-0103 split the overloaded `system` bucket ADDITIVELY: the 20 engine-owned + * objects moved to the new explicit `engine-owned`, and the 8 admin/user-writable + * ones stayed behind on `system`. That left the name pointing at the half it no + * longer described — "system" on precisely the objects a user writes — which is + * the kind of residual overload an author (especially a model author) resolves by + * guessing. v17 finishes the split by renaming the residue to `system-data` and + * retiring the bare value. + * + * Because v16 already drained the engine side, `system` → `system-data` is a + * ONE-TO-ONE mechanical replacement: there is no judgement call in the upgrade, + * which is why the message can prescribe a single answer. + */ +const MANAGED_BY_SYSTEM_RETIRED = + "`object.managedBy: 'system'` was removed in @objectstack/spec 17 (#3355, ADR-0103 v17 " + + 'addendum) — v16 moved every engine-owned object to `engine-owned`, leaving `system` ' + + 'labelling admin/user-writable platform DATA under a name that says the opposite. ' + + "Use `managedBy: 'system-data'` (platform-defined schema, admin/user-writable data; " + + 'authz stays the DelegatedAdminGate / RLS / permission sets). Rename the value; nothing ' + + 'else about the object changes. Note `system-data` defaults to FULL CRUD affordances ' + + '(the old `system` default was locked), so a `userActions` block that existed only to ' + + 're-open create/edit/delete is now redundant and can be deleted — keep it only to ' + + 'NARROW. Run `os migrate meta --from 16` to rewrite it automatically.'; + const ObjectSchemaBase = z.object({ /** * Identity & Metadata @@ -797,15 +824,22 @@ const ObjectSchemaBase = z.object({ * purpose-built "Import definition (JSON)" action instead). Example: * `sys_sharing_rule`, `sys_position`, `sys_permission_set`, `sys_view`, * `sys_app`. - * - `system` — Platform-defined schema that holds **admin/user-writable + * - `system-data` — Platform-defined schema that holds **admin/user-writable * DATA**: the RBAC link tables (`sys_user_position`, * `sys_user_permission_set`, `sys_position_permission_set`, governed by * the `DelegatedAdminGate`), `sys_user_preference`, the messaging config - * grids (`sys_notification_subscription`, `_template`, `_preference`). The - * bucket DEFAULT is locked; each object declares {@link userActions} to - * open the writes it takes. The affordance is a declaration only — the real - * authz stays the delegated-admin gate / RLS. (For rows the engine owns end - * to end with no user writes, use `engine-owned`.) + * grids (`sys_notification_subscription`, `_template`, `_preference`). Two + * boundaries define it: the SCHEMA is the platform's (versioned with the + * release, not tenant-modelled — that is `platform`), while the DATA is the + * admin's or the user's, written through a governed path (not the engine's — + * that is `engine-owned`). The bucket DEFAULT is therefore WRITABLE (full + * CRUD); narrow it with {@link userActions} where an object takes less. The + * affordance is a declaration only — the real authz stays the + * delegated-admin gate / RLS / permission sets. + * (Renamed from the residual `system` in v17 — #3355. `system` named the + * bucket after the half of the overload v16 had already moved out to + * `engine-owned`, so it read as "the engine owns this" on precisely the + * objects users write.) * - `engine-owned` — Runtime rows whose lifecycle a platform service owns * end to end (the approval engine, the sharing engine, the job runner, the * metadata store, …), written only via `isSystem` / a service `SYSTEM_CTX` / @@ -815,8 +849,8 @@ const ObjectSchemaBase = z.object({ * `sys_approval_request`, `sys_record_share`, `sys_notification`, * `sys_automation_run`, `sys_job`, `sys_metadata`, `sys_secret`. (ADR-0103; * the explicit successor to the old engine-owned-DEFAULT overload of - * `system`. `system` / `append-only` objects granting no resolved write are - * also treated as engine-owned by the write guard, so the split is a + * `system`. `append-only` objects granting no resolved write are also + * treated as engine-owned by the write guard, so the split is a * self-documenting relabel, not an enforcement change.) * - `append-only` — Immutable audit log. No New / Import / Edit / * Delete; only View and Export. Example: `sys_approval_action`, @@ -834,20 +868,29 @@ const ObjectSchemaBase = z.object({ * is {@link resolveCrudAffordances} (bucket default + `userActions`). * Enforcement happens in three places: * 1. Default permission sets ({@link packages/platform-objects/src/security/default-permission-sets.ts}) - * deny direct CRUD for `system` / `engine-owned` / `append-only` / `better-auth`. + * deny direct CRUD for `engine-owned` / `append-only` / `better-auth`. * 2. UI clients honour {@link resolveCrudAffordances} to gate the * New / Import / Edit / Delete / Export buttons accordingly. * 3. Engine write guards fail-closed on user-context generic writes to a * managed object whose resolved affordances forbid the verb — * `better-auth` via plugin-auth's identity write guard (ADR-0092), - * `system` / `engine-owned` / `append-only` via plugin-security's system + * `engine-owned` / `append-only` via plugin-security's engine-owned * write guard (ADR-0103). `isSystem` / context-less engine writes bypass. + * `system-data` — like `platform` / `config` — is writable by default and + * carries no such guard; its writes are adjudicated by the delegated-admin + * gate / RLS / permission sets. * * Use {@link userActions} to override the default matrix for a single * field (e.g. an "append-only" table that should still allow Export). */ - managedBy: z.enum(['platform', 'config', 'system', 'engine-owned', 'append-only', 'better-auth']).optional().describe( - 'Lifecycle bucket — platform (user CRUD) | config (admin authored) | system (engine-managed schema, writable via userActions) | engine-owned (engine owns the lifecycle, no user writes) | append-only (audit) | better-auth (identity). UI clients honour the resolved affordance matrix.', + managedBy: z.enum(['platform', 'config', 'system-data', 'engine-owned', 'append-only', 'better-auth'], { + // Only the value that USED to be legal gets the retirement prescription — + // telling the author of `managedBy: 'sytem'` that their value "was removed" + // would misinform. Everything else keeps zod's own enum message, which + // already lists the legal values. + error: (issue) => (issue.input === 'system' ? MANAGED_BY_SYSTEM_RETIRED : undefined), + }).optional().describe( + 'Lifecycle bucket — platform (user CRUD) | config (admin authored) | system-data (platform-defined schema, admin/user-writable data) | engine-owned (engine owns the lifecycle, no user writes) | append-only (audit) | better-auth (identity). UI clients honour the resolved affordance matrix.', ), /** @@ -1685,14 +1728,16 @@ export interface RowCrudPredicates { * config — admin authored: New/Edit/Delete OK, no CSV import * (definitions have nested envelopes; admins should use * a purpose-built "Import definition" action instead) - * system — platform-defined schema holding admin/user-writable data + * system-data — platform-defined schema holding admin/user-writable data * (RBAC link tables, prefs, messaging config). DEFAULT is - * locked; each object opens its writes via `userActions` — the - * affordance declaration only; authz stays the delegated-admin - * gate / RLS (ADR-0103) + * WRITABLE — the bucket exists to say "the data is yours" — + * and an object that takes less NARROWS via `userActions`. + * Affordance declaration only; authz stays the delegated-admin + * gate / RLS / permission sets (ADR-0103, renamed from the + * locked-default `system` in v17 — #3355) * engine-owned — runtime rows the engine owns end to end; no user writes. - * Same locked matrix as `system`; the explicit, self- - * documenting successor to system's old engine-owned default + * The explicit, self-documenting successor to the old + * engine-owned DEFAULT of `system` * append-only — audit log: View + Export only * better-auth — identity tables owned by better-auth driver; CRUD * routed through purpose-built actions (Invite, Reset @@ -1701,7 +1746,7 @@ export interface RowCrudPredicates { const CRUD_AFFORDANCE_DEFAULTS: Record | 'platform', CrudAffordances> = { platform: { create: true, import: true, edit: true, delete: true, exportCsv: true }, config: { create: true, import: false, edit: true, delete: true, exportCsv: true }, - system: { create: false, import: false, edit: false, delete: false, exportCsv: true }, + 'system-data': { create: true, import: true, edit: true, delete: true, exportCsv: true }, 'engine-owned': { create: false, import: false, edit: false, delete: false, exportCsv: true }, 'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true }, 'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true }, diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index d65a8f349b..89cba7b1ac 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -592,7 +592,32 @@ const step17: MigrationStep = { + 'honest order. A TS/API surface, never stored — one semantic TODO for driver authors, no ' + 'source rewrite, and no tombstone: `DriverInterfaceSchema` describes a contract that ' + 'code IMPLEMENTS and nothing ever `.parse()`d a driver, so tsc is the only channel that ' - + 'could carry the prescription, and it carries it where it matters — at a call site.', + + 'could carry the prescription, and it carries it where it matters — at a call site.\n\n' + + 'Finally, `object.managedBy: \'system\'` is retired in favour of `\'system-data\'` (#3355), ' + + 'finishing the split ADR-0103 began in v16. That split was deliberately ADDITIVE: the 20 ' + + 'engine-owned objects moved to the new explicit `engine-owned`, and the 8 admin/user-' + + 'writable ones — the RBAC link tables, `sys_user_preference`, the three messaging config ' + + 'grids — stayed behind on `system`. What was left is a value whose name describes the half ' + + 'that had already moved out: "system" sitting on precisely the objects a user writes. That ' + + 'is not a cosmetic complaint. An author choosing between `system` and `engine-owned` had ' + + 'nothing in the vocabulary to choose on, so the bucket was re-overloadable by anyone ' + + 'reading the name in good faith — a model author most of all. `system-data` states both ' + + 'boundaries: the SCHEMA is the platform\'s (versus `platform`, which is tenant-modelled), ' + + 'the DATA is the admin\'s or the user\'s (versus `engine-owned`, where the engine owns both). ' + + 'Reusing `config` was considered and rejected — `sys_user_preference` is user-owned rather ' + + 'than admin-authored, and `config` suppresses CSV import — as was `platform-data`, which ' + + 'sits one word away from the unrelated `platform` in the same closed enum and would ' + + 'reintroduce the confusion at the point of choosing. Because v16 already drained the ' + + 'engine side, the conversion is a ONE-TO-ONE mechanical value rename with no judgement ' + + 'call. One deliberate consequence: `system` defaulted LOCKED and each object re-opened its ' + + 'writes through `userActions`, while `system-data` defaults WRITABLE, so those blocks ' + + 'become redundant and are deleted (keep `userActions` only to NARROW). No enforcement ' + + 'moves — the engine write guard, the DelegatedAdminGate, RLS and permission sets all ' + + 'adjudicate off resolved affordances and the principal, never off the bucket name; ' + + '`system-data` simply joins `platform`/`config` as a bucket the guard does not cover, ' + + 'because a writable default has nothing to fail closed on. Retired from the load path: ' + + 'the enum rejection is what teaches the new spelling, and absorbing `\'system\'` silently at ' + + 'load would leave every author writing the name this rename exists to retire.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -622,6 +647,7 @@ const step17: MigrationStep = { 'datasource-read-replicas-removed', 'datasource-config-driver-key-aliases', 'flow-node-script-branch-keys-removed', + 'object-managed-by-system-to-system-data', ], semantic: [ { From c3f560e7e224ef0555116db21cf5da146a9e54a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 14:45:32 +0000 Subject: [PATCH 2/5] =?UTF-8?q?test(spec,security,messaging,approvals):=20?= =?UTF-8?q?pin=20the=20`system`=20=E2=86=92=20`system-data`=20equivalence?= =?UTF-8?q?=20and=20the=20mis-assignment=20refusal=20(#3355)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR body claimed "no enforcement moves". These make it evidence: - per-object before/after pins for all 8 objects (4 packages): the v16 shape (`system` + a `userActions` re-open block) and the v17 shape (`system-data`, no block) must resolve the same create/edit/delete/exportCsv. The one deliberate delta — `import` false→true, per the maintainer's adjudication — gets its own named assertion so it cannot move silently. - `/me/permissions` clamp: same allowEdit/Create/Delete for both declaration shapes, plus a pin that a userActions-less `system-data` member is not clamped (the inverted false-negative removing `system` from GUARDED_WRITE_BUCKETS avoids). - write guard: `ENGINE_OWNED_BUCKETS` excludes both `system` and `system-data`. - new authoring-time refusal `assertSystemDataIsWritable`: `system-data` on an object granting no user write is a contradiction with no honest reading, and the writable default means the mistake now hands out generic CRUD. No guard covers the bucket, so `create()` is the only place to catch it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --- packages/objectql/src/registry.test.ts | 22 ++-- .../sys-user-preference.managed-by.test.ts | 49 +++++++++ .../sys-approval-delegation.object.test.ts | 52 +++++++-- .../src/fold-wildcard-superuser.test.ts | 65 +++++++++-- .../src/managed-object-write-denies.test.ts | 4 +- .../objects/managed-by-system-data.test.ts | 79 ++++++++++++++ .../src/system-write-guard.test.ts | 61 ++++++++--- .../objects/managed-by-system-data.test.ts | 63 +++++++++++ packages/spec/src/data/object.test.ts | 103 +++++++++++++++++- packages/spec/src/data/object.zod.ts | 56 ++++++++++ 10 files changed, 507 insertions(+), 47 deletions(-) create mode 100644 packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts create mode 100644 packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts create mode 100644 packages/services/service-messaging/src/objects/managed-by-system-data.test.ts diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index 3708d07d97..4400274e08 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -819,19 +819,22 @@ describe('reconcileManagedApiMethods', () => { expect(warn).not.toHaveBeenCalled(); }); - // ADR-0103 — the scope generalization: engine-owned system/append-only - // objects derive to reads, while the writable set keeps its verbs. - it('strips write verbs from a system-bucket object with no write affordances (engine-owned)', () => { + // ADR-0103 — the scope generalization: engine-owned/append-only objects + // derive to reads, while the writable set keeps its verbs. + it('strips write verbs from an engine-owned object with no write affordances', () => { const warn = vi.fn(); const engineOwned: any = { name: 'sys_automation_run', - managedBy: 'system', + // #3355: this object has been `engine-owned` since the v16 split; the + // fixture said `system` only because that value used to double as the + // engine-owned default. v17 retired the doubling. + managedBy: 'engine-owned', enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] }, }; const out = reconcileManagedApiMethods(engineOwned, { warn }); expect(out.enable.apiMethods).toEqual(['get', 'list']); expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0][0]).toContain("managedBy:'system'"); + expect(warn.mock.calls[0][0]).toContain("managedBy:'engine-owned'"); }); it('strips write verbs from an append-only object with no write affordances', () => { @@ -846,12 +849,15 @@ describe('reconcileManagedApiMethods', () => { expect(warn).toHaveBeenCalledTimes(1); }); - it('keeps CRUD on a system-bucket object whose userActions open the writes (writable set)', () => { + it('keeps CRUD on a `system-data` object — the bucket default grants the writes (#3355)', () => { const warn = vi.fn(); const writable: any = { name: 'sys_user_position', - managedBy: 'system', - userActions: { create: true, edit: true, delete: true }, + // #3355: was `managedBy: 'system'` + a `userActions` re-open block. + // The rename made full CRUD the bucket default, so the reconciliation + // must reach the same "strip nothing" answer with no `userActions` at + // all — that equivalence is the whole claim of the rename. + managedBy: 'system-data', enable: { apiEnabled: true, apiMethods: ['get', 'list', 'create', 'update', 'delete'] }, }; const out = reconcileManagedApiMethods(writable, { warn }); diff --git a/packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts b/packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts new file mode 100644 index 0000000000..c53e36c495 --- /dev/null +++ b/packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3355 — `sys_user_preference`'s half of the `managedBy: 'system'` → + * `'system-data'` equivalence pin. See the sibling files in `plugin-security` and + * `service-messaging` for the full rationale. + * + * This is the object that decided the NAMING. The first adjudication on #3355 + * proposed reusing `config`; `sys_user_preference` is what refuted it — a row here + * is authored by the user from their own settings page under an RLS self-grant, + * not by an admin, so `config` ("admin authored") would have been a fresh overload + * on day one of the bucket that exists to end an overload. It is also why the + * bucket is `system-data` and not `platform`: the user owns the DATA, but the + * SCHEMA is the platform's and no tenant may model it. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveCrudAffordances } from '@objectstack/spec/data'; +import { SysUserPreference } from './sys-user-preference.object'; + +const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; +const V17_EXPECTED = { create: true, import: true, edit: true, delete: true, exportCsv: true }; + +const asV16 = { managedBy: 'system', userActions: { create: true, edit: true, delete: true } }; + +describe('#3355 — sys_user_preference moves to `system-data` with its affordances intact', () => { + it('declares the new bucket and no longer carries a redundant `userActions` block', () => { + expect(SysUserPreference.managedBy).toBe('system-data'); + expect(SysUserPreference.userActions).toBeUndefined(); + }); + + it('resolves the full-CRUD matrix from the bucket default alone', () => { + expect(resolveCrudAffordances(SysUserPreference as never)).toEqual(V17_EXPECTED); + }); + + it('is write-equivalent to its v16 self on create / edit / delete / exportCsv', () => { + const v16 = resolveCrudAffordances(asV16 as never); + const v17 = resolveCrudAffordances(SysUserPreference as never); + expect(v16).toEqual(V16_EXPECTED); + for (const verb of ['create', 'edit', 'delete', 'exportCsv'] as const) { + expect(v17[verb], `sys_user_preference.${verb} must not move`).toBe(v16[verb]); + } + }); + + it('gains CSV import — the one adjudicated delta, pinned so it cannot move silently', () => { + expect(resolveCrudAffordances(asV16 as never).import).toBe(false); + expect(resolveCrudAffordances(SysUserPreference as never).import).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts index 9a120bcdef..f8be123ca1 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts @@ -4,19 +4,55 @@ * #3026 follow-up — `sys_approval_delegation` must expose the BATCH shape of * the write verbs it already grants. * - * The object is `managedBy: 'system'` but opens generic writes deliberately - * (`userActions: { create, edit, delete }` — an out-of-office rule is authored - * by its own user through the plain data endpoint), so the ADR-0103 D3 - * reconciliation strips nothing and its boilerplate CRUD-five whitelist reaches - * the REST gate as authored. Since the #3391 P1 contract made bulk - * `bulk ∧ derived(child)`, that whitelist — which never named the `bulk` - * primitive — 405s every batch route while the single-record verbs stay open. + * The object is `managedBy: 'system-data'` (#3355 — it was `'system'` plus a + * `userActions: { create, edit, delete }` re-open block until v17 renamed the + * bucket and made full CRUD the default) and grants generic writes deliberately: + * an out-of-office rule is authored by its own user through the plain data + * endpoint. So the ADR-0103 D3 reconciliation strips nothing and its boilerplate + * CRUD-five whitelist reaches the REST gate as authored. Since the #3391 P1 + * contract made bulk `bulk ∧ derived(child)`, that whitelist — which never named + * the `bulk` primitive — 405s every batch route while the single-record verbs + * stay open. */ import { describe, expect, it } from 'vitest'; -import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data'; +import { resolveEffectiveApiMethods, isApiOperationAllowed, resolveCrudAffordances } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object'; +/** + * #3355 — this object's half of the `system` → `system-data` equivalence pin. + * See `plugin-security/src/objects/managed-by-system-data.test.ts` for the full + * rationale; the contract asserted here is identical. + */ +describe('#3355 — sys_approval_delegation moves to `system-data` with its affordances intact', () => { + const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; + const V17_EXPECTED = { create: true, import: true, edit: true, delete: true, exportCsv: true }; + const asV16 = { managedBy: 'system', userActions: { create: true, edit: true, delete: true } }; + + it('declares the new bucket and no longer carries a redundant `userActions` block', () => { + expect(SysApprovalDelegation.managedBy).toBe('system-data'); + expect(SysApprovalDelegation.userActions).toBeUndefined(); + }); + + it('resolves the full-CRUD matrix from the bucket default alone', () => { + expect(resolveCrudAffordances(SysApprovalDelegation as never)).toEqual(V17_EXPECTED); + }); + + it('is write-equivalent to its v16 self on create / edit / delete / exportCsv', () => { + const v16 = resolveCrudAffordances(asV16 as never); + const v17 = resolveCrudAffordances(SysApprovalDelegation as never); + expect(v16).toEqual(V16_EXPECTED); + for (const verb of ['create', 'edit', 'delete', 'exportCsv'] as const) { + expect(v17[verb], `sys_approval_delegation.${verb} must not move`).toBe(v16[verb]); + } + }); + + it('gains CSV import — the one adjudicated delta, pinned so it cannot move silently', () => { + expect(resolveCrudAffordances(asV16 as never).import).toBe(false); + expect(resolveCrudAffordances(SysApprovalDelegation as never).import).toBe(true); + }); +}); + describe('sys_approval_delegation — batch exposure (#3026 / #3391 P1 companion)', () => { it('grants the bulk primitive alongside its single-record write verbs', () => { expect(SysApprovalDelegation.enable?.apiMethods).toContain('bulk'); diff --git a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts index 0b57b10b48..70c9d32d34 100644 --- a/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts +++ b/packages/plugins/plugin-hono-server/src/fold-wildcard-superuser.test.ts @@ -52,10 +52,10 @@ describe('foldWildcardSuperUser', () => { /** * ADR-0092 D2 / ADR-0103 — the engine write guards are a second enforcement * layer the permission sets don't model. The client hint must reflect - * permission ∩ guard: guarded (`better-auth`, and now engine-owned - * `system`/`append-only`) objects are user-context-writable only where the - * object opened the affordance via `userActions`; `config`/`platform` are - * untouched. + * permission ∩ guard: guarded (`better-auth`, and engine-owned + * `engine-owned`/`append-only`) objects are user-context-writable only where the + * object opened the affordance via `userActions`; `config`/`platform`/ + * `system-data` are untouched (#3355). */ describe('clampManagedObjectWrites', () => { const SCHEMAS: Record = { @@ -64,10 +64,11 @@ describe('clampManagedObjectWrites', () => { sys_session: { managedBy: 'better-auth' }, // ADR-0103: explicit engine-owned bucket → guarded (clamped). sys_automation_run: { managedBy: 'engine-owned' }, - // ADR-0103: a `system` object with no userActions still resolves locked → guarded. - sys_notification_receipt: { managedBy: 'system' }, - // ADR-0103: system that opened its writes → writable set → NOT clamped. - sys_user_position: { managedBy: 'system', userActions: { create: true, edit: true, delete: true } }, + // ADR-0103: an engine-owned receipt row → guarded (clamped). + sys_notification_receipt: { managedBy: 'engine-owned' }, + // #3355: was `system` + a `userActions` re-open block; now `system-data`, + // a bucket no guard covers → still NOT clamped. See the equivalence pin below. + sys_user_position: { managedBy: 'system-data' }, crm_lead: { managedBy: 'platform' }, }; const schemaOf = (n: string) => SCHEMAS[n]; @@ -105,7 +106,7 @@ describe('clampManagedObjectWrites', () => { expect(objects.crm_lead.allowEdit).toBe(true); }); - it('leaves the writable system set untouched (userActions opened the writes)', () => { + it('leaves the writable platform-data set untouched (the bucket default grants the writes)', () => { const objects: Record = { sys_user_position: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }, }; @@ -113,6 +114,52 @@ describe('clampManagedObjectWrites', () => { expect(objects.sys_user_position).toMatchObject({ allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }); }); + /** + * #3355 equivalence pin for the `/me/permissions` hint. + * + * This clamp reads `userActions` DIRECTLY rather than the resolved affordances, + * so removing `system` from GUARDED_WRITE_BUCKETS is load-bearing: had the + * bucket stayed listed while the 8 objects legitimately dropped their now- + * redundant `userActions` blocks, every one of them would report + * `allowEdit: false` for tables the engine happily writes — the exact false + * NEGATIVE this function exists to avoid, merely inverted. + * + * So: the answer must be identical for the v16 declaration shape (bucket + * `system` + `userActions`) and the v17 one (bucket `system-data`, no + * `userActions`), for all four flags, on the same input grant. + */ + it('reports the same allowEdit/Create/Delete for the v16 and v17 declaration shapes', () => { + const GRANT = { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }; + const v16Schemas: Record = { + sys_user_position: { managedBy: 'system', userActions: { create: true, edit: true, delete: true } }, + }; + const v16: Record = { sys_user_position: { ...GRANT } }; + const v17: Record = { sys_user_position: { ...GRANT } }; + + clampManagedObjectWrites(v16, (n) => v16Schemas[n]); + clampManagedObjectWrites(v17, schemaOf); + + expect(v17.sys_user_position).toEqual(v16.sys_user_position); + // …and that shared answer is "unclamped", not "both wrong the same way". + expect(v17.sys_user_position).toMatchObject(GRANT); + }); + + /** + * The inverted-failure pin: if `system-data` were (re-)added to + * GUARDED_WRITE_BUCKETS, a v17-shaped declaration carrying no `userActions` + * would clamp to read-only. This asserts the bucket is genuinely out of scope + * by showing a userActions-less member keeps its writes. + */ + it('does not clamp a `system-data` object that declares no userActions at all', () => { + const objects: Record = { + sys_notification_template: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }, + }; + clampManagedObjectWrites(objects, () => ({ managedBy: 'system-data' }) as ManagedSchemaLike); + expect(objects.sys_notification_template).toMatchObject({ + allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true, + }); + }); + it('treats the #2614 object form by its enabled flag only (predicates are UI gating, not a grant)', () => { const schemas: Record = { sys_user: { diff --git a/packages/plugins/plugin-security/src/managed-object-write-denies.test.ts b/packages/plugins/plugin-security/src/managed-object-write-denies.test.ts index 4b681b8d3e..45b14ebb4f 100644 --- a/packages/plugins/plugin-security/src/managed-object-write-denies.test.ts +++ b/packages/plugins/plugin-security/src/managed-object-write-denies.test.ts @@ -18,7 +18,7 @@ const schemas = [ { name: 'sys_user', managedBy: 'better-auth', userActions: { edit: true } }, // intentional divergence { name: 'sys_sso_provider', managedBy: 'better-auth' }, { name: 'crm_lead', managedBy: 'platform' }, - { name: 'sys_setting', managedBy: 'system' }, + { name: 'sys_setting', managedBy: 'system-data' }, // #3355: was 'system' { name: 'sys_audit_log', managedBy: 'append-only' }, { name: 'sys_sharing_rule', managedBy: 'config' }, { name: 'sys_no_bucket' }, // unset @@ -49,7 +49,7 @@ describe('applyManagedWriteDenies (#3325)', () => { expect(s.objects.sys_user.allowEdit).toBe(false); }); - it('ignores platform / config / system / append-only / unset buckets (pins the ADR-0103 deferral)', () => { + it('ignores platform / config / system-data / append-only / unset buckets (pins the ADR-0103 deferral)', () => { const s = set('member_default'); applyManagedWriteDenies([s], schemas); for (const name of ['crm_lead', 'sys_setting', 'sys_audit_log', 'sys_sharing_rule', 'sys_no_bucket']) { diff --git a/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts new file mode 100644 index 0000000000..7b0582f52d --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3355 — the RBAC link tables' half of the `managedBy: 'system'` → `'system-data'` + * equivalence pin. + * + * The PR claims "no enforcement moves": these objects were writable in v16 because + * each re-opened create/edit/delete with a `userActions` block on top of the LOCKED + * `system` default, and they are writable in v17 because the `system-data` default + * grants those verbs outright. That is an argument; this file is the evidence. + * + * It fails in BOTH mis-edit directions, which is the point: + * - a `userActions` block left behind that narrows a verb → the object resolves + * less than {@link V17_EXPECTED} and the per-object assert goes red; + * - a bucket left on the old value (or moved to `engine-owned`) → same. + * + * The one DELIBERATE non-equivalence is `import`, and it is pinned as such rather + * than waved past: `system` was locked-with-no-import and the `userActions` blocks + * only ever re-opened create/edit/delete, so CSV import resolved FALSE; the + * `system-data` default grants it. That flip is the maintainer's explicit + * adjudication on #3355 ("默认 affordance 可写 create/edit/delete/import/exportCsv: + * true"). It is an affordance only — the DelegatedAdminGate still adjudicates every + * row a CSV import would write — but it IS new UI surface on the RBAC link tables, + * so it gets its own named assertion that a future edit cannot flip back silently. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveCrudAffordances } from '@objectstack/spec/data'; +import { SysUserPosition } from './sys-user-position.object'; +import { SysUserPermissionSet } from './sys-user-permission-set.object'; +import { SysPositionPermissionSet } from './sys-position-permission-set.object'; + +/** What each object resolved to in v16: LOCKED `system` + `userActions: {c,e,d}`. */ +const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; + +/** What each object resolves to in v17: the `system-data` default, no `userActions`. */ +const V17_EXPECTED = { create: true, import: true, edit: true, delete: true, exportCsv: true }; + +/** The v16 declaration shape, reconstructed so the equivalence is computed, not asserted twice. */ +const asV16 = (obj: { userActions?: unknown }) => ({ + managedBy: 'system', + userActions: obj.userActions ?? { create: true, edit: true, delete: true }, +}); + +const OBJECTS = [ + ['sys_user_position', SysUserPosition], + ['sys_user_permission_set', SysUserPermissionSet], + ['sys_position_permission_set', SysPositionPermissionSet], +] as const; + +describe('#3355 — RBAC link tables move to `system-data` with their affordances intact', () => { + for (const [name, obj] of OBJECTS) { + describe(name, () => { + it('declares the new bucket and no longer carries a redundant `userActions` block', () => { + expect(obj.managedBy).toBe('system-data'); + // The whole point of the writable default: the re-open block is gone. + expect(obj.userActions).toBeUndefined(); + }); + + it('resolves the full-CRUD matrix from the bucket default alone', () => { + expect(resolveCrudAffordances(obj as never)).toEqual(V17_EXPECTED); + }); + + it('is write-equivalent to its v16 self on create / edit / delete / exportCsv', () => { + const v16 = resolveCrudAffordances(asV16(obj) as never); + const v17 = resolveCrudAffordances(obj as never); + expect(v16).toEqual(V16_EXPECTED); // the reconstruction is honest + for (const verb of ['create', 'edit', 'delete', 'exportCsv'] as const) { + expect(v17[verb], `${name}.${verb} must not move`).toBe(v16[verb]); + } + }); + + it('gains CSV import — the one adjudicated delta, pinned so it cannot move silently', () => { + expect(resolveCrudAffordances(asV16(obj) as never).import).toBe(false); + expect(resolveCrudAffordances(obj as never).import).toBe(true); + }); + }); + } +}); diff --git a/packages/plugins/plugin-security/src/system-write-guard.test.ts b/packages/plugins/plugin-security/src/system-write-guard.test.ts index cbf4150bb0..7003c1a7e1 100644 --- a/packages/plugins/plugin-security/src/system-write-guard.test.ts +++ b/packages/plugins/plugin-security/src/system-write-guard.test.ts @@ -1,5 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// ADR-0103 — engine-owned write guard for the `engine-owned` / `system` / `append-only` buckets. +// ADR-0103 — engine-owned write guard for the `engine-owned` / `append-only` buckets. +// #3355 — `system` left this guard in v17: it was renamed `system-data` with a +// WRITABLE default, which puts it with `platform` / `config` on the unguarded side. import { describe, it, expect } from 'vitest'; import { assertEngineOwnedWriteAllowed, ENGINE_OWNED_BUCKETS } from './system-write-guard.js'; @@ -13,12 +15,17 @@ const SYSTEM_CTX = { userId: 'u1', isSystem: true }; const CONTEXTLESS = { transaction: {} }; const engineOwned = { name: 'sys_automation_run', managedBy: 'engine-owned' }; -// A `system` object with no userActions still resolves locked → engine-owned. -const lockedSystem = { name: 'sys_thing', managedBy: 'system' }; const appendOnly = { name: 'sys_audit_log', managedBy: 'append-only' }; +// #3355 — the platform tables that used to be `system` + a `userActions` re-open +// block. They now sit in `system-data`, which this guard does not cover at all; +// the pin below asserts they pass for the NEW reason (out of scope) as well as +// they passed for the old one (userActions opened the verb). +const systemData = { name: 'sys_user_position', managedBy: 'system-data' }; +// An `append-only` member that opens a verb — the in-scope bucket still honours +// `userActions`, which is what keeps this guard affordance-keyed, not name-keyed. const writable = { - name: 'sys_user_position', - managedBy: 'system', + name: 'sys_audit_note', + managedBy: 'append-only', userActions: { create: true, edit: true, delete: true }, }; @@ -36,22 +43,25 @@ function expectDenied(fn: () => void): void { } describe('assertEngineOwnedWriteAllowed (ADR-0103)', () => { - it('scopes to the engine-owned, system and append-only buckets', () => { - expect([...ENGINE_OWNED_BUCKETS].sort()).toEqual(['append-only', 'engine-owned', 'system']); + it('scopes to the engine-owned and append-only buckets', () => { + expect([...ENGINE_OWNED_BUCKETS].sort()).toEqual(['append-only', 'engine-owned']); }); - describe('engine-owned / system / append-only objects', () => { + // #3355 pin: the retired bucket must not linger in the guard's scope set, and + // its successor must not be added to it. A writable-default bucket has nothing + // to fail closed on, and listing it would deny the very writes it exists to allow. + it('scopes out the retired `system` bucket and its `system-data` successor', () => { + expect(ENGINE_OWNED_BUCKETS.has('system')).toBe(false); + expect(ENGINE_OWNED_BUCKETS.has('system-data')).toBe(false); + }); + + describe('engine-owned / append-only objects', () => { it('rejects user-context insert/update/delete on an explicit engine-owned object', () => { for (const op of ['insert', 'update', 'delete', 'upsert', 'purge', 'transfer', 'restore']) { expectDenied(() => assertEngineOwnedWriteAllowed(engineOwned, op, USER_CTX)); } }); - it('rejects user-context writes to a locked `system` object (no userActions)', () => { - expectDenied(() => assertEngineOwnedWriteAllowed(lockedSystem, 'insert', USER_CTX)); - expectDenied(() => assertEngineOwnedWriteAllowed(lockedSystem, 'delete', USER_CTX)); - }); - it('rejects user-context writes to append-only objects too', () => { expectDenied(() => assertEngineOwnedWriteAllowed(appendOnly, 'update', USER_CTX)); expectDenied(() => assertEngineOwnedWriteAllowed(appendOnly, 'delete', USER_CTX)); @@ -86,7 +96,7 @@ describe('assertEngineOwnedWriteAllowed (ADR-0103)', () => { }); }); - describe('the writable set (system + userActions)', () => { + describe('the writable set (an in-scope bucket + userActions)', () => { it('allows user-context insert/update/delete when userActions opened them', () => { for (const op of ['insert', 'update', 'delete']) { expect(() => assertEngineOwnedWriteAllowed(writable, op, USER_CTX)).not.toThrow(); @@ -94,7 +104,7 @@ describe('assertEngineOwnedWriteAllowed (ADR-0103)', () => { }); it('allows only the opened verbs — a partial userActions still guards the rest', () => { - const editOnly = { name: 'sys_thing', managedBy: 'system', userActions: { edit: true } }; + const editOnly = { name: 'sys_thing', managedBy: 'append-only', userActions: { edit: true } }; expect(() => assertEngineOwnedWriteAllowed(editOnly, 'update', USER_CTX)).not.toThrow(); expectDenied(() => assertEngineOwnedWriteAllowed(editOnly, 'insert', USER_CTX)); expectDenied(() => assertEngineOwnedWriteAllowed(editOnly, 'delete', USER_CTX)); @@ -102,8 +112,25 @@ describe('assertEngineOwnedWriteAllowed (ADR-0103)', () => { }); describe('out of scope', () => { - it('ignores platform / config buckets (no guard)', () => { - for (const bucket of ['platform', 'config']) { + // #3355 equivalence pin. `sys_user_position` passed this guard in v16 because + // its `userActions` block opened the verb; it passes in v17 because the guard + // no longer covers its bucket. Same answer, different reason — and this test + // is what makes "no enforcement moved" a fact rather than an assertion in a + // PR description. It goes red if `system-data` is ever added to + // ENGINE_OWNED_BUCKETS (which would deny delegated admin its RBAC writes). + it('never denies a `system-data` write, with or without userActions', () => { + for (const op of ['insert', 'update', 'delete', 'upsert', 'transfer', 'restore']) { + expect(() => assertEngineOwnedWriteAllowed(systemData, op, USER_CTX)).not.toThrow(); + expect(() => assertEngineOwnedWriteAllowed( + { ...systemData, userActions: { create: true, edit: true, delete: true } }, + op, + USER_CTX, + )).not.toThrow(); + } + }); + + it('ignores platform / config / system-data buckets (no guard)', () => { + for (const bucket of ['platform', 'config', 'system-data']) { expect(() => assertEngineOwnedWriteAllowed({ name: 'x', managedBy: bucket }, 'delete', USER_CTX), ).not.toThrow(); diff --git a/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts b/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts new file mode 100644 index 0000000000..b63f699d86 --- /dev/null +++ b/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #3355 — the messaging config grids' half of the `managedBy: 'system'` → + * `'system-data'` equivalence pin. See the sibling file in `plugin-security` for + * the full rationale; the contract asserted here is identical. + * + * These three are the clearest members of the bucket the rename creates: the + * SCHEMA ships with the platform (an admin cannot add a column to + * `sys_notification_template`), while the DATA is authored by an admin — or, for + * `sys_notification_preference`, by the end user muting their own topics — through + * the Setup grids. That is exactly the "platform schema / your data" split + * `system-data` names and the old `system` obscured. + */ + +import { describe, expect, it } from 'vitest'; +import { resolveCrudAffordances } from '@objectstack/spec/data'; +import { NotificationTemplate } from './notification-template.object'; +import { NotificationSubscription } from './notification-subscription.object'; +import { NotificationPreference } from './notification-preference.object'; + +const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; +const V17_EXPECTED = { create: true, import: true, edit: true, delete: true, exportCsv: true }; + +const asV16 = (obj: { userActions?: unknown }) => ({ + managedBy: 'system', + userActions: obj.userActions ?? { create: true, edit: true, delete: true }, +}); + +const OBJECTS = [ + ['sys_notification_template', NotificationTemplate], + ['sys_notification_subscription', NotificationSubscription], + ['sys_notification_preference', NotificationPreference], +] as const; + +describe('#3355 — messaging config grids move to `system-data` with their affordances intact', () => { + for (const [name, obj] of OBJECTS) { + describe(name, () => { + it('declares the new bucket and no longer carries a redundant `userActions` block', () => { + expect(obj.managedBy).toBe('system-data'); + expect(obj.userActions).toBeUndefined(); + }); + + it('resolves the full-CRUD matrix from the bucket default alone', () => { + expect(resolveCrudAffordances(obj as never)).toEqual(V17_EXPECTED); + }); + + it('is write-equivalent to its v16 self on create / edit / delete / exportCsv', () => { + const v16 = resolveCrudAffordances(asV16(obj) as never); + const v17 = resolveCrudAffordances(obj as never); + expect(v16).toEqual(V16_EXPECTED); + for (const verb of ['create', 'edit', 'delete', 'exportCsv'] as const) { + expect(v17[verb], `${name}.${verb} must not move`).toBe(v16[verb]); + } + }); + + it('gains CSV import — the one adjudicated delta, pinned so it cannot move silently', () => { + expect(resolveCrudAffordances(asV16(obj) as never).import).toBe(false); + expect(resolveCrudAffordances(obj as never).import).toBe(true); + }); + }); + } +}); diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 27cd8dc258..0f79531af8 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1409,11 +1409,12 @@ describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', expect(aff.deletePredicates?.visibleWhen).toEqual({ dialect: 'cel', source: 'record.frozen != true' }); }); - it('engine-owned bucket resolves fully locked (same matrix as system/append-only, ADR-0103)', () => { + it('engine-owned bucket resolves fully locked (same matrix as append-only, ADR-0103)', () => { const locked = { create: false, import: false, edit: false, delete: false, exportCsv: true }; expect(resolveCrudAffordances({ managedBy: 'engine-owned' } as never)).toEqual(locked); - // Parity with the other engine-owned-default buckets. - expect(resolveCrudAffordances({ managedBy: 'system' } as never)).toEqual(locked); + // Parity with the other engine-owned-default bucket. `system` used to sit + // here too; #3355 renamed it to the writable-default `system-data`, which is + // pinned against this matrix below. expect(resolveCrudAffordances({ managedBy: 'append-only' } as never)).toEqual(locked); // The enum accepts the new value. expect(ObjectSchema.safeParse({ name: 'sys_thing', label: 'T', fields: { id: { type: 'text' } }, managedBy: 'engine-owned' }).success).toBe(true); @@ -1588,3 +1589,99 @@ describe('#3543 apiMethods legacy-value strip (ObjectCapabilities)', () => { expect(warn).not.toHaveBeenCalled(); }); }); + +/** + * #3355 — the v17 retirement of the residual `managedBy: 'system'` bucket. + * + * ADR-0103 split the overloaded value additively in v16 (the 20 engine-owned + * objects moved out to the new `engine-owned`), which left `system` naming the + * half that had already gone: writable platform data under a word that says the + * engine owns it. v17 renames the residue `system-data` and retires the bare + * value from the load path. + * + * These are the pin tests for that contract. Every one of them fails on the + * pre-fix tree. + */ +describe('managedBy: retiring the overloaded `system` bucket (#3355)', () => { + const object = (managedBy: string, extra: Record = {}) => ({ + name: 'sys_thing', + label: 'Thing', + fields: { id: { type: 'text' } }, + managedBy, + ...extra, + }); + + it('rejects the retired `system` value', () => { + const result = ObjectSchema.safeParse(object('system')); + expect(result.success).toBe(false); + }); + + it('the rejection carries the prescription, not "invalid enum value"', () => { + const result = ObjectSchema.safeParse(object('system')); + expect(result.success).toBe(false); + const msg = JSON.stringify(result.error?.issues ?? []); + // The retirement kit's contract: name the key, say it was removed, name the + // replacement, and hand over the automated fix. + expect(msg).toMatch(/managedBy/s); + expect(msg).toMatch(/removed in @objectstack\/spec 17/s); + expect(msg).toMatch(/system-data/s); + expect(msg).toMatch(/os migrate meta --from 16/s); + }); + + it('accepts the replacement value', () => { + expect(ObjectSchema.safeParse(object('system-data')).success).toBe(true); + }); + + it('a genuine typo still gets zod\'s own enum message, NOT the retirement prescription', () => { + // Telling the author of `managedBy: 'sytem'` that their value "was removed + // in v17" would misinform — they never had it. Only the value that used to + // be legal earns the tombstone. + const result = ObjectSchema.safeParse(object('sytem')); + expect(result.success).toBe(false); + const msg = JSON.stringify(result.error?.issues ?? []); + expect(msg).not.toMatch(/removed in @objectstack\/spec 17/s); + }); + + it('`system-data` defaults to full CRUD — the bucket says the data is yours', () => { + expect(resolveCrudAffordances({ managedBy: 'system-data' } as never)).toEqual({ + create: true, import: true, edit: true, delete: true, exportCsv: true, + }); + }); + + it('`userActions` on `system-data` NARROWS, and narrowing still resolves', () => { + const aff = resolveCrudAffordances({ + managedBy: 'system-data', + userActions: { delete: false, import: false }, + } as never); + expect(aff).toEqual({ + create: true, import: false, edit: true, delete: false, exportCsv: true, + }); + }); + + /** + * The mis-assignment guard. `system` defaulted LOCKED, so an engine-owned + * object mislabelled into it inherited a harmless read-only matrix. + * `system-data` defaults WRITABLE, so the same mistake now advertises generic + * CRUD on a table that should never take a user write — and the engine write + * guard does NOT cover `system-data` (a writable default has nothing to fail + * closed on), so authoring time is the only place it can be caught. + */ + describe('refuses a `system-data` object that grants no user write at all', () => { + it('throws at create() naming the bucket it should have used', () => { + expect(() => ObjectSchema.create(object('system-data', { + userActions: { create: false, edit: false, delete: false }, + }) as never)).toThrow(/system-data.*no create, edit or delete.*engine-owned/s); + }); + + it('permits a partial narrow — only the all-writes-false shape is a contradiction', () => { + expect(() => ObjectSchema.create(object('system-data', { + userActions: { create: false, delete: false }, + }) as never)).not.toThrow(); + }); + + it('leaves every other bucket alone (engine-owned is legitimately write-less)', () => { + expect(() => ObjectSchema.create(object('engine-owned') as never)).not.toThrow(); + expect(() => ObjectSchema.create(object('append-only') as never)).not.toThrow(); + }); + }); +}); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index e8be59d704..a8b9b74006 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1537,6 +1537,58 @@ function warnGenericPasswordFields( ); } +/** + * [#3355] Authoring-time refusal for the ONE way `system-data` can be + * mis-assigned: declaring it on an object that grants no user write at all. + * + * The v17 rename fixed a name that lied. This keeps it from lying again. The two + * halves of `system-data` are "platform-defined SCHEMA" and "admin/user-writable + * DATA"; an object whose resolved affordances forbid create AND edit AND delete + * satisfies the first half and contradicts the second, and the value it is + * actually describing is `engine-owned` (or `append-only` for an audit log). + * That contradiction is fully computable from the declaration alone — no call + * graph needed — so it is refused rather than reviewed. + * + * This matters more under the v17 defaults than it would have under v16. `system` + * defaulted LOCKED, so a mislabelled engine-owned object inherited a harmless + * read-only matrix; `system-data` defaults WRITABLE, so the same mistake now + * hands a table generic CRUD affordances it should never advertise. The write + * guard does not cover `system-data` (nothing to fail closed on when the default + * grants the write), so authoring time is the only place this can be caught — + * hence a throw, not a `console.warn`. + * + * Threshold is "no write verb at all", not "any narrowing": `system-data` + + * `userActions: { create: false, delete: false }` (an editable-only config grid) + * is a legitimate NARROW and passes. Only the all-writes-false shape is a + * contradiction, and it has no honest reading. + * + * Lives at `create()` — the authoring surface (ADR-0077) — alongside + * {@link warnGenericPasswordFields}, rather than in raw `.parse()`: stored rows + * arriving through the protocol-17 conversion are a 1:1 rename of values that + * were already writable, and failing a LOAD on metadata already at rest would + * turn an authoring defect into an outage. + */ +function assertSystemDataIsWritable( + objectName: unknown, + managedBy: unknown, + userActions: unknown, +): void { + if (managedBy !== 'system-data') return; + const aff = resolveCrudAffordances({ managedBy, userActions } as never); + if (aff.create || aff.edit || aff.delete) return; + const name = typeof objectName === 'string' && objectName.length > 0 ? objectName : ''; + throw new Error( + `ObjectSchema.create('${name}'): \`managedBy: 'system-data'\` declares "platform-defined ` + + 'schema, admin/user-writable DATA", but this object\'s resolved affordances grant no ' + + 'create, edit or delete — so nothing about it is user-writable and the bucket name is ' + + 'false. Use `managedBy: \'engine-owned\'` for rows a platform service owns end to end ' + + '(written via `isSystem` / a service SYSTEM_CTX), or `append-only` for an immutable ' + + 'audit log. If the object IS user-writable, drop the `userActions` entries closing ' + + 'create/edit/delete — the `system-data` default is full CRUD, and `userActions` is for ' + + 'NARROWING only (#3355).', + ); +} + /** * [ADR-0079] Back-compat alias normalization: an object authored with the * deprecated `displayNameField` key still parses by mapping it onto the @@ -1652,6 +1704,10 @@ export const ObjectSchema = lazySchema(() => { // rest, not hashed. `create()` is the authoring surface (ADR-0077), so the // steer lives here rather than in raw `.parse()`. warnGenericPasswordFields(cfg.name, cfg.fields, cfg.managedBy); + // [#3355] `system-data` on an object that grants no user write is a + // contradiction with no honest reading — refuse it here, where it is cheap + // to fix, rather than shipping a bucket whose name lies again. + assertSystemDataIsWritable(cfg.name, cfg.managedBy, cfg.userActions); const withDefaults = { ...cfg, label: cfg.label ?? snakeCaseToLabel(cfg.name as string), From 28504f38dbea660c3a584b1e9a4b7068693bb4ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 14:51:49 +0000 Subject: [PATCH 3/5] docs(spec,adr): regenerate the v17 artifacts and record the ADR-0103 close-out (#3355) - gen:schema / gen:spec-changes / gen:upgrade-guide / gen:docs / gen:api-surface - objects.mdx: the bucket table, the engine-owned-vs-system-data section, the `userActions`-narrows contract and a v16 upgrade callout - ADR-0103: a v17 addendum completing D5 (which had explicitly deferred the removal), recording the rejected alternatives, the default flip and its one affordance consequence, and the new mis-assignment refusal All 11 spec gates pass (liveness, empty-state, authorable-surface, docs, api-surface, spec-changes, upgrade-guide, skill-refs, skill-docs, skill-examples, variant-docs). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --- content/docs/data-modeling/objects.mdx | 54 ++++++++++------- content/docs/references/data/object.mdx | 2 +- ...dby-write-policy-and-engine-write-guard.md | 60 ++++++++++++++++++- packages/spec/spec-changes.json | 12 ++++ 4 files changed, 106 insertions(+), 22 deletions(-) diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 23a67954e0..bc342b6c3e 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -215,8 +215,8 @@ indexes: [ | Property | Type | Description | | :--- | :--- | :--- | | `isSystem` | `boolean` | System object, protected from deletion (default: `false`) | -| `managedBy` | `enum` | Lifecycle bucket that sets the default CRUD affordances and write policy — `'platform'` (default), `'config'`, `'system'`, `'engine-owned'`, `'append-only'`, `'better-auth'`. See [Lifecycle bucket](#lifecycle-bucket-managedby) below. | -| `userActions` | `object` | Per-object override of the CRUD affordances the `managedBy` default implies — `{ create?, edit?, delete?, import?, exportCsv? }`. This is what makes a `system`/`append-only` object admin/user-writable. See [Lifecycle bucket](#lifecycle-bucket-managedby). | +| `managedBy` | `enum` | Lifecycle bucket that sets the default CRUD affordances and write policy — `'platform'` (default), `'config'`, `'system-data'`, `'engine-owned'`, `'append-only'`, `'better-auth'`. See [Lifecycle bucket](#lifecycle-bucket-managedby) below. | +| `userActions` | `object` | Per-object override of the CRUD affordances the `managedBy` default implies — `{ create?, edit?, delete?, import?, exportCsv? }`. This is what NARROWS a `system-data` object, or opens a verb on an `engine-owned`/`append-only` one. See [Lifecycle bucket](#lifecycle-bucket-managedby). | | `sharingModel` | `enum` | Org-Wide Default record visibility (ADR-0055/0056/0090). Canonical four only: `'private'`, `'public_read'`, `'public_read_write'`, `'controlled_by_parent'` (detail visibility derived from its master). The legacy aliases (`'read'`, `'read_write'`, `'full'`) were removed from the enum (ADR-0090 D4) — authoring rejects them. Unset on a custom object resolves to `'private'` (ADR-0090 D1) | | `ownership` | `enum` | Record-ownership model: `'user'` (default — injects the reassignable `owner_id` lookup, engaging owner-scoped RLS, "My" views and owner reports), `'org'`, or `'none'` (no per-record owner — Dataverse-style catalog / junction tables, skips `owner_id`). Distinct from the package `own`/`extend` contribution kind. | | `validations` | `ValidationRule[]` | Object-level validation rules (see [Validation](/docs/data-modeling/validation)) | @@ -233,44 +233,58 @@ bare bucket string. | :--- | :--- | | `platform` | **Default.** User-owned business data — full New / Import / Edit / Delete. | | `config` | Admin-authored configuration — New / Edit / Delete, no CSV import. | -| `system` | Platform-defined schema holding **admin/user-writable data** (RBAC link tables, preferences, messaging config). Locked by default; each object opens its writes via `userActions`. | +| `system-data` | Platform-defined schema holding **admin/user-writable data** (RBAC link tables, preferences, messaging config). Full CRUD by default; narrow it with `userActions`. | | `engine-owned` | Runtime rows a platform service owns end to end — generic CRUD hidden, exposed `['get', 'list']` only, **no user writes ever**. | | `append-only` | Immutable audit trail — View + Export only. | | `better-auth` | Identity tables owned by the better-auth driver — generic user-context CRUD is suppressed; mutations flow through the auth API (sign-in, invite, reset). | -**`engine-owned` vs. writable `system` objects (ADR-0103).** Two buckets share -the same locked default matrix but say different things: +**`engine-owned` vs. `system-data` (ADR-0103, #3355).** Both hold a +platform-defined schema no tenant may model; they differ on who owns the *rows*: - **`engine-owned`** — jobs, notifications, approval runtime rows, `sys_record_share`, `sys_automation_run`, the metadata store, `sys_secret`, audit trails — written only by their owning service under a system context, - never through the generic `/data` API. A fail-closed guard - (`assertEngineOwnedWriteAllowed`) rejects user-context generic writes to them. -- **`system`** — platform-defined schema holding admin/user-writable *data*: the - RBAC link tables, `sys_user_preference`, `sys_approval_delegation`, the - messaging config grids. These declare `userActions` to open the writes they - legitimately take: + never through the generic `/data` API. Locked by default, and a fail-closed + guard (`assertEngineOwnedWriteAllowed`) rejects user-context generic writes. +- **`system-data`** — the schema is the platform's, the *data* is the admin's or + the user's: the RBAC link tables, `sys_user_preference`, + `sys_approval_delegation`, the messaging config grids. Full CRUD by default, + and no write guard covers the bucket — a writable default has nothing to fail + closed on: ```typescript export const SysUserPreference = ObjectSchema.create({ name: 'sys_user_preference', - managedBy: 'system', - // Affordance only — RLS / delegated administration is the actual authz. - userActions: { create: true, edit: true, delete: true }, + // Full CRUD by default — no `userActions` needed. RLS / delegated + // administration is the actual authz. + managedBy: 'system-data', // … }); ``` -The same override works on `append-only`. `userActions` is an *affordance* -declaration; the real authorization for these rows is still enforced by RLS, -delegated administration, and permission sets. +Pick between them on the *data*, not the table name: if no user ever writes a +row through the generic API, it is `engine-owned`. Declaring `system-data` on an +object whose resolved affordances grant no create, edit or delete is a +contradiction, and `ObjectSchema.create()` refuses it. + +`userActions` NARROWS `system-data` (an editable-only grid: `{ create: false, +delete: false }`) and OPENS a verb on `append-only`. Either way it is an +*affordance* declaration; the real authorization for these rows is still +enforced by RLS, delegated administration, and permission sets. + + +**Upgrading from v16.** `managedBy: 'system'` was retired in protocol 17 — +rename it to `'system-data'`, or run `os migrate meta --from 16`. Because the +new bucket defaults to full CRUD, a `userActions` block that existed only to +re-open create/edit/delete is now redundant and can be deleted. + A managed object may not advertise `enable.apiMethods` verbs its resolved affordances forbid — the registry strips the contradiction at registration -(`reconcileManagedApiMethods`, ADR-0049). To expose a generic write verb on a -`system`/`append-only` object, declare the matching `userActions` rather than -listing the verb in `apiMethods`. +(`reconcileManagedApiMethods`, ADR-0049). To expose a generic write verb on an +`engine-owned`/`append-only` object, declare the matching `userActions` rather +than listing the verb in `apiMethods`. ## Naming Conventions diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 32e60c2e5e..d79d00e756 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -115,7 +115,7 @@ const result = ApiMethod.parse(data); | **description** | `string` | optional | Developer documentation / description | | **icon** | `string` | optional | Icon name (Lucide/Material) for UI representation | | **isSystem** | `boolean` | optional | Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing) | -| **managedBy** | `Enum<'platform' \| 'config' \| 'system' \| 'engine-owned' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system (engine-managed schema, writable via userActions) \| engine-owned (engine owns the lifecycle, no user writes) \| append-only (audit) \| better-auth (identity). UI clients honour the resolved affordance matrix. | +| **managedBy** | `Enum<'platform' \| 'config' \| 'system-data' \| 'engine-owned' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system-data (platform-defined schema, admin/user-writable data) \| engine-owned (engine owns the lifecycle, no user writes) \| append-only (audit) \| better-auth (identity). UI clients honour the resolved affordance matrix. | | **ownership** | `Enum<'user' \| 'org' \| 'none'>` | optional | Record-ownership model: user (default — injects reassignable owner_id) \| org \| none (no per-record owner, skips owner_id). Distinct from the package own/extend contribution kind. | | **userActions** | `{ create?: boolean; import?: boolean; edit?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; delete?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; … }` | optional | Per-object override of the resolved CRUD affordance matrix. | | **systemFields** | `'false' \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. | diff --git a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md index b0aaf8084d..086903396e 100644 --- a/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md +++ b/docs/adr/0103-managedby-write-policy-and-engine-write-guard.md @@ -1,6 +1,7 @@ # ADR-0103: `managedBy` write policy — split the overloaded `system` bucket, enforce engine-owned writes -- **Status**: Accepted +- **Status**: Accepted (D5 completed in v17 — see the #3355 addendum at the end: + the residual `system` bucket is renamed `system-data` and the bare value retired) - **Date**: 2026-07-19 - **Issue**: #3220 (root cause surfaced by the #1591 / #3213 better-auth guard work; safe slice shipped in #3222) - **Relates to**: ADR-0049 (no unenforced security properties), ADR-0092 (identity @@ -192,3 +193,60 @@ renders an unknown `engine-owned` object editable but the server still 405s the write (point 1). Removing the overloaded `system` entirely — moving the 8 writable objects to a dedicated writable-platform-data bucket (or `config`) and retiring `system` — is a genuinely breaking rename deferred to **v17**. + +--- + +## Addendum (v17, #3355) — the deferred half: `system` → `system-data` + +The v16 sequencing note above deferred "removing the overloaded `system` entirely" +to v17. This addendum records that close-out. It **completes** D5 rather than +revising it; nothing decided above is reversed. + +**What the additive split left behind.** D5 moved the 20 engine-owned objects out +to the explicit `engine-owned` and had the 8 writable ones *keep* `system`. That +was correct for a release that could not break authors, but the value that +remained named the half that had just moved out: "system" sitting on precisely the +objects a user writes. The practical cost is not aesthetic — an author choosing +between `system` and `engine-owned` had nothing in the vocabulary to choose *on*, +so the bucket was re-overloadable by anyone reading the name in good faith, and a +model author most of all ("system table" reads as engine-owned everywhere else). + +**Decision.** The residual bucket is renamed **`system-data`** and the bare +`system` value is retired from the load path (the enum rejects it with a +prescription; stored metadata is converted by the ADR-0087 D2 entry +`object-managed-by-system-to-system-data`). The name states both boundaries the +old one hid: the **schema** is the platform's — versus `platform`, which is +tenant-modelled — and the **data** is the admin's or the user's, versus +`engine-owned`, where the engine owns both. + +Rejected alternatives: reusing **`config`** (`sys_user_preference` is user-owned, +not admin-authored, and `config` suppresses CSV import — a fresh overload on day +one of the bucket that exists to end one), and **`platform-data`**, which sits one +word from the semantically unrelated `platform` in the same closed enum and would +reintroduce the confusion at the moment of choosing. + +**One deliberate consequence — the default flips.** `system` defaulted LOCKED with +each object re-opening its writes via `userActions`; `system-data` defaults +**WRITABLE** (full CRUD), because a bucket whose purpose is to say "the data is +yours" should not make every member ask for it back. The 8 objects' re-open blocks +are therefore deleted, and `userActions` on this bucket now only NARROWS. The +affordance side-effect is that CSV `import` resolves `true` where it resolved +`false` under the locked default — an affordance change only; every row a CSV +import writes is still adjudicated by the `DelegatedAdminGate` / RLS / permission +sets. + +**Enforcement is unchanged, as in D5.** `system-data` joins `platform` / `config` +as a bucket neither `ENGINE_OWNED_BUCKETS` (guard) nor `GUARDED_WRITE_BUCKETS` +(clamp) covers — a writable default has nothing to fail closed on. The 8 objects +passed the guard before via `userActions` and pass now via the bucket default, for +the same resolved-affordance reason; equivalence pins in `plugin-security`, +`service-messaging`, `plugin-approvals`, `platform-objects` and +`plugin-hono-server` assert this per object rather than leaving it to argument. + +**New in v17 — the mis-assignment refusal.** Because the default is now writable, +mislabelling an engine-owned object into this bucket is no longer harmless: it +advertises generic CRUD on a table that should take no user write, and no guard +covers the bucket to catch it. `ObjectSchema.create()` therefore **refuses** +`system-data` on an object whose resolved affordances grant no create, edit or +delete — a contradiction with no honest reading, computable from the declaration +alone. Partial narrowing stays legal; only the all-writes-false shape is refused. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 35d3b15f2d..acbd08ca46 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -235,6 +235,12 @@ "to": "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343)", "conversionId": "flow-node-script-branch-keys-removed", "toMajor": 17 + }, + { + "surface": "object.managedBy", + "to": "object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data)", + "conversionId": "object-managed-by-system-to-system-data", + "toMajor": 17 } ], "migrated": [ @@ -755,6 +761,12 @@ "to": "script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343)", "conversionId": "flow-node-script-branch-keys-removed", "toMajor": 17 + }, + { + "surface": "object.managedBy", + "to": "object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data)", + "conversionId": "object-managed-by-system-to-system-data", + "toMajor": 17 } ], "migrated": [ From f6aca000b73faf85b95bffbc02439cc10b093369 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 14:58:26 +0000 Subject: [PATCH 4/5] fix(test): reconstruct the v16 baseline via `engine-owned`, not the retired literal (#3355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The equivalence pins built their v16 side by passing `managedBy: 'system'` to `resolveCrudAffordances` — but v17 deletes that row from `CRUD_AFFORDANCE_DEFAULTS`, so the lookup fell through to the `platform` default and reconstructed a baseline with `import: true`. The pins caught it (that is what they are for): the `expect(v16).toEqual(V16_EXPECTED)` guard failed rather than the comparison silently passing against a wrong baseline. ADR-0103 D5 gave `engine-owned` the byte-identical locked row `system` carried in v16, so it is an exact stand-in, and the V16_EXPECTED assertion keeps it honest. Also adds the NodeNext `.js` extensions the new test files needed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --- .../sys-user-preference.managed-by.test.ts | 11 ++++++++-- .../sys-approval-delegation.object.test.ts | 9 +++++++- .../objects/managed-by-system-data.test.ts | 22 ++++++++++++++----- .../objects/managed-by-system-data.test.ts | 21 ++++++++++++++---- 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts b/packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts index c53e36c495..f4a8c0b38e 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts @@ -16,12 +16,19 @@ import { describe, expect, it } from 'vitest'; import { resolveCrudAffordances } from '@objectstack/spec/data'; -import { SysUserPreference } from './sys-user-preference.object'; +import { SysUserPreference } from './sys-user-preference.object.js'; const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; const V17_EXPECTED = { create: true, import: true, edit: true, delete: true, exportCsv: true }; -const asV16 = { managedBy: 'system', userActions: { create: true, edit: true, delete: true } }; +/** + * The v16 shape, reconstructed via `engine-owned` — which ADR-0103 D5 gave the + * byte-identical locked default row `system` carried in v16. The retired literal + * cannot be used: v17 deleted its row from `CRUD_AFFORDANCE_DEFAULTS`, so it would + * fall through to the `platform` default and reconstruct the wrong baseline. The + * `toEqual(V16_EXPECTED)` assertion below keeps the stand-in honest. + */ +const asV16 = { managedBy: 'engine-owned', userActions: { create: true, edit: true, delete: true } }; describe('#3355 — sys_user_preference moves to `system-data` with its affordances intact', () => { it('declares the new bucket and no longer carries a redundant `userActions` block', () => { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts index f8be123ca1..f63f565d38 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts @@ -27,7 +27,14 @@ import { SysApprovalDelegation } from './sys-approval-delegation.object'; describe('#3355 — sys_approval_delegation moves to `system-data` with its affordances intact', () => { const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; const V17_EXPECTED = { create: true, import: true, edit: true, delete: true, exportCsv: true }; - const asV16 = { managedBy: 'system', userActions: { create: true, edit: true, delete: true } }; + /** + * The v16 shape, reconstructed via `engine-owned` — which ADR-0103 D5 gave the + * byte-identical locked default row `system` carried in v16. The retired + * literal cannot be used: v17 deleted its row from `CRUD_AFFORDANCE_DEFAULTS`, + * so it would fall through to the `platform` default and reconstruct the wrong + * baseline. The `toEqual(V16_EXPECTED)` assertion below keeps it honest. + */ + const asV16 = { managedBy: 'engine-owned', userActions: { create: true, edit: true, delete: true } }; it('declares the new bucket and no longer carries a redundant `userActions` block', () => { expect(SysApprovalDelegation.managedBy).toBe('system-data'); diff --git a/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts index 7b0582f52d..56613665fc 100644 --- a/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts +++ b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts @@ -26,9 +26,9 @@ import { describe, expect, it } from 'vitest'; import { resolveCrudAffordances } from '@objectstack/spec/data'; -import { SysUserPosition } from './sys-user-position.object'; -import { SysUserPermissionSet } from './sys-user-permission-set.object'; -import { SysPositionPermissionSet } from './sys-position-permission-set.object'; +import { SysUserPosition } from './sys-user-position.object.js'; +import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; +import { SysPositionPermissionSet } from './sys-position-permission-set.object.js'; /** What each object resolved to in v16: LOCKED `system` + `userActions: {c,e,d}`. */ const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; @@ -36,9 +36,21 @@ const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, ex /** What each object resolves to in v17: the `system-data` default, no `userActions`. */ const V17_EXPECTED = { create: true, import: true, edit: true, delete: true, exportCsv: true }; -/** The v16 declaration shape, reconstructed so the equivalence is computed, not asserted twice. */ +/** + * The v16 declaration shape, reconstructed so the equivalence is COMPUTED rather + * than asserted twice. + * + * It is spelled `engine-owned`, not `system`, on purpose: v17 deleted the `system` + * row from `CRUD_AFFORDANCE_DEFAULTS`, so passing the retired literal would fall + * through to the `platform` default and quietly reconstruct the wrong baseline + * (that mistake is what this comment exists to prevent — it read green-ish and was + * wrong). ADR-0103 D5 gave `engine-owned` the byte-identical locked row `system` + * carried in v16 — `{create,import,edit,delete: false, exportCsv: true}` — so it is + * an exact stand-in for the old bucket default. `expect(v16).toEqual(V16_EXPECTED)` + * below is the check that this stand-in stayed honest. + */ const asV16 = (obj: { userActions?: unknown }) => ({ - managedBy: 'system', + managedBy: 'engine-owned', userActions: obj.userActions ?? { create: true, edit: true, delete: true }, }); diff --git a/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts b/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts index b63f699d86..356fa39e7d 100644 --- a/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts +++ b/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts @@ -15,15 +15,28 @@ import { describe, expect, it } from 'vitest'; import { resolveCrudAffordances } from '@objectstack/spec/data'; -import { NotificationTemplate } from './notification-template.object'; -import { NotificationSubscription } from './notification-subscription.object'; -import { NotificationPreference } from './notification-preference.object'; +import { NotificationTemplate } from './notification-template.object.js'; +import { NotificationSubscription } from './notification-subscription.object.js'; +import { NotificationPreference } from './notification-preference.object.js'; const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; const V17_EXPECTED = { create: true, import: true, edit: true, delete: true, exportCsv: true }; +/** + * The v16 declaration shape, reconstructed so the equivalence is COMPUTED rather + * than asserted twice. + * + * It is spelled `engine-owned`, not `system`, on purpose: v17 deleted the `system` + * row from `CRUD_AFFORDANCE_DEFAULTS`, so passing the retired literal would fall + * through to the `platform` default and quietly reconstruct the wrong baseline + * (that mistake is what this comment exists to prevent — it read green-ish and was + * wrong). ADR-0103 D5 gave `engine-owned` the byte-identical locked row `system` + * carried in v16 — `{create,import,edit,delete: false, exportCsv: true}` — so it is + * an exact stand-in for the old bucket default. `expect(v16).toEqual(V16_EXPECTED)` + * below is the check that this stand-in stayed honest. + */ const asV16 = (obj: { userActions?: unknown }) => ({ - managedBy: 'system', + managedBy: 'engine-owned', userActions: obj.userActions ?? { create: true, edit: true, delete: true }, }); From 77f29a40ee9ccd3af9ced391689446e56d4a698b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 15:16:40 +0000 Subject: [PATCH 5/5] chore(spec): regenerate the upgrade guide after rebasing onto main (#3355) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --- docs/protocol-upgrade-guide.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 40466e50c5..462e1304aa 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -164,6 +164,8 @@ The `script` flow node converges on its one real path (#4343). It had four ways The same audit reaches the driver contract itself: `IDataDriver.findStream` is removed (#4484). It was REQUIRED — every driver and every test double had to implement it — and documented as the read "optimized for large datasets to avoid memory overflow", while two of its three implementations awaited `find()` for the whole result set and then yielded it row by row, reaching exactly the peak it promised to avoid; the third streamed for real but was the one read in that driver that skipped `buildFindOptions`, so it dropped `query.fields`. Nothing anywhere called it, which is why a contract method could carry an inverted guarantee for this long and why ~20 test doubles could satisfy it by throwing `not implemented`. Paged `find()` is the read that exists and is enforced (its total-order guarantee is checked by the shared pagination-conformance cases); a cursor-based read is worth building when a caller asks for one, which is the honest order. A TS/API surface, never stored — one semantic TODO for driver authors, no source rewrite, and no tombstone: `DriverInterfaceSchema` describes a contract that code IMPLEMENTS and nothing ever `.parse()`d a driver, so tsc is the only channel that could carry the prescription, and it carries it where it matters — at a call site. +Finally, `object.managedBy: 'system'` is retired in favour of `'system-data'` (#3355), finishing the split ADR-0103 began in v16. That split was deliberately ADDITIVE: the 20 engine-owned objects moved to the new explicit `engine-owned`, and the 8 admin/user-writable ones — the RBAC link tables, `sys_user_preference`, the three messaging config grids — stayed behind on `system`. What was left is a value whose name describes the half that had already moved out: "system" sitting on precisely the objects a user writes. That is not a cosmetic complaint. An author choosing between `system` and `engine-owned` had nothing in the vocabulary to choose on, so the bucket was re-overloadable by anyone reading the name in good faith — a model author most of all. `system-data` states both boundaries: the SCHEMA is the platform's (versus `platform`, which is tenant-modelled), the DATA is the admin's or the user's (versus `engine-owned`, where the engine owns both). Reusing `config` was considered and rejected — `sys_user_preference` is user-owned rather than admin-authored, and `config` suppresses CSV import — as was `platform-data`, which sits one word away from the unrelated `platform` in the same closed enum and would reintroduce the confusion at the point of choosing. Because v16 already drained the engine side, the conversion is a ONE-TO-ONE mechanical value rename with no judgement call. One deliberate consequence: `system` defaulted LOCKED and each object re-opened its writes through `userActions`, while `system-data` defaults WRITABLE, so those blocks become redundant and are deleted (keep `userActions` only to NARROW). No enforcement moves — the engine write guard, the DelegatedAdminGate, RLS and permission sets all adjudicate off resolved affordances and the principal, never off the bucket name; `system-data` simply joins `platform`/`config` as a bucket the guard does not cover, because a writable default has nothing to fail closed on. Retired from the load path: the enum rejection is what teaches the new spelling, and absorbing `'system'` silently at load would leave every author writing the name this rename exists to retire. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -196,6 +198,7 @@ The same audit reaches the driver contract itself: `IDataDriver.findStream` is r | `datasource-inert-blocks-removed` | `datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission` | datasource keys 'retryPolicy'/'healthCheck' and external 'label'/'requirePermission' removed (#4583 — nothing retried, nothing probed on a schedule, and the federation label/permission were read by nobody) | retired — `migrate meta` only | | `datasource-config-driver-key-aliases` | `datasource.config` | datasource config keys → canonical per driver: sqlite 'file'/'database' → 'filename', postgres/mysql 'connectionString' → 'url' and 'user' → 'username', mongo 'uri' → 'url' and 'user' → 'username' (#4456 — driver-factory `??` fallback graduation) | retired — `migrate meta` only | | `flow-node-script-branch-keys-removed` | `flow.node.script.config.actionType / flow.node.script.config.template / flow.node.script.config.recipients / flow.node.script.config.variables / flow.node.script.config.script` | script flow-node config keys 'actionType' (→ 'function' when it was shorthand for one; otherwise removed — 'email'/'slack' were logger-backed stubs that delivered nothing), plus 'template' / 'recipients' / 'variables' (fed those stubs) and 'script' (inline JS the runtime never executed) (#4343) | retired — `migrate meta` only | +| `object-managed-by-system-to-system-data` | `object.managedBy` | object managedBy 'system' → 'system-data' (#3355 — ADR-0103's residual bucket named the engine-owned half v16 had already moved out to `engine-owned`; the rename leaves the name describing what the bucket actually holds: admin/user-writable platform data) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria)