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
27 changes: 27 additions & 0 deletions .changeset/ownership-record-model-field.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions content/docs/references/data/object.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/commands/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})`) +
Expand Down
4 changes: 3 additions & 1 deletion packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/liveness/object.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
27 changes: 27 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down