Skip to content

Commit f724f69

Browse files
os-zhuangclaude
andauthored
feat(spec): ADR-0117 scoped 接受 —— owning_business_unit_id 规范名进入登记处 (#4611) (#5675)
* 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)`;D5/D2 默认值/D8/D4 仍为 Proposed - 新增 SystemFieldName.OWNING_BUSINESS_UNIT_ID,标注 open-core 暂不注入 - PUBLIC_FORM_SERVER_MANAGED_FIELDS 收该名(fail-closed,先于列存在) - 刻意不动 ownership 枚举:registry 的 wantOwner 是排除式判定,加值会让 business_unit 档照常注入 owner_id,与 D1 相反(ADR-0049 禁止的形态); 加 pin 钉住当前的响亮拒绝,防止后人顺手补全 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D * docs(adr): ADR-0117 落地状态表补上后续实施链单号 (#4611) #5677(engine-core:wantOwner 翻正面清单 + 列注入)、#5678(spec:枚举扩展), 并把 D2/D3/D4/D8 单列一行,标明其中四项尚未裁定。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ddb2ec commit f724f69

8 files changed

Lines changed: 196 additions & 8 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): ADR-0117 scoped 接受 —— `owning_business_unit_id` 规范名进入登记处并列入公开表单拒收名单 (#4611)
6+
7+
ADR-0105 D13 的 promotion 工具要求「按子树的 scoping field 回填 `organization_id`」,
8+
但没有任何元数据声明「哪个字段承载 BU 归属」。维护者裁定加速 ADR-0117 补上这一层。
9+
本次落地**协议决定的名字面**,不含运行时注入。
10+
11+
- **ADR-0117 定稿为 `Accepted (D1/D3 scoped)`**:仅 D1(`business_unit` 档与
12+
`owning_business_unit_id` 记录戳的命名与语义)与 D3(`record.organization_id ==
13+
BU(owning_business_unit_id).organization_id` 不变量)进入协议;D5、D2 的默认盖章
14+
策略、D8 的启用门粒度、D4 的权限位仍为 Proposed,合并本 ADR 不构成对这四项的裁定。
15+
- **新增 `SystemFieldName.OWNING_BUSINESS_UNIT_ID`**(`'owning_business_unit_id'`),
16+
明确标注 **open-core 暂不注入** —— 该表是**名字登记处而非注入集合**,`tenant_id` /
17+
`user_id` / `deleted_at` 是既有的三条同类先例。早登记是为了阻断消费方各造一个
18+
`business_unit_id` / `bu_id` / `dept_id`,即 cloud#982`tenant_id`/`org_id`/`space`
19+
付过学费的漂移形态。
20+
- **`PUBLIC_FORM_SERVER_MANAGED_FIELDS` 新增该名**:它是与 `owner_id` /
21+
`organization_id` 同类的归属锚点,一旦盖章,匿名面上被伪造的值会把记录推到别的部门
22+
墙后。在列存在**之前**就拒收是零成本且 fail-closed 的;等列上线后再补名单,中间那个
23+
版本就是一个带着发布号的洞。
24+
25+
**本轮刻意不动 `ownership` 枚举。** `packages/objectql/src/registry.ts``wantOwner`
26+
**排除式**判定(只排除 `'org'` / `'none'`),此刻加入第四个值会让 `business_unit`
27+
照常注入 `owner_id` —— 与 ADR-0117 D1 表格恰好相反,属 ADR-0049「spec 不得声明运行时
28+
不执行的东西」所禁止的形态。因此 `ownership: 'business_unit'` 目前仍被 Zod 响亮拒绝
29+
(并列出 user / org / none 三个合法值),这是**正确**行为,已加 pin 钉住,防止后人
30+
「顺手补全」。枚举值与其注入实现同 PR 落地。
31+
32+
**行为变更提示**:若某应用自行声明了名为 `owning_business_unit_id` 的业务字段并将其
33+
放在匿名公开表单上,该字段自本版本起不再接受客户端提交的值。本仓内无任何此类声明;
34+
ADR-0117 已将该名收为协议保留的系统列名。

docs/adr/0117-owning-business-unit-record-stamp.md

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
# ADR-0117: 记录级业务单元归属(owning business unit)
22

3-
- **状态**: Proposed(提案,待评审)
4-
- **日期**: 2026-07-31
3+
- **状态**: **Accepted (D1/D3 scoped)**(2026-08-05)—— 仅 **D1**`ownership` 新增
4+
`business_unit` 一档与 `owning_business_unit_id` 记录戳的命名与语义)与 **D3**
5+
`record.organization_id == sys_business_unit(owning_business_unit_id).organization_id`
6+
不变量)进入协议。**D5、D2 的默认盖章策略、D8 的启用门粒度、D4 的权限位选择仍为
7+
Proposed**,见文末「未决问题」——合并本 ADR ****构成对这四项的裁定,它们需各自单独评审。
8+
- **日期**: 2026-07-31(提案)/ 2026-08-05(scoped 接受)
9+
- **裁定依据**: #4611(ADR-0105 D13 的「scoping field」无元数据落点)维护者 2026-08-05
10+
选 1 —— 加速 ADR-0117,使 promotion 获得可校验的后置条件。该裁定回答了本 ADR 未决问题
11+
第 2 条(这一档值得新增),其余四条未被触及。
512
- **关联**: ADR-0057(BU 树与深度档位)、ADR-0090(岗位与任职锚点)、ADR-0091(授权时效)、
613
ADR-0103(`managedBy` 写策略)、ADR-0105(租户姿态与 org 作用域)
714
- **动因**: 集团管控场景需要"记录属于哪个部门/法人"成为结构事实,而不是从所有者推导
@@ -186,14 +193,34 @@ unit 档用户眼前抹掉。因此:
186193
- D7 豁免清单需长期维护,遗漏的故障形态(子公司看不到集团主数据)在测试里不显眼,
187194
需要专门的 conformance 用例守住。
188195
189-
## 未决问题(提交评审)
196+
## 落地状态(2026-08-05,scoped 接受时)
197+
198+
被接受的 D1/D3 是**协议决定**,其运行时执行分两步落地,本轮只完成第一步:
199+
200+
| 面 | 本轮(#4611) | 后续 |
201+
|---|---|---|
202+
| 规范名 `owning_business_unit_id` | ✅ 已登记为 `SystemFieldName.OWNING_BUSINESS_UNIT_ID`,标注 **open-core 暂不注入**,并进入公开表单 server-managed 拒收名单(防御纵深,匿名面永不可由客户端提供) | —— |
203+
| `ownership: 'business_unit'` 枚举档 | ❌ **本轮不加**。`packages/objectql/src/registry.ts``wantOwner` 是**排除式**判定(只排除 `org` / `none`),此时加入枚举会让该档照常注入 `owner_id`,与 D1 表格相反 —— 属 ADR-0049 所禁止的「声明而不执行」 | #5678(须与 #5677 同 PR 或严格后置) |
204+
| 列注入(`wantOwner` 翻为正面清单 + 列) | ❌ 未实现 | #5677(engine-core 车道) |
205+
| 盖章策略(D2)/ D3 校验 / D4 守卫 / D8 迁移 | ❌ 未实现,且 D2 默认值等四项**尚未裁定** | 各自单独评审后再开单 |
206+
207+
即:**协议已接受,执行待实现**;在注入落地前,`ownership: 'business_unit'` 会被 Zod
208+
以「合法值为 user / org / none」响亮拒绝,这是**正确**行为,不得「顺手补全」。
209+
210+
## 未决问题
211+
212+
**已裁定**
213+
214+
- ~~2. **`ownership: 'business_unit'` 是否值得新增**~~ —— **是**(#4611 维护者 2026-08-05
215+
裁定选 1)。ERP 场景(库存、台账、部门预算)有真实需求,且 promotion 的可校验后置条件
216+
依赖它。
217+
218+
**仍待评审(合并本 ADR 不视为通过)**
190219
191220
1. **D5 的偏离**:法人归属做成解析规则而非物化列,是否接受?(上游表述为"盖章时
192221
物化"。)若坚持物化,需先决定多态外键 vs 两个可空列。
193-
2. **`ownership: 'business_unit'` 是否值得新增**,还是让这类对象用 `ownership:'user'`
194-
并接受一个名义所有者?(Dataverse 没有对应档位;ERP 场景有真实需求。)
195-
3. **`pinned` 作为默认值**是否正确——平台既有对象以 CRM 形态居多,默认 `pinned`
222+
2. **`pinned` 作为默认值**是否正确——平台既有对象以 CRM 形态居多,默认 `pinned`
196223
会与它们的直觉相反;但对新建的 ERP 类对象,默认 `follow_owner` 更危险。
197-
4. **D8 启用门的粒度**:按对象启用,还是按部署一次性启用?前者迁移更平滑,后者
224+
3. **D8 启用门的粒度**:按对象启用,还是按部署一次性启用?前者迁移更平滑,后者
198225
语义更简单。
199-
5. **是否需要独立的权限位**(如 `allowChangeOwningUnit`)而不是复用 `allowTransfer`
226+
4. **是否需要独立的权限位**(如 `allowChangeOwningUnit`)而不是复用 `allowTransfer`

packages/objectql/src/system-managed-fields-conformance.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,22 @@ describe('[#3058] PUBLIC_FORM_SERVER_MANAGED_FIELDS conformance', () => {
5151
// • tenant_id — legacy/enterprise tenant key (not injected by open-core).
5252
// • is_deleted / deleted_at — soft-delete state, written by the lifecycle/
5353
// trash layer at runtime, never client-suppliable on a public form.
54+
// • owning_business_unit_id — ADR-0117 D1's record-level BU ownership stamp.
55+
// The NAME is reserved (SystemFieldName.OWNING_BUSINESS_UNIT_ID, #4611) but
56+
// open-core does NOT inject it yet: `applySystemFields`' `wantOwner` is a
57+
// DENY-list (only 'org'/'none' opt out), so the `ownership: 'business_unit'`
58+
// tier cannot land until that flips to an allow-list — until then the enum
59+
// value stays rejected by ObjectSchema on purpose. Denied here in advance
60+
// because it is an ownership anchor of exactly the owner_id/organization_id
61+
// forge class, and a denylist entry added only once the column ships is a
62+
// hole with a release in it. MOVE THIS to the injected group (Group A) in
63+
// the same PR that lands the injection — it is derived from live code
64+
// there, so leaving it here would then fail as a stray entry.
5465
const reservedDefenseInDepth = new Set<string>([
5566
SystemFieldName.TENANT_ID, // 'tenant_id'
5667
'is_deleted',
5768
SystemFieldName.DELETED_AT, // 'deleted_at'
69+
SystemFieldName.OWNING_BUSINESS_UNIT_ID, // 'owning_business_unit_id'
5870
]);
5971

6072
it('registry injection actually produces the fields this test reasons about', () => {

packages/spec/src/data/object.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,58 @@ describe('ObjectSchema.create()', () => {
873873
fields: { title: { type: 'text' } },
874874
})).toThrow(/record-ownership model|registerObject/);
875875
});
876+
877+
// [#4611 / ADR-0117] DELIBERATE REJECTION — do not "complete" this enum.
878+
//
879+
// ADR-0117 (Accepted, D1/D3 scoped) reserves a fourth tier,
880+
// `ownership: 'business_unit'`, whose contract is: NO `owner_id`, and a
881+
// kernel-stamped `owning_business_unit_id` instead (D1's table). The
882+
// protocol name is already registered —
883+
// `SystemFieldName.OWNING_BUSINESS_UNIT_ID` — but the VALUE must not be
884+
// added here yet, because `applySystemFields` decides owner injection with
885+
// a DENY-list (`packages/objectql/src/registry.ts`):
886+
//
887+
// wantOwner = ownership !== 'org' && ownership !== 'none' && …
888+
//
889+
// so a fourth value would fall through to the default branch and be
890+
// stamped with `owner_id` — the exact INVERSE of what D1 declares. Adding
891+
// the value alone therefore converts today's loud rejection into a silent
892+
// wrong result: ADR-0049's "spec must not declare what the runtime does not
893+
// enforce", in miniature.
894+
//
895+
// The enum member lands in the SAME PR that flips `wantOwner` to an
896+
// allow-list and injects the column. Until then this pin holds the line —
897+
// and when that PR arrives, this test failing is the intended signal to
898+
// rewrite it (not to delete the guard).
899+
//
900+
// NOTE the direction: 'business_unit' was ALREADY rejected before #4611 —
901+
// this test does not change behaviour, it PINS the pre-existing rejection
902+
// so a later "obvious" enum completion cannot pass unnoticed. It also
903+
// asserts the message still enumerates the three legal values, since that
904+
// enumeration is what tells an author (or an AI) what to write instead.
905+
it('rejects `business_unit` until ADR-0117 D1 injection lands, naming the three legal values (#4611)', () => {
906+
let message = '';
907+
try {
908+
ObjectSchema.create({
909+
name: 'inventory_item',
910+
// @ts-expect-error — reserved by ADR-0117; not a legal value until the injection lands
911+
ownership: 'business_unit',
912+
fields: { sku: { type: 'text' } },
913+
});
914+
throw new Error('expected ObjectSchema.create to reject ownership: business_unit');
915+
} catch (e) {
916+
message = e instanceof Error ? e.message : String(e);
917+
}
918+
919+
expect(message).not.toContain('expected ObjectSchema.create to reject');
920+
// The rejection must keep listing what IS legal — an author pointed at
921+
// ADR-0117 needs to land on 'user' today, not guess.
922+
for (const legal of ['user', 'org', 'none']) {
923+
expect(message, `rejection should enumerate the legal value '${legal}'`).toContain(legal);
924+
}
925+
// And it must not have silently become legal.
926+
expect(message).not.toBe('');
927+
});
876928
});
877929

878930
// ADR-0032 "no silent failure" for metadata shape (issue #1535): unknown

packages/spec/src/security/public-form.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export const PUBLIC_FORM_SERVER_MANAGED_FIELDS: ReadonlySet<string> = new Set([
2727
'id',
2828
// Ownership anchor (OWD/RLS owner scoping keys off it; #3004-class forge).
2929
'owner_id',
30+
// Business-unit ownership anchor (ADR-0117 D1, name reserved by #4611).
31+
// Not injected by open-core yet — denied here BEFORE it exists, because once
32+
// stamped a forged value moves the row behind another department's wall, and
33+
// a denylist entry added after the column ships is a hole with a release in it.
34+
'owning_business_unit_id',
3035
// Tenant anchors — a forged value lands the row in another tenant.
3136
'organization_id',
3237
'tenant_id',

packages/spec/src/system/constants/system-names.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,28 @@ describe('SystemFieldName', () => {
8686
expect(SystemFieldName.TENANT_ID).toBe('tenant_id');
8787
expect(SystemFieldName.USER_ID).toBe('user_id');
8888
expect(SystemFieldName.DELETED_AT).toBe('deleted_at');
89+
expect(SystemFieldName.OWNING_BUSINESS_UNIT_ID).toBe('owning_business_unit_id');
90+
});
91+
92+
// [#4611 / ADR-0117 D1] The BU ownership stamp's canonical spelling is
93+
// reserved here BEFORE open-core injects it, so consumers stop inventing
94+
// `business_unit_id` / `bu_id` / `dept_id` — the same drift that put
95+
// `tenant_id`/`org_id`/`space` into three hand-copied lists (cloud#982).
96+
//
97+
// This table is a NAME registry, not the injected set, so a reserved-but-not-
98+
// injected entry is a legitimate row (tenant_id / user_id / deleted_at are the
99+
// precedents). The gate that keeps the classification honest lives in objectql
100+
// (`system-managed-fields-conformance.test.ts`): it pins the public-form
101+
// denylist to exactly (actively-injected ∪ documented-reserved), and this name
102+
// is currently in the RESERVED half.
103+
it('reserves the ADR-0117 business-unit ownership stamp without claiming injection (#4611)', () => {
104+
expect(SystemFieldName.OWNING_BUSINESS_UNIT_ID).toBe('owning_business_unit_id');
105+
// Guard the naming discipline ADR-0117 D10 spells out: the record stamp must
106+
// NOT be confused with `sys_user.primary_business_unit_id`, which is a USER
107+
// attribute projected from BU membership — different object, different concept.
108+
expect(SystemFieldName.OWNING_BUSINESS_UNIT_ID).not.toBe('primary_business_unit_id');
109+
const names: readonly string[] = Object.values(SystemFieldName);
110+
expect(names).not.toContain('primary_business_unit_id');
89111
});
90112

91113
it('should be readonly (const assertion)', () => {

packages/spec/src/system/constants/system-names.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,35 @@ export const SystemFieldName = {
175175
UPDATED_BY: 'updated_by',
176176
/** Record owner (lookup to user). INJECTED unless `ownership: 'org' | 'none'`. */
177177
OWNER_ID: 'owner_id',
178+
/**
179+
* Record-level business-unit ownership — the middle tier between
180+
* {@link SystemFieldName.OWNER_ID} (a person) and
181+
* {@link SystemFieldName.ORGANIZATION_ID} (the tenant wall): *which department
182+
* / legal entity does this row belong to*. A lookup to `sys_business_unit`.
183+
*
184+
* **NOT injected by open-core** — nothing provisions this column today. The
185+
* NAME is reserved here by ADR-0117 (Accepted, D1/D3 scoped) so the canonical
186+
* spelling has one reference before the injection lands, and so consumers stop
187+
* inventing their own (`business_unit_id`, `bu_id`, `dept_id` …) — the drift
188+
* mode framework#4330 / cloud#982 already paid for with `tenant_id`/`org_id`/
189+
* `space`.
190+
*
191+
* It is on the public-form denylist as defense-in-depth: once stamped it is a
192+
* kernel-owned ownership anchor, and a forged value on the anonymous surface
193+
* would move the row behind another department's wall — the same forge class
194+
* `owner_id`/`organization_id` are denied for. Denying it before it exists is
195+
* free and fail-closed; adding it after would be a hole with a release in it.
196+
*
197+
* When injection lands (ADR-0117 D1 — gated on the `ownership` enum gaining
198+
* its `business_unit` tier AND `applySystemFields`' `wantOwner` deny-list
199+
* becoming an allow-list), this doc must flip to INJECTED and the objectql
200+
* conformance test moves it from the reserved group to the injected group.
201+
* Until then `ownership: 'business_unit'` is deliberately REJECTED by
202+
* `ObjectSchema` — see `packages/spec/src/data/object.test.ts`.
203+
*
204+
* @see docs/adr/0117-owning-business-unit-record-stamp.md
205+
*/
206+
OWNING_BUSINESS_UNIT_ID: 'owning_business_unit_id',
178207
/**
179208
* THE tenant isolation key — a lookup to `sys_organization`. INJECTED unless
180209
* tenancy is disabled for the object; org-scoping populates it on insert and

scripts/adr-anchors.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,13 @@
213213
"ADR-0105"
214214
],
215215
"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."
216+
},
217+
{
218+
"file": "packages/spec/src/system/constants/system-names.ts",
219+
"adrs": [
220+
"ADR-0117"
221+
],
222+
"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."
216223
}
217224
]
218225
}

0 commit comments

Comments
 (0)