diff --git a/.changeset/owning-business-unit-injected-jsdoc.md b/.changeset/owning-business-unit-injected-jsdoc.md new file mode 100644 index 0000000000..4d9b268797 --- /dev/null +++ b/.changeset/owning-business-unit-injected-jsdoc.md @@ -0,0 +1,39 @@ +--- +"@objectstack/spec": patch +--- + +Declaration sync for ADR-0117 D1: `owning_business_unit_id` is documented as INJECTED, +while the `business_unit` ownership tier is documented as still unauthorable. + +#5677 landed D1's execution surface in `packages/objectql`: `applySystemFields`' owner +decision became an allow-list and the `owning_business_unit_id` column is now injected on +every ownership-eligible object — i.e. under `ownership: 'user'` and when `ownership` is +omitted, withheld under `'org' | 'none'` and on `managedBy` / `sys_*` tables. The spec's +own prose had not followed: `SystemFieldName.OWNING_BUSINESS_UNIT_ID` still read +"**NOT injected by open-core** — nothing provisions this column today", which had become +false on the default tier every ordinary business object uses. + +The flip is deliberately PARTIAL, because the condition it was written against was +two-part and only one half landed. The JSDoc gated itself on both (a) the `ownership` +enum gaining a `business_unit` member and (b) the `wantOwner` deny-list becoming an +allow-list. Only (b) shipped. `ObjectSchema`'s `ownership` enum is still +`'user' | 'org' | 'none'`, so `ownership: 'business_unit'` remains deliberately rejected +(the enum member is tracked separately). A flat "INJECTED" would have deleted a true +sentence and implied an authorable tier that does not exist — declaring what the runtime +rejects, which is the dangerous direction of ADR-0049, and the inverse of the benign +runtime-ahead-of-docs gap this closes. Both facts are therefore stated together, in every +place that states either: + +- `SystemFieldName.OWNING_BUSINESS_UNIT_ID` — INJECTED, plus an explicit "the column + being injected does not mean the tier is authorable" paragraph, plus the + provisioned-but-inert note (the D2/D4 stamping middleware has not landed, so nothing + writes a value yet). +- `ObjectSchema.systemFields` — the injected-column list gains `owning_business_unit_id`, + with its governing property, its `organization_id`-shaped column definition, and the + same tier caveat. +- `resolveInjectedSystemColumns` — its per-tier table already matched D1; it gains one + note that the `business_unit` row is implemented ahead of the acceptance surface, so + the row is not misread as a claim that the tier is available. + +Documentation only: no schema, no value, and no injected column changes, so no metadata +document changes what it parses to. diff --git a/packages/spec/src/data/injected-system-columns.ts b/packages/spec/src/data/injected-system-columns.ts index c9aab769e4..b5cd44864a 100644 --- a/packages/spec/src/data/injected-system-columns.ts +++ b/packages/spec/src/data/injected-system-columns.ts @@ -121,6 +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. + * * @param def An object definition, or any bare record shaped like one. */ export function resolveInjectedSystemColumns(def: unknown): InjectedSystemColumnPlan { diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 401f016162..d772f62384 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -935,23 +935,32 @@ describe('ObjectSchema.create()', () => { // 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 already registered — - // `SystemFieldName.OWNING_BUSINESS_UNIT_ID` — but the VALUE must not be - // added here yet, because `applySystemFields` decides owner injection with - // a DENY-list (`packages/objectql/src/registry.ts`): + // 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. // - // wantOwner = ownership !== 'org' && ownership !== 'none' && … + // ⚠️ 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. // - // so a fourth value would fall through to the default branch and be - // stamped with `owner_id` — the exact INVERSE of what D1 declares. Adding - // the value alone therefore converts today's loud rejection into a silent - // wrong result: ADR-0049's "spec must not declare what the runtime does not - // enforce", in miniature. + // 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. // - // The enum member lands in the SAME PR that flips `wantOwner` to an - // allow-list and injects the column. Until then this pin holds the line — - // and when that PR arrives, this test failing is the intended signal to - // rewrite it (not to delete the guard). + // 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 diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 865edcb57b..09d044a62f 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1334,6 +1334,28 @@ const ObjectSchemaBase = z.object({ * 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. + * - `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. + * + * The authority on which of these an object actually carries is + * `resolveInjectedSystemColumns` (`@objectstack/spec/data`): `applySystemFields` + * consumes it, and author-time lint reads the same derivation rather than + * re-deriving the conditions from this prose. * * Author-declared fields with the same name always win over injection * (no overwrite). Objects with `managedBy` set (and the `sys_*` namespace) diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index 94a2fe1fa3..ca15a36870 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -89,18 +89,21 @@ describe('SystemFieldName', () => { expect(SystemFieldName.OWNING_BUSINESS_UNIT_ID).toBe('owning_business_unit_id'); }); - // [#4611 / ADR-0117 D1] The BU ownership stamp's canonical spelling is - // reserved here BEFORE open-core injects it, so consumers stop inventing + // [#4611 / ADR-0117 D1] The BU ownership stamp's canonical spelling was + // reserved here BEFORE open-core injected it, so consumers stop inventing // `business_unit_id` / `bu_id` / `dept_id` — the same drift that put // `tenant_id`/`org_id`/`space` into three hand-copied lists (cloud#982). // - // This table is a NAME registry, not the injected set, so a reserved-but-not- - // injected entry is a legitimate row (tenant_id / user_id / deleted_at are the - // precedents). The gate that keeps the classification honest lives in objectql - // (`system-managed-fields-conformance.test.ts`): it pins the public-form - // denylist to exactly (actively-injected ∪ documented-reserved), and this name - // is currently in the RESERVED half. - it('reserves the ADR-0117 business-unit ownership stamp without claiming injection (#4611)', () => { + // 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. + 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 // NOT be confused with `sys_user.primary_business_unit_id`, which is a USER @@ -110,6 +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. + it('should be readonly (const assertion)', () => { const names: readonly string[] = Object.values(SystemFieldName); expect(names).toContain('id'); @@ -135,6 +145,11 @@ describe('SystemFieldName', () => { 'updated_at', 'updated_by', 'owner_id', + // [ADR-0117 D1 / #5677] Injected since the `wantOwner` allow-list landed, + // on the same objects `owner_id` is (default / `ownership: 'user'`). It + // belongs in THIS list, not in the reserved half — that reclassification + // is what the constant's JSDoc flip records. + 'owning_business_unit_id', ]) { expect(names, injected).toContain(injected); } diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index d14e2cfaf9..a5c062483a 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -181,25 +181,44 @@ export const SystemFieldName = { * {@link SystemFieldName.ORGANIZATION_ID} (the tenant wall): *which department * / legal entity does this row belong to*. A lookup to `sys_business_unit`. * - * **NOT injected by open-core** — nothing provisions this column today. The - * NAME is reserved here by ADR-0117 (Accepted, D1/D3 scoped) so the canonical - * spelling has one reference before the injection lands, and so consumers stop - * inventing their own (`business_unit_id`, `bu_id`, `dept_id` …) — the drift - * mode framework#4330 / cloud#982 already paid for with `tenant_id`/`org_id`/ - * `space`. + * **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. * - * It is on the public-form denylist as defense-in-depth: once stamped it is a - * kernel-owned ownership anchor, and a forged value on the anonymous surface - * would move the row behind another department's wall — the same forge class - * `owner_id`/`organization_id` are denied for. Denying it before it exists is - * free and fail-closed; adding it after would be a hole with a release in it. + * ⚠️ 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". * - * When injection lands (ADR-0117 D1 — gated on the `ownership` enum gaining - * its `business_unit` tier AND `applySystemFields`' `wantOwner` deny-list - * becoming an allow-list), this doc must flip to INJECTED and the objectql - * conformance test moves it from the reserved group to the injected group. - * Until then `ownership: 'business_unit'` is deliberately REJECTED by - * `ObjectSchema` — see `packages/spec/src/data/object.test.ts`. + * The column is also 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 + * `applySystemFields`' injection site (`packages/objectql/src/registry.ts`) + * for why that shape presumes nothing about the undecided D2 policy. + * + * The NAME was reserved here by ADR-0117 (Accepted, D1/D3 scoped) ahead of the + * injection so the canonical spelling had one reference from the start, and so + * consumers stopped inventing their own (`business_unit_id`, `bu_id`, + * `dept_id` …) — the drift mode framework#4330 / cloud#982 already paid for + * with `tenant_id`/`org_id`/`space`. + * + * It is on the public-form denylist as defense-in-depth: it is a kernel-owned + * ownership anchor, and a forged value on the anonymous surface would move the + * row behind another department's wall — the same forge class + * `owner_id`/`organization_id` are denied for. Denying it before it existed + * was free and fail-closed; adding it only now that open-core injects it would + * have been a hole with a release in it. * * @see docs/adr/0117-owning-business-unit-record-stamp.md */