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
47 changes: 47 additions & 0 deletions .changeset/ownership-business-unit-enum-member.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
"@objectstack/spec": minor
"@objectstack/cli": patch
---

ADR-0117 D1 declaration surface: `ownership` gains its fourth tier, `'business_unit'`.

`ObjectSchema.ownership` now reads `'user' | 'business_unit' | 'org' | 'none'`. The new
tier means *owned by an org unit, not by a person*: it injects
`owning_business_unit_id` and deliberately **no** `owner_id` — D1's table, for objects
that belong to a department rather than to someone (inventory, equipment ledgers,
departmental budgets).

```ts
ObjectSchema.create({
name: 'inventory_item',
ownership: 'business_unit', // owner_id ❌ · owning_business_unit_id ✅
fields: { sku: { type: 'text' } },
});
```

The per-tier authority is unchanged and unmoved — `resolveInjectedSystemColumns`
(`@objectstack/spec/data`), which `applySystemFields` and author-time lint both consume:

| `ownership` | `owner_id` | `owning_business_unit_id` |
|---|---|---|
| `'user'` / omitted | ✅ | ✅ |
| `'business_unit'` | ❌ | ✅ |
| `'org'` / `'none'` | ❌ | ❌ |

**Why this is a separate release from #5677.** The engine had to honour the tier before
the schema could emit it. Until #5677, owner injection was a DENY-list
(`ownership !== 'org' && ownership !== 'none'`), so a fourth value would have fallen
through and been stamped `owner_id` — the exact inverse of what the tier means. #5677
flipped that to an allow-list; this change is strictly after it, and the pin recording
both directions of that sequence lives in `packages/spec/src/data/object.test.ts`.

**What this does NOT decide.** ADR-0117 is Accepted for D1/D3 only. The stamping policy
(D2), the transfer guard (D4), legal-entity resolution (D5) and the enablement gate (D8)
remain undecided, so `owning_business_unit_id` stays provisioned-but-**inert**: declaring
`ownership: 'business_unit'` gets you the column and the withheld `owner_id`, and nothing
writes a value into it yet.

