diff --git a/.changeset/ownership-business-unit-enum-member.md b/.changeset/ownership-business-unit-enum-member.md new file mode 100644 index 0000000000..dfb9e68c47 --- /dev/null +++ b/.changeset/ownership-business-unit-enum-member.md @@ -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. diff --git a/content/docs/data-modeling/objects.mdx b/content/docs/data-modeling/objects.mdx index f3725f4712..da927646bf 100644 --- a/content/docs/data-modeling/objects.mdx +++ b/content/docs/data-modeling/objects.mdx @@ -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`) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index a4bc434158..8d1100fd70 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -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. | diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index 271905f8b9..3f57bed43b 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -35,7 +35,7 @@ export const SCHEMAS: Record = { { 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' }, ], diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index c694c159a1..27e2996e58 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -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"'); diff --git a/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts b/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts index b77134db9d..39b569f20d 100644 --- a/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts +++ b/packages/metadata-protocol/src/protocol.injected-system-columns.test.ts @@ -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) { diff --git a/packages/objectql/src/registry.test.ts b/packages/objectql/src/registry.test.ts index 7a76cfd005..709eb853d0 100644 --- a/packages/objectql/src/registry.test.ts +++ b/packages/objectql/src/registry.test.ts @@ -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`) 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(); @@ -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(); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 2126812e1e..ab8fc44b11 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -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 // diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 0d7b6b7e8d..e04c15716d 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -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", diff --git a/packages/spec/src/data/injected-system-columns.ts b/packages/spec/src/data/injected-system-columns.ts index b5cd44864a..1ce669ba13 100644 --- a/packages/spec/src/data/injected-system-columns.ts +++ b/packages/spec/src/data/injected-system-columns.ts @@ -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. */ @@ -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; diff --git a/packages/spec/src/data/object-strictness-batch20.test.ts b/packages/spec/src/data/object-strictness-batch20.test.ts index b7bb5a4565..202d84a103 100644 --- a/packages/spec/src/data/object-strictness-batch20.test.ts +++ b/packages/spec/src/data/object-strictness-batch20.test.ts @@ -363,6 +363,11 @@ describe('#4001 批 20 — curation is anchored to the sibling contract that mak // Dataverse-style catalog vs junction table). expect(msg).toContain('org-wide catalog'); expect(msg).toContain('junction/link'); + // Since #5678 a THIRD value skips `owner_id`, for a different reason than + // the other two — the prescription has to say so, or an author reading + // "`'org'` and `'none'` BOTH skip it" concludes those are the only two. + expect(resolveInjectedSystemColumns({ ...OBJ, ownership: 'business_unit' }).owner).toBe(false); + expect(msg).toContain('business_unit'); }); it('`systemFields.ownership` names BOTH ownership anchors — since #5677 the property governs `owning_business_unit_id` too (#6365)', () => { @@ -371,14 +376,22 @@ describe('#4001 批 20 — curation is anchored to the sibling contract that mak expect(msg).toContain('owner_id'); expect(msg).toContain('owning_business_unit_id'); - // The claim, against the authority: across the whole AUTHORABLE enum the - // two anchors move together — injected under `'user'`/omitted, withheld - // under `'org'`/`'none'`. (ADR-0117 D1's fourth tier is the one case that - // splits them, and it is deliberately unauthorable today — #5678.) + // The claim, against the authority: on three of the four tiers the two + // anchors move together — injected under `'user'`/omitted, withheld under + // `'org'`/`'none'`. for (const ownership of [undefined, 'user', 'org', 'none'] as const) { const plan = resolveInjectedSystemColumns({ ...OBJ, ownership }); expect(plan.owningBusinessUnit, `ownership: ${String(ownership)}`).toBe(plan.owner); } + + // …and ADR-0117 D1's fourth tier is the one case that SPLITS them. Since + // #5678 that split is a shape an author can actually reach, not a latent + // engine row — which is precisely why the guidance above has to name BOTH + // anchors rather than treating them as one lever. + accept(ObjectSchema, { ...OBJ, ownership: 'business_unit' }); + const unitOwned = resolveInjectedSystemColumns({ ...OBJ, ownership: 'business_unit' }); + expect(unitOwned.owner).toBe(false); + expect(unitOwned.owningBusinessUnit).toBe(true); }); it('`external.allowWrites` names the DOUBLE opt-in — the datasource half and the object half', () => { diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index b434555884..f1cc49c862 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; // only became visible when tsconfig.test.json put these files in front of tsc // (#5286). import { ObjectSchema, ObjectCapabilities, IndexSchema, ObjectFieldGroupSchema, ObjectExternalBindingSchema, ObjectAccessConfigSchema, LifecycleSchema, TenancyConfigSchema, isTenancyDisabled, resolveCrudAffordances, type ServiceObject } from './object.zod'; +import { resolveInjectedSystemColumns } from './injected-system-columns'; import type { StateMachineValidation } from './validation.zod'; describe('ObjectCapabilities', () => { @@ -960,7 +961,7 @@ describe('ObjectSchema.create()', () => { // 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) { + for (const ownership of ['user', 'business_unit', 'org', 'none'] as const) { const obj = ObjectSchema.create({ name: 'catalog', ownership, fields: { title: { type: 'text' } } }); expect(obj.ownership).toBe(ownership); } @@ -980,64 +981,81 @@ describe('ObjectSchema.create()', () => { })).toThrow(/record-ownership model|registerObject/); }); - // [#4611 / ADR-0117] DELIBERATE REJECTION — do not "complete" this enum. + // [#4611 → #5678 / ADR-0117 D1] The REWRITTEN #4611 pin — direction flipped. // - // ADR-0117 (Accepted, D1/D3 scoped) reserves a fourth tier, - // `ownership: 'business_unit'`, whose contract is: NO `owner_id`, and a - // kernel-stamped `owning_business_unit_id` instead (D1's table). The - // protocol name is registered (`SystemFieldName.OWNING_BUSINESS_UNIT_ID`) - // and, since #5677, open-core INJECTS the column — but the enum VALUE is - // still not added here. + // History, because the flip is the point and a reader who only sees the + // current assertions will re-derive the wrong rule. #4611 pinned the + // OPPOSITE: `ownership: 'business_unit'` REJECTED, and the rejection message + // enumerating exactly three legal values. That pin was correct for its + // window and it named its own expiry — "when #5678 arrives, this test + // failing is the intended signal to REWRITE it (not to delete the guard)". + // This is that rewrite. // - // ⚠️ The ORIGINAL reason recorded here has EXPIRED, and the pin outlived it. - // It read: `applySystemFields` decides owner injection with a DENY-list - // (`wantOwner = ownership !== 'org' && ownership !== 'none' && …`), so a - // fourth value would fall through and be stamped with `owner_id` — the exact - // INVERSE of what D1 declares. #5677 flipped that judgement to an ALLOW-list - // (`packages/objectql/src/registry.ts`, and the shared derivation - // `resolveInjectedSystemColumns` in `./injected-system-columns.ts`), so the - // engine now implements D1's `business_unit` row correctly and the inverse- - // stamping hazard is gone. Do NOT re-derive the old argument from this pin. + // The ordering the two pins encode, end to end: + // #4611 — enum has 3 values; `applySystemFields` used a DENY-list + // (`ownership !== 'org' && ownership !== 'none'`), so a fourth + // value would have been stamped `owner_id` — the exact INVERSE of + // D1's table. Rejecting it was the honest answer. + // #5677 — flipped the judgement to an ALLOW-list in + // `packages/objectql/src/registry.ts` and the shared derivation + // `resolveInjectedSystemColumns` (`./injected-system-columns.ts`). + // The engine now implements D1's `business_unit` row correctly. + // #5678 — THIS change: the acceptance surface catches up. Strictly after + // #5677, never before — a tier the schema emits before the engine + // honours it gets the inverse result on its first appearance. // - // What survives is the plain sequencing fact: extending the acceptance - // surface is its own change, tracked as #5678 (protocol seat). Until it - // lands, the value is rejected, and the rejection is the honest answer — a - // tier an author cannot write is not a tier the schema should advertise. + // What this pin now guards, and why each half is here: + // • the fourth value is ACCEPTED — the D1 declaration surface exists; + // • it resolves to D1's row (`owner_id` ❌ / `owning_business_unit_id` ✅) + // asserted against the injection AUTHORITY, not against prose, so the + // enum member cannot drift away from what the engine does with it; + // • a FIFTH value is still rejected, and the rejection enumerates all four + // legal values — that enumeration is what tells an author (or an AI) + // what to write instead, and it is the half that silently rots when a + // later tier is added to the enum without updating the message. // - // When #5678 arrives, this test failing is the intended signal to REWRITE it - // (not to delete the guard) — assert the fourth value is accepted and that a - // fifth is still rejected naming four legal values. Co-update targets in the - // same PR, both of which currently state "still rejected" in prose: - // • `packages/spec/src/system/constants/system-names.ts` — the - // `OWNING_BUSINESS_UNIT_ID` JSDoc (its "not authorable yet" paragraph); - // • the `systemFields` JSDoc in this directory's `object.zod.ts`. - // - // NOTE the direction: 'business_unit' was ALREADY rejected before #4611 — - // this test does not change behaviour, it PINS the pre-existing rejection - // so a later "obvious" enum completion cannot pass unnoticed. It also - // asserts the message still enumerates the three legal values, since that - // enumeration is what tells an author (or an AI) what to write instead. - it('rejects `business_unit` until ADR-0117 D1 injection lands, naming the three legal values (#4611)', () => { + // Still deliberately ABSENT: anything about the D2 stamping policy, the D4 + // transfer guard, D5 legal-entity resolution or the D8 enablement gate. + // Those four remain undecided in ADR-0117 (Accepted D1/D3 scoped only); the + // column stays provisioned-but-inert, so an object declaring this tier gets + // the COLUMN today and no value in it. Do not read acceptance here as a + // decision on any of them. + it('accepts `business_unit` and resolves it to D1s row — owner_id withheld, unit anchor injected (#4611 → #5678)', () => { + const obj = ObjectSchema.create({ + name: 'inventory_item', + ownership: 'business_unit', + fields: { sku: { type: 'text' } }, + }); + expect(obj.ownership).toBe('business_unit'); + + // The declaration must mean what D1's table says it means. Asserted + // against `resolveInjectedSystemColumns` — the single derivation both + // `applySystemFields` and author-time lint consume. + const plan = resolveInjectedSystemColumns(obj); + expect(plan.owner, 'business_unit is owned by a UNIT, not a person').toBe(false); + expect(plan.owningBusinessUnit).toBe(true); + expect(plan.names.has('owner_id')).toBe(false); + expect(plan.names.has('owning_business_unit_id')).toBe(true); + }); + + it('still rejects a fifth value, and the rejection enumerates all four legal values (#4611 → #5678)', () => { let message = ''; try { ObjectSchema.create({ name: 'inventory_item', - // @ts-expect-error — reserved by ADR-0117; not a legal value until the injection lands - ownership: 'business_unit', + // @ts-expect-error — 'team' is not an ADR-0117 tier; the enum has exactly four members + ownership: 'team', fields: { sku: { type: 'text' } }, }); - throw new Error('expected ObjectSchema.create to reject ownership: business_unit'); + throw new Error('expected ObjectSchema.create to reject ownership: team'); } catch (e) { message = e instanceof Error ? e.message : String(e); } expect(message).not.toContain('expected ObjectSchema.create to reject'); - // The rejection must keep listing what IS legal — an author pointed at - // ADR-0117 needs to land on 'user' today, not guess. - for (const legal of ['user', 'org', 'none']) { + for (const legal of ['user', 'business_unit', 'org', 'none']) { expect(message, `rejection should enumerate the legal value '${legal}'`).toContain(legal); } - // And it must not have silently become legal. expect(message).not.toBe(''); }); }); diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 2c4934c68c..830338ddf6 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1380,22 +1380,30 @@ const ObjectSchemaBase = strictObject( * * - `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.) + * first-admin bootstrap. Also carries `owning_business_unit_id`. + * - `business_unit` — owned by an org UNIT, not by a person (inventory, + * equipment ledgers, departmental budgets): `owner_id` is NOT injected, + * `owning_business_unit_id` IS. [ADR-0117 D1] + * - `org` / `none` — no per-record owner of EITHER kind (Dataverse-style + * catalog / junction tables); neither anchor is injected. (Platform-managed + * tables — `managedBy` set, or the `sys_` namespace — skip ownership + * injection regardless.) + * + * The per-tier authority is `resolveInjectedSystemColumns` + * (`@objectstack/spec/data`) — its table, not this prose, is what + * `applySystemFields` and author-time lint both read. * * 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'], { + ownership: z.enum(['user', 'business_unit', 'org', 'none'], { error: - "`ownership` is the record-ownership model — one of 'user' (default) | 'org' | 'none'. " + + "`ownership` is the record-ownership model — one of 'user' (default) | 'business_unit' | '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.", + "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.", ), /** @@ -1488,24 +1496,21 @@ const ObjectSchemaBase = strictObject( * - `owner_id` — `lookup → sys_user`, auto-provisioned on user-authored * business objects (auto-stamped to the creating user on insert; * reassignable). Governed by the object-level `ownership` property - * (`'user' | 'org' | 'none'`), NOT by `owner` below. + * (`'user' | 'business_unit' | 'org' | 'none'`), NOT by `owner` below. + * Injected under `'user'` and when `ownership` is omitted; withheld under + * `'business_unit' | 'org' | 'none'`. * - `owning_business_unit_id` — `lookup → sys_business_unit`, the * record-level ORG-UNIT ownership tier between `owner_id` (a person) and * `organization_id` (the tenant wall). [ADR-0117 D1, landed in #5677] - * Governed by the same `ownership` property, on the same objects - * `owner_id` is: injected under `'user'` and when `ownership` is omitted, - * withheld under `'org' | 'none'`. Shaped after `organization_id` - * (`readonly` + `hidden` + `system`), not after `owner_id` — it is a - * server-stamped scope anchor. Provisioned but **inert**: the stamping - * middleware (ADR-0117 D2/D4) has not landed, so nothing writes a value - * yet. - * - * ⚠️ D1 also defines a fourth tier, `ownership: 'business_unit'` (owning - * unit, no owning person), which `applySystemFields` already implements — - * but the `ownership` enum below is still `'user' | 'org' | 'none'`, so - * that value is still deliberately REJECTED by this schema (the enum - * member is #5678). The column being injected does NOT mean the tier is - * authorable. + * Governed by the same `ownership` property, but over a WIDER set of + * tiers than `owner_id`: injected under `'user'`, when `ownership` is + * omitted, and under `'business_unit'` (the tier that carries this anchor + * and deliberately no `owner_id`); withheld under `'org' | 'none'`. + * Shaped after `organization_id` (`readonly` + `hidden` + `system`), not + * after `owner_id` — it is a server-stamped scope anchor. Provisioned but + * **inert**: the stamping middleware (ADR-0117 D2/D4) has not landed, so + * nothing writes a value yet — including on `'business_unit'` objects, + * where the declaration is authorable (#5678) while the stamp is not. * * The authority on which of these an object actually carries is * `resolveInjectedSystemColumns` (`@objectstack/spec/data`): `applySystemFields` @@ -1549,7 +1554,10 @@ const ObjectSchemaBase = strictObject( "object-level `ownership` property: `'user'` (or omitted) injects it, while " + "`'org'` and `'none'` BOTH skip it and no `owner_id` is injected at all — " + "`'org'` for an org-wide catalog (Dataverse-style), `'none'` for a junction/link " + - 'table. `systemFields` controls only `tenant` (organization_id) and `audit` ' + + "table. `'business_unit'` skips it too, for a different reason: that tier is " + + 'owned by an org UNIT rather than a person, so it carries ' + + '`owning_business_unit_id` and deliberately no `owner_id` (ADR-0117 D1). ' + + '`systemFields` controls only `tenant` (organization_id) and `audit` ' + '(created_at/created_by/updated_at/updated_by).', ownership: '`ownership` is a TOP-LEVEL object key, not a `systemFields` key — write it ' + diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index ca15a36870..505d1dbd0d 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -97,12 +97,12 @@ describe('SystemFieldName', () => { // The name has since MOVED out of the reserved half: #5677 landed D1's // injection, so `system-managed-fields-conformance.test.ts` (objectql) now // derives it into Group A — the actively-injected side of the public-form - // partition — and the constant's JSDoc says INJECTED. What has NOT moved is - // the acceptance surface: `ObjectSchema`'s `ownership` enum is still - // `'user' | 'org' | 'none'`, so D1's fourth tier `ownership: 'business_unit'` - // remains unauthorable (#5678), pinned in `../../data/object.test.ts`. Both - // halves of that state are asserted below, because reading either one alone - // gets the contract wrong in a different direction. + // partition — and the constant's JSDoc says INJECTED. #5678 then closed the + // remaining half: `ObjectSchema`'s `ownership` enum reads + // `'user' | 'business_unit' | 'org' | 'none'`, so D1's fourth tier is + // authorable, pinned in `../../data/object.test.ts`. The column reaching a row + // and the tier being declarable are now BOTH true — a change from the split + // state this comment used to record, not a restatement of it. it('registers the ADR-0117 business-unit ownership stamp, distinct from the user attribute (#4611)', () => { expect(SystemFieldName.OWNING_BUSINESS_UNIT_ID).toBe('owning_business_unit_id'); // Guard the naming discipline ADR-0117 D10 spells out: the record stamp must @@ -113,12 +113,13 @@ describe('SystemFieldName', () => { expect(names).not.toContain('primary_business_unit_id'); }); - // Fact 2 of the pair — `ownership: 'business_unit'` still being unauthorable — - // is pinned ONCE, by `../../data/object.test.ts`, which asserts the rejection - // and its message. Deliberately not re-asserted here: a second copy of that - // fact is the drift mode this whole file exists to prevent. That pin's comment - // names this constant's JSDoc as a co-update target, so #5678 cannot flip the - // enum and leave "still deliberately REJECTED" standing in a doc comment. + // Fact 2 of the pair — `ownership: 'business_unit'` now being AUTHORABLE, and + // resolving to D1's row (`owner_id` ❌ / `owning_business_unit_id` ✅) — is + // pinned ONCE, by `../../data/object.test.ts`. Deliberately not re-asserted + // here: a second copy of that fact is the drift mode this whole file exists to + // prevent. That pin's comment names this constant's JSDoc as a co-update + // target, which is how #5678 flipped the enum without leaving "still + // deliberately REJECTED" standing in a doc comment. it('should be readonly (const assertion)', () => { const names: readonly string[] = Object.values(SystemFieldName); diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index a5c062483a..5b79339b5f 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -173,7 +173,7 @@ export const SystemFieldName = { UPDATED_AT: 'updated_at', /** User who last modified the record (lookup to user). INJECTED (audit provenance). */ UPDATED_BY: 'updated_by', - /** Record owner (lookup to user). INJECTED unless `ownership: 'org' | 'none'`. */ + /** Record owner (lookup to user). INJECTED unless `ownership: 'business_unit' | 'org' | 'none'`. */ OWNER_ID: 'owner_id', /** * Record-level business-unit ownership — the middle tier between @@ -182,25 +182,25 @@ export const SystemFieldName = { * / legal entity does this row belong to*. A lookup to `sys_business_unit`. * * **INJECTED** (ADR-0117 D1, landed in #5677) — `applySystemFields` provisions - * the column on every ownership-eligible object, i.e. wherever - * {@link SystemFieldName.OWNER_ID} is injected. Withheld on `managedBy` / - * `sys_*` tables and under `ownership: 'org' | 'none'`, exactly like - * `owner_id`. The per-object derivation both the engine and author-time lint - * read is `resolveInjectedSystemColumns` (`@objectstack/spec/data`) — its - * table, not this sentence, is the authority on the per-tier answer. + * the column on every ownership-eligible object. Withheld on `managedBy` / + * `sys_*` tables and under `ownership: 'org' | 'none'`. Note its reach is + * WIDER than {@link SystemFieldName.OWNER_ID}'s rather than identical to it: + * it also covers `ownership: 'business_unit'`, the tier that carries this + * anchor and deliberately no owning person. The per-object derivation both the + * engine and author-time lint read is `resolveInjectedSystemColumns` + * (`@objectstack/spec/data`) — its table, not this sentence, is the authority + * on the per-tier answer. * - * ⚠️ Injected — but the unit-owned TIER is **not authorable yet**, and the two - * facts must be read together. D1's table adds `ownership: 'business_unit'` - * (an owning unit, deliberately no owning person) and `applySystemFields` - * already implements that row, yet the `ownership` enum in - * `packages/spec/src/data/object.zod.ts` is still `'user' | 'org' | 'none'`: - * `ownership: 'business_unit'` is therefore still deliberately REJECTED by - * `ObjectSchema` (pinned in `packages/spec/src/data/object.test.ts`; the enum - * member is #5678). Today the column reaches objects through the DEFAULT - * (`ownership` omitted) and `'user'` tiers only. Do not read "INJECTED" as - * "the business-unit tier is available". + * The unit-owned TIER is **authorable** as of #5678: `ObjectSchema`'s + * `ownership` enum reads `'user' | 'business_unit' | 'org' | 'none'`, so an + * author can declare `ownership: 'business_unit'` and get this column with no + * `owner_id` — D1's row, end to end. It was deliberately REJECTED until then, + * and the ordering mattered: #5677 had to flip the injection judgement to an + * allow-list FIRST, or the tier's first appearance would have been stamped + * `owner_id` — the inverse of what it means. Both directions of that sequence + * are pinned in `packages/spec/src/data/object.test.ts`. * - * The column is also provisioned but **inert**: it is shaped after + * The column is still provisioned but **inert**: it is shaped after * `organization_id` (`readonly`, `hidden`), not after `owner_id`, because it * is a server-stamped scope anchor — and the stamping middleware (ADR-0117 * D2/D4) has not landed, so nothing writes a value yet. See diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index a566dcf8b9..e26fbc4cd4 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -568,8 +568,8 @@ export const accountExtension = defineObjectExtension({ - `priority` controls merge order (default `200`; range `0–999`) - Extensions can add fields, validations, and indexes — but cannot remove them - Do **not** author `ownership: 'extend'` on an object schema — the object-level - `ownership` property is the *record-ownership* enum (`'user' | 'org' | 'none'`), - unrelated to extensions + `ownership` property is the *record-ownership* enum + (`'user' | 'business_unit' | 'org' | 'none'`), unrelated to extensions ---