diff --git a/.changeset/adr-0117-owning-business-unit-name-reservation.md b/.changeset/adr-0117-owning-business-unit-name-reservation.md new file mode 100644 index 0000000000..bc98a9db92 --- /dev/null +++ b/.changeset/adr-0117-owning-business-unit-name-reservation.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): ADR-0117 scoped 接受 —— `owning_business_unit_id` 规范名进入登记处并列入公开表单拒收名单 (#4611) + +ADR-0105 D13 的 promotion 工具要求「按子树的 scoping field 回填 `organization_id`」, +但没有任何元数据声明「哪个字段承载 BU 归属」。维护者裁定加速 ADR-0117 补上这一层。 +本次落地**协议决定的名字面**,不含运行时注入。 + +- **ADR-0117 定稿为 `Accepted (D1/D3 scoped)`**:仅 D1(`business_unit` 档与 + `owning_business_unit_id` 记录戳的命名与语义)与 D3(`record.organization_id == + BU(owning_business_unit_id).organization_id` 不变量)进入协议;D5、D2 的默认盖章 + 策略、D8 的启用门粒度、D4 的权限位仍为 Proposed,合并本 ADR 不构成对这四项的裁定。 +- **新增 `SystemFieldName.OWNING_BUSINESS_UNIT_ID`**(`'owning_business_unit_id'`), + 明确标注 **open-core 暂不注入** —— 该表是**名字登记处而非注入集合**,`tenant_id` / + `user_id` / `deleted_at` 是既有的三条同类先例。早登记是为了阻断消费方各造一个 + `business_unit_id` / `bu_id` / `dept_id`,即 cloud#982 用 `tenant_id`/`org_id`/`space` + 付过学费的漂移形态。 +- **`PUBLIC_FORM_SERVER_MANAGED_FIELDS` 新增该名**:它是与 `owner_id` / + `organization_id` 同类的归属锚点,一旦盖章,匿名面上被伪造的值会把记录推到别的部门 + 墙后。在列存在**之前**就拒收是零成本且 fail-closed 的;等列上线后再补名单,中间那个 + 版本就是一个带着发布号的洞。 + +**本轮刻意不动 `ownership` 枚举。** `packages/objectql/src/registry.ts` 的 `wantOwner` +是**排除式**判定(只排除 `'org'` / `'none'`),此刻加入第四个值会让 `business_unit` 档 +照常注入 `owner_id` —— 与 ADR-0117 D1 表格恰好相反,属 ADR-0049「spec 不得声明运行时 +不执行的东西」所禁止的形态。因此 `ownership: 'business_unit'` 目前仍被 Zod 响亮拒绝 +(并列出 user / org / none 三个合法值),这是**正确**行为,已加 pin 钉住,防止后人 +「顺手补全」。枚举值与其注入实现同 PR 落地。 + +**行为变更提示**:若某应用自行声明了名为 `owning_business_unit_id` 的业务字段并将其 +放在匿名公开表单上,该字段自本版本起不再接受客户端提交的值。本仓内无任何此类声明; +ADR-0117 已将该名收为协议保留的系统列名。 diff --git a/docs/adr/0117-owning-business-unit-record-stamp.md b/docs/adr/0117-owning-business-unit-record-stamp.md index 2146d77212..ed08274b0d 100644 --- a/docs/adr/0117-owning-business-unit-record-stamp.md +++ b/docs/adr/0117-owning-business-unit-record-stamp.md @@ -1,7 +1,14 @@ # ADR-0117: 记录级业务单元归属(owning business unit) -- **状态**: Proposed(提案,待评审) -- **日期**: 2026-07-31 +- **状态**: **Accepted (D1/D3 scoped)**(2026-08-05)—— 仅 **D1**(`ownership` 新增 + `business_unit` 一档与 `owning_business_unit_id` 记录戳的命名与语义)与 **D3** + (`record.organization_id == sys_business_unit(owning_business_unit_id).organization_id` + 不变量)进入协议。**D5、D2 的默认盖章策略、D8 的启用门粒度、D4 的权限位选择仍为 + Proposed**,见文末「未决问题」——合并本 ADR **不**构成对这四项的裁定,它们需各自单独评审。 +- **日期**: 2026-07-31(提案)/ 2026-08-05(scoped 接受) +- **裁定依据**: #4611(ADR-0105 D13 的「scoping field」无元数据落点)维护者 2026-08-05 + 选 1 —— 加速 ADR-0117,使 promotion 获得可校验的后置条件。该裁定回答了本 ADR 未决问题 + 第 2 条(这一档值得新增),其余四条未被触及。 - **关联**: ADR-0057(BU 树与深度档位)、ADR-0090(岗位与任职锚点)、ADR-0091(授权时效)、 ADR-0103(`managedBy` 写策略)、ADR-0105(租户姿态与 org 作用域) - **动因**: 集团管控场景需要"记录属于哪个部门/法人"成为结构事实,而不是从所有者推导 @@ -186,14 +193,34 @@ unit 档用户眼前抹掉。因此: - D7 豁免清单需长期维护,遗漏的故障形态(子公司看不到集团主数据)在测试里不显眼, 需要专门的 conformance 用例守住。 -## 未决问题(提交评审) +## 落地状态(2026-08-05,scoped 接受时) + +被接受的 D1/D3 是**协议决定**,其运行时执行分两步落地,本轮只完成第一步: + +| 面 | 本轮(#4611) | 后续 | +|---|---|---| +| 规范名 `owning_business_unit_id` | ✅ 已登记为 `SystemFieldName.OWNING_BUSINESS_UNIT_ID`,标注 **open-core 暂不注入**,并进入公开表单 server-managed 拒收名单(防御纵深,匿名面永不可由客户端提供) | —— | +| `ownership: 'business_unit'` 枚举档 | ❌ **本轮不加**。`packages/objectql/src/registry.ts` 的 `wantOwner` 是**排除式**判定(只排除 `org` / `none`),此时加入枚举会让该档照常注入 `owner_id`,与 D1 表格相反 —— 属 ADR-0049 所禁止的「声明而不执行」 | #5678(须与 #5677 同 PR 或严格后置) | +| 列注入(`wantOwner` 翻为正面清单 + 列) | ❌ 未实现 | #5677(engine-core 车道) | +| 盖章策略(D2)/ D3 校验 / D4 守卫 / D8 迁移 | ❌ 未实现,且 D2 默认值等四项**尚未裁定** | 各自单独评审后再开单 | + +即:**协议已接受,执行待实现**;在注入落地前,`ownership: 'business_unit'` 会被 Zod +以「合法值为 user / org / none」响亮拒绝,这是**正确**行为,不得「顺手补全」。 + +## 未决问题 + +**已裁定** + +- ~~2. **`ownership: 'business_unit'` 是否值得新增**~~ —— **是**(#4611 维护者 2026-08-05 + 裁定选 1)。ERP 场景(库存、台账、部门预算)有真实需求,且 promotion 的可校验后置条件 + 依赖它。 + +**仍待评审(合并本 ADR 不视为通过)** 1. **D5 的偏离**:法人归属做成解析规则而非物化列,是否接受?(上游表述为"盖章时 物化"。)若坚持物化,需先决定多态外键 vs 两个可空列。 -2. **`ownership: 'business_unit'` 是否值得新增**,还是让这类对象用 `ownership:'user'` - 并接受一个名义所有者?(Dataverse 没有对应档位;ERP 场景有真实需求。) -3. **`pinned` 作为默认值**是否正确——平台既有对象以 CRM 形态居多,默认 `pinned` +2. **`pinned` 作为默认值**是否正确——平台既有对象以 CRM 形态居多,默认 `pinned` 会与它们的直觉相反;但对新建的 ERP 类对象,默认 `follow_owner` 更危险。 -4. **D8 启用门的粒度**:按对象启用,还是按部署一次性启用?前者迁移更平滑,后者 +3. **D8 启用门的粒度**:按对象启用,还是按部署一次性启用?前者迁移更平滑,后者 语义更简单。 -5. **是否需要独立的权限位**(如 `allowChangeOwningUnit`)而不是复用 `allowTransfer`。 +4. **是否需要独立的权限位**(如 `allowChangeOwningUnit`)而不是复用 `allowTransfer`。 diff --git a/packages/objectql/src/system-managed-fields-conformance.test.ts b/packages/objectql/src/system-managed-fields-conformance.test.ts index d95809d922..02583df148 100644 --- a/packages/objectql/src/system-managed-fields-conformance.test.ts +++ b/packages/objectql/src/system-managed-fields-conformance.test.ts @@ -51,10 +51,22 @@ describe('[#3058] PUBLIC_FORM_SERVER_MANAGED_FIELDS conformance', () => { // • tenant_id — legacy/enterprise tenant key (not injected by open-core). // • is_deleted / deleted_at — soft-delete state, written by the lifecycle/ // trash layer at runtime, never client-suppliable on a public form. + // • owning_business_unit_id — ADR-0117 D1's record-level BU ownership stamp. + // The NAME is reserved (SystemFieldName.OWNING_BUSINESS_UNIT_ID, #4611) but + // open-core does NOT inject it yet: `applySystemFields`' `wantOwner` is a + // DENY-list (only 'org'/'none' opt out), so the `ownership: 'business_unit'` + // tier cannot land until that flips to an allow-list — until then the enum + // value stays rejected by ObjectSchema on purpose. Denied here in advance + // because it is an ownership anchor of exactly the owner_id/organization_id + // forge class, and a denylist entry added only once the column ships is a + // hole with a release in it. MOVE THIS to the injected group (Group A) in + // the same PR that lands the injection — it is derived from live code + // there, so leaving it here would then fail as a stray entry. const reservedDefenseInDepth = new Set([ SystemFieldName.TENANT_ID, // 'tenant_id' 'is_deleted', SystemFieldName.DELETED_AT, // 'deleted_at' + SystemFieldName.OWNING_BUSINESS_UNIT_ID, // 'owning_business_unit_id' ]); it('registry injection actually produces the fields this test reasons about', () => { diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 31fe80a2c3..94a4f980ac 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -873,6 +873,58 @@ describe('ObjectSchema.create()', () => { fields: { title: { type: 'text' } }, })).toThrow(/record-ownership model|registerObject/); }); + + // [#4611 / ADR-0117] DELIBERATE REJECTION — do not "complete" this enum. + // + // 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`): + // + // wantOwner = ownership !== 'org' && ownership !== 'none' && … + // + // 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. + // + // 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). + // + // 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)', () => { + 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', + fields: { sku: { type: 'text' } }, + }); + throw new Error('expected ObjectSchema.create to reject ownership: business_unit'); + } 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']) { + expect(message, `rejection should enumerate the legal value '${legal}'`).toContain(legal); + } + // And it must not have silently become legal. + expect(message).not.toBe(''); + }); }); // ADR-0032 "no silent failure" for metadata shape (issue #1535): unknown diff --git a/packages/spec/src/security/public-form.ts b/packages/spec/src/security/public-form.ts index b340ab4896..a1c4b5d95f 100644 --- a/packages/spec/src/security/public-form.ts +++ b/packages/spec/src/security/public-form.ts @@ -27,6 +27,11 @@ export const PUBLIC_FORM_SERVER_MANAGED_FIELDS: ReadonlySet = new Set([ 'id', // Ownership anchor (OWD/RLS owner scoping keys off it; #3004-class forge). 'owner_id', + // Business-unit ownership anchor (ADR-0117 D1, name reserved by #4611). + // Not injected by open-core yet — denied here BEFORE it exists, because once + // stamped a forged value moves the row behind another department's wall, and + // a denylist entry added after the column ships is a hole with a release in it. + 'owning_business_unit_id', // Tenant anchors — a forged value lands the row in another tenant. 'organization_id', 'tenant_id', diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index 68f36727ad..94a2fe1fa3 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -86,6 +86,28 @@ describe('SystemFieldName', () => { expect(SystemFieldName.TENANT_ID).toBe('tenant_id'); expect(SystemFieldName.USER_ID).toBe('user_id'); expect(SystemFieldName.DELETED_AT).toBe('deleted_at'); + 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 + // `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)', () => { + 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 + // attribute projected from BU membership — different object, different concept. + expect(SystemFieldName.OWNING_BUSINESS_UNIT_ID).not.toBe('primary_business_unit_id'); + const names: readonly string[] = Object.values(SystemFieldName); + expect(names).not.toContain('primary_business_unit_id'); }); it('should be readonly (const assertion)', () => { diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index 28bce71f91..d14e2cfaf9 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -175,6 +175,35 @@ export const SystemFieldName = { UPDATED_BY: 'updated_by', /** Record owner (lookup to user). INJECTED unless `ownership: 'org' | 'none'`. */ OWNER_ID: 'owner_id', + /** + * Record-level business-unit ownership — the middle tier between + * {@link SystemFieldName.OWNER_ID} (a person) and + * {@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`. + * + * 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. + * + * 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`. + * + * @see docs/adr/0117-owning-business-unit-record-stamp.md + */ + OWNING_BUSINESS_UNIT_ID: 'owning_business_unit_id', /** * THE tenant isolation key — a lookup to `sys_organization`. INJECTED unless * tenancy is disabled for the object; org-scoping populates it on insert and diff --git a/scripts/adr-anchors.json b/scripts/adr-anchors.json index ea475df87b..352f5b5d36 100644 --- a/scripts/adr-anchors.json +++ b/scripts/adr-anchors.json @@ -213,6 +213,13 @@ "ADR-0105" ], "invariant": "ADR-0120's posture-portability acceptance test: ONE fixture app, booted under `single | group | isolated`, must materialize BYTE-IDENTICAL unique shapes — 'no index shape reads the posture' is false the moment any two differ, and a posture flip must emit zero drift ops. The cross-posture comparison is a sameness assertion, so it is paired with a positive assertion against EXPECTED_UNIQUE_KEY_PARTS: three identically-empty runs must not read as agreement. S6's un-closed NULL hole is pinned as the honest status quo (zero forced drift), not papered over." + }, + { + "file": "packages/spec/src/system/constants/system-names.ts", + "adrs": [ + "ADR-0117" + ], + "invariant": "`OWNING_BUSINESS_UNIT_ID` is ADR-0117 D1's record-level BU ownership stamp, RESERVED-BUT-NOT-INJECTED (#4611): open-core provisions no such column yet, so the entry looks like dead weight to anyone reading the table alone — and this repo actively hunts dead surface. It is a NAME registry, not the injected set (tenant_id / user_id / deleted_at are the standing precedents), and reserving the spelling early is what stops consumers minting `business_unit_id` / `bu_id` / `dept_id` — the drift cloud#982 paid for with `tenant_id`/`org_id`/`space`. The enum value `ownership: 'business_unit'` is deliberately NOT added alongside it: `applySystemFields`' `wantOwner` is a DENY-list (`registry.ts` — only 'org'/'none' opt out), so a fourth enum member would be stamped with `owner_id`, the exact inverse of D1's table and an ADR-0049 declare-without-enforce violation. Name first, value with its injection. The public-form denylist entry is fail-closed on purpose: an ownership anchor of the owner_id/organization_id forge class must be un-suppliable on the anonymous surface BEFORE the column exists, because adding the denial after it ships is a hole with a release in it." } ] }