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
34 changes: 34 additions & 0 deletions .changeset/adr-0117-owning-business-unit-name-reservation.md
Original file line number Diff line number Diff line change
@@ -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 已将该名收为协议保留的系统列名。
43 changes: 35 additions & 8 deletions docs/adr/0117-owning-business-unit-record-stamp.md
Original file line number Diff line number Diff line change
@@ -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 作用域)
- **动因**: 集团管控场景需要"记录属于哪个部门/法人"成为结构事实,而不是从所有者推导
Expand Down Expand Up @@ -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`。
12 changes: 12 additions & 0 deletions packages/objectql/src/system-managed-fields-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([
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', () => {
Expand Down
52 changes: 52 additions & 0 deletions packages/spec/src/data/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/spec/src/security/public-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const PUBLIC_FORM_SERVER_MANAGED_FIELDS: ReadonlySet<string> = 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',
Expand Down
22 changes: 22 additions & 0 deletions packages/spec/src/system/constants/system-names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
29 changes: 29 additions & 0 deletions packages/spec/src/system/constants/system-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions scripts/adr-anchors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
]
}
Loading