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/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/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md
index dd0447651e..0c5568f8ea 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.
+Separately, `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.
+
Finally, five keys retire because the advisory lint could never have warned about them (#4509): mapping `extractQuery` / `errorPolicy` / `batchSize`, and app `contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, and a default materialises at parse time — so the liveness lint cannot tell a value the author wrote from one the schema supplied, and marking them would have warned on every mapping and every selector in existence. For a key in that state removal is not the escalation after a warning; it is the only channel that ever reaches the author, which is why they ship inside the 17.0.0 window rather than after a deprecation cycle. What they claimed: `extractQuery` promised an export path no exporter implements (exports go through the ordinary query API); `errorPolicy` offered skip/abort/retry where error handling belongs to the import REQUEST; `batchSize` sized batches the write path sizes itself; `placement` offered a topbar that places nothing. `includeAll` is the one worth reading twice — it was not unread but deliberately DISOBEYED, because context selectors are mandatory-scope and an "All" row would clear the scope: on Studio's package selector that means listing the platform's own system/cloud kernel packages to a developer who scoped to their package. `STUDIO_APP` authored `includeAll: true` against a renderer that ignored it. The mapping prescription for `batchSize` deliberately offers no rename: bulk-action, connector, sync, offline, seed-loader and NoSQL-cursor `batchSize` are all live, but each is a different key sizing its own path — the same trap `datasource.retryPolicy` vs `hook`/`job` `retryPolicy` had to defuse one issue earlier.
### Mechanical (applied for you)
@@ -199,6 +201,7 @@ Finally, five keys retire because the advisory lint could never have warned abou
| `mapping-inert-keys-removed` | `mapping.extractQuery / mapping.errorPolicy / mapping.batchSize` | mapping keys 'extractQuery'/'errorPolicy'/'batchSize' removed (#4509 — no exporter reads a mapping, error handling belongs to the import request, and the write path sizes its own batches) | 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)
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..f4a8c0b38e
--- /dev/null
+++ b/packages/platform-objects/src/identity/sys-user-preference.managed-by.test.ts
@@ -0,0 +1,56 @@
+// 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.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 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', () => {
+ 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/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.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation.object.test.ts
index 9a120bcdef..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
@@ -4,19 +4,62 @@
* #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 };
+ /**
+ * 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');
+ 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-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-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..56613665fc
--- /dev/null
+++ b/packages/plugins/plugin-security/src/objects/managed-by-system-data.test.ts
@@ -0,0 +1,91 @@
+// 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.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 };
+
+/** 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 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: 'engine-owned',
+ 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/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.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/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/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..356fa39e7d
--- /dev/null
+++ b/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts
@@ -0,0 +1,76 @@
+// 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.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: 'engine-owned',
+ 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/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/spec-changes.json b/packages/spec/spec-changes.json
index 19321f904d..08d240f1fb 100644
--- a/packages/spec/spec-changes.json
+++ b/packages/spec/spec-changes.json
@@ -241,6 +241,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": [
@@ -767,6 +773,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": [
diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts
index 164e99b027..3f509b264f 100644
--- a/packages/spec/src/conversions/registry.ts
+++ b/packages/spec/src/conversions/registry.ts
@@ -2835,6 +2835,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],
@@ -2872,6 +2951,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly {
+ 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 5b28bb1341..a8b9b74006 100644
--- a/packages/spec/src/data/object.zod.ts
+++ b/packages/spec/src/data/object.zod.ts
@@ -759,6 +759,33 @@ const objectUnknownKeyError: z.core.$ZodErrorMap = (issue) =>
* 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.',
),
/**
@@ -1494,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
@@ -1609,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),
@@ -1685,14 +1784,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 +1802,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 831506ca14..ee52502f18 100644
--- a/packages/spec/src/migrations/registry.ts
+++ b/packages/spec/src/migrations/registry.ts
@@ -593,6 +593,31 @@ const step17: MigrationStep = {
+ '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.\n\n'
+ + 'Separately, `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.\n\n'
+ 'Finally, five keys retire because the advisory lint could never have warned about them '
+ '(#4509): mapping `extractQuery` / `errorPolicy` / `batchSize`, and app '
+ '`contextSelectors[].includeAll` / `.placement`. Four of the five carry schema DEFAULTS, '
@@ -643,6 +668,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: [
{