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
19 changes: 19 additions & 0 deletions .changeset/managedby-engine-owned-bucket.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-security": minor
"@objectstack/plugin-hono-server": minor
"@objectstack/metadata-core": patch
"@objectstack/platform-objects": patch
"@objectstack/plugin-approvals": patch
"@objectstack/plugin-sharing": patch
"@objectstack/service-automation": patch
"@objectstack/service-messaging": patch
---

**Split the overloaded `managedBy: 'system'` bucket with an explicit `engine-owned` value (ADR-0103 addendum, #3343).** ADR-0103 deferred the enum split ("revisitable later as a rename") because a new `managedBy` value would fall through to the fully-editable `platform` default on deployed Console clients. Both reasons against it are now retired — the server-side write guard / `apiMethods` reconciliation / `/me/permissions` clamp make that fallthrough cosmetic (the write is rejected regardless of what the client renders), and objectui#2712 closed the UI union — so v16 lands it, **additively**.

- **New enum value `engine-owned`** with the same all-locked default affordance row as `system` (`create/import/edit/delete: false`, `exportCsv: true`). It joins `ENGINE_OWNED_BUCKETS` (the engine write guard) and `GUARDED_WRITE_BUCKETS` (the `/me/permissions` clamp); the guard, `reconcileManagedApiMethods`, and the clamp mechanisms are unchanged — `engine-owned` is an explicit member of the set they already covered by resolved affordance.
- **20 objects relabelled `system → engine-owned`** — the ones the engine owns end to end and that declared no write-opening `userActions` (the metadata store, jobs, approval runtime rows, sharing rows, `sys_automation_run`, the messaging delivery/receipt pipeline, `sys_secret`, settings). One-line, behaviour-identical per object.
- **8 admin/user-writable objects keep `managedBy: 'system'`** (the RBAC link tables, `sys_user_preference`, `sys_approval_delegation`, the messaging config grids) — `system` now reads as "engine-managed schema, writable via `userActions`".

Behaviour-, enforcement- and wire-identical: resolved affordances, the guard verdict, the 405 `apiMethods` reconciliation, and the permissions clamp are the same before and after — this is a self-documenting relabel, not a policy change. No data migration (`managedBy` is schema metadata) and no code branches on the `'system'` literal. Retiring the overloaded `system` entirely (moving the 8 writable objects to a dedicated bucket) is a breaking rename deferred to v17.
27 changes: 15 additions & 12 deletions content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ 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'`, `'append-only'`, `'better-auth'`. See [Lifecycle bucket](#lifecycle-bucket-managedby) below. |
| `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). |
| `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. |
Expand All @@ -235,20 +235,23 @@ 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. **Engine-owned by default**: a platform service owns the row lifecycle, generic CRUD is hidden, and the object is exposed `['get', 'list']` only. |
| `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`. |
| `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. admin-writable `system` objects (ADR-0103).** The `system`
bucket covers two kinds of object. Most are *engine-owned* — jobs,
notifications, approval rows, `sys_record_share`, `sys_automation_run`, audit
trails, `sys_secret` — 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.
A minority are *platform-defined schema with admin/user-writable data* — the
RBAC link tables, `sys_user_preference`, `sys_approval_delegation`, the
messaging config grids. These keep `managedBy: 'system'` but declare
`userActions` to open the writes they legitimately take:
**`engine-owned` vs. writable `system` objects (ADR-0103).** Two buckets share
the same locked default matrix but say different things:

- **`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:

```typescript
export const SysUserPreference = ObjectSchema.create({
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 @@ -101,7 +101,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' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system (engine-managed) \| append-only (audit) \| better-auth (identity). UI clients honour the resolved affordance matrix. |
| **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. |
| **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
48 changes: 48 additions & 0 deletions docs/adr/0103-managedby-write-policy-and-engine-write-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,51 @@ a row users never hand-edit.
the intended ADR-0049 correction, called out in the release note.
- The taxonomy is now honest: a reader can tell engine-owned from writable by the
resolved affordances, and the guard enforces it.

## Addendum (2026-07-20, v16) — the anticipated enum split lands: explicit `engine-owned` bucket

D1 deferred the enum split ("Splitting the enum later remains possible as a pure
rename on top of the now-correct affordances"). Both reasons D1 cited *against* an
enum value are now retired, so v16 adopts it — additively — as the self-documenting
successor to the engine-owned-DEFAULT overload of `system`.

**Why it is now safe** (the D1 objections, resolved):
1. *Silent fully-editable fallthrough on deployed clients* — neutralised by the
server-side enforcement this same ADR added: an unknown bucket resolving to the
`platform` default on an old client is now **cosmetic**, because the engine write
guard (D2), `apiMethods` reconciliation (D3) and the `/me/permissions` clamp (D4)
reject the write regardless of what the client renders. (Re-verified end to end
in the showcase: a generic `/data` create on an engine-owned object returns 405.)
2. *Open-ended UI type across three mirrors* — closed by **objectui#2712**: the
`ManagedByBucket` union is now a single closed type, so a new value is a compile
error to miss, not a silent fallthrough.

**The split (additive — `system` is retained, nothing is removed):**
- New enum value **`engine-owned`** with the same all-locked default affordance row
as `system` (`create/import/edit/delete: false`, `exportCsv: true`). It joins
`ENGINE_OWNED_BUCKETS` (guard) and `GUARDED_WRITE_BUCKETS` (clamp); the guard,
reconciliation and clamp mechanisms are **unchanged** — engine-owned is simply an
explicit member of the set they already covered by resolved affordance.
- The **20** objects that were `system` with no write-opening `userActions` (the
metadata store, jobs, approvals runtime rows, sharing rows, automation runs, the
messaging delivery/receipt pipeline, secrets, settings) are relabelled
`system → engine-owned` — a one-line, behaviour-identical change per object.
- The **8** objects that are platform-schema **admin/user-writable DATA** (the RBAC
link tables `sys_user_position` / `sys_user_permission_set` /
`sys_position_permission_set`, `sys_user_preference`, `sys_approval_delegation`,
and the messaging config grids) **keep `managedBy: 'system'`**, which now reads as
"engine-managed schema, writable via `userActions`" — the residual meaning of the
bucket after the engine-owned rows move out.

**Not a behaviour or enforcement change.** Resolved affordances, the guard verdict,
the 405 reconciliation and the permissions clamp are byte-identical before and
after; this is a self-documenting relabel. No data migration (`managedBy` is schema
metadata, not row data), and no code branches on the `'system'` literal (all
enforcement keys off `resolveCrudAffordances` / the bucket-set membership).

**Sequencing (v16 RC).** The framework enum and the objectui union land together and
the vendored console is re-pinned before GA; during any sync window an old console
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**.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const SysMetadataCommitObject = ObjectSchema.create({
pluralLabel: 'Metadata Commits',
icon: 'git-commit',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Package-scoped commit log grouping a turn’s metadata changes (ADR-0067).',

fields: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const SysMetadataHistoryObject = ObjectSchema.create({
pluralLabel: 'Metadata History',
icon: 'history',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Durable event log of metadata overlay changes (per-org, append-only)',

fields: {
Expand Down
6 changes: 3 additions & 3 deletions packages/metadata-core/src/objects/sys-metadata.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ export const SysMetadataObject = ObjectSchema.create({
pluralLabel: 'System Metadata',
icon: 'settings',
isSystem: true,
// managedBy: 'system' — the metadata table backs every other config
// managedBy: 'engine-owned' — the metadata table backs every other config
// object. Writing rows directly here bypasses the typed Zod APIs and
// would let an admin inject malformed payloads. The "All Metadata"
// menu is therefore a read-only debug surface (Export only); typed
// edits flow through the dedicated per-type pages (Approval Process,
// Sharing Rule, etc.).
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Stores platform and user-scope metadata records (objects, views, flows, etc.)',

fields: {
Expand Down Expand Up @@ -227,7 +227,7 @@ export const SysMetadataObject = ObjectSchema.create({
trackHistory: true,
searchable: false,
apiEnabled: true,
// #3220 — sys_metadata is `managedBy: 'system'`: customization overlays are
// #3220 — sys_metadata is `managedBy: 'engine-owned'`: customization overlays are
// authored ONLY through the metadata-protocol RPC (its engine writes carry a
// transaction context, not a user session), never the generic /data route.
// Neither the framework nor the Console (objectui) POSTs /data/sys_metadata,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const SysImportJob = ObjectSchema.create({
pluralLabel: 'Import Jobs',
icon: 'upload',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Asynchronous bulk-import job state, progress, and history',
displayNameField: 'object_name',
nameField: 'object_name', // [ADR-0079] canonical primary-title pointer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const SysJobQueue = ObjectSchema.create({
pluralLabel: 'Job Queue Messages',
icon: 'inbox',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Durable job/message queue including dead letters',
displayNameField: 'queue',
nameField: 'queue', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
Expand Down
2 changes: 1 addition & 1 deletion packages/platform-objects/src/audit/sys-job.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const SysJob = ObjectSchema.create({
pluralLabel: 'Background Jobs',
icon: 'clock',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Catalogue of registered background jobs',
displayNameField: 'name',
nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export const SysNotification = ObjectSchema.create({
pluralLabel: 'Notification Events',
icon: 'bell',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
// ADR-0057: one 90d window across the whole notification pipeline
// (event → delivery → receipt/inbox), enforced by the LifecycleService
// (the retired NotificationRetention sweeper kept the same default).
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-objects/src/system/sys-secret.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
* (Phase 4) without polluting `sys_setting_audit` with plaintext
* reads of e.g. feature flags.
*
* managedBy: 'system' — never edited from a generic Object grid. All
* managedBy: 'engine-owned' — never edited from a generic Object grid. All
* writes flow through `SettingsService` and an `ICryptoProvider`.
*
* @namespace sys
Expand All @@ -35,7 +35,7 @@ export const SysSecret = ObjectSchema.create({
pluralLabel: 'Secrets',
icon: 'key',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
// [ADR-0066 D2/④] Secure-by-default: the environment's encrypted-secrets
// store (settings/datasource credentials). Not covered by the wildcard `'*'`
// grant — ordinary members get 403 from the generic data layer. Platform
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const SysSettingAudit = ObjectSchema.create({
pluralLabel: 'Setting Audit',
icon: 'history',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Append-only audit trail for SettingsService mutations.',
highlightFields: ['namespace', 'key', 'scope', 'action', 'actor_id', 'created_at'],
listViews: {
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-objects/src/system/sys-setting.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
* and leave `value` null. The plain value is never written to audit log
* or history snapshots — only an `'<encrypted>'` placeholder + a digest.
*
* managedBy: 'system' — the admin grid in Setup is a diagnostic surface
* managedBy: 'engine-owned' — the admin grid in Setup is a diagnostic surface
* only; all writes flow through `SettingsService.set()` so the resolver
* stays the single source of truth.
*
Expand All @@ -37,7 +37,7 @@ export const SysSetting = ObjectSchema.create({
pluralLabel: 'Settings',
icon: 'sliders',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Generic K/V store backing the SettingsManifest contract.',
displayNameField: 'key',
nameField: 'key', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const SysApprovalApprover = ObjectSchema.create({
pluralLabel: 'Approval Approvers',
icon: 'users',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Normalized pending-approver rows for indexed inbox queries',
displayNameField: 'id',
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const SysApprovalRequest = ObjectSchema.create({
pluralLabel: 'Approval Requests',
icon: 'inbox',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Live approval instance tracked per submission',
displayNameField: 'id',
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const SysApprovalToken = ObjectSchema.create({
pluralLabel: 'Approval Action Tokens',
icon: 'key',
isSystem: true,
managedBy: 'system',
managedBy: 'engine-owned',
description: 'Single-use tokens behind actionable approval links',
displayNameField: 'id',
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const SCHEMAS = {
sys_member: { name: 'sys_member', managedBy: 'better-auth' },
sys_session: { name: 'sys_session', managedBy: 'better-auth' },
crm_lead: { name: 'crm_lead' },
sys_automation_run: { name: 'sys_automation_run', managedBy: 'system' },
sys_automation_run: { name: 'sys_automation_run', managedBy: 'engine-owned' },
};

/** Session shapes as ObjectQLEngine.buildSession produces them. */
Expand Down
Loading
Loading