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
81 changes: 81 additions & 0 deletions .changeset/adr0120-isolated-install-gate-and-truth-sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
---
"@objectstack/types": minor
"@objectstack/cloud-connection": minor
"@objectstack/lint": minor
"@objectstack/cli": minor
"@objectstack/spec": patch
"@objectstack/driver-sql": patch
---

feat(types,cloud-connection,lint,cli): ADR-0120 17.x 收尾 —— `isolated` 安装期姿态硬门(D5e)、D5c 重拼写 advisory、成文契约扫荡与三姿态 conformance (#5081)

ADR-0120 17.x 波的第三块,也是最后一块。前两块已在 main 上:#5212(driver 侧
D3+D4 —— `COALESCE(organization_id, '__global__')` 物化、drift 两侧同步、重复预检)
与 #5208(spec 词汇 `'organization'` + D5a/D5b lint)。本次补齐三件事:安装期的
姿态决策点、剩余的成文契约、以及把「一个 app 包跑遍三种姿态」从假设变成测试。

**D5e —— 装进 `isolated` 环境时的硬门。** 词汇本身是姿态无关的:作者说的是业务
边界(`'organization'` 一个组织一份 / `'global'` 整个安装一份),没有任何索引形状
读姿态。唯一的残留在一个方向上:`isolated` 下组织就是**不同客户**,此时 app 业务
对象上的 `'global'` 唯一既跨客户过度约束,又变成跨客户的存在性预言机(S10/S14)。
维护者裁定这是**硬门而非 advisory**:把带 `'global'` 唯一(非 `sys` 对象)的 app
装进 `isolated` 环境会**停下来并逐索引列出**,安装者(通常是 AI agent)要么确认它
确实是平台级的,要么改写为 `'organization'`;确认按 ADR-0104 attestation 风格
留痕在安装清单里(`InstalledManifestEntry.globalUniqueAttestation` —— 确认了什么、
谁确认的、何时、在哪个姿态下问的),**之后不复问**。

