diff --git a/.changeset/ownership-record-model-field.md b/.changeset/ownership-record-model-field.md new file mode 100644 index 0000000000..1fab1f596d --- /dev/null +++ b/.changeset/ownership-record-model-field.md @@ -0,0 +1,27 @@ +--- +"@objectstack/spec": minor +"@objectstack/cli": patch +--- + +fix(spec): declare `ownership` as a first-class ObjectSchema field (#3175) + +The object-level record-ownership model — `ownership: 'user' | 'org' | 'none'`, +which drives the registry's `owner_id` auto-provisioning (`applySystemFields`) — +was read by the engine via `(schema as any).ownership` while `ObjectSchema.create()` +**rejected** it as an unknown top-level key (ADR-0032 / #1535). So a tested engine +opt-out (`ownership: 'org' | 'none'` on catalog / junction tables) could not be +set through the sanctioned authoring path, and the same `ownership` word was read +elsewhere as the unrelated package-contribution kind (`own` / `extend`). + +- **spec**: `ObjectSchema` now declares `ownership: z.enum(['user','org','none']).optional()`. + Authoring the record-ownership opt-out validates cleanly; the registry reads it + off the typed schema (no `as any`). A retired `ownership: 'own'` / `'extend'` + value fails with guidance pointing at the record-ownership model and noting that + `own`/`extend` is the contribution kind (`registerObject`), not an object-schema value. +- **cli**: the `object` scaffold no longer emits the now-invalid `ownership: 'own'` + (owner injection is the default), and `objectstack info` labels the record model + with the correct `user` default. + +No runtime behavior change: `applySystemFields` and its `owner_id` injection logic +are unchanged — this makes the property the engine already honors legally authorable +and consistently typed. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 25fbc6161d..ecb03593eb 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -105,6 +105,7 @@ const result = ApiMethod.parse(data); | **isSystem** | `boolean` | optional | Is system object (protected from deletion) | | **abstract** | `boolean` | optional | Is abstract base object (cannot be instantiated) | | **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. | +| **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' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; delete?: boolean \| { enabled?: boolean; visibleWhen?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; disabledWhen?: string \| { dialect: Enum<'cel' \| 'js' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }; … }` | optional | Per-object override of the resolved CRUD affordance matrix. | | **systemFields** | `'false' \| { tenant?: boolean; owner?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. | | **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. | diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index 92155e4969..661c5c82a8 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -25,7 +25,6 @@ const ${toCamelCase(name)}: Data.Object = { name: '${toSnakeCase(name)}', label: '${toTitleCase(name)}', pluralLabel: '${toTitleCase(name)}s', - ownership: 'own', fields: { name: { type: 'text', diff --git a/packages/cli/src/commands/info.ts b/packages/cli/src/commands/info.ts index 18130b5296..5062260d73 100644 --- a/packages/cli/src/commands/info.ts +++ b/packages/cli/src/commands/info.ts @@ -74,7 +74,8 @@ export default class Info extends Command { console.log(chalk.bold(' Objects:')); for (const obj of config.objects) { const fieldCount = obj.fields ? Object.keys(obj.fields).length : 0; - const ownership = obj.ownership || 'own'; + // Record-ownership model (#3175); defaults to user-owned when unset. + const ownership = obj.ownership || 'user'; console.log( ` ${chalk.cyan(obj.name || '?')}` + chalk.dim(` (${fieldCount} fields, ${ownership})`) + diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index cd0f838fea..480262f393 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -269,7 +269,9 @@ export function applySystemFields( // junction tables). Note this is the SAFE default direction: forgetting the // opt-out leaves a harmless spare column, whereas the old opt-IN model let // authors silently ship objects with no working ownership at all. - const ownership = (schema as any).ownership as 'user' | 'org' | 'none' | undefined; + // `ownership` is now a declared ObjectSchema field (record-ownership model), + // so it reads off the typed schema — no `as any` (#3175). + const ownership = schema.ownership; const wantOwner = ownership !== 'org' && ownership !== 'none' && diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 1eff94918f..57cd3763fb 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -97,6 +97,11 @@ "evidence": "packages/objectql/src/registry.ts:208", "note": "default perms; ui crudAffordances." }, + "ownership": { + "status": "live", + "evidence": "packages/objectql/src/registry.ts:272", + "note": "#3175 record-ownership model. applySystemFields injects the reassignable owner_id lookup by default (ownership:'user'); 'org'|'none' opt out (Dataverse-style catalog/junction tables). Proven in objectql/src/registry.test.ts." + }, "access": { "status": "live", "evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts", diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 6d1f39806e..e0c7da9cc2 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -880,6 +880,33 @@ describe('ObjectSchema.create()', () => { })).toThrow(); }); + // #3175 — `ownership` (record-ownership model) is now a declared field. + // Before, the registry read it via `(schema as any).ownership` while + // ObjectSchema.create() rejected it as an unknown key; these lock the two ends + // together. + describe('ownership record-model field (#3175)', () => { + it('accepts the record-ownership opt-out values the registry reads', () => { + for (const ownership of ['user', 'org', 'none'] as const) { + const obj = ObjectSchema.create({ name: 'catalog', ownership, fields: { title: { type: 'text' } } }); + expect(obj.ownership).toBe(ownership); + } + }); + + it('leaves ownership undefined when omitted (registry defaults to user-owned)', () => { + const obj = ObjectSchema.create({ name: 'lead', fields: { title: { type: 'text' } } }); + expect(obj.ownership).toBeUndefined(); + }); + + it('rejects the retired `own`/`extend` contribution-kind value with guidance', () => { + expect(() => ObjectSchema.create({ + name: 'demo', + // @ts-expect-error — 'own' is the package-contribution kind, not a record-ownership value + ownership: 'own', + fields: { title: { type: 'text' } }, + })).toThrow(/record-ownership model|registerObject/); + }); + }); + // ADR-0032 "no silent failure" for metadata shape (issue #1535): unknown // top-level keys used to be stripped silently, shipping dead metadata. describe('unknown-key rejection (#1535)', () => { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 2127e365ae..bbf612b4dd 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -663,6 +663,31 @@ const ObjectSchemaBase = z.object({ 'Lifecycle bucket — platform (user CRUD) | config (admin authored) | system (engine-managed) | append-only (audit) | better-auth (identity). UI clients honour the resolved affordance matrix.', ), + /** + * Record-ownership model — who a *record* belongs to. Drives the registry's + * `owner_id` auto-provisioning (`packages/objectql/src/registry.ts` → + * `applySystemFields`): + * + * - `user` (default) — per-record owner: injects the reassignable `owner_id` + * lookup, engaging owner-scoped RLS, "My" views, owner reports and + * first-admin bootstrap. + * - `org` / `none` — no per-record owner (Dataverse-style catalog / junction + * tables); `owner_id` is NOT injected. (Platform-managed tables — `managedBy` + * set, or the `sys_` namespace — skip owner injection regardless.) + * + * NOTE: this is the RECORD-ownership model, DISTINCT from the package + * *contribution* kind (`own` | `extend`, {@link ObjectOwnershipEnum}) that lives + * on the registry's contributor record and is set via `registerObject` — do not + * conflate the two despite the shared word. + */ + ownership: z.enum(['user', 'org', 'none'], { + error: + "`ownership` is the record-ownership model — one of 'user' (default) | 'org' | 'none'. " + + "The package-contribution kind 'own'/'extend' is set via registerObject, not on the object schema.", + }).optional().describe( + "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.", + ), + /** * Per-object override of the generic CRUD affordances that the UI * surfaces. Each flag overrides the default derived from