Skip to content

Commit e5c06cd

Browse files
committed
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
1 parent 8325cd0 commit e5c06cd

5 files changed

Lines changed: 109 additions & 22 deletions

File tree

‎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.

‎docs/protocol-upgrade-guide.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ The datasource close-out also graduates the four legacy `datasource.config` spel
162162

163163
The `script` flow node converges on its one real path (#4343). It had four ways to name what it ran and only one of them ran anything: `config.actionType: 'email' | 'slack'` were logger-backed stubs that wrote a line, reported success and delivered nothing under any configuration — with `config.template` / `.recipients` / `.variables` feeding a message no channel ever sent; inline `config.script` was recognized and never executed (the built-in runtime has no server-side JS sandbox), so the node warned and no-op'd; and every other `actionType` value was shorthand for a registered-function name, a second spelling of `config.function`. All five keys are retired and `function` becomes required, which is also what finally made the contract PARSEABLE: while the legal key set depended on `actionType`, a flat parse would either reject valid shapes or wave everything through, so `script` (with `subflow`) now runs through the same execute-time contract parse #4277 gave the flat builtins. A shorthand `actionType` CONVERTS into `function` — that is what it meant — unless `function` is already set, in which case it was dead metadata the executor never reached. The other four are dropped outright: nothing read them, so there is no value to preserve, and rebuilding the intent is an authoring decision the tombstones prescribe per branch (a `notify` node for mail — it delivers through the messaging service, the in-app inbox by default and real email once `@objectstack/plugin-email` is installed; a `connector_action` with the Slack connector, or an `http` node posting to a webhook, for Slack; a registered function for an inline body). Retired from the load path for the same reason as the rest: absorbing `actionType: 'email'` silently would let an author keep believing the flow sends mail.
164164

165+
Finally, `object.managedBy: 'system'` is retired in favour of `'system-data'` (#3355), finishing the split ADR-0103 began in v16. That split was deliberately ADDITIVE: the 20 engine-owned objects moved to the new explicit `engine-owned`, and the 8 admin/user-writable ones — the RBAC link tables, `sys_user_preference`, the three messaging config grids — stayed behind on `system`. What was left is a value whose name describes the half that had already moved out: "system" sitting on precisely the objects a user writes. That is not a cosmetic complaint. An author choosing between `system` and `engine-owned` had nothing in the vocabulary to choose on, so the bucket was re-overloadable by anyone reading the name in good faith — a model author most of all. `system-data` states both boundaries: the SCHEMA is the platform's (versus `platform`, which is tenant-modelled), the DATA is the admin's or the user's (versus `engine-owned`, where the engine owns both). Reusing `config` was considered and rejected — `sys_user_preference` is user-owned rather than admin-authored, and `config` suppresses CSV import — as was `platform-data`, which sits one word away from the unrelated `platform` in the same closed enum and would reintroduce the confusion at the point of choosing. Because v16 already drained the engine side, the conversion is a ONE-TO-ONE mechanical value rename with no judgement call. One deliberate consequence: `system` defaulted LOCKED and each object re-opened its writes through `userActions`, while `system-data` defaults WRITABLE, so those blocks become redundant and are deleted (keep `userActions` only to NARROW). No enforcement moves — the engine write guard, the DelegatedAdminGate, RLS and permission sets all adjudicate off resolved affordances and the principal, never off the bucket name; `system-data` simply joins `platform`/`config` as a bucket the guard does not cover, because a writable default has nothing to fail closed on. Retired from the load path: the enum rejection is what teaches the new spelling, and absorbing `'system'` silently at load would leave every author writing the name this rename exists to retire.
166+
165167
### Mechanical (applied for you)
166168

167169
| Conversion | Surface | Change | Load window |
@@ -194,6 +196,7 @@ The `script` flow node converges on its one real path (#4343). It had four ways
194196
| `datasource-inert-blocks-removed` | `datasource.retryPolicy / datasource.healthCheck / datasource.external.label / datasource.external.requirePermission` | datasource keys 'retryPolicy'/'healthCheck' and external 'label'/'requirePermission' removed (#4583 — nothing retried, nothing probed on a schedule, and the federation label/permission were read by nobody) | retired — `migrate meta` only |
195197
| `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 |
196198
| `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 |
199+
| `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 |
197200

198201
### Semantic (delegated to you, with acceptance criteria)
199202

0 commit comments

Comments
 (0)