From b7f58169d2d40dce6dcec8d36c346dd7271b51fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 08:49:01 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(spec,lint):=20unique=20=E8=8C=83?= =?UTF-8?q?=E5=9B=B4=E6=98=BE=E5=BC=8F=E8=AF=8D=E6=B1=87=20'organization'?= =?UTF-8?q?=20+=20D5a/D5b=20lint=20=E8=A7=84=E5=88=99=20(#4986,=20ADR-0120?= =?UTF-8?q?=2017.x=20=E6=B3=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UniqueScopeSchema → boolean | 'global' | 'organization'(D1,17.x 纯增量); 'tenant'/'org' 拒绝且报错指名 'organization'(§Terminology,strictTenancyError 先例) - IndexSchema.unique 注释与 describe() 按 D1/D6 重写:verbatim 收窄到 'global'; 'organization' = 注册期并入 NULL-safe 组织键部(COALESCE 物化随 #5030); 裸 true 弃用(17.x 警告 → 协议 18 拒绝,#5082) - UniqueScopeSchema 文档块单租户假豁免替换为 D3 真相(NULL 桶 + COALESCE); Resolved #2 非规范性引导入注释(示例/脚手架输出 'organization',裸 true 永久合法) - 新导出 isOrganizationUnique;isUniqueDeclared 计入 'organization'(ADR-0078) - lint 新规则 unique/unscoped-declared-index(D5a,警告,只判拼写不推断租户性); R10 unique/double-declaration 四象限重写(D5b),fix 改说 'organization' 词汇 - 测试:spec unique-scope 契约测试扩充;cli data-model-rules 测试按四象限重写 - 再生成:references/data/{field,object}.mdx、api-surface.json;adr-anchors 三条; changeset 两包(spec minor / lint minor) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7 --- .changeset/unique-scope-lint-rules.md | 27 +++ .../unique-scope-organization-vocabulary.md | 34 +++ content/docs/references/data/field.mdx | 8 +- content/docs/references/data/object.mdx | 6 +- packages/cli/test/data-model-rules.test.ts | 221 +++++++++++++++--- packages/lint/src/authoring-rules.ts | 9 +- packages/lint/src/data-model-rules.ts | 172 +++++++++++--- packages/lint/src/index.ts | 8 +- packages/spec/api-surface.json | 1 + packages/spec/src/data/field.zod.ts | 116 +++++++-- packages/spec/src/data/object.zod.ts | 37 ++- packages/spec/src/data/unique-scope.test.ts | 83 +++++-- scripts/adr-anchors.json | 15 ++ 13 files changed, 610 insertions(+), 127 deletions(-) create mode 100644 .changeset/unique-scope-lint-rules.md create mode 100644 .changeset/unique-scope-organization-vocabulary.md diff --git a/.changeset/unique-scope-lint-rules.md b/.changeset/unique-scope-lint-rules.md new file mode 100644 index 0000000000..3dbd531f05 --- /dev/null +++ b/.changeset/unique-scope-lint-rules.md @@ -0,0 +1,27 @@ +--- +"@objectstack/lint": minor +--- + +feat(lint): uniqueness-scope rules speak the ADR-0120 vocabulary (#4986, D5a/D5b) + +- **New rule `unique/unscoped-declared-index`** (warning, advisory): a declared + index with bare `unique: true` — the spelling whose scope is unstated, the + #4986 trap. Fires on the spelling alone (no tenancy/posture inference — + `organization_id` is kernel-injected at registration, so an authoring-time + guess would be wrong half the time; see #4698). The fix names both words: + `'global'` (installation-wide — exactly today's behavior) or + `'organization'` (one holder per organization). Protocol 18 rejects the + spelling (#5082). Exported as `lintUnscopedDeclaredIndexes` + + `UNIQUE_UNSCOPED_DECLARED_INDEX`, and reported through + `lintUniqueDeclarations` / `lintDataModel`, so `os validate`, `os build` and + `os lint` all carry it. +- **R10 `unique/double-declaration` rewritten as the four-quadrant scope + matrix** (ADR-0120 D5b): field `true`/`'organization'` × declared `'global'` + (or bare `true`, its deprecated spelling) on the same single column = + CONTRADICTION (the installation-wide index wins physically; the + per-organization intent is silently dead) — and the mirror, field `'global'` + × declared `'organization'`, likewise; same scope on both sides = REDUNDANCY + (the same index declared twice). The old field-`'global'` exemption is gone + (now reported as redundancy), and the fix text replaces the hand-written + `fields: ['organization_id', …]` advice with the `'organization'` spelling — + the hand-written composite is not NULL-safe (#5030). diff --git a/.changeset/unique-scope-organization-vocabulary.md b/.changeset/unique-scope-organization-vocabulary.md new file mode 100644 index 0000000000..21f9c5e38c --- /dev/null +++ b/.changeset/unique-scope-organization-vocabulary.md @@ -0,0 +1,34 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): `unique` scope vocabulary gains `'organization'` — scope is said, not positional (#4986, ADR-0120 D1/D6) + +`UniqueScopeSchema` (field-level `unique` and `IndexSchema.unique`) widens from +`boolean | 'global'` to `boolean | 'global' | 'organization'`. Purely additive +in 17.x — no existing spelling changes meaning: + +- **Field-level** `'organization'` is the explicit synonym of `true` + (per-organization uniqueness, identical materialization through the driver + predicates: `isUniqueDeclared` counts it, `isGlobalUnique` does not). Bare + `true` stays valid indefinitely; official examples and scaffolding emit + `'organization'` in new code (non-normative, ADR-0120 Resolved #2). +- **Declared-index** contract is now stated per word (ADR-0120 D1, amending + #3696): `'global'` = today's verbatim behavior — materialized over exactly + `fields`, no organization column injected; `'organization'` = the driver + prepends the NULL-safe organization key part + (`COALESCE(organization_id, '__global__')`, ADR-0120 D3) at registration — + materialization lands with #5030's driver PR, which this change must follow; + bare `true` = the deprecated positional spelling of `'global'` — warned in + 17.x by lint `unique/unscoped-declared-index`, rejected at protocol 18 + (#5082). +- **Rejected words carry the fix**: `'tenant'` and `'org'` are not accepted and + are not aliases — the parse error names `'organization'` (ADR-0120 + §Terminology). +- New export `isOrganizationUnique` — detects the explicit `'organization'` + spelling, single source of truth for the declared-index distinction across + SQL/Mongo index sync. +- The `UniqueScopeSchema` doc block's false single-tenant exemption ("the + tenant column is constant, so the composite index degenerates to the + single-column one" — falsified by #5030: the constant is NULL and SQL UNIQUE + is NULL-distinct) is replaced with the D3 truth (NULL bucket + COALESCE). diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index a626eabb95..519127aa53 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -80,7 +80,7 @@ const result = AddressSchema.parse(data); | **storage** | `{ notNull?: boolean }` | optional | Physical storage constraints (ADR-0113). Owns the DDL the write contract deliberately does not imply. Absent = no storage-level constraint requested. | | **searchable** | `boolean` | optional | Is searchable | | **multiple** | `boolean` | optional | Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image. | -| **unique** | `boolean \| 'global'` | optional | Unique constraint. true = unique within the tenant (composite with the tenant column on tenant-scoped objects); 'global' = unique platform-wide across all tenants | +| **unique** | `boolean \| 'global' \| 'organization'` | optional | Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization' | | **defaultValue** | `any` | optional | Default value | | **maxLength** | `number` | optional | Max character length | | **minLength** | `number` | optional | Min character length | @@ -245,6 +245,12 @@ Type: `'global'` --- +#### Option 3 + +Type: `'organization'` + +--- + --- diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index d79d00e756..35a9cd9e17 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -68,7 +68,7 @@ const result = ApiMethod.parse(data); | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | | **type** | `Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>` | ✅ | Index algorithm type | -| **unique** | `boolean \| 'global'` | ✅ | Whether the index enforces uniqueness. Materialized over exactly `fields` — no tenant column is injected; list the tenant column explicitly for a per-tenant index. 'global' is a synonym of true, for symmetry with field-level `unique` | +| **unique** | `boolean \| 'global' \| 'organization'` | ✅ | Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | | **partial** | `string` | optional | Partial index condition (SQL WHERE clause for conditional indexes) | @@ -122,7 +122,7 @@ const result = ApiMethod.parse(data); | **datasource** | `string` | optional | Target Datasource ID. "default" is the primary DB. | | **external** | `{ remoteName?: string; remoteSchema?: string; writable?: boolean; columnMap?: Record; … }` | optional | Remote table binding for federated (external) objects. | | **fields** | `Record; description?: string; … }>` | ✅ | Field definitions map. Keys must be snake_case identifiers. | -| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global'; … }[]` | optional | Database performance indexes | +| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global' \| 'organization'; … }[]` | optional | Database performance indexes | | **fieldGroups** | `{ key: string; label: string; icon?: string; description?: string; … }[]` | optional | Ordered list of field groups (array order = display order). See ObjectFieldGroupSchema. | | **tenancy** | `{ enabled: boolean; tenantField?: string }` | optional | Multi-tenancy configuration for SaaS applications | | **access** | `{ default?: Enum<'public' \| 'private'> }` | optional | [ADR-0066 D2] Object exposure posture (public-by-default vs private secure-by-default). | @@ -196,7 +196,7 @@ const result = ApiMethod.parse(data); | **pluralLabel** | `string` | optional | Override plural label for the extended object | | **description** | `string` | optional | Override description for the extended object | | **validations** | `any[]` | optional | Additional validation rules to merge into the target object | -| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global'; … }[]` | optional | Additional indexes to merge into the target object | +| **indexes** | `{ name?: string; fields: string[]; type?: Enum<'btree' \| 'hash' \| 'gin' \| 'gist' \| 'fulltext'>; unique?: boolean \| 'global' \| 'organization'; … }[]` | optional | Additional indexes to merge into the target object | | **priority** | `integer` | optional | Merge priority (higher = applied later) | diff --git a/packages/cli/test/data-model-rules.test.ts b/packages/cli/test/data-model-rules.test.ts index 03527b4917..ae885d3e48 100644 --- a/packages/cli/test/data-model-rules.test.ts +++ b/packages/cli/test/data-model-rules.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { lintDataModel, lintUniqueDeclarations } from '@objectstack/lint'; +import { lintDataModel, lintUniqueDeclarations, lintUnscopedDeclaredIndexes } from '@objectstack/lint'; import { lintConfig } from '../src/commands/lint'; const rulesOf = (issues: { rule: string }[]) => issues.map((i) => i.rule); @@ -174,10 +174,94 @@ describe('lintConfig integration', () => { }); }); -// #3991 — the same column declared unique twice, in two spellings that mean -// different things. One of the two intents is always discarded. -describe('lintUniqueDeclarations — contradictory uniqueness (#3991)', () => { +// ADR-0120 — uniqueness scope is an explicit vocabulary. Two rules read the +// declarations, and only the declarations (no tenancy inference — that dead +// end is documented on #4698): +// R10 `unique/double-declaration` — both spellings on one column, judged by +// the four-quadrant scope matrix (D5b); +// R11 `unique/unscoped-declared-index` — a declared index with bare +// `unique: true`, the spelling whose scope is unstated (D5a; the #4986 trap). + +describe('lintUnscopedDeclaredIndexes — bare unique: true on a declared index (ADR-0120 D5a)', () => { + const RULE = 'unique/unscoped-declared-index'; + + it('returns [] for empty input', () => { + expect(lintUnscopedDeclaredIndexes([])).toEqual([]); + expect(lintUnscopedDeclaredIndexes(undefined as any)).toEqual([]); + }); + + it('warns on a bare declared unique, whatever the column count, and prescribes both words', () => { + const issues = lintUnscopedDeclaredIndexes([ + { + name: 'crm_case', + fields: { code: { type: 'text' } }, + indexes: [ + { fields: ['code'], unique: true }, + { name: 'uniq_dept_code', fields: ['department', 'code'], unique: true }, + ], + }, + ]); + expect(issues).toHaveLength(2); + for (const issue of issues) { + expect(issue.rule).toBe(RULE); + expect(issue.severity).toBe('warning'); // 17.x warns; protocol 18 rejects the spelling (#5082) + // D5a: the fix names both words, and identifies 'global' as today's behavior. + expect(issue.fix).toContain("unique: 'global'"); + expect(issue.fix).toContain("unique: 'organization'"); + expect(issue.fix).toMatch(/today's behavior/); + expect(issue.message).toContain('ADR-0120'); + } + expect(issues[0].path).toBe('objects[0].indexes[0]'); + expect(issues[1].path).toBe('objects[0].indexes[1]'); + expect(issues[1].message).toContain("'uniq_dept_code'"); + }); + + it("stays quiet for 'global', 'organization', non-unique, and unique: false", () => { + expect(lintUnscopedDeclaredIndexes([ + { + name: 'runtime', + fields: { hostname: { type: 'text' } }, + indexes: [ + { fields: ['hostname'], unique: 'global' }, + { fields: ['code'], unique: 'organization' }, + { fields: ['created_at'] }, + { fields: ['status'], unique: false }, + ], + }, + ])).toEqual([]); + }); + + it('fires on the spelling alone — no tenancy or posture inference', () => { + // Deliberate: whether the object is organization-scoped is unknowable at + // authoring time (`organization_id` is kernel-injected at registration), + // so the rule judges the spelling only — an explicitly tenancy-less + // object warns exactly like any other. + const issues = lintUnscopedDeclaredIndexes([ + { + name: 'sys_job', + tenancy: { enabled: false }, + fields: { name: { type: 'text' } }, + indexes: [{ fields: ['name'], unique: true }], + }, + ]); + expect(issues).toHaveLength(1); + expect(issues[0].rule).toBe(RULE); + }); + + it('rides along in lintUniqueDeclarations and lintDataModel, so validate/build/lint all report it', () => { + const objs = [{ name: 'a', fields: {}, indexes: [{ fields: ['x'], unique: true }] }]; + expect(has(lintUniqueDeclarations(objs), RULE)).toBe(true); + expect(has(lintDataModel(objs), RULE)).toBe(true); + }); +}); + +// #3991 / ADR-0120 D5b — the same column declared unique twice. The scope +// vocabulary turns the old conditional narrative ("on a tenant-scoped object +// they contradict; on a tenancy-less one they are redundant") into a matrix +// judged from the two spellings alone. +describe('lintUniqueDeclarations — double declaration, four scope quadrants (#3991, ADR-0120 D5b)', () => { const RULE = 'unique/double-declaration'; + const doubles = (issues: { rule: string }[]) => issues.filter((i) => i.rule === RULE); const withBoth = [ { @@ -192,45 +276,109 @@ describe('lintUniqueDeclarations — contradictory uniqueness (#3991)', () => { expect(lintUniqueDeclarations(undefined as any)).toEqual([]); }); - it('flags field-level unique + a single-column unique index on the same column', () => { - const issues = lintUniqueDeclarations(withBoth); + // ── Quadrant 1: field per-organization × index installation-wide = CONTRADICTION ── + + it('Q1 — field `true` (per-organization) × declared bare `true` (installation-wide): contradiction', () => { + const issues = doubles(lintUniqueDeclarations(withBoth)); expect(issues).toHaveLength(1); - expect(issues[0].rule).toBe(RULE); expect(issues[0].severity).toBe('warning'); // advisory — never fails a build expect(issues[0].message).toContain('crm_contact.email'); - // The message must name BOTH readings, since tenancy is not inferred here. - expect(issues[0].message).toMatch(/per tenant|tenant/i); - expect(issues[0].message).toMatch(/platform-wide/i); - // And the fix must spell out both ways to resolve it. + expect(issues[0].message).toContain('CONTRADICT'); + expect(issues[0].message).toContain('silently dead'); + // The fix speaks the ADR-0120 vocabulary — the old advice to hand-write + // `['organization_id', …]` is retired (that spelling is not NULL-safe, #5030). expect(issues[0].fix).toContain("unique: 'global'"); - expect(issues[0].fix).toContain('organization_id'); + expect(issues[0].fix).toContain("unique: 'organization'"); + expect(issues[0].fix).not.toContain('organization_id'); }); - it('surfaces through lintDataModel too, so `os lint` reports it', () => { - expect(has(lintDataModel(withBoth), RULE)).toBe(true); + it("Q1 — field `'organization'` × declared `'global'`: same contradiction, explicit spellings", () => { + const issues = doubles(lintUniqueDeclarations([ + { + name: 'crm_contact', + fields: { email: { type: 'email', unique: 'organization' } }, + indexes: [{ fields: ['email'], unique: 'global' }], + }, + ])); + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain('CONTRADICT'); + expect(issues[0].message).toContain("unique: 'organization'"); + expect(issues[0].message).toContain("unique: 'global'"); }); - // ── Shapes that must stay quiet ────────────────────────────────────── + // ── Quadrant 2: field 'global' × index installation-wide = REDUNDANCY ── - it("exempts unique: 'global' — the index restates the intent, it does not lose it", () => { - const issues = lintUniqueDeclarations([ + it("Q2 — field `'global'` × declared `'global'`: redundancy, not contradiction", () => { + const issues = doubles(lintUniqueDeclarations([ { name: 'runtime', fields: { hostname: { type: 'text', unique: 'global' } }, - indexes: [{ fields: ['hostname'], unique: true }], + indexes: [{ fields: ['hostname'], unique: 'global' }], }, - ]); - expect(issues).toEqual([]); + ])); + expect(issues).toHaveLength(1); + expect(issues[0].severity).toBe('warning'); + expect(issues[0].message).toContain('declared twice'); + expect(issues[0].message).not.toContain('CONTRADICT'); }); - it('exempts an explicit tenant COMPOSITE index — that agrees with the field-level default', () => { - const issues = lintUniqueDeclarations([ + // ── Quadrant 3: field per-organization × index 'organization' = REDUNDANCY ── + + it("Q3 — field `true` × declared `'organization'` on the same single column: redundancy (same index either way)", () => { + const issues = doubles(lintUniqueDeclarations([ { name: 'crm_contact', fields: { email: { type: 'email', unique: true } }, - indexes: [{ fields: ['organization_id', 'email'], unique: true }], + indexes: [{ fields: ['email'], unique: 'organization' }], }, - ]); + ])); + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain('declared twice'); + expect(issues[0].message).not.toContain('CONTRADICT'); + expect(issues[0].fix).toContain("unique: 'organization'"); + }); + + it("Q3 — field `'organization'` × declared `'organization'`: redundancy in explicit spellings too", () => { + const issues = doubles(lintUniqueDeclarations([ + { + name: 'crm_contact', + fields: { email: { type: 'email', unique: 'organization' } }, + indexes: [{ fields: ['email'], unique: 'organization' }], + }, + ])); + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain('declared twice'); + }); + + // ── Quadrant 4: field 'global' × index 'organization' = CONTRADICTION (mirror) ── + + it("Q4 — field `'global'` × declared `'organization'`: mirror contradiction — the global side wins physically", () => { + const issues = doubles(lintUniqueDeclarations([ + { + name: 'runtime', + fields: { hostname: { type: 'text', unique: 'global' } }, + indexes: [{ fields: ['hostname'], unique: 'organization' }], + }, + ])); + expect(issues).toHaveLength(1); + expect(issues[0].message).toContain('CONTRADICT'); + expect(issues[0].message).toContain('silently dead'); + }); + + it('surfaces through lintDataModel too, so `os lint` reports it', () => { + expect(has(lintDataModel(withBoth), RULE)).toBe(true); + }); + + // ── Shapes that must stay quiet (for THIS rule) ────────────────────── + + it('exempts a hand-written organization COMPOSITE index — the legacy explicit spelling agrees with the field-level default', () => { + const issues = doubles(lintUniqueDeclarations([ + { + name: 'crm_contact', + fields: { email: { type: 'email', unique: true } }, + indexes: [{ fields: ['organization_id', 'email'], unique: 'global' }], + }, + ])); expect(issues).toEqual([]); }); @@ -246,13 +394,13 @@ describe('lintUniqueDeclarations — contradictory uniqueness (#3991)', () => { }); it('ignores a unique index on a DIFFERENT column', () => { - const issues = lintUniqueDeclarations([ + const issues = doubles(lintUniqueDeclarations([ { name: 'crm_contact', fields: { email: { type: 'email', unique: true }, code: { type: 'text' } }, - indexes: [{ fields: ['code'], unique: true }], + indexes: [{ fields: ['code'], unique: 'global' }], }, - ]); + ])); expect(issues).toEqual([]); }); @@ -261,30 +409,35 @@ describe('lintUniqueDeclarations — contradictory uniqueness (#3991)', () => { { name: 'a', fields: { email: { type: 'email', unique: true } } }, ])).toEqual([]); expect(lintUniqueDeclarations([ - { name: 'b', fields: { email: { type: 'email' } }, indexes: [{ fields: ['email'], unique: true }] }, + { name: 'b', fields: { email: { type: 'email' } }, indexes: [{ fields: ['email'], unique: 'global' }] }, ])).toEqual([]); + // A LONE bare-true declared index is not a double declaration — it is + // R11's finding (unscoped spelling), and only R11's. + expect(doubles(lintUniqueDeclarations([ + { name: 'c', fields: { email: { type: 'email' } }, indexes: [{ fields: ['email'], unique: true }] }, + ]))).toEqual([]); }); it('names the declared index when it carries an explicit name', () => { - const issues = lintUniqueDeclarations([ + const issues = doubles(lintUniqueDeclarations([ { name: 'crm_product', fields: { sku: { type: 'text', unique: true } }, - indexes: [{ name: 'uniq_product_sku', fields: ['sku'], unique: true }], + indexes: [{ name: 'uniq_product_sku', fields: ['sku'], unique: 'global' }], }, - ]); + ])); expect(issues[0].message).toContain("'uniq_product_sku'"); }); it('reports each offending column once, across several objects', () => { - const issues = lintUniqueDeclarations([ + const issues = doubles(lintUniqueDeclarations([ ...withBoth, { name: 'crm_lead', fields: { email: { type: 'email', unique: true }, sku: { type: 'text', unique: true } }, - indexes: [{ fields: ['email'], unique: true }, { fields: ['sku'], unique: true }], + indexes: [{ fields: ['email'], unique: 'global' }, { fields: ['sku'], unique: 'organization' }], }, - ]); + ])); expect(issues.map((i) => i.message.match(/"([^"]+)"/)?.[1]).sort()) .toEqual(['crm_contact.email', 'crm_lead.email', 'crm_lead.sku']); }); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 177b4634e0..a9d1302e53 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -742,9 +742,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ hint: f.hint, })), }, - // #3991 — a column carrying BOTH a field-level `unique: true` and a - // single-column declared unique index has two intents, of which exactly one - // takes effect (the global index wins; the tenant composite is unreachable). + // #3991 / ADR-0120 D5a+D5b — the uniqueness-declaration pair: a column + // carrying BOTH a field-level `unique` and a single-column declared unique + // index states two scopes of which at most one takes effect + // (`unique/double-declaration`), and a declared index with bare + // `unique: true` states no scope at all (`unique/unscoped-declared-index` — + // the #4986 trap; protocol 18 rejects the spelling, #5082). { name: 'lintUniqueDeclarations', tier: 'advisory', diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index 9221b55581..d55a47a85a 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -69,46 +69,120 @@ function refOf(def: any): string | undefined { return def?.reference || def?.reference_to; } -// ─── Uniqueness declarations ──────────────────────────────────────── +// ─── Uniqueness declarations (ADR-0120) ───────────────────────────── export const UNIQUE_DOUBLE_DECLARATION = 'unique/double-declaration'; +export const UNIQUE_UNSCOPED_DECLARED_INDEX = 'unique/unscoped-declared-index'; /** Is `unique` declared at all? Mirrors `isUniqueDeclared` in @objectstack/spec/data. */ function uniqueDeclared(u: unknown): boolean { - return u === true || u === 'global'; + return u === true || u === 'global' || u === 'organization'; } /** - * R10 — the same column carries BOTH a field-level `unique: true` and an - * object-level single-column unique index (#3991). + * Which boundary does a FIELD-level `unique` ask for? Bare `true` is the + * positional synonym of `'organization'` (#3696, ADR-0120 D1 — valid + * indefinitely). + */ +function fieldUniqueScope(u: unknown): 'organization' | 'global' { + return u === 'global' ? 'global' : 'organization'; +} + +/** + * Which boundary does a DECLARED-index `unique` ask for? Bare `true` is the + * DEPRECATED positional spelling of `'global'` (verbatim columns — today's + * behavior; warned by `unique/unscoped-declared-index`, rejected at protocol + * 18, #5082). `'organization'` asks for the NULL-safe organization key part + * to be prepended at registration (ADR-0120 D1/D3). + */ +function indexUniqueScope(u: unknown): 'organization' | 'global' { + return u === 'organization' ? 'organization' : 'global'; +} + +/** + * R11 (ADR-0120 D5a) — a declared index carries bare `unique: true`: the one + * spelling whose scope is unstated. * - * The two spellings are deliberately different (see `IndexSchema`): field-level - * `unique: true` is tenant-scoped since #3696 — it materializes as - * `(organization_id, col)`, unique *within* the tenant — while a declared index - * is materialized over exactly the columns listed, i.e. platform-wide. Both are - * legitimate on their own; together on one column they are never right: + * Positional intent is the #4986 trap: an author writes + * `indexes: [{ fields: ['name'], unique: true }]` on an organization-scoped + * object, intends "unique per organization", and silently gets + * installation-wide. This rule fires on the SPELLING alone — deliberately no + * tenancy or posture inference (`organization_id` is kernel-injected at + * registration, not authored, so an authoring-time guess would be wrong half + * the time; that dead end is documented on #4698). Both replacement words are + * checkable at authoring time, which is what makes this the first gate in the + * #4986 saga that can actually run here. * - * - On a tenant-scoped object they CONTRADICT. The stricter one wins - * physically, so the global index enforces uniqueness and the tenant - * composite becomes a constraint nothing can ever trip. One of the two - * intents the author wrote is silently discarded. - * - On a tenancy-less object they are exactly REDUNDANT — both describe the - * same single-column unique index, under the same generated name. + * 17.x: warning. Protocol 18 rejects the spelling at validate/publish (#5082). + * Advisory — never fails a build in 17.x. + */ +export function lintUnscopedDeclaredIndexes(objects: any[]): LintIssue[] { + const issues: LintIssue[] = []; + if (!Array.isArray(objects) || objects.length === 0) return issues; + + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + if (!obj?.name) continue; + const declaredIndexes = Array.isArray(obj.indexes) ? obj.indexes : []; + for (let j = 0; j < declaredIndexes.length; j++) { + const idx = declaredIndexes[j]; + if (idx?.unique !== true) continue; // fires on the bare spelling only + const cols = Array.isArray(idx?.fields) + ? idx.fields.filter((f: unknown) => typeof f === 'string').join(', ') + : ''; + const indexLabel = typeof idx?.name === 'string' && idx.name.trim() ? ` '${idx.name.trim()}'` : ''; + issues.push({ + severity: 'warning', + rule: UNIQUE_UNSCOPED_DECLARED_INDEX, + message: + `"${obj.name}" declares index${indexLabel} [${cols}] with bare \`unique: true\` — a unique index whose scope is ` + + `unstated (ADR-0120). Today the bare spelling materializes over exactly its \`fields\`, i.e. installation-wide; ` + + `an author who meant "unique per organization" gets no per-organization constraint and no error. ` + + `Protocol 18 rejects this spelling (#5082).`, + path: `objects[${i}].indexes[${j}]`, + fix: + `State the scope: \`unique: 'global'\` (installation-wide — exactly today's behavior) or ` + + `\`unique: 'organization'\` (one holder per organization — the driver prepends the NULL-safe ` + + `organization key part at registration).`, + }); + } + } + return issues; +} + +/** + * R10 (ADR-0120 D5b) — the same single column carries BOTH a field-level + * `unique` and a declared single-column unique index (#3991), judged in the + * scope vocabulary. Each side states (or positionally implies) a boundary — + * field: `true`/`'organization'` = per-organization, `'global'` = + * installation-wide; declared index: `'global'` (or bare `true`, its + * deprecated spelling) = installation-wide, `'organization'` = + * per-organization — giving four quadrants: + * + * - Different scopes (field per-organization × index `'global'`, or field + * `'global'` × index `'organization'`): CONTRADICTION. The installation-wide + * index is physically stricter and wins; the per-organization constraint + * can never be tripped. One declared intent is silently dead. + * - Same scope (both per-organization, or both installation-wide): + * REDUNDANCY — the same index declared twice. * - * Tenancy is deliberately NOT inferred here: `organization_id` is injected by - * the kernel at registration rather than authored, so an authoring-time guess - * would be wrong half the time. The combination is worth flagging either way, - * and the message names both readings so the author picks the one they meant. + * Tenancy is deliberately NOT inferred here — the quadrants are judged from + * the two spellings alone, which is exactly what the vocabulary buys + * (pre-ADR-0120, the contradiction quadrant could only be described + * conditionally on unknowable tenancy). * - * A field declared `unique: 'global'` is exempt: it already says - * platform-wide, so the declared index restates the same intent rather than - * contradicting it (still redundant, but not a silent loss of meaning). + * A composite declared index (`['organization_id', 'email']`) stays exempt: + * it is the legacy hand-written organization spelling and agrees with the + * field-level default (its `'organization'` respelling nudge is ADR-0120 D5c, + * a separate wave). * * Advisory. The resulting stack is well-defined — the cost is an intent that * never takes effect, not a broken artifact — so this never fails a build. */ export function lintUniqueDeclarations(objects: any[]): LintIssue[] { - const issues: LintIssue[] = []; + // D5a rides along so `os build`'s narrow unique sweep (#3991) reports the + // unscoped spelling too — both rules read only the declarations. + const issues: LintIssue[] = lintUnscopedDeclaredIndexes(objects); if (!Array.isArray(objects) || objects.length === 0) return issues; for (let i = 0; i < objects.length; i++) { @@ -118,8 +192,8 @@ export function lintUniqueDeclarations(objects: any[]): LintIssue[] { if (declaredIndexes.length === 0) continue; // Columns covered by a declared SINGLE-column unique index. A composite - // (`['organization_id', 'email']`) is the explicit tenant-scoped spelling — - // it agrees with the field-level default rather than fighting it. + // (`['organization_id', 'email']`) is the legacy hand-written organization + // spelling — it agrees with the field-level default rather than fighting it. const singleColumnUniqueIndexes = new Map(); for (const idx of declaredIndexes) { if (!uniqueDeclared(idx?.unique)) continue; @@ -131,24 +205,50 @@ export function lintUniqueDeclarations(objects: any[]): LintIssue[] { for (const { name, def } of fieldEntries(obj.fields)) { if (!uniqueDeclared(def?.unique)) continue; - if (def.unique === 'global') continue; // already says platform-wide — no lost intent const idx = singleColumnUniqueIndexes.get(name); if (!idx) continue; + + const fScope = fieldUniqueScope(def.unique); + const iScope = indexUniqueScope(idx.unique); const indexLabel = typeof idx?.name === 'string' && idx.name.trim() ? ` '${idx.name.trim()}'` : ''; + const fieldSpelling = `\`unique: ${typeof def.unique === 'string' ? `'${def.unique}'` : def.unique}\``; + const indexSpelling = `\`unique: ${typeof idx.unique === 'string' ? `'${idx.unique}'` : idx.unique}\``; + + let message: string; + let fix: string; + if (fScope === iScope) { + // Same scope on both sides — the same index declared twice. + const boundary = fScope === 'global' ? 'installation-wide' : 'per-organization'; + message = + `"${obj.name}.${name}" declares field-level ${fieldSpelling} AND a single-column unique index${indexLabel} ` + + `(${indexSpelling}) on the same column. Both ask for the same ${boundary} boundary — the same unique ` + + `index declared twice (ADR-0120 D5b). Redundant, not contradictory: drop one so the intent has a single home.`; + fix = + fScope === 'global' + ? `Keep ONE spelling of installation-wide uniqueness: \`unique: 'global'\` on '${name}', or the declared index — not both.` + : `Keep ONE spelling of per-organization uniqueness: \`unique: 'organization'\` on '${name}' (preferred), or the declared \`'organization'\` index — not both.`; + } else { + // Different scopes — the installation-wide side is physically stricter + // and wins; the per-organization intent is dead on arrival. + const globalSide = fScope === 'global' ? `field-level ${fieldSpelling}` : `declared index${indexLabel} (${indexSpelling})`; + const orgSide = fScope === 'global' ? `declared index${indexLabel} (${indexSpelling})` : `field-level ${fieldSpelling}`; + message = + `"${obj.name}.${name}" declares an installation-wide unique (${globalSide}) AND a per-organization unique ` + + `(${orgSide}) on the same column — the two scopes CONTRADICT (ADR-0120 D5b). The installation-wide index is ` + + `physically stricter and wins; the per-organization constraint can never be tripped, so one of the two ` + + `intents you wrote is silently dead.`; + fix = + `Pick ONE scope and say it once: for installation-wide uniqueness keep \`unique: 'global'\` and drop the ` + + `per-organization declaration; for per-organization uniqueness set \`unique: 'organization'\` (field-level on ` + + `'${name}', or on the declared index) and drop the installation-wide one.`; + } + issues.push({ severity: 'warning', rule: UNIQUE_DOUBLE_DECLARATION, - message: - `"${obj.name}.${name}" declares field-level \`unique: true\` AND a single-column unique index${indexLabel} on the same column. ` + - `Since #3696 the field-level form is scoped per tenant — \`(tenant, ${name})\` — while a declared index is materialized ` + - `over exactly its \`fields\`, i.e. platform-wide. On a tenant-scoped object the global index wins and the per-tenant ` + - `constraint can never be reached; on a tenancy-less object the two are the same index declared twice. Either way one of ` + - `the two declarations has no effect.`, + message, path: `objects[${i}]`, - fix: - `Pick the intent: for platform-wide uniqueness set \`unique: 'global'\` on '${name}' and drop the duplicate index; ` + - `for per-tenant uniqueness drop the index (the field-level declaration already builds the tenant composite), ` + - `or spell the index out as \`fields: ['organization_id', '${name}']\` if you want it explicit.`, + fix, }); } } diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index acde454239..fa8bfb98c8 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -459,7 +459,13 @@ export { VIEW_REF_FORM_TARGET_KIND, } from './lint-view-refs.js'; -export { lintUniqueDeclarations, lintDataModel, UNIQUE_DOUBLE_DECLARATION } from './data-model-rules.js'; +export { + lintUniqueDeclarations, + lintUnscopedDeclaredIndexes, + lintDataModel, + UNIQUE_DOUBLE_DECLARATION, + UNIQUE_UNSCOPED_DECLARED_INDEX, +} from './data-model-rules.js'; export type { LintIssue, Severity } from './data-model-rules.js'; // ─── The registry itself (#4409, relocated #4463) ──────────────────── diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 9afa0e13d7..3dc8a56a5e 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -688,6 +688,7 @@ "isLegacyApiMethod (function)", "isMultiValueField (function)", "isNowDefaultToken (function)", + "isOrganizationUnique (function)", "isPlainRecord (function)", "isRuntimeDefaultToken (function)", "isTenancyDisabled (function)", diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 1d2d063609..ce6780e87c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -235,35 +235,81 @@ export const AddressSchema = lazySchema(() => z.object({ * } */ /** - * Uniqueness scope for a `unique` constraint (#3696). + * Prescriptive rejection for a mis-spelled `unique` scope (ADR-0120 + * §Terminology; pattern of `strictTenancyError`): the error must carry the + * vocabulary and, for the two predictable near-misses (`'tenant'`, `'org'`), + * name `'organization'` explicitly — a typo must be a loud, fixable parse + * error, never a silent scope change. Declared before `UniqueScopeSchema` + * because `OS_EAGER_SCHEMAS=1` evaluates the factory at module load (TDZ). + */ +const uniqueScopeError: z.core.$ZodErrorMap = (issue) => { + if (issue.code !== 'invalid_union') return undefined; + const input = (issue as { input?: unknown }).input; + const spelled = typeof input === 'string' ? `'${input}'` : String(input); + const nearMiss = + input === 'tenant' || input === 'org' + ? ` ${spelled} is not accepted and is not an alias — the per-organization scope is spelled 'organization' (ADR-0120: "tenant" is overloaded across deployment topologies, and the platform spells the word out).` + : ''; + return ( + `Invalid unique scope ${spelled}. Allowed: true/false, 'organization' ` + + `(one holder per organization — the explicit spelling of true), or 'global' ` + + `(one holder across the whole installation).${nearMiss}` + ); +}; + +/** + * Uniqueness scope for a `unique` constraint (#3696, ADR-0120 D1). + * + * The vocabulary is `boolean | 'global' | 'organization'` — the scope of a + * unique constraint is *said*, never inferred from where the declaration sits. + * + * `unique: true` on an organization-scoped object materializes as a COMPOSITE + * unique index `(organization key part, field)` — "unique within the + * organization" — matching how every other tenant-aware subsystem already + * behaves (reads are RLS-filtered, writes stamp the tenant column, and the + * autonumber sequence table is keyed by `(object, tenant_id, field, scope)` so + * each organization counts from 1). A single-column global index contradicted + * that: two organizations each issuing `PROD-00001` collided on an index + * neither of them could see, and the resulting UNIQUE violation doubled as a + * cross-tenant existence oracle (a rejected insert told org B that *somebody + * else* holds the value). * - * `unique: true` on a tenant-scoped object materializes as a COMPOSITE unique - * index `(tenantField, field)` — "unique within the tenant" — matching how - * every other tenant-aware subsystem already behaves (reads are RLS-filtered, - * writes stamp the tenant column, and the autonumber sequence table is keyed by - * `(object, tenant_id, field, scope)` so each tenant counts from 1). A - * single-column global index contradicted that: two tenants each issuing - * `PROD-00001` collided on an index neither of them could see, and the - * resulting UNIQUE violation doubled as a cross-tenant existence oracle - * (a rejected insert told tenant B that *somebody else* holds the value). + * `unique: 'organization'` is the EXPLICIT spelling of that same + * per-organization scope (ADR-0120 D1) — a synonym of `true` at field level, + * with identical materialization. Non-normative guidance: official examples, + * scaffolding, and generators emit `'organization'` in new code so intent is + * legible without knowing the positional default; bare `true` stays valid + * indefinitely (it has exactly one documented meaning here and no trap). * - * `unique: 'global'` opts back into the old single-column behavior for the - * genuinely platform-wide identifiers where it is correct: an external - * provider id (`stripe_customer_id`), a DNS hostname, a globally reserved - * slug, a device identity. Global uniqueness is the special case and now has - * to say so. + * `unique: 'global'` opts into installation-wide uniqueness for the genuinely + * platform-wide identifiers where it is correct: an external provider id + * (`stripe_customer_id`), a DNS hostname, a globally reserved slug, a device + * identity. Global uniqueness is the special case and has to say so. * - * On an object with no tenant column (`tenancy.enabled: false`, or simply no - * tenant field) both spellings materialize identically — single-column unique. - * Single-tenant deployments are therefore unaffected: the tenant column is - * constant, so the composite index degenerates to the single-column one. + * NULL-safety of the per-organization scope (ADR-0120 D3, #5030): the kernel + * injects `organization_id` unconditionally, so on single-organization stacks + * the column exists and is NULL on every row — and SQL UNIQUE is + * NULL-distinct, so a raw-column composite `(organization_id, field)` enforces + * NOTHING there. The organization key part therefore materializes NULL-safe as + * `COALESCE(organization_id, '__global__')` (driver-side, #5030): NULL-org + * rows collapse into one platform bucket, unique among themselves; non-NULL + * rows are untouched. On an object with no tenant column at all + * (`tenancy.enabled: false`) both per-organization spellings degrade to the + * listed column alone. + * + * Rejected words (ADR-0120 §Terminology): `'tenant'` and `'org'` are not + * accepted and are NOT aliases — "tenant" is overloaded across deployment + * topologies and the platform spells the noun out (`organization_id`). The + * parse error names `'organization'` so the fix ships inside the rejection. */ export const UniqueScopeSchema = lazySchema(() => - z.union([z.boolean(), z.literal('global')]), + z.union([z.boolean(), z.literal('global'), z.literal('organization')], { + error: uniqueScopeError, + }), ); /** @see UniqueScopeSchema */ -export type UniqueScope = boolean | 'global'; +export type UniqueScope = boolean | 'global' | 'organization'; /** * Does this `unique` declaration ask for platform-wide (cross-tenant) @@ -276,10 +322,30 @@ export function isGlobalUnique(unique: unknown): boolean { /** * Does this `unique` declaration ask for a unique constraint at all? - * Both `true` and `'global'` do; `false`/absent do not. + * `true`, `'global'` and `'organization'` do; `false`/absent do not. + * `'organization'` counts from the moment the word exists (ADR-0120 D1) — + * a scope the vocabulary accepts but no driver reads would be + * declarable-but-inert, the exact ADR-0078 class this vocabulary closes. */ export function isUniqueDeclared(unique: unknown): boolean { - return unique === true || unique === 'global'; + return unique === true || unique === 'global' || unique === 'organization'; +} + +/** + * Is this the EXPLICIT `'organization'` spelling (ADR-0120 D1)? + * + * Deliberately narrow — it detects the word, not the scope. At field level, + * bare `true` also means per-organization (the positional default; + * `isUniqueDeclared(u) && !isGlobalUnique(u)` is that question), so field + * consumers need no new predicate. This helper exists for the DECLARED-index + * side, where the two spellings differ: `'organization'` asks the driver to + * prepend the NULL-safe organization key part at registration, while bare + * `true` stays verbatim (deprecated spelling of `'global'` — warned in 17.x, + * rejected at protocol 18). Single source of truth so SQL and Mongo index + * sync cannot drift on the distinction. + */ +export function isOrganizationUnique(unique: unknown): boolean { + return unique === 'organization'; } /** @@ -399,8 +465,8 @@ export const FieldSchema = lazySchema(() => strictObject({ multiple: z.boolean().default(false).describe('Allow multiple values (Stores as Array/JSON). Applicable for select, lookup, file, image.'), // `true` = unique WITHIN the tenant on a tenant-scoped object (composite // `(tenantField, field)` index); `'global'` = platform-wide single-column - // unique. See {@link UniqueScopeSchema} for why `true` is tenant-scoped. - unique: UniqueScopeSchema.default(false).describe("Unique constraint. true = unique within the tenant (composite with the tenant column on tenant-scoped objects); 'global' = unique platform-wide across all tenants"), + // unique. See {@link UniqueScopeSchema} for the scope vocabulary (ADR-0120). + unique: UniqueScopeSchema.default(false).describe("Unique constraint and its scope (ADR-0120). 'organization' = one holder per organization (NULL-safe composite with the organization key part on organization-scoped objects) — prefer this explicit spelling in new code; true = same per-organization scope (positional synonym, stays valid); 'global' = one holder across the whole installation. 'tenant'/'org' are rejected — the word is 'organization'"), defaultValue: z.unknown().optional().describe('Default value'), /** Text/String Constraints */ diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 4171ff9ba8..16ed1ca6ef 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -279,18 +279,33 @@ export const IndexSchema = lazySchema(() => z.object({ name: z.string().optional().describe('Index name (auto-generated if not provided)'), fields: z.array(z.string()).describe('Fields included in the index'), type: z.enum(['btree', 'hash', 'gin', 'gist', 'fulltext']).optional().default('btree').describe('Index algorithm type'), - // A DECLARED index is materialized over exactly the columns listed in - // `fields` — no tenant column is injected, unlike field-level `unique` - // (#3696). The distinction is deliberate: field-level `unique` has no syntax - // for a composite, so its default had to carry the tenant scope; here the - // author already spells the columns out, and many declared indexes are - // legitimately platform-wide (a DNS hostname, a reserved slug, an external - // provider id). Tenant-scoped declared indexes are written explicitly and - // always have been — `fields: ['organization_id', 'code']`. + // Unique scope on a DECLARED index (ADR-0120 D1, amending #3696): // - // `'global'` is accepted as a synonym of `true` so a schema can state the - // intent in one vocabulary across both spellings; it changes nothing here. - unique: UniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness. Materialized over exactly `fields` — no tenant column is injected; list the tenant column explicitly for a per-tenant index. 'global' is a synonym of true, for symmetry with field-level `unique`"), + // - `'global'` — the VERBATIM contract: materialized over exactly the + // columns listed in `fields`, no organization column injected. Correct + // for genuinely installation-wide reservations (a DNS hostname, a + // reserved slug, an external provider id, every engine dedup key). + // - `'organization'` — one holder per organization: the driver prepends + // the organization key part to the listed columns at REGISTRATION, + // where tenancy is known (authoring-time inference is impossible — + // `organization_id` is kernel-injected, not authored). The key part is + // NULL-safe — `COALESCE(organization_id, '__global__')` (ADR-0120 D3, + // #5030): NULL-organization rows form one platform bucket instead of + // escaping the constraint under SQL's NULL-distinct semantics. + // Materialization lands with #5030's driver PR. On an object with no + // organization column it degrades to the listed columns alone, + // mirroring field-level behavior. + // - bare `true` — the DEPRECATED positional spelling of `'global'` + // (today's verbatim behavior, unchanged). It is the spelling whose + // meaning was encoded by position — the #4986 trap — so 17.x warns + // (lint `unique/unscoped-declared-index`) and protocol 18 rejects it + // with a prescriptive error (#5082). State the scope. + // + // The old advice "spell a per-tenant index as + // `fields: ['organization_id', 'code']`" survives as valid legacy input, + // but new code says `unique: 'organization'` — the hand-written composite + // is NOT NULL-safe (#5030). + unique: UniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'"), partial: z.string().optional().describe('Partial index condition (SQL WHERE clause for conditional indexes)'), })); diff --git a/packages/spec/src/data/unique-scope.test.ts b/packages/spec/src/data/unique-scope.test.ts index 8b98f945fa..b5952de869 100644 --- a/packages/spec/src/data/unique-scope.test.ts +++ b/packages/spec/src/data/unique-scope.test.ts @@ -1,22 +1,25 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; -import { FieldSchema, isGlobalUnique, isUniqueDeclared } from './field.zod'; +import { FieldSchema, isGlobalUnique, isOrganizationUnique, isUniqueDeclared } from './field.zod'; import { IndexSchema } from './object.zod'; /** - * `unique` scope contract (#3696). + * `unique` scope contract (#3696, ADR-0120 D1). * - * `unique: true` means "unique within the tenant"; `unique: 'global'` means - * "unique platform-wide". The vocabulary lives here so the SQL driver and the + * The scope of a unique constraint is said, never inferred from position: + * `'organization'` = one holder per organization (field-level `true` is its + * positional synonym, valid indefinitely), `'global'` = one holder across the + * whole installation. The vocabulary lives here so the SQL driver and the * Mongo driver read the same declaration the same way rather than each * re-deriving it (the drift that let three DDL paths disagree in the first - * place). + * place). Rejected words carry the fix: `'tenant'`/`'org'` name + * `'organization'` in the parse error (ADR-0120 §Terminology). */ -describe('UniqueScope (#3696)', () => { +describe('UniqueScope (#3696, ADR-0120)', () => { describe('FieldSchema.unique', () => { - it('accepts true / false / global', () => { - for (const unique of [true, false, 'global'] as const) { + it("accepts true / false / 'global' / 'organization'", () => { + for (const unique of [true, false, 'global', 'organization'] as const) { const parsed = FieldSchema.parse({ type: 'text', unique }); expect(parsed.unique).toBe(unique); } @@ -30,15 +33,39 @@ describe('UniqueScope (#3696)', () => { // `'tenant'` / `'org'` / `'globl'` are the plausible mis-spellings. Each // has to be a loud parse error: accepting it as truthy-but-unknown is how // a constraint quietly changes scope. - for (const bad of ['tenant', 'org', 'globl', 'GLOBAL', '']) { + for (const bad of ['tenant', 'org', 'globl', 'GLOBAL', 'ORGANIZATION', '']) { expect(() => FieldSchema.parse({ type: 'text', unique: bad })).toThrow(); } }); + + it("names 'organization' in the rejection for 'tenant' and 'org' — no alias, prescriptive error (ADR-0120 §Terminology)", () => { + for (const bad of ['tenant', 'org']) { + try { + FieldSchema.parse({ type: 'text', unique: bad }); + expect.unreachable(`'${bad}' must not parse`); + } catch (e) { + const message = JSON.stringify((e as { issues?: unknown }).issues ?? String(e)); + expect(message).toContain("'organization'"); + expect(message).toContain('not an alias'); + } + } + }); + + it('carries the full vocabulary in every scope rejection, so the fix ships inside the error', () => { + try { + FieldSchema.parse({ type: 'text', unique: 'globl' }); + expect.unreachable("'globl' must not parse"); + } catch (e) { + const message = JSON.stringify((e as { issues?: unknown }).issues ?? String(e)); + expect(message).toContain("'global'"); + expect(message).toContain("'organization'"); + } + }); }); describe('IndexSchema.unique', () => { - it('accepts true / false / global', () => { - for (const unique of [true, false, 'global'] as const) { + it("accepts true / false / 'global' / 'organization'", () => { + for (const unique of [true, false, 'global', 'organization'] as const) { expect(IndexSchema.parse({ fields: ['a'], unique }).unique).toBe(unique); } }); @@ -46,21 +73,51 @@ describe('UniqueScope (#3696)', () => { it('defaults to false', () => { expect(IndexSchema.parse({ fields: ['a'] }).unique).toBe(false); }); + + it("rejects 'tenant' with the 'organization' prescription, matching the field-level error", () => { + try { + IndexSchema.parse({ fields: ['a'], unique: 'tenant' }); + expect.unreachable("'tenant' must not parse"); + } catch (e) { + const message = JSON.stringify((e as { issues?: unknown }).issues ?? String(e)); + expect(message).toContain("'organization'"); + } + }); }); describe('predicates', () => { - it('isUniqueDeclared is true for both true and global', () => { + it("isUniqueDeclared is true for true, 'global' and 'organization' — the new word must never be declarable-but-inert (ADR-0078)", () => { expect(isUniqueDeclared(true)).toBe(true); expect(isUniqueDeclared('global')).toBe(true); + expect(isUniqueDeclared('organization')).toBe(true); expect(isUniqueDeclared(false)).toBe(false); expect(isUniqueDeclared(undefined)).toBe(false); }); - it('isGlobalUnique singles out the platform-wide spelling', () => { + it('isGlobalUnique singles out the platform-wide spelling — unchanged by ADR-0120', () => { expect(isGlobalUnique('global')).toBe(true); expect(isGlobalUnique(true)).toBe(false); expect(isGlobalUnique(false)).toBe(false); + expect(isGlobalUnique('organization')).toBe(false); expect(isGlobalUnique(undefined)).toBe(false); }); + + it("isOrganizationUnique detects the explicit word only — field-level bare true stays the drivers' isUniqueDeclared && !isGlobalUnique path", () => { + expect(isOrganizationUnique('organization')).toBe(true); + expect(isOrganizationUnique(true)).toBe(false); + expect(isOrganizationUnique('global')).toBe(false); + expect(isOrganizationUnique(false)).toBe(false); + expect(isOrganizationUnique(undefined)).toBe(false); + }); + + it("field-level 'organization' is a pure synonym of true through the driver predicates — declared, non-global (ADR-0120 D1)", () => { + // The pair every driver consults: same answers for `true` and + // `'organization'` = same materialization, which is the "explicit + // synonym, zero semantic change" contract. + for (const spelling of [true, 'organization'] as const) { + expect(isUniqueDeclared(spelling)).toBe(true); + expect(isGlobalUnique(spelling)).toBe(false); + } + }); }); }); diff --git a/scripts/adr-anchors.json b/scripts/adr-anchors.json index 64ad269944..a5fb2aba11 100644 --- a/scripts/adr-anchors.json +++ b/scripts/adr-anchors.json @@ -100,6 +100,21 @@ "file": "packages/spec/src/kernel/functional-completeness.ts", "adrs": ["ADR-0078"], "invariant": "Every rule here cites the runtime line that silently skips the instance, and every deliberate NON-rule cites the evidence that exempts it (ADR-0078 §6). `multiselect` without `options` is NOT flagged — `record-validator.ts` blesses it verbatim as free-form tags, which is §1 case (3) genuinely-optional; `user` relationships and `timeline`/`tree` views are exempt for their own stated reasons. A rule added without its skip-site citation, or an exemption 'fixed', is a false prescription: it tells an AI author to change working metadata, which is the failure this gate exists to prevent." + }, + { + "file": "packages/spec/src/data/field.zod.ts", + "adrs": ["ADR-0120"], + "invariant": "`UniqueScopeSchema` is the closed scope vocabulary `boolean | 'global' | 'organization'` (ADR-0120 D1). `'organization'` is the explicit synonym of field-level `true` — identical materialization, and `isUniqueDeclared` MUST count it (a word the vocabulary accepts but no driver reads is declarable-but-inert, ADR-0078). `'tenant'` and `'org'` are rejected, never aliased — the parse error names `'organization'` (§Terminology; PD #12: one contract, no dialects). The single-tenant story is D3's NULL-safe COALESCE bucket, NOT the retired 'composite degenerates to single-column' claim #5030 falsified." + }, + { + "file": "packages/spec/src/data/object.zod.ts", + "adrs": ["ADR-0120"], + "invariant": "`IndexSchema.unique` scope contract (ADR-0120 D1, amending #3696): `'global'` = verbatim columns (no organization column injected); `'organization'` = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration; bare `true` = deprecated positional spelling of `'global'` — warned in 17.x (lint unique/unscoped-declared-index), rejected at protocol 18 (#5082). Do NOT re-broaden the describe() back to 'true and global are synonyms, list the tenant column yourself' — that is the #4986 trap, and the hand-written composite is not NULL-safe (#5030)." + }, + { + "file": "packages/lint/src/data-model-rules.ts", + "adrs": ["ADR-0120"], + "invariant": "The uniqueness rules judge SPELLINGS only, never inferred tenancy or posture (authoring-time tenancy inference is impossible — `organization_id` is kernel-injected; the dead end is documented on #4698). `unique/unscoped-declared-index` (D5a) fires on bare declared `unique: true` — warning in 17.x, the protocol-18 gate rejects the spelling (#5082). `unique/double-declaration` (D5b) is the four-quadrant scope matrix: cross-scope = contradiction (the installation-wide side wins physically), same-scope = redundancy. Fix texts speak the `'organization'`/`'global'` vocabulary — never resurrect the hand-written `['organization_id', …]` advice (not NULL-safe, #5030)." } ] } From 6dd30889b1a301808bb6957afaf35f780d22cd5a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 09:03:23 +0000 Subject: [PATCH 2/3] chore(spec): regen api-surface after origin/main merge (merge-driver deferred artifact) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7 --- packages/spec/api-surface.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 3dc8a56a5e..c7ad34180e 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3125,6 +3125,7 @@ "envelopeViolations (function)", "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", + "normalizeEndpointPath (function)", "readServiceSelfInfo (function)", "standardErrorCodeForHttpStatus (function)" ], @@ -3565,6 +3566,7 @@ "AnalyticsQueryInput (type)", "AnalyticsResult (interface)", "AnalyticsStrategy (interface)", + "ApiEndpointMatch (interface)", "ApprovalActionAttachment (interface)", "ApprovalActionKind (type)", "ApprovalActionRow (interface)", From 565f130bb8da6f9dde4ddeaddff68549af1aeab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 09:15:12 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(lint):=20unique/unscoped-declared-index?= =?UTF-8?q?=20=E7=8B=AC=E7=AB=8B=E6=B3=A8=E5=86=8C=E8=BF=9B=20AUTHORING=5F?= =?UTF-8?q?RULES,=E4=B8=8D=E5=86=8D=E6=90=AD=E8=BD=A6=20R10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导出面即承诺(#4409 wiring 测试):导出的规则必须入注册表或入账。搭车在 lintUniqueDeclarations 里跑对注册表不可见,且 validate/build 会经两条路径 各报一次。改为:R11 自己的 AUTHORING_RULES 条目(validate/build,advisory), lintDataModel 直接调用它服务 os lint —— 三命令全覆盖,每条 finding 恰好一报。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Akrzh2mHi2siSNVPPtfTw7 --- .changeset/unique-scope-lint-rules.md | 6 ++-- packages/cli/test/data-model-rules.test.ts | 9 ++++-- packages/lint/src/authoring-rules.ts | 37 ++++++++++++++++++---- packages/lint/src/data-model-rules.ts | 17 ++++++---- 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/.changeset/unique-scope-lint-rules.md b/.changeset/unique-scope-lint-rules.md index 3dbd531f05..82f72edd7f 100644 --- a/.changeset/unique-scope-lint-rules.md +++ b/.changeset/unique-scope-lint-rules.md @@ -12,9 +12,9 @@ feat(lint): uniqueness-scope rules speak the ADR-0120 vocabulary (#4986, D5a/D5b `'global'` (installation-wide — exactly today's behavior) or `'organization'` (one holder per organization). Protocol 18 rejects the spelling (#5082). Exported as `lintUnscopedDeclaredIndexes` + - `UNIQUE_UNSCOPED_DECLARED_INDEX`, and reported through - `lintUniqueDeclarations` / `lintDataModel`, so `os validate`, `os build` and - `os lint` all carry it. + `UNIQUE_UNSCOPED_DECLARED_INDEX`, registered as its own AUTHORING_RULES + entry (validate/build) and called by `lintDataModel` for `os lint`, so all + three commands report it — each finding exactly once. - **R10 `unique/double-declaration` rewritten as the four-quadrant scope matrix** (ADR-0120 D5b): field `true`/`'organization'` × declared `'global'` (or bare `true`, its deprecated spelling) on the same single column = diff --git a/packages/cli/test/data-model-rules.test.ts b/packages/cli/test/data-model-rules.test.ts index ae885d3e48..9a9abab525 100644 --- a/packages/cli/test/data-model-rules.test.ts +++ b/packages/cli/test/data-model-rules.test.ts @@ -248,10 +248,15 @@ describe('lintUnscopedDeclaredIndexes — bare unique: true on a declared index expect(issues[0].rule).toBe(RULE); }); - it('rides along in lintUniqueDeclarations and lintDataModel, so validate/build/lint all report it', () => { + it('surfaces through lintDataModel (os lint) but NOT through lintUniqueDeclarations — one report per command', () => { + // `os validate`/`os build` run R11 via its own AUTHORING_RULES entry and + // R10 via lintUniqueDeclarations; `os lint` runs both via lintDataModel. + // If lintUniqueDeclarations also emitted R11, validate/build would report + // every finding twice. const objs = [{ name: 'a', fields: {}, indexes: [{ fields: ['x'], unique: true }] }]; - expect(has(lintUniqueDeclarations(objs), RULE)).toBe(true); expect(has(lintDataModel(objs), RULE)).toBe(true); + expect(has(lintUniqueDeclarations(objs), RULE)).toBe(false); + expect(lintDataModel(objs).filter((i) => i.rule === RULE)).toHaveLength(1); }); }); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index a9d1302e53..8de21edadc 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -128,7 +128,7 @@ import { lintFlowPatterns } from './lint-flow-patterns.js'; import { lintLivenessProperties } from './lint-liveness-properties.js'; import { lintAutonumberFormats } from './lint-autonumber-formats.js'; import { lintViewRefs } from './lint-view-refs.js'; -import { lintUniqueDeclarations } from './data-model-rules.js'; +import { lintUniqueDeclarations, lintUnscopedDeclaredIndexes } from './data-model-rules.js'; type AnyRec = Record; @@ -742,12 +742,35 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ hint: f.hint, })), }, - // #3991 / ADR-0120 D5a+D5b — the uniqueness-declaration pair: a column - // carrying BOTH a field-level `unique` and a single-column declared unique - // index states two scopes of which at most one takes effect - // (`unique/double-declaration`), and a declared index with bare - // `unique: true` states no scope at all (`unique/unscoped-declared-index` — - // the #4986 trap; protocol 18 rejects the spelling, #5082). + // ADR-0120 D5a — a declared index with bare `unique: true` states no scope + // at all (`unique/unscoped-declared-index` — the #4986 trap). Fires on the + // spelling alone, no tenancy inference; 17.x warns, protocol 18 rejects the + // spelling (#5082). + { + name: 'lintUnscopedDeclaredIndexes', + tier: 'advisory', + input: 'parsed', + commands: ['validate', 'build'], + source: 'packages/lint/src/data-model-rules.ts', + surfaces: CLI_ONLY, + surfaceReason: RUNTIME_OBJECT_WRITES_P2, + scopeReason: + '`os lint` already reports this rule through `lintDataModel`, which calls it directly ahead of ' + + 'R10 in its best-practice sweep — registering it for `lint` as well would report every finding ' + + 'twice. This is coverage recorded, not coverage missing: all three commands report the rule.', + run: (stack) => + lintUnscopedDeclaredIndexes(Array.isArray(stack.objects) ? (stack.objects as unknown[]) : []).map((f) => ({ + severity: f.severity === 'suggestion' ? ('info' as const) : f.severity, + rule: f.rule, + where: f.path, + path: f.path, + message: f.message, + hint: f.fix ?? '', + })), + }, + // #3991 / ADR-0120 D5b — a column carrying BOTH a field-level `unique` and a + // single-column declared unique index states two scopes of which at most one + // takes effect (`unique/double-declaration`, the four-quadrant matrix). { name: 'lintUniqueDeclarations', tier: 'advisory', diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index d55a47a85a..3dfd7bd3c4 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -115,6 +115,9 @@ function indexUniqueScope(u: unknown): 'organization' | 'global' { * * 17.x: warning. Protocol 18 rejects the spelling at validate/publish (#5082). * Advisory — never fails a build in 17.x. + * + * Wiring: own AUTHORING_RULES entry (validate/build), and `lintDataModel` + * calls it for `os lint` — each command reports each finding exactly once. */ export function lintUnscopedDeclaredIndexes(objects: any[]): LintIssue[] { const issues: LintIssue[] = []; @@ -180,9 +183,7 @@ export function lintUnscopedDeclaredIndexes(objects: any[]): LintIssue[] { * never takes effect, not a broken artifact — so this never fails a build. */ export function lintUniqueDeclarations(objects: any[]): LintIssue[] { - // D5a rides along so `os build`'s narrow unique sweep (#3991) reports the - // unscoped spelling too — both rules read only the declarations. - const issues: LintIssue[] = lintUnscopedDeclaredIndexes(objects); + const issues: LintIssue[] = []; if (!Array.isArray(objects) || objects.length === 0) return issues; for (let i = 0; i < objects.length; i++) { @@ -263,9 +264,13 @@ export function lintUniqueDeclarations(objects: any[]): LintIssue[] { * metadata-generation scorer. */ export function lintDataModel(objects: any[]): LintIssue[] { - // R10 lives in its own exported function so `os build` can run that ONE rule - // without pulling in the whole best-practice sweep (#3991). - const issues: LintIssue[] = lintUniqueDeclarations(objects); + // R10/R11 live in their own exported functions so `os validate`/`os build` + // can run those rules without pulling in the whole best-practice sweep + // (#3991, ADR-0120 D5a) — here `os lint` picks both up. + const issues: LintIssue[] = [ + ...lintUnscopedDeclaredIndexes(objects), + ...lintUniqueDeclarations(objects), + ]; if (!Array.isArray(objects) || objects.length === 0) return issues; // Index: parent object name → child relationships pointing at it.