Skip to content

Commit 0848bea

Browse files
os-zhuangclaude
andauthored
feat(spec)!: retire the overloaded managedBy: 'system' bucket → system-data (#3355) (#4660)
* feat(spec)!: retire the overloaded `managedBy: 'system'` bucket → `system-data` (#3355) ADR-0103 split `system` additively in v16: the 20 engine-owned objects moved to the new explicit `engine-owned`, the 8 admin/user-writable ones stayed behind. The surviving value therefore names the half that already moved out — "system" on precisely the objects a user writes — leaving an author choosing between `system` and `engine-owned` with nothing in the vocabulary to choose on. v17 finishes the split: the residue is renamed `system-data`, the bare value is retired from the load path with a prescription, and the affordance default flips from LOCKED to WRITABLE (so the 8 objects' `userActions` re-open blocks become redundant and are deleted). Stored metadata is converted by the ADR-0087 entry `object-managed-by-system-to-system-data`, not reinterpreted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 * test(spec,security,messaging,approvals): pin the `system` → `system-data` equivalence and the mis-assignment refusal (#3355) The PR body claimed "no enforcement moves". These make it evidence: - per-object before/after pins for all 8 objects (4 packages): the v16 shape (`system` + a `userActions` re-open block) and the v17 shape (`system-data`, no block) must resolve the same create/edit/delete/exportCsv. The one deliberate delta — `import` false→true, per the maintainer's adjudication — gets its own named assertion so it cannot move silently. - `/me/permissions` clamp: same allowEdit/Create/Delete for both declaration shapes, plus a pin that a userActions-less `system-data` member is not clamped (the inverted false-negative removing `system` from GUARDED_WRITE_BUCKETS avoids). - write guard: `ENGINE_OWNED_BUCKETS` excludes both `system` and `system-data`. - new authoring-time refusal `assertSystemDataIsWritable`: `system-data` on an object granting no user write is a contradiction with no honest reading, and the writable default means the mistake now hands out generic CRUD. No guard covers the bucket, so `create()` is the only place to catch it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 * docs(spec,adr): regenerate the v17 artifacts and record the ADR-0103 close-out (#3355) - gen:schema / gen:spec-changes / gen:upgrade-guide / gen:docs / gen:api-surface - objects.mdx: the bucket table, the engine-owned-vs-system-data section, the `userActions`-narrows contract and a v16 upgrade callout - ADR-0103: a v17 addendum completing D5 (which had explicitly deferred the removal), recording the rejected alternatives, the default flip and its one affordance consequence, and the new mis-assignment refusal All 11 spec gates pass (liveness, empty-state, authorable-surface, docs, api-surface, spec-changes, upgrade-guide, skill-refs, skill-docs, skill-examples, variant-docs). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 * fix(test): reconstruct the v16 baseline via `engine-owned`, not the retired literal (#3355) The equivalence pins built their v16 side by passing `managedBy: 'system'` to `resolveCrudAffordances` — but v17 deletes that row from `CRUD_AFFORDANCE_DEFAULTS`, so the lookup fell through to the `platform` default and reconstructed a baseline with `import: true`. The pins caught it (that is what they are for): the `expect(v16).toEqual(V16_EXPECTED)` guard failed rather than the comparison silently passing against a wrong baseline. ADR-0103 D5 gave `engine-owned` the byte-identical locked row `system` carried in v16, so it is an exact stand-in, and the V16_EXPECTED assertion keeps it honest. Also adds the NodeNext `.js` extensions the new test files needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 * chore(spec): regenerate the upgrade guide after rebasing onto main (#3355) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012C2cd7tL8QDoZ2QKN3djJ5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 98877c9 commit 0848bea

28 files changed

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

content/docs/data-modeling/objects.mdx

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,8 @@ indexes: [
215215
| Property | Type | Description |
216216
| :--- | :--- | :--- |
217217
| `isSystem` | `boolean` | System object, protected from deletion (default: `false`) |
218-
| `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. |
219-
| `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). |
218+
| `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. |
219+
| `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). |
220220
| `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) |
221221
| `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. |
222222
| `validations` | `ValidationRule[]` | Object-level validation rules (see [Validation](/docs/data-modeling/validation)) |
@@ -233,44 +233,58 @@ bare bucket string.
233233
| :--- | :--- |
234234
| `platform` | **Default.** User-owned business data — full New / Import / Edit / Delete. |
235235
| `config` | Admin-authored configuration — New / Edit / Delete, no CSV import. |
236-
| `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`. |
236+
| `system-data` | Platform-defined schema holding **admin/user-writable data** (RBAC link tables, preferences, messaging config). Full CRUD by default; narrow it with `userActions`. |
237237
| `engine-owned` | Runtime rows a platform service owns end to end — generic CRUD hidden, exposed `['get', 'list']` only, **no user writes ever**. |
238238
| `append-only` | Immutable audit trail — View + Export only. |
239239
| `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). |
240240

241-
**`engine-owned` vs. writable `system` objects (ADR-0103).** Two buckets share
242-
the same locked default matrix but say different things:
241+
**`engine-owned` vs. `system-data` (ADR-0103, #3355).** Both hold a
242+
platform-defined schema no tenant may model; they differ on who owns the *rows*:
243243

244244
- **`engine-owned`** — jobs, notifications, approval runtime rows,
245245
`sys_record_share`, `sys_automation_run`, the metadata store, `sys_secret`,
246246
audit trails — written only by their owning service under a system context,
247-
never through the generic `/data` API. A fail-closed guard
248-
(`assertEngineOwnedWriteAllowed`) rejects user-context generic writes to them.
249-
- **`system`** — platform-defined schema holding admin/user-writable *data*: the
250-
RBAC link tables, `sys_user_preference`, `sys_approval_delegation`, the
251-
messaging config grids. These declare `userActions` to open the writes they
252-
legitimately take:
247+
never through the generic `/data` API. Locked by default, and a fail-closed
248+
guard (`assertEngineOwnedWriteAllowed`) rejects user-context generic writes.
249+
- **`system-data`** — the schema is the platform's, the *data* is the admin's or
250+
the user's: the RBAC link tables, `sys_user_preference`,
251+
`sys_approval_delegation`, the messaging config grids. Full CRUD by default,
252+
and no write guard covers the bucket — a writable default has nothing to fail
253+
closed on:
253254

254255
```typescript
255256
export const SysUserPreference = ObjectSchema.create({
256257
name: 'sys_user_preference',
257-
managedBy: 'system',
258-
// Affordance only — RLS / delegated administration is the actual authz.
259-
userActions: { create: true, edit: true, delete: true },
258+
// Full CRUD by default — no `userActions` needed. RLS / delegated
259+
// administration is the actual authz.
260+
managedBy: 'system-data',
260261
//
261262
});
262263
```
263264

264-
The same override works on `append-only`. `userActions` is an *affordance*
265-
declaration; the real authorization for these rows is still enforced by RLS,
266-
delegated administration, and permission sets.
265+
Pick between them on the *data*, not the table name: if no user ever writes a
266+
row through the generic API, it is `engine-owned`. Declaring `system-data` on an
267+
object whose resolved affordances grant no create, edit or delete is a
268+
contradiction, and `ObjectSchema.create()` refuses it.
269+
270+
`userActions` NARROWS `system-data` (an editable-only grid: `{ create: false,
271+
delete: false }`) and OPENS a verb on `append-only`. Either way it is an
272+
*affordance* declaration; the real authorization for these rows is still
273+
enforced by RLS, delegated administration, and permission sets.
274+
275+
<Callout type="info">
276+
**Upgrading from v16.** `managedBy: 'system'` was retired in protocol 17 —
277+
rename it to `'system-data'`, or run `os migrate meta --from 16`. Because the
278+
new bucket defaults to full CRUD, a `userActions` block that existed only to
279+
re-open create/edit/delete is now redundant and can be deleted.
280+
</Callout>
267281

268282
<Callout type="warn">
269283
A managed object may not advertise `enable.apiMethods` verbs its resolved
270284
affordances forbid — the registry strips the contradiction at registration
271-
(`reconcileManagedApiMethods`, ADR-0049). To expose a generic write verb on a
272-
`system`/`append-only` object, declare the matching `userActions` rather than
273-
listing the verb in `apiMethods`.
285+
(`reconcileManagedApiMethods`, ADR-0049). To expose a generic write verb on an
286+
`engine-owned`/`append-only` object, declare the matching `userActions` rather
287+
than listing the verb in `apiMethods`.
274288
</Callout>
275289

276290
## Naming Conventions

content/docs/references/data/object.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ const result = ApiMethod.parse(data);
115115
| **description** | `string` | optional | Developer documentation / description |
116116
| **icon** | `string` | optional | Icon name (Lucide/Material) for UI representation |
117117
| **isSystem** | `boolean` | optional | Is system object (protected from deletion; defaults its org-wide sharing to public when no sharingModel is set — plugin-sharing) |
118-
| **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. |
118+
| **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. |
119119
| **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. |
120120
| **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. |
121121
| **systemFields** | `'false' \| { tenant?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |

docs/adr/0103-managedby-write-policy-and-engine-write-guard.md

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# ADR-0103: `managedBy` write policy — split the overloaded `system` bucket, enforce engine-owned writes
22

3-
- **Status**: Accepted
3+
- **Status**: Accepted (D5 completed in v17 — see the #3355 addendum at the end:
4+
the residual `system` bucket is renamed `system-data` and the bare value retired)
45
- **Date**: 2026-07-19
56
- **Issue**: #3220 (root cause surfaced by the #1591 / #3213 better-auth guard work; safe slice shipped in #3222)
67
- **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
192193
write (point 1). Removing the overloaded `system` entirely — moving the 8 writable
193194
objects to a dedicated writable-platform-data bucket (or `config`) and retiring
194195
`system` — is a genuinely breaking rename deferred to **v17**.
196+
197+
---
198+
199+
## Addendum (v17, #3355) — the deferred half: `system``system-data`
200+
201+
The v16 sequencing note above deferred "removing the overloaded `system` entirely"
202+
to v17. This addendum records that close-out. It **completes** D5 rather than
203+
revising it; nothing decided above is reversed.
204+
205+
**What the additive split left behind.** D5 moved the 20 engine-owned objects out
206+
to the explicit `engine-owned` and had the 8 writable ones *keep* `system`. That
207+
was correct for a release that could not break authors, but the value that
208+
remained named the half that had just moved out: "system" sitting on precisely the
209+
objects a user writes. The practical cost is not aesthetic — an author choosing
210+
between `system` and `engine-owned` had nothing in the vocabulary to choose *on*,
211+
so the bucket was re-overloadable by anyone reading the name in good faith, and a
212+
model author most of all ("system table" reads as engine-owned everywhere else).
213+
214+
**Decision.** The residual bucket is renamed **`system-data`** and the bare
215+
`system` value is retired from the load path (the enum rejects it with a
216+
prescription; stored metadata is converted by the ADR-0087 D2 entry
217+
`object-managed-by-system-to-system-data`). The name states both boundaries the
218+
old one hid: the **schema** is the platform's — versus `platform`, which is
219+
tenant-modelled — and the **data** is the admin's or the user's, versus
220+
`engine-owned`, where the engine owns both.
221+
222+
Rejected alternatives: reusing **`config`** (`sys_user_preference` is user-owned,
223+
not admin-authored, and `config` suppresses CSV import — a fresh overload on day
224+
one of the bucket that exists to end one), and **`platform-data`**, which sits one
225+
word from the semantically unrelated `platform` in the same closed enum and would
226+
reintroduce the confusion at the moment of choosing.
227+
228+
**One deliberate consequence — the default flips.** `system` defaulted LOCKED with
229+
each object re-opening its writes via `userActions`; `system-data` defaults
230+
**WRITABLE** (full CRUD), because a bucket whose purpose is to say "the data is
231+
yours" should not make every member ask for it back. The 8 objects' re-open blocks
232+
are therefore deleted, and `userActions` on this bucket now only NARROWS. The
233+
affordance side-effect is that CSV `import` resolves `true` where it resolved
234+
`false` under the locked default — an affordance change only; every row a CSV
235+
import writes is still adjudicated by the `DelegatedAdminGate` / RLS / permission
236+
sets.
237+
238+
**Enforcement is unchanged, as in D5.** `system-data` joins `platform` / `config`
239+
as a bucket neither `ENGINE_OWNED_BUCKETS` (guard) nor `GUARDED_WRITE_BUCKETS`
240+
(clamp) covers — a writable default has nothing to fail closed on. The 8 objects
241+
passed the guard before via `userActions` and pass now via the bucket default, for
242+
the same resolved-affordance reason; equivalence pins in `plugin-security`,
243+
`service-messaging`, `plugin-approvals`, `platform-objects` and
244+
`plugin-hono-server` assert this per object rather than leaving it to argument.
245+
246+
**New in v17 — the mis-assignment refusal.** Because the default is now writable,
247+
mislabelling an engine-owned object into this bucket is no longer harmless: it
248+
advertises generic CRUD on a table that should take no user write, and no guard
249+
covers the bucket to catch it. `ObjectSchema.create()` therefore **refuses**
250+
`system-data` on an object whose resolved affordances grant no create, edit or
251+
delete — a contradiction with no honest reading, computable from the declaration
252+
alone. Partial narrowing stays legal; only the all-writes-false shape is refused.

0 commit comments

Comments
 (0)