diff --git a/.changeset/managedby-engine-owned-bucket.md b/.changeset/managedby-engine-owned-bucket.md new file mode 100644 index 0000000000..a1a81d1e3b --- /dev/null +++ b/.changeset/managedby-engine-owned-bucket.md @@ -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. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index 600a1934b8..61d4bba085 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -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. | @@ -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({ diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index c9f1acd0a3..93b11cfd62 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -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. | 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 3835d51b7b..b0aaf8084d 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 @@ -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**. diff --git a/packages/metadata-core/src/objects/sys-metadata-commit.object.ts b/packages/metadata-core/src/objects/sys-metadata-commit.object.ts index 10e3f4b806..f838b30529 100644 --- a/packages/metadata-core/src/objects/sys-metadata-commit.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-commit.object.ts @@ -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: { diff --git a/packages/metadata-core/src/objects/sys-metadata-history.object.ts b/packages/metadata-core/src/objects/sys-metadata-history.object.ts index 800afedc56..eeeac2db5f 100644 --- a/packages/metadata-core/src/objects/sys-metadata-history.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-history.object.ts @@ -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: { diff --git a/packages/metadata-core/src/objects/sys-metadata.object.ts b/packages/metadata-core/src/objects/sys-metadata.object.ts index 144274ab8a..96bb2f3a9c 100644 --- a/packages/metadata-core/src/objects/sys-metadata.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata.object.ts @@ -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: { @@ -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, diff --git a/packages/platform-objects/src/audit/sys-import-job.object.ts b/packages/platform-objects/src/audit/sys-import-job.object.ts index 6df826bb3f..69b4e445e8 100644 --- a/packages/platform-objects/src/audit/sys-import-job.object.ts +++ b/packages/platform-objects/src/audit/sys-import-job.object.ts @@ -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 diff --git a/packages/platform-objects/src/audit/sys-job-queue.object.ts b/packages/platform-objects/src/audit/sys-job-queue.object.ts index 3fda7b92b8..b808f9132c 100644 --- a/packages/platform-objects/src/audit/sys-job-queue.object.ts +++ b/packages/platform-objects/src/audit/sys-job-queue.object.ts @@ -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) diff --git a/packages/platform-objects/src/audit/sys-job.object.ts b/packages/platform-objects/src/audit/sys-job.object.ts index fe4c267745..037911fc69 100644 --- a/packages/platform-objects/src/audit/sys-job.object.ts +++ b/packages/platform-objects/src/audit/sys-job.object.ts @@ -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) diff --git a/packages/platform-objects/src/audit/sys-notification.object.ts b/packages/platform-objects/src/audit/sys-notification.object.ts index 2437e31db0..e52f65f9ba 100644 --- a/packages/platform-objects/src/audit/sys-notification.object.ts +++ b/packages/platform-objects/src/audit/sys-notification.object.ts @@ -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). diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index 9e41f25e78..e289d9b608 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -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 @@ -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 diff --git a/packages/platform-objects/src/system/sys-setting-audit.object.ts b/packages/platform-objects/src/system/sys-setting-audit.object.ts index 68cb328856..8daacfef14 100644 --- a/packages/platform-objects/src/system/sys-setting-audit.object.ts +++ b/packages/platform-objects/src/system/sys-setting-audit.object.ts @@ -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: { diff --git a/packages/platform-objects/src/system/sys-setting.object.ts b/packages/platform-objects/src/system/sys-setting.object.ts index 902f27614c..4959de7767 100644 --- a/packages/platform-objects/src/system/sys-setting.object.ts +++ b/packages/platform-objects/src/system/sys-setting.object.ts @@ -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 `''` 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. * @@ -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) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts index 77c3feef66..bc8569c215 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-approver.object.ts @@ -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) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index f3e04c2572..24551c9811 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -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) diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts index 408bbc133e..e7be162675 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-token.object.ts @@ -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) diff --git a/packages/plugins/plugin-auth/src/identity-write-guard.test.ts b/packages/plugins/plugin-auth/src/identity-write-guard.test.ts index ff297ec9d4..8114f4924d 100644 --- a/packages/plugins/plugin-auth/src/identity-write-guard.test.ts +++ b/packages/plugins/plugin-auth/src/identity-write-guard.test.ts @@ -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. */ 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 36923ffbac..6a54967808 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 @@ -62,8 +62,10 @@ describe('clampManagedObjectWrites', () => { sys_user: { managedBy: 'better-auth', userActions: { edit: true } }, sys_member: { managedBy: 'better-auth' }, sys_session: { managedBy: 'better-auth' }, - // ADR-0103: system with no userActions → engine-owned → guarded (clamped). - sys_automation_run: { managedBy: 'system' }, + // 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 } }, crm_lead: { managedBy: 'platform' }, @@ -88,14 +90,17 @@ describe('clampManagedObjectWrites', () => { expect(objects.sys_session.allowRead).toBe(true); // read never clamped }); - it('clamps engine-owned system objects (ADR-0103) but leaves config/platform untouched', () => { + it('clamps engine-owned + locked-system objects (ADR-0103) but leaves config/platform untouched', () => { const objects: Record = { sys_automation_run: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }, + sys_notification_receipt: { allowRead: true, allowEdit: true, allowCreate: true, allowDelete: true }, crm_lead: { allowEdit: true }, }; clampManagedObjectWrites(objects, schemaOf); - // system + no userActions → engine-owned → writes clamped off; read kept. + // explicit engine-owned bucket → writes clamped off; read kept. expect(objects.sys_automation_run).toMatchObject({ allowRead: true, allowEdit: false, allowCreate: false, allowDelete: false }); + // locked `system` (no userActions) → also clamped. + expect(objects.sys_notification_receipt).toMatchObject({ allowEdit: false, allowCreate: false, allowDelete: false }); // platform bucket → not guarded → untouched. expect(objects.crm_lead.allowEdit).toBe(true); }); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index debdc580da..84adcb3b9f 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -237,11 +237,11 @@ 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), - * `system` / `append-only` by plugin-security's engine-owned write guard - * (ADR-0103). `config` / `platform` have no such guard — their permission-set - * result stands. + * `engine-owned` / `system` / `append-only` by plugin-security's engine-owned + * write guard (ADR-0103). `config` / `platform` have no such guard — their + * permission-set result stands. */ -const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'system', 'append-only']); +const GUARDED_WRITE_BUCKETS: ReadonlySet = new Set(['better-auth', 'system', 'engine-owned', 'append-only']); /** * Re-clamp a `/me/permissions` `objects` map by the SECOND server-side diff --git a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts index 1aaf64db24..aabb52fa8c 100644 --- a/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-audience-binding-suggestion.object.ts @@ -28,7 +28,7 @@ export const SysAudienceBindingSuggestion = ObjectSchema.create({ pluralLabel: 'Audience Binding Suggestions', icon: 'shield-question', isSystem: true, - managedBy: 'system', + managedBy: 'engine-owned', description: 'Package-suggested audience-anchor binding awaiting admin confirmation (ADR-0090 D5/D9).', titleFormat: '{package_id}: {permission_set_name} → {anchor}', highlightFields: ['package_id', 'permission_set_name', 'anchor', 'status'], 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 27b3ecebbf..8e6d620ec3 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,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// ADR-0103 — engine-owned write guard for the `system` / `append-only` buckets. +// ADR-0103 — engine-owned write guard for the `engine-owned` / `system` / `append-only` buckets. import { describe, it, expect } from 'vitest'; import { assertEngineOwnedWriteAllowed, ENGINE_OWNED_BUCKETS } from './system-write-guard.js'; @@ -12,7 +12,9 @@ const SYSTEM_CTX = { userId: 'u1', isSystem: true }; // No session (raw-engine / transaction context) → bypasses. const CONTEXTLESS = { transaction: {} }; -const engineOwned = { name: 'sys_automation_run', managedBy: 'system' }; +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' }; const writable = { name: 'sys_user_position', @@ -34,17 +36,22 @@ function expectDenied(fn: () => void): void { } describe('assertEngineOwnedWriteAllowed (ADR-0103)', () => { - it('scopes to the system and append-only buckets only', () => { - expect([...ENGINE_OWNED_BUCKETS].sort()).toEqual(['append-only', 'system']); + it('scopes to the engine-owned, system and append-only buckets', () => { + expect([...ENGINE_OWNED_BUCKETS].sort()).toEqual(['append-only', 'engine-owned', 'system']); }); - describe('engine-owned system/append-only objects', () => { - it('rejects user-context insert/update/delete', () => { + describe('engine-owned / system / 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)); diff --git a/packages/plugins/plugin-security/src/system-write-guard.ts b/packages/plugins/plugin-security/src/system-write-guard.ts index 5e24549c88..abf007cf76 100644 --- a/packages/plugins/plugin-security/src/system-write-guard.ts +++ b/packages/plugins/plugin-security/src/system-write-guard.ts @@ -1,11 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * ADR-0103 — engine-owned write guard for the `system` / `append-only` buckets. + * ADR-0103 — engine-owned write guard for the `engine-owned` / `system` / + * `append-only` buckets. * - * `managedBy: 'system'` and `managedBy: 'append-only'` default to *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 + * `managedBy: 'engine-owned'` (and the locked defaults of `system` / + * `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 * guard that promise was enforced by nothing but UI affordances and default * permission sets — a wildcard admin could raw-write these rows through the @@ -37,8 +39,13 @@ import { resolveCrudAffordances } from '@objectstack/spec/data'; import { PermissionDeniedError } from './errors.js'; -/** Buckets whose DEFAULT affordance row is engine-owned (no user writes). */ -export const ENGINE_OWNED_BUCKETS: ReadonlySet = new Set(['system', 'append-only']); +/** + * 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. + */ +export const ENGINE_OWNED_BUCKETS: ReadonlySet = new Set(['system', 'engine-owned', 'append-only']); /** * Engine write operation → the {@link resolveCrudAffordances} flag it needs. diff --git a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts index 8b48becdd0..53b290a745 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-record-share.object.ts @@ -36,7 +36,7 @@ export const SysRecordShare = ObjectSchema.create({ pluralLabel: 'Record Shares', icon: 'share', isSystem: true, - managedBy: 'system', + managedBy: 'engine-owned', description: 'Per-record sharing grant — extends OWD with explicit access', titleFormat: '{object_name}/{record_id} → {recipient_id} ({access_level})', highlightFields: ['object_name', 'record_id', 'recipient_id', 'access_level', 'source'], diff --git a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts index 07e2633802..816535e9ba 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts @@ -34,7 +34,7 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * - `redact_fields` overlays on top of the schema-default redaction * set declared on `object.publicSharing.redactFields` * - * managedBy: 'system' — admins inspect via the audit grid but all + * managedBy: 'engine-owned' — admins inspect via the audit grid but all * writes flow through `IShareLinkService` so the per-object opt-in, * expiry caps, and audit hooks fire. * @@ -46,7 +46,7 @@ export const SysShareLink = ObjectSchema.create({ pluralLabel: 'Share Links', icon: 'link-2', isSystem: true, - managedBy: 'system', + managedBy: 'engine-owned', description: 'Opaque capability token granting access to a single record. Notion/Figma-style public link sharing.', titleFormat: '{object_name}/{record_id} ({permission})', highlightFields: ['object_name', 'record_id', 'permission', 'audience', 'expires_at', 'revoked_at'], diff --git a/packages/services/service-automation/src/sys-automation-run.object.ts b/packages/services/service-automation/src/sys-automation-run.object.ts index 7be1a68c39..e6516bb4b6 100644 --- a/packages/services/service-automation/src/sys-automation-run.object.ts +++ b/packages/services/service-automation/src/sys-automation-run.object.ts @@ -39,7 +39,7 @@ export const SysAutomationRun = ObjectSchema.create({ pluralLabel: 'Automation Runs', icon: 'pause-circle', isSystem: true, - managedBy: 'system', + managedBy: 'engine-owned', // ADR-0057 (#2834): MIXED table — live suspended runs (resumable workflow // state, record semantics: an approval may legitimately stay paused for // months) interleaved with terminal run history (telemetry semantics). diff --git a/packages/services/service-messaging/src/objects/http-delivery.object.ts b/packages/services/service-messaging/src/objects/http-delivery.object.ts index 9391d66269..11db543353 100644 --- a/packages/services/service-messaging/src/objects/http-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/http-delivery.object.ts @@ -31,7 +31,7 @@ export const HttpDelivery = ObjectSchema.create({ pluralLabel: 'HTTP Deliveries', icon: 'globe', isSystem: true, - managedBy: 'system', + managedBy: 'engine-owned', // ADR-0057: webhook delivery attempts are debugging telemetry — 30d is // ample for retry forensics. lifecycle: { diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index ecce455256..8ca4e4b744 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -23,7 +23,7 @@ export const NotificationDelivery = ObjectSchema.create({ pluralLabel: 'Notification Deliveries', icon: 'send', isSystem: true, - managedBy: 'system', + managedBy: 'engine-owned', // ADR-0057: pipeline telemetry — same 90d window as sys_notification. lifecycle: { class: 'telemetry', diff --git a/packages/services/service-messaging/src/objects/notification-receipt.object.ts b/packages/services/service-messaging/src/objects/notification-receipt.object.ts index b648c8dedb..edb1e44c3c 100644 --- a/packages/services/service-messaging/src/objects/notification-receipt.object.ts +++ b/packages/services/service-messaging/src/objects/notification-receipt.object.ts @@ -25,7 +25,7 @@ export const NotificationReceipt = ObjectSchema.create({ pluralLabel: 'Notification Receipts', icon: 'check-check', isSystem: true, - managedBy: 'system', + managedBy: 'engine-owned', // ADR-0057: read-state rows expire with the pipeline's 90d window — NOT // shorter, or read notifications would resurface as unread while their // inbox rows are still alive. diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 0e5226d26a..e2f168f91a 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -1393,6 +1393,16 @@ describe('userActions row predicates + resolveCrudAffordances (objectui#2614)', expect(aff.deletePredicates?.visibleWhen).toEqual({ dialect: 'cel', source: 'record.frozen != true' }); }); + it('engine-owned bucket resolves fully locked (same matrix as system/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); + 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); + }); + it('object form without predicates behaves exactly like the boolean form', () => { const aff = resolveCrudAffordances({ managedBy: 'config', diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 96545bcfbc..b2838ec948 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -590,24 +590,27 @@ 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. The bucket DEFAULT is - * *engine-owned*: runtime rows whose lifecycle a platform service owns - * end to end (the approval engine, the sharing engine, the job runner, - * …), written only via `isSystem` / a service `SYSTEM_CTX` / a - * context-less engine call. Generic CRUD is hidden — users interact - * with these via *domain actions* on the source record (e.g. "Submit - * for Approval" creates an `sys_approval_request`). Example: - * `sys_approval_request`, `sys_record_share`, `sys_notification`, - * `sys_automation_run`, `sys_job`. - * Some `system` objects are platform schema but hold **admin/user-writable - * DATA** — the RBAC link tables (`sys_user_position`, + * - `system` — 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. They declare {@link userActions} to open the writes they take; - * the affordance is a declaration only — the real authz stays the - * delegated-admin gate / RLS. An `engine-owned` object is precisely a - * `system`/`append-only` object whose resolved affordances grant no write - * (ADR-0103). + * 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`.) + * - `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` / + * a context-less engine call. No user writes, ever. Generic CRUD is hidden — + * users interact via *domain actions* on the source record (e.g. "Submit + * for Approval" creates a `sys_approval_request`). Example: + * `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 + * 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`, * `sys_audit_log`, `sys_activity`, `sys_email`, `sys_presence`. @@ -624,20 +627,20 @@ 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` / `append-only` / `better-auth`. + * deny direct CRUD for `system` / `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` / `append-only` via plugin-security's system write guard - * (ADR-0103). `isSystem` / context-less engine writes bypass. + * `system` / `engine-owned` / `append-only` via plugin-security's system + * write guard (ADR-0103). `isSystem` / context-less engine writes bypass. * * 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', 'append-only', 'better-auth']).optional().describe( - '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: 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.', ), /** @@ -1437,23 +1440,26 @@ 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. DEFAULT is engine-owned: no - * generic CRUD; users interact via domain actions on the - * source record. `userActions` opens the admin/user-writable - * ones (RBAC link tables, prefs, messaging config) — the + * system — 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) + * 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 * append-only — audit log: View + Export only * better-auth — identity tables owned by better-auth driver; CRUD * routed through purpose-built actions (Invite, Reset * PW, Revoke, …) */ 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 }, - 'append-only': { create: false, import: false, edit: false, delete: false, exportCsv: true }, - 'better-auth': { create: false, import: false, edit: false, delete: false, exportCsv: true }, + 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 }, + '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 }, }; /**