Skip to content

Commit fefcd54

Browse files
os-zhuangclaude
andauthored
fix(spec): declare ownership as a first-class ObjectSchema field (#3175) (#3185)
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 "no silent failure" / #1535). So a tested engine opt-out (`ownership: 'org' | 'none'` on catalog / junction tables) could not be authored through the sanctioned `create()` path, the scaffold still emitted a now-invalid `ownership: 'own'`, and the same word was read elsewhere as the unrelated package-contribution kind (`own` / `extend`). Resolves the collision by formalizing the property the engine already honors: - spec: `ObjectSchema` declares `ownership: z.enum(['user','org','none']).optional()`. The registry reads it off the typed schema (no `as any`). A retired `own`/`extend` value fails with guidance distinguishing it from the contribution kind (registerObject). - cli: the `object` scaffold no longer emits the invalid `ownership: 'own'` (owner injection is the default), and `objectstack info` labels the record model with the correct `user` default. - Regenerated the object reference doc; liveness ledger + changeset added. No runtime behavior change: `applySystemFields` and its `owner_id` injection logic are unchanged — this makes the already-honored property legally authorable and typed. Claude-Session: https://claude.ai/code/session_014343Qv6DFAykuAJc9yjANb Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5c0de05 commit fefcd54

8 files changed

Lines changed: 90 additions & 3 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix(spec): declare `ownership` as a first-class ObjectSchema field (#3175)
7+
8+
The object-level record-ownership model — `ownership: 'user' | 'org' | 'none'`,
9+
which drives the registry's `owner_id` auto-provisioning (`applySystemFields`) —
10+
was read by the engine via `(schema as any).ownership` while `ObjectSchema.create()`
11+
**rejected** it as an unknown top-level key (ADR-0032 / #1535). So a tested engine
12+
opt-out (`ownership: 'org' | 'none'` on catalog / junction tables) could not be
13+
set through the sanctioned authoring path, and the same `ownership` word was read
14+
elsewhere as the unrelated package-contribution kind (`own` / `extend`).
15+
16+
- **spec**: `ObjectSchema` now declares `ownership: z.enum(['user','org','none']).optional()`.
17+
Authoring the record-ownership opt-out validates cleanly; the registry reads it
18+
off the typed schema (no `as any`). A retired `ownership: 'own'` / `'extend'`
19+
value fails with guidance pointing at the record-ownership model and noting that
20+
`own`/`extend` is the contribution kind (`registerObject`), not an object-schema value.
21+
- **cli**: the `object` scaffold no longer emits the now-invalid `ownership: 'own'`
22+
(owner injection is the default), and `objectstack info` labels the record model
23+
with the correct `user` default.
24+
25+
No runtime behavior change: `applySystemFields` and its `owner_id` injection logic
26+
are unchanged — this makes the property the engine already honors legally authorable
27+
and consistently typed.

content/docs/references/data/object.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ const result = ApiMethod.parse(data);
105105
| **isSystem** | `boolean` | optional | Is system object (protected from deletion) |
106106
| **abstract** | `boolean` | optional | Is abstract base object (cannot be instantiated) |
107107
| **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. |
108+
| **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. |
108109
| **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. |
109110
| **systemFields** | `'false' \| { tenant?: boolean; owner?: boolean; audit?: boolean }` | optional | Opt out of, or selectively disable, registry-level system-field auto-injection. |
110111
| **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. |

packages/cli/src/commands/generate.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ const ${toCamelCase(name)}: Data.Object = {
2525
name: '${toSnakeCase(name)}',
2626
label: '${toTitleCase(name)}',
2727
pluralLabel: '${toTitleCase(name)}s',
28-
ownership: 'own',
2928
fields: {
3029
name: {
3130
type: 'text',

packages/cli/src/commands/info.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ export default class Info extends Command {
7474
console.log(chalk.bold(' Objects:'));
7575
for (const obj of config.objects) {
7676
const fieldCount = obj.fields ? Object.keys(obj.fields).length : 0;
77-
const ownership = obj.ownership || 'own';
77+
// Record-ownership model (#3175); defaults to user-owned when unset.
78+
const ownership = obj.ownership || 'user';
7879
console.log(
7980
` ${chalk.cyan(obj.name || '?')}` +
8081
chalk.dim(` (${fieldCount} fields, ${ownership})`) +

packages/objectql/src/registry.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,9 @@ export function applySystemFields(
269269
// junction tables). Note this is the SAFE default direction: forgetting the
270270
// opt-out leaves a harmless spare column, whereas the old opt-IN model let
271271
// authors silently ship objects with no working ownership at all.
272-
const ownership = (schema as any).ownership as 'user' | 'org' | 'none' | undefined;
272+
// `ownership` is now a declared ObjectSchema field (record-ownership model),
273+
// so it reads off the typed schema — no `as any` (#3175).
274+
const ownership = schema.ownership;
273275
const wantOwner =
274276
ownership !== 'org' &&
275277
ownership !== 'none' &&

packages/spec/liveness/object.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@
9797
"evidence": "packages/objectql/src/registry.ts:208",
9898
"note": "default perms; ui crudAffordances."
9999
},
100+
"ownership": {
101+
"status": "live",
102+
"evidence": "packages/objectql/src/registry.ts:272",
103+
"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."
104+
},
100105
"access": {
101106
"status": "live",
102107
"evidence": "packages/plugins/plugin-security/src/permission-evaluator.ts",

packages/spec/src/data/object.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,33 @@ describe('ObjectSchema.create()', () => {
880880
})).toThrow();
881881
});
882882

883+
// #3175 — `ownership` (record-ownership model) is now a declared field.
884+
// Before, the registry read it via `(schema as any).ownership` while
885+
// ObjectSchema.create() rejected it as an unknown key; these lock the two ends
886+
// together.
887+
describe('ownership record-model field (#3175)', () => {
888+
it('accepts the record-ownership opt-out values the registry reads', () => {
889+
for (const ownership of ['user', 'org', 'none'] as const) {
890+
const obj = ObjectSchema.create({ name: 'catalog', ownership, fields: { title: { type: 'text' } } });
891+
expect(obj.ownership).toBe(ownership);
892+
}
893+
});
894+
895+
it('leaves ownership undefined when omitted (registry defaults to user-owned)', () => {
896+
const obj = ObjectSchema.create({ name: 'lead', fields: { title: { type: 'text' } } });
897+
expect(obj.ownership).toBeUndefined();
898+
});
899+
900+
it('rejects the retired `own`/`extend` contribution-kind value with guidance', () => {
901+
expect(() => ObjectSchema.create({
902+
name: 'demo',
903+
// @ts-expect-error — 'own' is the package-contribution kind, not a record-ownership value
904+
ownership: 'own',
905+
fields: { title: { type: 'text' } },
906+
})).toThrow(/record-ownership model|registerObject/);
907+
});
908+
});
909+
883910
// ADR-0032 "no silent failure" for metadata shape (issue #1535): unknown
884911
// top-level keys used to be stripped silently, shipping dead metadata.
885912
describe('unknown-key rejection (#1535)', () => {

packages/spec/src/data/object.zod.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,31 @@ const ObjectSchemaBase = z.object({
663663
'Lifecycle bucket — platform (user CRUD) | config (admin authored) | system (engine-managed) | append-only (audit) | better-auth (identity). UI clients honour the resolved affordance matrix.',
664664
),
665665

666+
/**
667+
* Record-ownership model — who a *record* belongs to. Drives the registry's
668+
* `owner_id` auto-provisioning (`packages/objectql/src/registry.ts` →
669+
* `applySystemFields`):
670+
*
671+
* - `user` (default) — per-record owner: injects the reassignable `owner_id`
672+
* lookup, engaging owner-scoped RLS, "My" views, owner reports and
673+
* first-admin bootstrap.
674+
* - `org` / `none` — no per-record owner (Dataverse-style catalog / junction
675+
* tables); `owner_id` is NOT injected. (Platform-managed tables — `managedBy`
676+
* set, or the `sys_` namespace — skip owner injection regardless.)
677+
*
678+
* NOTE: this is the RECORD-ownership model, DISTINCT from the package
679+
* *contribution* kind (`own` | `extend`, {@link ObjectOwnershipEnum}) that lives
680+
* on the registry's contributor record and is set via `registerObject` — do not
681+
* conflate the two despite the shared word.
682+
*/
683+
ownership: z.enum(['user', 'org', 'none'], {
684+
error:
685+
"`ownership` is the record-ownership model — one of 'user' (default) | 'org' | 'none'. " +
686+
"The package-contribution kind 'own'/'extend' is set via registerObject, not on the object schema.",
687+
}).optional().describe(
688+
"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.",
689+
),
690+
666691
/**
667692
* Per-object override of the generic CRUD affordances that the UI
668693
* surfaces. Each flag overrides the default derived from

0 commit comments

Comments
 (0)