- 停下的安装**什么都不留**:先于 hot-register 和任何 ledger 写入,所以作者改完
元数据可以直接重试,不需要先卸载。
- 逐索引确认是有牙齿的:`confirmGlobalUniques` 收 `true` 或明确的 id 数组,只确认
其中一条仍会在剩下的那条上停住。
- 升级引入的**新**约束会被问,老的答案继续算数。
- 另一个姿态下给出的确认**不算同意** —— `isolated` 那个问题在 `single` 下从未被
问过,所以按「未确认」处理(唯一不会静默放行跨客户约束的方向)。
- ⛔ **永不做成启动期告警**(#4884 纪律)。boot 时的 rehydrate 不评估此门;门够不到
的两类存量 —— 门禁上线前的安装、装后姿态变更的环境 —— 由 `os doctor` 与
`os migrate plan` 的 advisory 形态覆盖。

判定里有三条是承重的,别「简化」掉:声明索引上的裸 `unique: true` **算**(D1 说它
就是 `'global'` 的位置式拼写,排除它等于让整个 17.x 可以靠拼写绕过);字段级
`true` **不算**(它是 `'organization'`,永久合法);`sys_`/`base_` 对象**不算**
(S5 那批引擎幂等键天然就是平台级的,每次安装都问一遍就是 #4884 的误报类)。

CLI: `os package install` 新增 `--confirm-global-uniques`,并把 409 渲染成可读的
逐条清单而不是一句 "Install failed (409)"。

**D5c —— 遗留手写组织复合索引的 advisory。** 新规则
`unique/legacy-organization-composite`:声明的唯一索引自己列出了组织列
(`{ fields: ['name','organization_id'], unique: true }`)—— 这是词汇出现之前手写
per-organization 的写法。它读起来像「每组织唯一」,物化出来却是普通复合索引,而
SQL UNIQUE 是 NULL-distinct 的:组织列为 NULL 的行上它**什么都不约束**(#5030),
在单组织部署上那就是每一行。改写成 `unique: 'organization'`(`fields` 原样保留,
driver 会把已列出的组织列**就地**变成 NULL-safe 形式)正是补上这个洞的动作。
**永远只是 advisory,永远不自动修**:老拼写永久合法、零强制 drift,而 opt-in 是
真实的物理收紧,要走 D4 的 `recreate_index` + 重复预检。

**D6 —— 成文契约扫荡。** `content/docs/data-modeling/indexing.mdx` 的
§Two ways to say "unique" 全节按新词汇重写(含 `os:check` 代码块);
`content/docs/protocol/objectql/schema.mdx` 的 §Uniqueness and tenancy 重写为
§Uniqueness and scope —— 其中那句「单租户部署不受影响,租户列是常量,复合索引
退化为单列索引」是 #5030 **证伪过的原话**,现已替换为 D3 的 NULL-safe 事实;
`content/docs/deployment/cli.mdx` 的 `replace_unique_index` / `recreate_index`
条目补上 NULL-safe 形状与重复预检;`content/docs/references/**` 经
`gen:schema && gen:docs` 再生成,未手改。

按 ADR-0120 Resolved #2 的非规范性引导(官方示例/脚手架/生成器在新代码中输出
显式拼写),`skills/objectstack-data/**` 的索引与校验规则整体扫过:声明索引一律
说清 scope,并新增一节完整讲 `'organization'` 的 NULL-safe 语义与「永远不写姿态」。
顺带修掉那里长期使用的 `tenant_id` —— 平台的列叫 `organization_id`。
`examples/**`、`create-objectstack` 模板与 `os generate` 经核查**根本没有声明任何
唯一约束**,故无可扫;这是核查结论,不是遗漏。

**三姿态 conformance(ADR §Acceptance tests)。** 同一个 fixture app 在
`single | group | isolated` 三姿态下启动,逐 S 行用**真实的违规插入**断言 enforcement
(S1/S2/S3/S4/S5/S6/S7/S8/S9/S11/S12),并逐姿态捕获物化出的索引键,断言三者
**逐字节相同** —— 「没有任何索引形状读姿态」这句话一旦有两者不同就是假的。相同性
断言配了一条正向断言(对着期望的键形状),这样「三次都什么都没建」不会读成「一致」。
外加 ADR 只要的那一条 transition smoke:在 `single` 下建库、`isolated` 下重新打开,
drift op 为零。

对既有部署的影响:除新增的安装期确认外,本次不改变任何已有物化行为。字段级
`unique: true` 一如既往合法。
2 changes: 1 addition & 1 deletion content/docs/data-modeling/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Email address with built-in format validation.
| `maxLength` | `number` | — | Maximum character length |

```typescript
{ name: 'email', label: 'Email', type: 'email', required: true, unique: true }
{ name: 'email', label: 'Email', type: 'email', required: true, unique: 'organization' }
```

### `url`
Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const Contact = ObjectSchema.create({
fields: {
first_name: Field.text({ label: 'First Name', required: true }),
last_name: Field.text({ label: 'Last Name', required: true }),
email: Field.email({ label: 'Email', unique: true }),
email: Field.email({ label: 'Email', unique: 'organization' }),
phone: Field.phone({ label: 'Phone' }),
birth_date: Field.date({ label: 'Date of Birth' }),
is_active: Field.boolean({ label: 'Active', defaultValue: true }),
Expand Down
133 changes: 100 additions & 33 deletions content/docs/data-modeling/indexing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,63 +12,130 @@ Optimize query performance with indexes:
indexes: [
// Single field index
{ fields: ['name'], unique: false },

// Unique index
{ fields: ['email'], unique: true },


// Unique index — one holder per organization
{ fields: ['email'], unique: 'organization' },

// Unique index — one holder across the whole installation
{ fields: ['hostname'], unique: 'global' },

// Compound index
{ fields: ['type', 'is_active'], unique: false },

// Lookup field index
{ fields: ['owner'], unique: false },
]
```

## Two ways to say "unique" — and they mean different things
## Two ways to say "unique" — one vocabulary for both

Uniqueness can be declared in two places, and the choice is not cosmetic:
Uniqueness can be declared in two places: on a field, or as a declared index.
Both use the same word to say **which boundary the value must be unique
within** ([ADR-0120](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0120-unique-scope-vocabulary-and-null-safe-tenant-uniqueness.md)):

| Declaration | Materializes as | Scope |
| Scope | Meaning | Materializes as |
|:---|:---|:---|
| Field-level `unique: true` | `(organization_id, field)` | Unique **within** an organization |
| Field-level `unique: 'global'` | `(field)` | Platform-wide |
| Declared index `{ fields: ['email'], unique: true }` | `(email)` — exactly the listed columns | Platform-wide |
| `unique: 'organization'` | One holder **per organization** | `(COALESCE(organization_id, '__global__'), …fields)` |
| `unique: 'global'` | One holder across the **whole installation** | exactly the listed column(s) |

```typescript
// Field level — the scope is the same word in both places
email: Field.email({ unique: 'organization' }), // one contact per org
hostname: Field.text({ unique: 'global' }), // one holder, platform-wide

A field-level `unique: true` is **tenant-scoped**. It has no syntax for a
composite, so the platform supplies the tenant column for you — which is what
a multi-tenant application almost always wants: two organizations may each
have a contact `john@acme.com`.
indexes: [
// per organization — you do NOT list organization_id yourself
{ fields: ['department', 'code'], unique: 'organization' },
// platform-wide — exactly the columns you list
{ fields: ['source', 'dedup_key'], unique: 'global' },
]
```

A **declared index is taken verbatim**. No tenant column is injected, because
many declared indexes are legitimately platform-wide (a DNS hostname, a
reserved slug, an external provider id). To scope one per tenant, list the
column yourself: `{ fields: ['organization_id', 'email'], unique: true }`.
Two rules make the choice safe to write and safe to deploy:

1. **You state a business boundary, never a deployment shape.** The same app
package runs unmodified under every tenancy posture — no index shape reads
the posture, so moving a deployment between postures has no schema
consequence.
2. **`'organization'` is NULL-safe.** Rows with no organization (platform rows,
and *every* row on a single-organization deployment) collapse into one
platform bucket and are unique among themselves. Without this a plain
`(organization_id, …)` composite enforces nothing on those rows, because SQL
`UNIQUE` treats every `NULL` as distinct.

<Callout type="info">
**Bare `unique: true`.** On a **field** it means `'organization'` and stays
valid indefinitely — `email: Field.email({ unique: true })` is correct, and the
explicit spelling is simply preferred in new code.

On a **declared index** it is the deprecated spelling of `'global'`: it
materializes over exactly the listed columns. Because that reads like the
field-level meaning but does the opposite, `os lint` / `os build` / `os validate`
report it as `unique/unscoped-declared-index`, and it is rejected outright at
protocol 18. State the scope.
</Callout>

<Callout type="warn">
**Do not declare both on the same column.** The stricter one wins physically,
so the platform-wide index enforces uniqueness and the per-tenant constraint
can never be reached — one of the two intents you wrote is silently discarded:
**Do not declare both on the same column.** The installation-wide one wins
physically, so the per-organization constraint can never be reached — one of the
two intents you wrote is silently discarded:

```typescript
// ⚠️ contradictory — the global index wins, the per-tenant scope is dead
email: Field.email({ unique: true }),
indexes: [{ fields: ['email'], unique: true }],
// ⚠️ contradictory — the global index wins, the per-organization scope is dead
email: Field.email({ unique: 'organization' }),
indexes: [{ fields: ['email'], unique: 'global' }],
```

`os lint` / `os build` report this as `unique/double-declaration`. Pick one:
set `unique: 'global'` on the field and drop the index for platform-wide
uniqueness, or drop the index for per-tenant uniqueness (the field-level
declaration already builds the composite).
keep `unique: 'global'` on the field and drop the index for platform-wide
uniqueness, or drop the index and keep `unique: 'organization'` on the field.
</Callout>

<Callout type="warn">
**Never put a platform-wide unique index on an `autonumber` field.** The
autonumber sequence is per tenant — every organization counts from `1` — so a
global unique index rejects the second organization's `CASE-00001` on insert.
Use `{ fields: ['organization_id', 'case_number'], unique: true }` so the
constraint matches the sequence that feeds it.
**Never put a `'global'` unique index on an `autonumber` field.** The autonumber
sequence is per organization — every organization counts from `1` — so an
installation-wide unique index rejects the second organization's `CASE-00001`
on insert. Use `unique: 'organization'` so the constraint matches the sequence
that feeds it.
</Callout>

### Already have a hand-written organization composite?

Metadata written before the vocabulary existed spells the per-organization
constraint by listing the column itself:

```typescript
// the legacy spelling — still valid, still materialized exactly as written
indexes: [{ fields: ['name', 'organization_id'], unique: true }]
```

This keeps working forever and forces no migration, so `os lint` only *suggests*
the change (`unique/legacy-organization-composite`). Opting in is worth it
because it closes the NULL hole above — keep `fields` exactly as they are and
change the scope word:

```typescript
indexes: [{ fields: ['name', 'organization_id'], unique: 'organization' }]
```

The listed organization column becomes NULL-safe in place; no second
organization key part is added. Because this genuinely tightens the constraint,
it surfaces as a `recreate_index` migration guarded by a duplicate pre-flight
probe — if rows the old index wrongly admitted are still there, `os migrate plan`
reports them instead of failing a boot. See
[CLI · migrations](/docs/deployment/cli).

### Installing an app into an `isolated` deployment

Under the `isolated` posture, organizations are separate **customers**. A
`'global'` unique on an app's own object therefore constrains *across
customers* — right for a DNS hostname or an external provider id, almost never
right for a business rule. Installing such an app stops and lists each index so
the installer can confirm it or rewrite it to `'organization'`; the answer is
recorded in the install manifest and never asked again. `os doctor` and
`os migrate plan` report the same finding for apps installed before that check
existed, or for environments whose posture changed afterwards.

### When to Add Indexes

✅ **Add indexes for:**
Expand Down
2 changes: 1 addition & 1 deletion content/docs/data-modeling/objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ Optimize query performance:
```typescript
indexes: [
{ fields: ['name'], type: 'btree', unique: false },
{ fields: ['email'], type: 'btree', unique: true },
{ fields: ['email'], type: 'btree', unique: 'organization' },
{ fields: ['type', 'status'], type: 'btree', unique: false },
]
```
Expand Down
8 changes: 4 additions & 4 deletions content/docs/data-modeling/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ description: Define data integrity rules — formula conditions, uniqueness, for
<Callout type="info">
Three patterns that look like validation rules are deliberately **not** rule types, because each needs I/O or is a client-side concern. Use the layer that already does each one correctly:

- **Uniqueness** → a unique index (`ObjectSchema.indexes`, `{ fields, unique: true }`, with `partial` for a scoped constraint) or field-level `unique: true`. A SELECT-then-INSERT rule is inherently racy (TOCTOU); a DB unique constraint is not.
- **Uniqueness** → a unique index (`ObjectSchema.indexes`, `{ fields, unique: 'organization' | 'global' }` — state the scope, [ADR-0120](/docs/data-modeling/indexing); `partial` for a scoped constraint) or field-level `unique`. A SELECT-then-INSERT rule is inherently racy (TOCTOU); a DB unique constraint is not.
- **Async / remote validation** → a client-form concern, and an SSRF/latency hazard on the server write path. Keep it in the form layer, or enforce the invariant with a `unique` index / lifecycle hook.
- **Custom handler** → a `beforeInsert` / `beforeUpdate` lifecycle hook, the supported extension point for arbitrary validation code.
- **Delete-time guards** → a `beforeDelete` lifecycle hook. Validation rules run only on insert/update (a delete carries no record payload to validate), so there is no `'delete'` validation event — block or gate deletions from a `beforeDelete` hook.
Expand Down Expand Up @@ -127,11 +127,11 @@ There is **no** `uniqueness` validation type. A SELECT-then-INSERT rule is inher

```typescript
// Field-level uniqueness
email: Field.email({ label: 'Contact Email', unique: true }),
email: Field.email({ label: 'Contact Email', unique: 'organization' }),

// Composite / scoped uniqueness via ObjectSchema.indexes
indexes: [
{ fields: ['code', 'organization'], unique: true },
{ fields: ['code'], unique: 'organization' },
// `partial` expresses a scoped/conditional constraint
]
```
Expand Down Expand Up @@ -332,7 +332,7 @@ validations: [
]

// Order-number uniqueness is enforced with an index, not a validation rule:
// indexes: [{ fields: ['order_number'], unique: true }]
// indexes: [{ fields: ['order_number'], unique: 'global' }]
```

## CEL Functions and Operators
Expand Down
8 changes: 4 additions & 4 deletions content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ occupancy on its own.

| Category | Examples | Applied by |
|----------|----------|------------|
| `safe` | relax `NOT NULL` → nullable, widen a `varchar`, create a declared index, replace a legacy global unique with its tenant-scoped composite | `os migrate apply` (and dev auto-reconcile) |
| `safe` | relax `NOT NULL` → nullable, widen a `varchar`, create a declared index, replace a legacy installation-wide unique with its per-organization composite | `os migrate apply` (and dev auto-reconcile) |
| `needs_confirm` | non-narrowing type change, rebuild a non-unique index whose columns changed | `os migrate apply` |
| `destructive` | drop an orphaned column or index, tighten `NOT NULL`, narrow a type, rebuild an index as `UNIQUE` | `os migrate apply --allow-destructive` |

Expand All @@ -609,8 +609,8 @@ occupancy on its own.
| Op | What it means |
|----|---------------|
| `create_index` | Metadata declares an index the database does not have |
| `replace_unique_index` | A field's `unique` used to be enforced platform-wide, but metadata now scopes it per tenant — the legacy single-column index is swapped for the `(tenantField, field)` composite. A pure relaxation: it creates before it drops, and cannot fail |
| `recreate_index` | An index exists under the declared name but with different columns/uniqueness. The additive sync skips it by name, so it must be dropped and rebuilt |
| `replace_unique_index` | A field's `unique` used to be enforced installation-wide, but metadata now scopes it per organization — the legacy single-column index is swapped for the NULL-safe `(COALESCE(organization_id, '__global__'), field)` composite. A pure relaxation: it creates before it drops, and cannot fail |
| `recreate_index` | An index exists under the declared name but with different columns/uniqueness. The additive sync skips it by name, so it must be dropped and rebuilt. This is also how a per-organization unique becomes NULL-safe: a **tightening**, so it runs a duplicate pre-flight probe first — rows the old NULL-distinct index wrongly admitted **block** the op with a report instead of failing a boot, and the old index stays in place until they are resolved |
| `drop_index` | An index carrying ObjectStack's generated naming (`uniq_…` / `idx_…`) that metadata no longer declares |

Orphan detection is deliberately limited to indexes ObjectStack itself
Expand All @@ -620,7 +620,7 @@ drift, and `--allow-destructive` will not delete it.
<Callout type="tip">
**Dev self-heal.** `os dev` runs the SQL driver with `autoMigrate: 'safe'`, so
safe changes (you just made a field optional; a `unique` field became
tenant-scoped) are applied to your existing dev database automatically on
organization-scoped) are applied to your existing dev database automatically on
restart — no `os migrate` needed, no data loss. Auto-reconcile is **dev-only and
never destructive**; it is force-disabled under `NODE_ENV=production`, where
every change is shown by `os migrate plan` before you apply it deliberately.
Expand Down
Loading
Loading