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
66 changes: 66 additions & 0 deletions .changeset/system-data-import-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
"@objectstack/spec": major
---

feat(spec)!: `system-data` 的桶默认不再包含 CSV `import`,改为按对象显式 opt-in (#4671)

**FROM → TO:`managedBy: 'system-data'` 的默认 affordance 从
`create/import/edit/delete/exportCsv: true` 收窄为
`create/edit/delete/exportCsv: true`,`import: false`。** 需要 CSV 导入向导的对象
显式写一行:

```ts
export const SysHolidayCalendar = ObjectSchema.create({
name: 'sys_holiday_calendar',
managedBy: 'system-data',
userActions: { import: true }, // 明确要这个入口
});
```

`platform` 现在是唯一默认授予 `import` 的桶。其余五个桶(`config`、`system-data`、
`engine-owned`、`append-only`、`better-auth`)一致地把它留给对象自己声明。

## 具体消失的是哪几个 UI 入口

仓内 8 个 `system-data` 对象都不再从桶默认继承导入向导,其中要紧的是三张 RBAC 关联表 ——
它们是整个权限模型的**授予面**:

| 对象 | v17-rc.3 之前的管理台入口 | 本次之后 |
| :--- | :--- | :--- |
| `sys_user_position` | 「CSV 批量绑定用户 ↔ 岗位」 | 不再出现(需显式 opt-in) |
| `sys_user_permission_set` | 「CSV 批量绑定用户 ↔ 权限集」 | 不再出现(需显式 opt-in) |
| `sys_position_permission_set` | 「CSV 批量绑定岗位 ↔ 权限集」 | 不再出现(需显式 opt-in) |

另外 5 个成员(`sys_user_preference`、`sys_approval_delegation`、
`sys_notification_template`、`sys_notification_subscription`、
`sys_notification_preference`)同样从「有导入入口」回到「无导入入口」。

**要恢复其中任意一个,在该对象上加 `userActions: { import: true }` 即可** —— 只动
`import` 这一个动词,create/edit/delete/exportCsv 仍走桶默认,不需要像 v16 那样把整块
`userActions` 抄回来。

## 为什么

授权边界一点没动。`import` 是 **affordance**,只决定 UI 入口是否渲染;CSV 导入写下的每一行
仍然逐条经过 `DelegatedAdminGate`、RLS 与权限集裁决 —— 一个无权手工授予某权限集的 admin,
通过 CSV 同样授不出去(ADR-0103 D5 关于 enforcement 的结论完全不变)。

变的是**杠杆**:逐行点选时一次误操作影响一个人;一份错误 CSV 就是一次批量授权,且没有天然的
复核节奏 —— 而这三张表恰好决定「谁能做什么」。所以批量授予入口应当是一次显式声明,而不是
「被归进了正确的桶」就自动继承的东西。对成批继承桶默认的 AI 生成对象元数据尤其如此:
「没想过 import」的默认结果落在安全侧,打开它则是 reviewer 能看见的一行。

原先「默认含 import」出自 #3355 上更早的 agent 会话(评论带 Claude Code 脚注),不是维护者
拍板;当时的实现 agent 自己标注了这条 security-adjacent 并指出裁决可能未考虑批量绑定权限集
这一具体场景。维护者 2026-08-03 正式裁决收窄,2026-08-06 最终确认。记录见 ADR-0103 的
#4671 addendum。

## 升级影响

**从 v16 升上来的用户:零影响。** v16 的 `managedBy: 'system'` 默认 LOCKED,8 个成员各自用
`userActions: { create, edit, delete }` 重开写入,没有一个重开 `import` —— 所以 CSV 导入在
v16 就解析为 `false`,改名后仍是 `false`。#3355 的 4 个包逐对象 before/after 等价 pin 因此
从「四动词等价 + 一条 import 差异」变成**五动词全等价**,并新增一条 opt-in 可达性 pin。

**已在 v17 rc.1–rc.3 上依赖 `system-data` 默认导入入口的用户:** 加
`userActions: { import: true }`。
52 changes: 43 additions & 9 deletions content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ indexes: [
| :--- | :--- | :--- |
| `isSystem` | `boolean` | System object, protected from deletion (default: `false`) |
| `managedBy` | `enum` | Lifecycle bucket that sets the default CRUD affordances and write policy — `'platform'` (default), `'config'`, `'system-data'`, `'engine-owned'`, `'append-only'`, `'better-auth'`. See [Lifecycle bucket](#lifecycle-bucket-managedby) below. |
| `userActions` | `object` | Per-object override of the CRUD affordances the `managedBy` default implies — `{ create?, edit?, delete?, import?, exportCsv? }`. This is what NARROWS a `system-data` object, or opens a verb on an `engine-owned`/`append-only` one. See [Lifecycle bucket](#lifecycle-bucket-managedby). |
| `userActions` | `object` | Per-object override of the CRUD affordances the `managedBy` default implies — `{ create?, edit?, delete?, import?, exportCsv? }`. This is what NARROWS a `system-data` object, opens CSV `import` on one, or opens a verb on an `engine-owned`/`append-only` one. See [Lifecycle bucket](#lifecycle-bucket-managedby). |
| `sharingModel` | `enum` | Org-Wide Default record visibility (ADR-0055/0056/0090). Canonical four only: `'private'`, `'public_read'`, `'public_read_write'`, `'controlled_by_parent'` (detail visibility derived from its master). The legacy aliases (`'read'`, `'read_write'`, `'full'`) were removed from the enum (ADR-0090 D4) — authoring rejects them. Unset on a custom object resolves to `'private'` (ADR-0090 D1) |
| `ownership` | `enum` | Record-ownership model: `'user'` (default — injects the reassignable `owner_id` lookup, engaging owner-scoped RLS, "My" views and owner reports), `'org'`, or `'none'` (no per-record owner — Dataverse-style catalog / junction tables, skips `owner_id`). Distinct from the package `own`/`extend` contribution kind. |
| `validations` | `ValidationRule[]` | Object-level validation rules (see [Validation](/docs/data-modeling/validation)) |
Expand All @@ -260,7 +260,7 @@ bare bucket string.
| :--- | :--- |
| `platform` | **Default.** User-owned business data — full New / Import / Edit / Delete. |
| `config` | Admin-authored configuration — New / Edit / Delete, no CSV import. |
| `system-data` | Platform-defined schema holding **admin/user-writable data** (RBAC link tables, preferences, messaging config). Full CRUD by default; narrow it with `userActions`. |
| `system-data` | Platform-defined schema holding **admin/user-writable data** (RBAC link tables, preferences, messaging config). New / Edit / Delete / Export by default — **no CSV import**, which is opt-in per object; narrow the rest with `userActions`. |
| `engine-owned` | Runtime rows a platform service owns end to end — generic CRUD hidden, exposed `['get', 'list']` only, **no user writes ever**. |
| `append-only` | Immutable audit trail — View + Export only. |
| `better-auth` | Identity tables owned by the better-auth driver — generic user-context CRUD is suppressed; mutations flow through the auth API (sign-in, invite, reset). |
Expand All @@ -275,15 +275,15 @@ platform-defined schema no tenant may model; they differ on who owns the *rows*:
guard (`assertEngineOwnedWriteAllowed`) rejects user-context generic writes.
- **`system-data`** — the schema is the platform's, the *data* is the admin's or
the user's: the RBAC link tables, `sys_user_preference`,
`sys_approval_delegation`, the messaging config grids. Full CRUD by default,
and no write guard covers the bucket — a writable default has nothing to fail
closed on:
`sys_approval_delegation`, the messaging config grids. New / Edit / Delete /
Export by default, and no write guard covers the bucket — a writable default
has nothing to fail closed on:

```typescript
export const SysUserPreference = ObjectSchema.create({
name: 'sys_user_preference',
// Full CRUD by default — no `userActions` needed. RLS / delegated
// administration is the actual authz.
// New / Edit / Delete / Export by default — no `userActions` needed. RLS /
// delegated administration is the actual authz.
managedBy: 'system-data',
// …
});
Expand All @@ -299,11 +299,45 @@ delete: false }`) and OPENS a verb on `append-only`. Either way it is an
*affordance* declaration; the real authorization for these rows is still
enforced by RLS, delegated administration, and permission sets.

#### CSV import on `system-data` is opt-in

`system-data` is the one writable bucket that does **not** hand out the CSV
bulk-import wizard. `platform` is now the only bucket whose default grants
`import`:

```typescript
export const SysUserPermissionSet = ObjectSchema.create({
name: 'sys_user_permission_set',
managedBy: 'system-data',
// No `userActions` → New / Edit / Delete / Export, but no Import wizard.
});

export const SysHolidayCalendar = ObjectSchema.create({
name: 'sys_holiday_calendar',
managedBy: 'system-data',
// Bulk loading a year of dates from a spreadsheet is the whole point here,
// so this object asks for the wizard explicitly.
userActions: { import: true },
});
```

The reason is leverage, not authorization. The bucket's charter members are the
RBAC link tables — `sys_user_position`, `sys_user_permission_set`,
`sys_position_permission_set` — which are the grant surface of the entire
permission model. Every row a CSV import writes still passes the delegated-admin
gate, RLS and permission-set adjudication one at a time, so an admin who cannot
grant a permission set by hand cannot grant it by file either. What differs is
blast radius: row by row, one misclick affects one person; one wrong CSV is a
bulk grant with no natural review rhythm. Making the wizard a per-object
declaration keeps "nobody thought about import" resolving to the safe answer.

<Callout type="info">
**Upgrading from v16.** `managedBy: 'system'` was retired in protocol 17 —
rename it to `'system-data'`, or run `os migrate meta --from 16`. Because the
new bucket defaults to full CRUD, a `userActions` block that existed only to
re-open create/edit/delete is now redundant and can be deleted.
new bucket defaults to New / Edit / Delete / Export, a `userActions` block that
existed only to re-open create/edit/delete is now redundant and can be deleted.
CSV import needs no attention either way: a v16 `system` object resolved
`import: false`, and so does its renamed `system-data` self.
</Callout>

<Callout type="warn">
Expand Down
63 changes: 62 additions & 1 deletion docs/adr/0103-managedby-write-policy-and-engine-write-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,7 +233,9 @@ are therefore deleted, and `userActions` on this bucket now only NARROWS. The
affordance side-effect is that CSV `import` resolves `true` where it resolved
`false` under the locked default — an affordance change only; every row a CSV
import writes is still adjudicated by the `DelegatedAdminGate` / RLS / permission
sets.
sets. *(This last sentence is **revised** by the #4671 addendum below: `import`
was taken back out of the bucket default before v17 shipped, so it resolves
`false` on both sides and the flip described here never reached a release.)*

**Enforcement is unchanged, as in D5.** `system-data` joins `platform` / `config`
as a bucket neither `ENGINE_OWNED_BUCKETS` (guard) nor `GUARDED_WRITE_BUCKETS`
Expand All @@ -250,3 +252,62 @@ covers the bucket to catch it. `ObjectSchema.create()` therefore **refuses**
`system-data` on an object whose resolved affordances grant no create, edit or
delete — a contradiction with no honest reading, computable from the declaration
alone. Partial narrowing stays legal; only the all-writes-false shape is refused.

---

## Addendum (v17, #4671) — CSV `import` is opt-in on `system-data`

This addendum **revises one line** of the #3355 addendum above: the affordance
side-effect that let CSV `import` resolve `true` on the writable default. Nothing
else about the rename, the enum retirement, the conversion, the enforcement
boundary or the mis-assignment refusal changes.

**Why it was reopened.** The two comments on #3355 that settled "the `system-data`
default is `create/edit/delete/import/exportCsv: true`" carry a Claude Code
footer — they are an earlier **agent** session's adjudication, not the
maintainer's. The implementing agent said so itself, flagged the consequence as
security-adjacent, and noted the adjudication may not have had the specific
scenario in view. It did not.

**The scenario.** Three of the bucket's eight charter members are the RBAC link
tables — `sys_user_position`, `sys_user_permission_set`,
`sys_position_permission_set` — i.e. the grant surface of the whole permission
model. Under v16 all eight resolved `import: false` (locked `system` plus
`userActions` blocks that only ever re-opened create/edit/delete), so the flip
would have put a "bulk-bind permission sets from a spreadsheet" entry point in the
admin console for the first time.

**What is NOT at stake.** No authorization boundary. `import` is an affordance —
it decides whether a UI entry point renders. Every row a CSV import writes still
goes through `DelegatedAdminGate`, RLS and permission-set adjudication one at a
time, and an admin who cannot grant a permission set by hand cannot grant it by
file. D5's enforcement conclusions stand unchanged.

**What is.** Leverage. Row by row, one misclick affects one person; one wrong CSV
is a bulk grant with no natural review rhythm, on precisely the tables that
decide who can do what.

**Decision (maintainer, 2026-08-03; reconfirmed 2026-08-06).**
`CRUD_AFFORDANCE_DEFAULTS['system-data']` grants
`create / edit / delete / exportCsv` and **not** `import`. An object that wants
the wizard declares `userActions: { import: true }`. `platform` is now the only
bucket whose default grants `import`.

Two axes carried it:

- **Long-term soundness.** A batch entry point onto authorization data should be
an explicit declaration, not something eight objects inherit by being filed in
the right bucket. The bucket still describes its members completely — none of
the eight needs a `userActions` block to claw a verb back, so the v16 shape this
rename existed to end does not return.
- **Making AI-written metadata hard to get wrong.** Model-authored object metadata
inherits bucket defaults in bulk. Minimum leverage by default means the result
of "forgot to think about import" lands on the safe side, and turning it on is a
line a reviewer can see.

**Consequence for the #3355 equivalence pins.** The one deliberate
non-equivalence is gone: the rename now moves **no** affordance on **any** verb,
so the per-object pins in `plugin-security`, `service-messaging`,
`plugin-approvals` and `platform-objects` assert full five-verb v16/v17 equality,
plus that the wizard is still reachable via the opt-in — a pin of the surviving
mechanism rather than of an absence.
Loading
Loading