Skip to content

Commit 28ad90e

Browse files
os-zhuangclaude
andauthored
feat(types,cloud-connection,lint,cli): ADR-0120 17.x 收尾 —— isolated 安装期姿态硬门 + D5c advisory + 成文契约扫荡 + 三姿态 conformance (#5081) (#5314)
* feat(types,cloud-connection,lint,cli): ADR-0120 17.x closeout — isolated install gate, D5c advisory, truth sweep, three-posture conformance (#5081) ADR-0120's third and final 17.x block, on top of #5212 (driver D3+D4) and #5208 (spec vocabulary + D5a/D5b lint). D5e — the isolated-posture install gate. Installing an app that carries 'global' uniques on its own (non-sys) objects into an isolated environment now stops and lists each index; the installer confirms them as genuinely platform-wide or rewrites them to 'organization'. The confirmation is recorded in the install manifest ADR-0104 attestation style (what, who, when, under which posture) and is never re-asked. A stopped install registers nothing and writes no ledger entry. NEVER a boot-time warning (#4884): rehydrate does not evaluate the gate, and the two populations the gate cannot reach — pre-gate installs, post-install posture changes — are covered by the advisory form in `os doctor` / `os migrate plan`. The pure enumerator lives in @objectstack/types so the hard stop and both advisories read one classification. Declared-index bare `unique: true` counts (D1 makes it the positional spelling of 'global'); field-level `true` does not; sys_/base_ objects do not. D5c — new advisory rule `unique/legacy-organization-composite` for the S6 hand-written organization composite, pointing at the 'organization' respelling that closes its NULL hole (#5030). Advisory forever, never auto-fixed: opting in is a real D4 tightening. D6 — indexing.mdx §Two ways to say "unique" rewritten in the new vocabulary; schema.mdx §Uniqueness and tenancy rewritten as §Uniqueness and scope, replacing the "composite degenerates to the single-column one" claim #5030 falsified; cli.mdx drift-op table updated; references regenerated via gen:schema && gen:docs; skills/objectstack-data swept (and its non-existent `tenant_id` column corrected to organization_id). Conformance — one fixture app booted under single | group | isolated, each S-row's enforcement asserted with real violating inserts, the materialized unique key parts asserted byte-identical across all three, plus the one transition smoke the ADR asks for: a posture flip emits zero drift ops. Fixes #5081 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB * fix(cli): read the `objectql` slot through its IObjectQLEngine contract, not `any` (#5081) The ADR-0120 D5e advisory's `SchemaStack.allObjects()` erased its service-lookup result to `any`, which `no-restricted-syntax` rejects (#4168/#4176/#4251): the lookup already returns the slot's contract, and the annotation switched that checking off while looking identical to code that has it. The `objectql` slot HAS a contract — `IObjectQLEngine`, whose `registry.getAllObjects()` is exactly what this reads — so this takes the prescription's first branch (pass the contract type) rather than the UNCONTRACTED_SLOTS registration. Concretely it buys what the rule is for: a rename of `registry` or `getAllObjects` now breaks this at compile time instead of silently returning zero objects and turning the advisory mute. `allObjects()` returns `unknown[]` accordingly; its only consumer, `collectGlobalUniques`, already accepts `unknown`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23e9c90 commit 28ad90e

35 files changed

Lines changed: 2345 additions & 145 deletions
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
---
2+
"@objectstack/types": minor
3+
"@objectstack/cloud-connection": minor
4+
"@objectstack/lint": minor
5+
"@objectstack/cli": minor
6+
"@objectstack/spec": patch
7+
"@objectstack/driver-sql": patch
8+
---
9+
10+
feat(types,cloud-connection,lint,cli): ADR-0120 17.x 收尾 —— `isolated` 安装期姿态硬门(D5e)、D5c 重拼写 advisory、成文契约扫荡与三姿态 conformance (#5081)
11+
12+
ADR-0120 17.x 波的第三块,也是最后一块。前两块已在 main 上:#5212(driver 侧
13+
D3+D4 —— `COALESCE(organization_id, '__global__')` 物化、drift 两侧同步、重复预检)
14+
与 #5208(spec 词汇 `'organization'` + D5a/D5b lint)。本次补齐三件事:安装期的
15+
姿态决策点、剩余的成文契约、以及把「一个 app 包跑遍三种姿态」从假设变成测试。
16+
17+
**D5e —— 装进 `isolated` 环境时的硬门。** 词汇本身是姿态无关的:作者说的是业务
18+
边界(`'organization'` 一个组织一份 / `'global'` 整个安装一份),没有任何索引形状
19+
读姿态。唯一的残留在一个方向上:`isolated` 下组织就是**不同客户**,此时 app 业务
20+
对象上的 `'global'` 唯一既跨客户过度约束,又变成跨客户的存在性预言机(S10/S14)。
21+
维护者裁定这是**硬门而非 advisory**:把带 `'global'` 唯一(非 `sys` 对象)的 app
22+
装进 `isolated` 环境会**停下来并逐索引列出**,安装者(通常是 AI agent)要么确认它
23+
确实是平台级的,要么改写为 `'organization'`;确认按 ADR-0104 attestation 风格
24+
留痕在安装清单里(`InstalledManifestEntry.globalUniqueAttestation` —— 确认了什么、
25+
谁确认的、何时、在哪个姿态下问的),**之后不复问**。
26+
27+
- 停下的安装**什么都不留**:先于 hot-register 和任何 ledger 写入,所以作者改完
28+
元数据可以直接重试,不需要先卸载。
29+
- 逐索引确认是有牙齿的:`confirmGlobalUniques` 收 `true` 或明确的 id 数组,只确认
30+
其中一条仍会在剩下的那条上停住。
31+
- 升级引入的**新**约束会被问,老的答案继续算数。
32+
- 另一个姿态下给出的确认**不算同意** —— `isolated` 那个问题在 `single` 下从未被
33+
问过,所以按「未确认」处理(唯一不会静默放行跨客户约束的方向)。
34+
- ⛔ **永不做成启动期告警**(#4884 纪律)。boot 时的 rehydrate 不评估此门;门够不到
35+
的两类存量 —— 门禁上线前的安装、装后姿态变更的环境 —— 由 `os doctor` 与
36+
`os migrate plan` 的 advisory 形态覆盖。
37+
38+
判定里有三条是承重的,别「简化」掉:声明索引上的裸 `unique: true` **算**(D1 说它
39+
就是 `'global'` 的位置式拼写,排除它等于让整个 17.x 可以靠拼写绕过);字段级
40+
`true` **不算**(它是 `'organization'`,永久合法);`sys_`/`base_` 对象**不算**
41+
(S5 那批引擎幂等键天然就是平台级的,每次安装都问一遍就是 #4884 的误报类)。
42+
43+
CLI: `os package install` 新增 `--confirm-global-uniques`,并把 409 渲染成可读的
44+
逐条清单而不是一句 "Install failed (409)"。
45+
46+
**D5c —— 遗留手写组织复合索引的 advisory。** 新规则
47+
`unique/legacy-organization-composite`:声明的唯一索引自己列出了组织列
48+
(`{ fields: ['name','organization_id'], unique: true }`)—— 这是词汇出现之前手写
49+
per-organization 的写法。它读起来像「每组织唯一」,物化出来却是普通复合索引,而
50+
SQL UNIQUE 是 NULL-distinct 的:组织列为 NULL 的行上它**什么都不约束**(#5030),
51+
在单组织部署上那就是每一行。改写成 `unique: 'organization'`(`fields` 原样保留,
52+
driver 会把已列出的组织列**就地**变成 NULL-safe 形式)正是补上这个洞的动作。
53+
**永远只是 advisory,永远不自动修**:老拼写永久合法、零强制 drift,而 opt-in 是
54+
真实的物理收紧,要走 D4 的 `recreate_index` + 重复预检。
55+
56+
**D6 —— 成文契约扫荡。** `content/docs/data-modeling/indexing.mdx` 的
57+
§Two ways to say "unique" 全节按新词汇重写(含 `os:check` 代码块);
58+
`content/docs/protocol/objectql/schema.mdx` 的 §Uniqueness and tenancy 重写为
59+
§Uniqueness and scope —— 其中那句「单租户部署不受影响,租户列是常量,复合索引
60+
退化为单列索引」是 #5030 **证伪过的原话**,现已替换为 D3 的 NULL-safe 事实;
61+
`content/docs/deployment/cli.mdx` 的 `replace_unique_index` / `recreate_index`
62+
条目补上 NULL-safe 形状与重复预检;`content/docs/references/**` 经
63+
`gen:schema && gen:docs` 再生成,未手改。
64+
65+
按 ADR-0120 Resolved #2 的非规范性引导(官方示例/脚手架/生成器在新代码中输出
66+
显式拼写),`skills/objectstack-data/**` 的索引与校验规则整体扫过:声明索引一律
67+
说清 scope,并新增一节完整讲 `'organization'` 的 NULL-safe 语义与「永远不写姿态」。
68+
顺带修掉那里长期使用的 `tenant_id` —— 平台的列叫 `organization_id`。
69+
`examples/**`、`create-objectstack` 模板与 `os generate` 经核查**根本没有声明任何
70+
唯一约束**,故无可扫;这是核查结论,不是遗漏。
71+
72+
**三姿态 conformance(ADR §Acceptance tests)。** 同一个 fixture app 在
73+
`single | group | isolated` 三姿态下启动,逐 S 行用**真实的违规插入**断言 enforcement
74+
(S1/S2/S3/S4/S5/S6/S7/S8/S9/S11/S12),并逐姿态捕获物化出的索引键,断言三者
75+
**逐字节相同** —— 「没有任何索引形状读姿态」这句话一旦有两者不同就是假的。相同性
76+
断言配了一条正向断言(对着期望的键形状),这样「三次都什么都没建」不会读成「一致」。
77+
外加 ADR 只要的那一条 transition smoke:在 `single` 下建库、`isolated` 下重新打开,
78+
drift op 为零。
79+
80+
对既有部署的影响:除新增的安装期确认外,本次不改变任何已有物化行为。字段级
81+
`unique: true` 一如既往合法。

‎content/docs/data-modeling/field-types.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ Email address with built-in format validation.
4949
| `maxLength` | `number` | — | Maximum character length |
5050

5151
```typescript
52-
{ name: 'email', label: 'Email', type: 'email', required: true, unique: true }
52+
{ name: 'email', label: 'Email', type: 'email', required: true, unique: 'organization' }
5353
```
5454

5555
### `url`

‎content/docs/data-modeling/fields.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const Contact = ObjectSchema.create({
2121
fields: {
2222
first_name: Field.text({ label: 'First Name', required: true }),
2323
last_name: Field.text({ label: 'Last Name', required: true }),
24-
email: Field.email({ label: 'Email', unique: true }),
24+
email: Field.email({ label: 'Email', unique: 'organization' }),
2525
phone: Field.phone({ label: 'Phone' }),
2626
birth_date: Field.date({ label: 'Date of Birth' }),
2727
is_active: Field.boolean({ label: 'Active', defaultValue: true }),

‎content/docs/data-modeling/indexing.mdx‎

Lines changed: 100 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -12,63 +12,130 @@ Optimize query performance with indexes:
1212
indexes: [
1313
// Single field index
1414
{ fields: ['name'], unique: false },
15-
16-
// Unique index
17-
{ fields: ['email'], unique: true },
18-
15+
16+
// Unique index — one holder per organization
17+
{ fields: ['email'], unique: 'organization' },
18+
19+
// Unique index — one holder across the whole installation
20+
{ fields: ['hostname'], unique: 'global' },
21+
1922
// Compound index
2023
{ fields: ['type', 'is_active'], unique: false },
21-
24+
2225
// Lookup field index
2326
{ fields: ['owner'], unique: false },
2427
]
2528
```
2629

27-
## Two ways to say "unique" — and they mean different things
30+
## Two ways to say "unique" — one vocabulary for both
2831

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

31-
| Declaration | Materializes as | Scope |
36+
| Scope | Meaning | Materializes as |
3237
|:---|:---|:---|
33-
| Field-level `unique: true` | `(organization_id, field)` | Unique **within** an organization |
34-
| Field-level `unique: 'global'` | `(field)` | Platform-wide |
35-
| Declared index `{ fields: ['email'], unique: true }` | `(email)` — exactly the listed columns | Platform-wide |
38+
| `unique: 'organization'` | One holder **per organization** | `(COALESCE(organization_id, '__global__'), …fields)` |
39+
| `unique: 'global'` | One holder across the **whole installation** | exactly the listed column(s) |
40+
41+
```typescript
42+
// Field level — the scope is the same word in both places
43+
email: Field.email({ unique: 'organization' }), // one contact per org
44+
hostname: Field.text({ unique: 'global' }), // one holder, platform-wide
3645

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

42-
A **declared index is taken verbatim**. No tenant column is injected, because
43-
many declared indexes are legitimately platform-wide (a DNS hostname, a
44-
reserved slug, an external provider id). To scope one per tenant, list the
45-
column yourself: `{ fields: ['organization_id', 'email'], unique: true }`.
54+
Two rules make the choice safe to write and safe to deploy:
55+
56+
1. **You state a business boundary, never a deployment shape.** The same app
57+
package runs unmodified under every tenancy posture — no index shape reads
58+
the posture, so moving a deployment between postures has no schema
59+
consequence.
60+
2. **`'organization'` is NULL-safe.** Rows with no organization (platform rows,
61+
and *every* row on a single-organization deployment) collapse into one
62+
platform bucket and are unique among themselves. Without this a plain
63+
`(organization_id, …)` composite enforces nothing on those rows, because SQL
64+
`UNIQUE` treats every `NULL` as distinct.
65+
66+
<Callout type="info">
67+
**Bare `unique: true`.** On a **field** it means `'organization'` and stays
68+
valid indefinitely — `email: Field.email({ unique: true })` is correct, and the
69+
explicit spelling is simply preferred in new code.
70+
71+
On a **declared index** it is the deprecated spelling of `'global'`: it
72+
materializes over exactly the listed columns. Because that reads like the
73+
field-level meaning but does the opposite, `os lint` / `os build` / `os validate`
74+
report it as `unique/unscoped-declared-index`, and it is rejected outright at
75+
protocol 18. State the scope.
76+
</Callout>
4677

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

5283
```typescript
53-
// ⚠️ contradictory — the global index wins, the per-tenant scope is dead
54-
email: Field.email({ unique: true }),
55-
indexes: [{ fields: ['email'], unique: true }],
84+
// ⚠️ contradictory — the global index wins, the per-organization scope is dead
85+
email: Field.email({ unique: 'organization' }),
86+
indexes: [{ fields: ['email'], unique: 'global' }],
5687
```
5788

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

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

102+
### Already have a hand-written organization composite?
103+
104+
Metadata written before the vocabulary existed spells the per-organization
105+
constraint by listing the column itself:
106+
107+
```typescript
108+
// the legacy spelling — still valid, still materialized exactly as written
109+
indexes: [{ fields: ['name', 'organization_id'], unique: true }]
110+
```
111+
112+
This keeps working forever and forces no migration, so `os lint` only *suggests*
113+
the change (`unique/legacy-organization-composite`). Opting in is worth it
114+
because it closes the NULL hole above — keep `fields` exactly as they are and
115+
change the scope word:
116+
117+
```typescript
118+
indexes: [{ fields: ['name', 'organization_id'], unique: 'organization' }]
119+
```
120+
121+
The listed organization column becomes NULL-safe in place; no second
122+
organization key part is added. Because this genuinely tightens the constraint,
123+
it surfaces as a `recreate_index` migration guarded by a duplicate pre-flight
124+
probe — if rows the old index wrongly admitted are still there, `os migrate plan`
125+
reports them instead of failing a boot. See
126+
[CLI · migrations](/docs/deployment/cli).
127+
128+
### Installing an app into an `isolated` deployment
129+
130+
Under the `isolated` posture, organizations are separate **customers**. A
131+
`'global'` unique on an app's own object therefore constrains *across
132+
customers* — right for a DNS hostname or an external provider id, almost never
133+
right for a business rule. Installing such an app stops and lists each index so
134+
the installer can confirm it or rewrite it to `'organization'`; the answer is
135+
recorded in the install manifest and never asked again. `os doctor` and
136+
`os migrate plan` report the same finding for apps installed before that check
137+
existed, or for environments whose posture changed afterwards.
138+
72139
### When to Add Indexes
73140

74141
✅ **Add indexes for:**

‎content/docs/data-modeling/objects.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ Optimize query performance:
197197
```typescript
198198
indexes: [
199199
{ fields: ['name'], type: 'btree', unique: false },
200-
{ fields: ['email'], type: 'btree', unique: true },
200+
{ fields: ['email'], type: 'btree', unique: 'organization' },
201201
{ fields: ['type', 'status'], type: 'btree', unique: false },
202202
]
203203
```

‎content/docs/data-modeling/validation.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ description: Define data integrity rules — formula conditions, uniqueness, for
1010
<Callout type="info">
1111
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:
1212

13-
- **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.
13+
- **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.
1414
- **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.
1515
- **Custom handler** → a `beforeInsert` / `beforeUpdate` lifecycle hook, the supported extension point for arbitrary validation code.
1616
- **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.
@@ -127,11 +127,11 @@ There is **no** `uniqueness` validation type. A SELECT-then-INSERT rule is inher
127127

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

132132
// Composite / scoped uniqueness via ObjectSchema.indexes
133133
indexes: [
134-
{ fields: ['code', 'organization'], unique: true },
134+
{ fields: ['code'], unique: 'organization' },
135135
// `partial` expresses a scoped/conditional constraint
136136
]
137137
```
@@ -332,7 +332,7 @@ validations: [
332332
]
333333

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

338338
## CEL Functions and Operators

‎content/docs/deployment/cli.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ occupancy on its own.
598598

599599
| Category | Examples | Applied by |
600600
|----------|----------|------------|
601-
| `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) |
601+
| `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) |
602602
| `needs_confirm` | non-narrowing type change, rebuild a non-unique index whose columns changed | `os migrate apply` |
603603
| `destructive` | drop an orphaned column or index, tighten `NOT NULL`, narrow a type, rebuild an index as `UNIQUE` | `os migrate apply --allow-destructive` |
604604

@@ -609,8 +609,8 @@ occupancy on its own.
609609
| Op | What it means |
610610
|----|---------------|
611611
| `create_index` | Metadata declares an index the database does not have |
612-
| `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 |
613-
| `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 |
612+
| `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 |
613+
| `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 |
614614
| `drop_index` | An index carrying ObjectStack's generated naming (`uniq_…` / `idx_…`) that metadata no longer declares |
615615

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

0 commit comments

Comments
 (0)