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
39 changes: 39 additions & 0 deletions .changeset/owning-business-unit-injected-jsdoc.md
Original file line numberDiff line numberDiff line change
@@ -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.
9 changes: 9 additions & 0 deletions packages/spec/src/data/injected-system-columns.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 {
Expand Down
37 changes: 23 additions & 14 deletions packages/spec/src/data/object.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
22 changes: 22 additions & 0 deletions packages/spec/src/data/object.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand Down
33 changes: 24 additions & 9 deletions packages/spec/src/system/constants/system-names.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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');
Expand All@@ -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);
}
Expand Down
53 changes: 36 additions & 17 deletions packages/spec/src/system/constants/system-names.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*/
Expand Down
Loading