Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .changeset/retire-managed-by-system-bucket.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 34 additions & 20 deletions content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)) |
Expand All @@ -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.

<Callout type="info">
**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.
</Callout>

<Callout type="warn">
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`.
</Callout>

## Naming Conventions
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
60 changes: 59 additions & 1 deletion docs/adr/0103-managedby-write-policy-and-engine-write-guard.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Loading
Loading