Co-updated in the same change so the vocabulary does not drift: `os explain object`'s
schema catalog (`@objectstack/cli`, hand-maintained — it does not derive from the enum),
the `SystemFieldName.OWNING_BUSINESS_UNIT_ID` and `systemFields` JSDoc, and the
data-modeling reference table.
2 changes: 1 addition & 1 deletion content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ indexes: [
| `managedBy` | `enum` | Lifecycle bucket that sets the default CRUD affordances and write policy — `'platform'` (default), `'config'`, `'system-data'`, `'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 NARROWS a `system-data` object, opens CSV `import` on one, or opens a verb on an `engine-owned`/`append-only` one. 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. |
| `ownership` | `enum` | Record-ownership model: `'user'` (default — injects the reassignable `owner_id` lookup, engaging owner-scoped RLS, "My" views and owner reports, plus `owning_business_unit_id`), `'business_unit'` (owned by an org unit rather than a person — injects `owning_business_unit_id` and deliberately **no** `owner_id`; ADR-0117 D1), `'org'`, or `'none'` (no per-record owner of either kind — Dataverse-style catalog / junction tables). Distinct from the package `own`/`extend` contribution kind. |
| `validations` | `ValidationRule[]` | Object-level validation rules (see [Validation](/docs/data-modeling/validation)) |

### Lifecycle bucket (`managedBy`)
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 @@ -116,7 +116,7 @@ const result = ApiMethod.parse(data);
| **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-data' \| 'engine-owned' \| 'append-only' \| 'better-auth'>` | optional | Lifecycle bucket — platform (user CRUD) \| config (admin authored) \| system-data (platform-defined schema, admin/user-writable data) \| 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. |
| **ownership** | `Enum<'user' \| 'business_unit' \| 'org' \| 'none'>` | optional | Record-ownership model: user (default — injects reassignable owner_id plus owning_business_unit_id) \| business_unit (unit-owned: owning_business_unit_id only, no owner_id) \| org \| none (no per-record owner, neither anchor). Distinct from the package own/extend contribution kind. |
| **userActions** | `{ create?: boolean; import?: boolean; edit?: boolean \| object; delete?: boolean \| 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. |
| **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. |
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/explain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const SCHEMAS: Record<string, SchemaInfo> = {
{ name: 'label', type: 'string', description: 'Human-readable display name' },
{ name: 'pluralLabel', type: 'string', description: 'Plural display name' },
{ name: 'description', type: 'string', description: 'Documentation for the object' },
{ name: 'ownership', type: "'user' | 'org' | 'none'", description: 'Record-ownership model: user (default, injects a reassignable owner_id) | org | none (no per-record owner). Distinct from the package own/extend contribution kind.' },
{ name: 'ownership', type: "'user' | 'business_unit' | 'org' | 'none'", description: 'Record-ownership model: user (default, injects a reassignable owner_id plus owning_business_unit_id) | business_unit (owned by an org unit, not a person: owning_business_unit_id only, no owner_id) | org | none (no per-record owner). Distinct from the package own/extend contribution kind.' },
{ name: 'enable', type: 'ObjectCapabilities', description: 'Feature flags (trackHistory, apiEnabled, etc.)' },
{ name: 'icon', type: 'string', description: 'Icon identifier for UI display' },
],
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,23 @@ describe('os explain — schema catalog accuracy', () => {
// `ownership` field as the package-contribution kind (`"own" | "extend"`),
// which is a DISTINCT concept (`ObjectOwnershipEnum`, set via registerObject).
// The real `ObjectSchema.ownership` field is the record-ownership model —
// `z.enum(['user','org','none'])` — see packages/spec/src/data/object.zod.ts.
// `z.enum(['user','business_unit','org','none'])` — see
// packages/spec/src/data/object.zod.ts.
//
// The token set is asserted EXACTLY, and that exactness is the point: this
// catalog (`packages/cli/src/commands/explain.ts`) is hand-maintained and does
// NOT derive from the spec enum, so a spec-side enum change that stops here is
// invisible to any review that only reads `packages/spec`. #5678 (ADR-0117 D1's
// fourth tier, `'business_unit'`) is the case that proved it — without the
// co-update, `os explain object` keeps telling authors a legal tier does not
// exist. Widen this set only together with the enum it mirrors.
it('documents object.ownership as the record-ownership model, not the own/extend contribution kind (#3244)', () => {
const ownership = SCHEMAS.object.optional.find((f) => f.name === 'ownership');
expect(ownership, 'object schema should document an `ownership` field').toBeDefined();

// The type string must enumerate exactly the record-ownership enum values.
const tokens = (ownership!.type.match(/'[^']+'|"[^"]+"/g) ?? []).map((t) => t.slice(1, -1));
expect(new Set(tokens)).toEqual(new Set(['user', 'org', 'none']));
expect(new Set(tokens)).toEqual(new Set(['user', 'business_unit', 'org', 'none']));

// …and must never regress back to the contribution-kind values.
expect(ownership!.type).not.toBe('"own" | "extend"');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,17 @@ describe('[#6562] the opt-out rows are the spec derivation’s, re-derived nowhe
['ownership: none — likewise',
{ ownership: 'none' },
['created_at', 'created_by', 'updated_at', 'updated_by', 'organization_id']],
// Not an opt-out row — the one tier that SPLITS the two anchors
// (ADR-0117 D1). It belongs in this table anyway: the read surface must
// report `owner_id` as ABSENT on a unit-owned object, and serving a
// person-owner on a row that has none is the same lie the rows above
// guard against, pointing the other way. Authorable since #5678, so this
// shape now arrives from authored metadata rather than only from an
// engine-side derivation no author could reach.
['ownership: business_unit — the unit anchor WITHOUT owner_id',
{ ownership: 'business_unit' },
['created_at', 'created_by', 'updated_at', 'updated_by', 'organization_id',
'owning_business_unit_id']],
];

for (const [label, opts, expected] of cases) {
Expand Down
20 changes: 9 additions & 11 deletions packages/objectql/src/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,22 +754,20 @@ describe('applySystemFields', () => {
// org ❌ ❌
// none ❌ ❌
//
// ⚠️ `ownership: 'business_unit'` is NOT a legal ObjectSchema value yet —
// the spec enum gains it in #5678, strictly AFTER this PR (that ordering is
// #5677's whole point: the engine must honour the tier before the schema
// can emit it, or the tier's first appearance gets the inverse result).
// These fixtures therefore duck-type the schema, exactly as every other
// opt-out case in this suite already does — `applySystemFields` takes a
// `ServiceObject`, and the registry reads `ownership` as a value, not as a
// Zod-parsed enum. When #5678 lands, the `as any` here can be dropped
// without changing a single assertion.
// `ownership: 'business_unit'` is a legal ObjectSchema value as of #5678,
// which landed strictly AFTER #5677 — that ordering is #5677's whole point:
// the engine must honour the tier before the schema can emit it, or the
// tier's first appearance gets the inverse result. These fixtures used to
// duck-type the schema through `as any` for exactly that gap; the casts are
// gone now that `ServiceObject` (`z.input<typeof ObjectSchemaBase>`) admits
// the value, and not one assertion below changed when they were removed.
describe('[ADR-0117 D1] owning_business_unit_id injection', () => {
it("does NOT inject owner_id for ownership: 'business_unit' — but DOES inject owning_business_unit_id", () => {
// THE regression this issue exists to prevent. Under the old
// deny-list this object was stamped `owner_id` (a person) even
// though the tier's entire meaning is "owned by a unit, not a
// person" — see #4611's one-shot probe.
const unitOwned: any = { ...baseLead, name: 'inventory_item', ownership: 'business_unit' };
const unitOwned: ServiceObject = { ...baseLead, name: 'inventory_item', ownership: 'business_unit' };
const out = applySystemFields(unitOwned, { multiTenant: false });

expect(out.fields.owner_id).toBeUndefined();
Expand All @@ -784,7 +782,7 @@ describe('applySystemFields', () => {
});

it("injects BOTH anchors on the default tier and on an explicit ownership: 'user'", () => {
for (const schema of [baseLead, { ...baseLead, ownership: 'user' } as any]) {
for (const schema of [baseLead, { ...baseLead, ownership: 'user' } satisfies ServiceObject]) {
const out = applySystemFields(schema, { multiTenant: false });
expect(out.fields.owner_id).toBeDefined();
expect(out.fields.owning_business_unit_id).toBeDefined();
Expand Down
21 changes: 13 additions & 8 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,26 +361,31 @@ export function applySystemFields(
// per-record owner is meaningless: any platform-managed table (`managedBy`
// is set — config/append-only/system/platform; `better-auth` already
// returned above), the `sys_*` namespace, or an explicit opt-out via
// `ownership: 'org' | 'none'` on the schema (Dataverse-style — catalog /
// junction tables). Note this is the SAFE default direction: forgetting the
// `ownership: 'business_unit' | 'org' | 'none'` on the schema (Dataverse-style
// — catalog / junction tables, plus D1's unit-owned tier, which takes the BU
// anchor instead). 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.
// `ownership` is a declared ObjectSchema field (record-ownership model), read
// off the typed schema by the plan — no `as any` (#3175).
//
// [ADR-0117 D1 / #5677] The plan treats the value as a `string`, not the
// enum, on purpose. The spec enum is `'user' | 'org' | 'none'` TODAY; D1's
// fourth tier `'business_unit'` lands in #5678 — the engine must recognise the
// tier BEFORE the schema can emit it, or the tier's first appearance would be
// judged by a no-overlap literal test and get the INVERSE of what D1 declares.
// enum, on purpose: it is also called on pre-parse input. That widening is
// what let the engine recognise D1's fourth tier `'business_unit'` BEFORE the
// schema could emit it — the ordering #5677 → #5678 exists to guarantee, since
// a tier the schema emits first would be judged by a no-overlap literal test
// and get the INVERSE of what D1 declares. As of #5678 the spec enum reads
// `'user' | 'business_unit' | 'org' | 'none'` and the tier is authorable.
//
// [ADR-0117 D1 / #5677] The ownership decision is a POSITIVE LIST, deliberately
// — it used to read `ownership !== 'org' && ownership !== 'none'`, i.e. a
// DENY-list, so ANY value outside the two exclusions fell through to "inject
// `owner_id`". That default is safe only while the enum has exactly three
// `owner_id`". That default was safe only while the enum had exactly three
// members: D1's `business_unit` tier means "owned by a UNIT, not a person"
// (`owner_id` ❌, `owning_business_unit_id` ✅), and under the deny-list it
// would have been stamped with `owner_id` — the exact inverse.
// would have been stamped with `owner_id` — the exact inverse. The same
// argument applies to any FIFTH tier a later ADR adds, which is why the list
// stays positive rather than being "completed" to the current enum.
//
// The `sys_*` / `managedBy` ineligibility and the per-tier table
//
Expand Down
2 changes: 1 addition & 1 deletion packages/spec/liveness/object.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@
"status": "live",
"verifiedAt": "2026-07-30",
"evidence": "packages/objectql/src/registry.ts:292 (applySystemFields reads schema.ownership)",
"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. Evidence line refreshed 2026-07-30 (was :272, drifted)."
"note": "#3175 record-ownership model. applySystemFields injects the reassignable owner_id lookup by default (ownership:'user'); 'org'|'none' opt out of BOTH ownership anchors (Dataverse-style catalog/junction tables); ADR-0117 D1's 'business_unit' tier (engine #5677, enum member #5678) takes owning_business_unit_id and deliberately no owner_id. Per-tier authority is resolveInjectedSystemColumns (spec/data). Proven in objectql/src/registry.test.ts. Evidence line refreshed 2026-07-30 (was :272, drifted)."
},
"access": {
"status": "live",
Expand Down
24 changes: 13 additions & 11 deletions packages/spec/src/data/injected-system-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,15 @@ export interface InjectedSystemColumnPlan {
* ownership tier inherits `owner_id` by accident, which for a unit-owned tier
* is the exact inverse of what it means.
*
* ⚠️ The `ownership: 'business_unit'` row is implemented here AHEAD of the
* acceptance surface: `ObjectSchema`'s `ownership` enum is still
* `'user' | 'org' | 'none'`, so that value cannot be authored today and is
* deliberately rejected (the enum member is #5678). The row exists so the tier's
* first appearance is judged by D1's table rather than by a deny-list default —
* it is not a claim that the tier is available. This function is deliberately
* typed on `string` rather than the enum for exactly that reason; see the
* `ownership` read below.
* The `ownership: 'business_unit'` row was implemented here AHEAD of the
* acceptance surface (#5677 before #5678), so that the tier's first appearance
* would be judged by D1's table rather than by a deny-list default. #5678 has
* since landed the enum member, so the row is now reachable from authored
* metadata: `ObjectSchema`'s `ownership` enum reads
* `'user' | 'business_unit' | 'org' | 'none'`. The `ownership` read below stays
* typed on `string` rather than the enum — this function accepts any bare record
* shaped like an object definition (`def: unknown`), including pre-parse input,
* so it must not presume a Zod-narrowed value.
*
* @param def An object definition, or any bare record shaped like one.
*/
Expand Down Expand Up @@ -162,9 +163,10 @@ export function resolveInjectedSystemColumns(def: unknown): InjectedSystemColumn
// Platform-managed tables and the `sys_*` namespace never carry a per-record
// ownership anchor, whichever tier is declared.
const ownershipEligible = !managedBy && !name.startsWith('sys_');
// Widened to `string` on purpose (ADR-0117 D1 / #5677): the spec enum is
// `'user' | 'org' | 'none'` today and the `business_unit` tier lands later,
// so the engine must already recognise it — see the injection site.
// Widened to `string` on purpose (ADR-0117 D1 / #5677): this function takes
// `unknown` and is called on pre-parse input as well as on parsed schemas, so
// it reads the value rather than a Zod-narrowed enum. (It was ALSO how the
// `business_unit` tier could be honoured before #5678 made it authorable.)
const ownership: string | undefined =
typeof obj.ownership === 'string' ? obj.ownership : undefined;

Expand Down
Loading
Loading