diff --git a/.changeset/5082-declared-index-unique-scope.md b/.changeset/5082-declared-index-unique-scope.md new file mode 100644 index 00000000000..5045d5892da --- /dev/null +++ b/.changeset/5082-declared-index-unique-scope.md @@ -0,0 +1,52 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@objectstack/platform-objects': patch +'@objectstack/metadata-core': patch +'@objectstack/metadata-protocol': patch +'@objectstack/plugin-security': patch +'@objectstack/plugin-auth': patch +'@objectstack/plugin-sharing': patch +'@objectstack/service-messaging': patch +'@objectstack/service-automation': patch +'@objectstack/service-realtime': patch +--- + +A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + +**Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + +**What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + +- the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; +- lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + +**What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + +**What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | +| …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | +| `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + +**The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + +**Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + +### The kit + +- **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. +- **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. +- **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. +- **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. +- **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. diff --git a/content/docs/data-modeling/indexing.mdx b/content/docs/data-modeling/indexing.mdx index d1c6a94cc95..3eecf9fd1b5 100644 --- a/content/docs/data-modeling/indexing.mdx +++ b/content/docs/data-modeling/indexing.mdx @@ -67,11 +67,15 @@ Two rules make the choice safe to write and safe to deploy: valid indefinitely — `email: Field.email({ unique: true })` is correct, and the explicit spelling is simply preferred in new code. -On a **declared index** it is the deprecated spelling of `'global'`: it -materializes over exactly the listed columns. Because that reads like the -field-level meaning but does the opposite, `os lint` / `os build` / `os validate` -report it as `unique/unscoped-declared-index`, and it is rejected outright at -protocol 18. State the scope. +On a **declared index** it is refused since protocol 18. It was the positional +spelling of `'global'` — it materialized over exactly the listed columns — +which reads like the field-level meaning but does the opposite. `os validate`, +`os build` and the runtime save door refuse it at the schema, `os lint` +reports it as `unique/unscoped-declared-index`, and every refusal names both +replacements. State the scope: `'global'` builds exactly the index bare `true` +built. Metadata already stored or built with the bare spelling converts to +`'global'` when it loads — the same physical index, no migration — and +`os migrate meta --from 17` lists the edits for your sources. @@ -105,7 +109,8 @@ constraint by listing the column itself: ```typescript // the legacy spelling — still valid, still materialized exactly as written -indexes: [{ fields: ['name', 'organization_id'], unique: true }] +// (bare `true` before protocol 18, which converts to 'global') +indexes: [{ fields: ['name', 'organization_id'], unique: 'global' }] ``` This keeps working forever and forces no migration, so `os lint` only *suggests* diff --git a/content/docs/protocol/objectql/schema.mdx b/content/docs/protocol/objectql/schema.mdx index 274e374a0c9..4995dd41bb2 100644 --- a/content/docs/protocol/objectql/schema.mdx +++ b/content/docs/protocol/objectql/schema.mdx @@ -628,10 +628,11 @@ indexes: unique: global ``` -Bare `unique: true` on a *declared index* is the deprecated spelling of -`'global'` (materialized over exactly the listed columns). It is warned by -`os lint` / `os build` / `os validate` as `unique/unscoped-declared-index` and -rejected at protocol 18 — state the scope instead. A legacy index that lists the +Bare `unique: true` on a *declared index* is refused since protocol 18. It was +the positional spelling of `'global'` (materialized over exactly the listed +columns); `os validate` / `os build` refuse it at the schema and `os lint` +reports it as `unique/unscoped-declared-index` — state the scope instead. +Stored metadata carrying it converts to `'global'`, the same physical index. A legacy index that lists the organization column itself (`fields: [organization_id, code]`) keeps working unchanged; respelling its scope to `'organization'` makes the listed column NULL-safe in place. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 84c3fa64f22..6aae27d2c2a 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -948,7 +948,7 @@ Metadata query with filtering, sorting, and pagination | **datasource** | `string` | optional (default: `"default"`) | 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; "__proto__", "constructor" and "prototype" are refused. | -| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes | +| **indexes** | `{ name?: string; fields: string[]; unique?: false \| '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). | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 8c945c4fc5c..ea202e0fe28 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -66,7 +66,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | 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) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | +| **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | 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; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | | **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | @@ -152,7 +152,7 @@ const result = ApiMethod.parse(data); | **datasource** | `string` | optional (default: `"default"`) | 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; "__proto__", "constructor" and "prototype" are refused. | -| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes | +| **indexes** | `{ name?: string; fields: string[]; unique?: false \| '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). | @@ -298,7 +298,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | 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) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | +| **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | 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; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | | **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | @@ -544,7 +544,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[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Additional indexes to merge into the target object | +| **indexes** | `{ name?: string; fields: string[]; unique?: false \| 'global' \| 'organization' }[]` | optional | Additional indexes to merge into the target object | | **priority** | `integer` | optional (default: `200`) | Merge priority (higher = applied later) | ### Nested Shape: `ObjectExtension.fields[string]` @@ -633,7 +633,7 @@ const result = ApiMethod.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | optional | Index name (auto-generated if not provided) | | **fields** | `string[]` | ✅ | Fields included in the index | -| **unique** | `boolean \| 'global' \| 'organization'` | optional (default: `false`) | 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) — state the scope. 'tenant'/'org' are rejected — the word is 'organization' | +| **unique** | `false \| 'global' \| 'organization'` | optional (default: `false`) | 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; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization' | | **type** | `never` | optional | [REMOVED] `indexes[].type` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever read it. `SqlDriver.syncDeclaredIndexes` creates every declared index through knex's `table.index()` / `table.unique()`, which cannot express an access method, so the value changed no DDL; its `.default('btree')` merely made an inert knob show up in every parse output. Delete the key. The index method is the driver/dialect's decision (Postgres defaults to B-tree; `gin`/`gist`/`fulltext` are dialect-specific and are chosen by a database-layer migration when a workload actually needs one). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | | **partial** | `never` | optional | [REMOVED] `indexes[].partial` was removed in @objectstack/spec 17.0.0 (ADR-0049) — no driver ever emitted the `WHERE` clause, so a declared partial index was materialized as a FULL index and the predicate silently did nothing. Delete the key. Partial indexes are built at the database layer, not the declaration surface: issue `CREATE [UNIQUE] INDEX … WHERE ` from a runtime migration (this is what `metadata-protocol`'s `ensureOverlayIndex` already does for `sys_metadata`). Drift detection is unaffected — it reads partiality back from the database's own DDL, never from this key. Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index c9ae5f91671..2d8ef09c80e 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -329,7 +329,7 @@ Create a new object | **datasource** | `string` | optional (default: `"default"`) | 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; "__proto__", "constructor" and "prototype" are refused. | -| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes | +| **indexes** | `{ name?: string; fields: string[]; unique?: false \| '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). | @@ -616,7 +616,7 @@ Create a new object | **datasource** | `string` | optional (default: `"default"`) | 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; "__proto__", "constructor" and "prototype" are refused. | -| **indexes** | `{ name?: string; fields: string[]; unique?: boolean \| 'global' \| 'organization' }[]` | optional | Database performance indexes | +| **indexes** | `{ name?: string; fields: string[]; unique?: false \| '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). | diff --git a/packages/cli/test/build-json-advisory-parity.e2e.test.ts b/packages/cli/test/build-json-advisory-parity.e2e.test.ts index 65d66047de5..cf9f7517c93 100644 --- a/packages/cli/test/build-json-advisory-parity.e2e.test.ts +++ b/packages/cli/test/build-json-advisory-parity.e2e.test.ts @@ -126,9 +126,13 @@ const PLANTED_DOC = 'advparity_guide.md'; * * - `requires` names an unknown token -> one #3366 capability hint (RECORD); * - `src/docs/*.md` has unreadable tags -> one ADR-0046 doc advisory (RECORD); - * - a bare `unique: true` index -> one authoring-rule advisory, so a - * regression that REPLACED `ruleAdvisories` while folding the new lists in - * goes red here instead of passing quietly. + * - one column unique twice, per organization on the field and `'global'` + * on a declared index -> one authoring-rule advisory + * (`unique/double-declaration`), so a regression that REPLACED + * `ruleAdvisories` while folding the new lists in goes red here instead of + * passing quietly. (It used to be a bare declared `unique: true`, R11's + * warning; protocol 18 refuses that spelling outright, so it can no longer + * sit in a stack that builds.) */ const CONFIG_PLANTED = ` import { defineStack } from '@objectstack/spec'; @@ -141,8 +145,8 @@ export default defineStack({ name: 'ap_thing', label: 'Thing', sharingModel: 'private', - indexes: [{ name: 'ap_title_idx', fields: ['title'], unique: true }], - fields: { title: { type: 'text', label: 'Title' } }, + indexes: [{ name: 'ap_title_idx', fields: ['title'], unique: 'global' }], + fields: { title: { type: 'text', label: 'Title', unique: true } }, }, ], }, { strict: false }); @@ -172,7 +176,7 @@ export default defineStack({ name: 'ac_thing', label: 'Thing', sharingModel: 'private', - indexes: [{ name: 'ac_title_idx', fields: ['title'], unique: true }], + indexes: [{ name: 'ac_title_idx', fields: ['title'], unique: 'global' }], fields: { title: { type: 'text', label: 'Title' } }, }, ], @@ -300,7 +304,7 @@ describe('#11727 — `os build --json` carries the capability-provider and packa expect( records.map((r) => r.rule), 'the authoring-rule advisory records were lost from `warnings`', - ).toContain('unique/unscoped-declared-index'); + ).toContain('unique/double-declaration'); }, 120_000); it('adds NO new top-level key to the payload — this fills a declared key, it is not a new surface', async () => { diff --git a/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts b/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts index 892fa32178c..722d21e38a9 100644 --- a/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts +++ b/packages/cli/test/build-json-undeclared-key-parity.e2e.test.ts @@ -114,9 +114,12 @@ const PLANTED_KEY = 'zzzUndeclaredProbeKey'; /** * A stack that builds cleanly and carries BOTH advisory kinds: * - * - a bare `unique: true` index -> one authoring-RULE advisory (a RECORD), so - * a regression that dropped `ruleAdvisories` while folding in the strings - * goes red here rather than passing quietly; + * - `title` unique twice — per organization on the field, `'global'` on a + * declared index -> one authoring-RULE advisory (a RECORD, + * `unique/double-declaration`), so a regression that dropped + * `ruleAdvisories` while folding in the strings goes red here rather than + * passing quietly (it was a bare declared `unique: true` until protocol 18 + * refused that spelling outright); * - `PLANTED_KEY` inside the field's `visibleWhen` -> one undeclared-key * finding (a STRING), the subject of this file. */ @@ -130,11 +133,12 @@ export default defineStack({ name: 'uk_ticket', label: 'Ticket', sharingModel: 'private', - indexes: [{ name: 'uk_title_idx', fields: ['title'], unique: true }], + indexes: [{ name: 'uk_title_idx', fields: ['title'], unique: 'global' }], fields: { title: { type: 'text', label: 'Title', + unique: true, visibleWhen: { dialect: 'cel', source: 'true', ${PLANTED_KEY}: 1 }, }, }, @@ -158,11 +162,12 @@ export default defineStack({ name: 'uk_ticket', label: 'Ticket', sharingModel: 'private', - indexes: [{ name: 'uk_title_idx', fields: ['title'], unique: true }], + indexes: [{ name: 'uk_title_idx', fields: ['title'], unique: 'global' }], fields: { title: { type: 'text', label: 'Title', + unique: true, visibleWhen: { dialect: 'cel', source: 'true' }, }, }, @@ -241,7 +246,7 @@ describe('#11643 — `os build --json` carries the undeclared-authoring-key warn expect( records.map((r) => r.rule), 'the authoring-rule advisory records were lost from `warnings`', - ).toContain('unique/unscoped-declared-index'); + ).toContain('unique/double-declaration'); // …and the undeclared-key members are STRINGS, which is the shape // `os validate --json` ships them in. A consumer reads one shape from diff --git a/packages/cli/test/data-model-rules.test.ts b/packages/cli/test/data-model-rules.test.ts index 3f38496037e..74fcf8f7fef 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, lintUnscopedDeclaredIndexes, lintLegacyOrganizationComposites } from '@objectstack/lint'; +import { authoringRulesFor, lintDataModel, lintUniqueDeclarations, lintUnscopedDeclaredIndexes, lintLegacyOrganizationComposites } from '@objectstack/lint'; import { FieldSchema } from '@objectstack/spec/data'; import { lintConfig } from '../src/commands/lint'; @@ -337,7 +337,7 @@ describe('lintUnscopedDeclaredIndexes — bare unique: true on a declared index expect(lintUnscopedDeclaredIndexes(undefined as any)).toEqual([]); }); - it('warns on a bare declared unique, whatever the column count, and prescribes both words', () => { + it('refuses a bare declared unique (error), whatever the column count, and prescribes both words', () => { const issues = lintUnscopedDeclaredIndexes([ { name: 'crm_case', @@ -351,11 +351,11 @@ describe('lintUnscopedDeclaredIndexes — bare unique: true on a declared index 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.severity).toBe('error'); // 17.x warned; protocol 18 refuses the spelling (#5082) + // D5a: the fix names both words, and identifies 'global' as the index bare `true` built. expect(issue.fix).toContain("unique: 'global'"); expect(issue.fix).toContain("unique: 'organization'"); - expect(issue.fix).toMatch(/today's behavior/); + expect(issue.fix).toMatch(/the exact index bare `true` built/); expect(issue.message).toContain('ADR-0120'); } expect(issues[0].path).toBe('objects[0].indexes[0]'); @@ -395,15 +395,21 @@ describe('lintUnscopedDeclaredIndexes — bare unique: true on a declared index expect(issues[0].rule).toBe(RULE); }); - 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. + it('surfaces through its own registry entry on all three commands, and NOT through lintDataModel or lintUniqueDeclarations — one report per command', () => { + // Protocol 18 (#5082): R11 is an error, so it is a GATING registry entry, + // and a gating rule runs on all three commands — `os lint` included. That + // is why `lintDataModel` stopped calling it: `os lint` runs both the + // registry and `lintDataModel`, and a second call there would report every + // finding twice. If lintUniqueDeclarations emitted R11, validate/build + // would report it twice the same way. const objs = [{ name: 'a', fields: {}, indexes: [{ fields: ['x'], unique: true }] }]; - expect(has(lintDataModel(objs), RULE)).toBe(true); + expect(has(lintDataModel(objs), RULE)).toBe(false); expect(has(lintUniqueDeclarations(objs), RULE)).toBe(false); - expect(lintDataModel(objs).filter((i) => i.rule === RULE)).toHaveLength(1); + for (const command of ['validate', 'build', 'lint'] as const) { + const entries = authoringRulesFor(command).filter((r) => r.name === 'lintUnscopedDeclaredIndexes'); + expect(entries, `R11 runs once under os ${command}`).toHaveLength(1); + expect(entries[0]!.tier).toBe('gating'); + } }); }); @@ -699,10 +705,13 @@ describe('lintLegacyOrganizationComposites — S6 respelling nudge (ADR-0120 D5c }]; expect(has(lintDataModel(objs), RULE)).toBe(true); expect(lintDataModel(objs).filter((i) => i.rule === RULE)).toHaveLength(1); - // R11 fires on the same index for the OTHER reason (unstated scope) — the + // R11 refuses the same index for the OTHER reason (unstated scope) — the // two rules are complementary, not duplicates: R11 says "say which scope", - // R12 says "the shape tells me which one you meant". - expect(has(lintDataModel(objs), 'unique/unscoped-declared-index')).toBe(true); + // R12 says "the shape tells me which one you meant". R11 reaches `os lint` + // through its own gating registry entry since #5082, not through + // lintDataModel — so here it is absent, and it fires on its own. + expect(has(lintDataModel(objs), 'unique/unscoped-declared-index')).toBe(false); + expect(has(lintUnscopedDeclaredIndexes(objs), 'unique/unscoped-declared-index')).toBe(true); }); }); diff --git a/packages/cli/test/per-package-dedup-positional-echo.test.ts b/packages/cli/test/per-package-dedup-positional-echo.test.ts index 9451fdf4e1d..e4029d910ad 100644 --- a/packages/cli/test/per-package-dedup-positional-echo.test.ts +++ b/packages/cli/test/per-package-dedup-positional-echo.test.ts @@ -37,9 +37,11 @@ * - a union twin differing in `where` — a different entity; * - a union twin differing in `rule`. * - * ⚠️ The first control is built on `unique/unscoped-declared-index`, ⛔ not on - * the `field-no-consumers` finding the echo cases use, and the fixture carries - * a bare `unique: true` index for no other reason. Measured, ⛔ not reasoned: + * ⚠️ The first control is built on `unique/legacy-organization-composite`, ⛔ not + * on the `field-no-consumers` finding the echo cases use, and the fixture + * carries a hand-written organization composite index for no other reason. (It + * was built on `unique/unscoped-declared-index` and a bare `unique: true` until + * protocol 18 refused that spelling at the parse this fixture goes through.) Measured, ⛔ not reasoned: * an ablation that widens the rewrite from the top-level index to EVERY index * left all six cases green while that control was written against a * `field-no-consumers` twin, because such a path ends in a field NAME and the @@ -127,8 +129,8 @@ const ORDERS_OBJECTS = [ name: { name: 'name', type: 'text', label: 'Order Number', required: true }, account: { name: 'account', type: 'lookup', label: 'Account', reference: 'pp_account' }, }, - // ⛔ Not decoration. A bare `unique: true` trips - // `unique/unscoped-declared-index`, whose path carries a NESTED index + // ⛔ Not decoration. A unique index LISTING the organization column trips + // `unique/legacy-organization-composite`, whose path carries a NESTED index // (`objects[0].indexes[0]`) — and a finding with a nested index is the ONLY // thing the NESTED control below can discriminate on. Every other rule this // fixture raises produces `objects[N].fields.`, where the sole index @@ -136,7 +138,7 @@ const ORDERS_OBJECTS = [ // of those cannot fail and is not a control. Measured: without this, an // ablation that rewrites EVERY index instead of the top-level one keeps all // six cases green. - indexes: [{ name: 'pp_order_name_uq', fields: ['name'], unique: true }], + indexes: [{ name: 'pp_order_name_uq', fields: ['name', 'organization_id'], unique: 'global' }], }, ]; @@ -225,14 +227,14 @@ describe('#18779 — the per-package de-duplication key is position-insensitive' // case is what goes red if someone does. // // ⚠️ It has to be built on a finding whose path ACTUALLY carries a nested - // index. `unique/unscoped-declared-index` is that finding here + // index. `unique/legacy-organization-composite` is that finding here // (`objects[0].indexes[0]`); `field-no-consumers` is not — its path ends in // a field NAME, so a twin built from it differs in a name rather than in a // position and stays distinct under any index rewrite at all. Measured: the // over-wide ablation keeps a `field-no-consumers` twin green and turns this // one red, which is the whole difference between a control and a decoration. const parsed = parsedArtifact(); - const target = rawSurvivors(parsed).find((f) => f.rule === 'unique/unscoped-declared-index'); + const target = rawSurvivors(parsed).find((f) => f.rule === 'unique/legacy-organization-composite'); expect(target, 'fixture no longer raises a finding with a NESTED index in its path').toBeTruthy(); const local = unprefixed(target!); expect(local.path).toMatch(/^objects\[\d+\]\.indexes\[\d+\]$/); diff --git a/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts index b4c56a89d8d..503d7fcf3a2 100644 --- a/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts @@ -1,7 +1,8 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { SqlDriver, classifyIndexKeyPart, parseIndexDdl } from '../src/index.js'; +import { applyConversionsToStoredItem } from '@objectstack/spec'; +import { SqlDriver, classifyIndexKeyPart, expectedIndexes, parseIndexDdl } from '../src/index.js'; /** * Unique-scope materialization: tenancy composites (#3696) + the explicit @@ -29,10 +30,14 @@ import { SqlDriver, classifyIndexKeyPart, parseIndexDdl } from '../src/index.js' * → single-column `(field)`. * field `unique: 'global'` * → single-column `(field)`, platform-wide, always. - * declared index `unique: true` / `'global'` + * declared index `unique: 'global'` * → VERBATIM listed columns, never rewritten — the #3696 verbatim contract, - * now the `'global'` arm of the vocabulary (bare `true` retires at - * protocol 18; the synonym pin below retires with it). + * now the `'global'` arm of the vocabulary. Bare `true`, its positional + * spelling, is refused by the spec since protocol 18 (#5082), and stored + * metadata converts it to `'global'` (ADR-0120 D2) — the D2 corpus pin + * below proves that respelling builds the same indexes byte for byte. + * The old "`'global'` is a synonym of `true`" pin retired with the bare + * spelling. * declared index `unique: 'organization'` * → NULL-safe organization key part PREPENDED to the listed columns; with * no tenant column it degrades to the listed columns (S11); a listed @@ -278,10 +283,12 @@ describe('SqlDriver unique × tenancy (#3696)', () => { // ── Declared indexes are NOT rewritten ──────────────────────────────────── - it('leaves declared object-level indexes exactly as authored', async () => { + it("leaves a declared 'global' index exactly as authored", async () => { // A declared index names its own columns. Many are platform-wide on // purpose (a DNS hostname, a reserved slug, a Stripe customer id), so - // injecting a tenant column here would silently break them. + // injecting a tenant column here would silently break them. Retained FOR + // `'global'` (ADR-0120 D6.6) — the spelling the verbatim contract has + // been stated in since bare `true` was refused at protocol 18. await driver.initObjects([ { name: 'slug_reservation', @@ -289,7 +296,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => { organization_id: { type: 'string' }, slug: { type: 'string' }, }, - indexes: [{ fields: ['slug'], unique: true }], + indexes: [{ fields: ['slug'], unique: 'global' }], } as any, ]); @@ -303,17 +310,104 @@ describe('SqlDriver unique × tenancy (#3696)', () => { ).rejects.toThrow(/UNIQUE constraint failed|duplicate key value/); }); - it("accepts unique: 'global' on a declared index as a synonym of true", async () => { - await driver.initObjects([ + // ── ADR-0120 D2: the bare-`true` → `'global'` conversion is zero-drift ──── + // + // The regression corpus ADR-0120 D2 names: the S4/S5 shapes, i.e. the nine + // engine-owned idempotency/dedup keys of the #4986 inventory, each as it was + // declared when the ADR was written — bare `unique: true` on a declared + // index. (Four have since moved to `'organization'` on purpose and the rest + // to `'global'`; the corpus is frozen at the ADR-time spelling because that + // is the population stored rows carry.) Each is replayed through the stored + // seam's own conversion pass, and two facts are pinned: + // + // 1. the expected-index output is BYTE-IDENTICAL before and after — the + // conversion changes the spelling and nothing the driver materializes; + // 2. on a database built from the bare spelling, the drift plan for the + // converted metadata is EMPTY — no index op, safe or destructive. + // + // The second has a control: the same database against one index moved to + // `'organization'` is NOT empty, so the empty plan is a reading, not a + // detector that cannot see index drift. + describe('ADR-0120 D2 — the nine-key corpus converts with zero drift', () => { + const NINE_KEY_CORPUS: Array<{ name: string; fields: Record; indexes: any[] }> = [ + { name: 'sys_job', fields: { name: { type: 'string' } }, indexes: [{ fields: ['name'], unique: true }] }, + { name: 'sys_notification', fields: { dedup_key: { type: 'string' } }, indexes: [{ fields: ['dedup_key'], unique: true }] }, { - name: 'domain', - fields: { organization_id: { type: 'string' }, host: { type: 'string' } }, - indexes: [{ fields: ['host'], unique: 'global' }], - } as any, - ]); + name: 'http_delivery', + fields: { source: { type: 'string' }, dedup_key: { type: 'string' } }, + indexes: [{ fields: ['source', 'dedup_key'], unique: true }], + }, + { name: 'sys_presence', fields: { session_id: { type: 'string' } }, indexes: [{ fields: ['session_id'], unique: true }] }, + { + name: 'sys_email_template', + fields: { name: { type: 'string' }, locale: { type: 'string' } }, + indexes: [{ fields: ['name', 'locale'], unique: true }], + }, + { + name: 'notification_delivery', + fields: { notification_id: { type: 'string' }, recipient_id: { type: 'string' }, channel: { type: 'string' } }, + indexes: [{ fields: ['notification_id', 'recipient_id', 'channel'], unique: true }], + }, + { + name: 'notification_receipt', + fields: { notification_id: { type: 'string' }, user_id: { type: 'string' }, channel: { type: 'string' } }, + indexes: [{ fields: ['notification_id', 'user_id', 'channel'], unique: true }], + }, + { + name: 'notification_subscription', + fields: { topic: { type: 'string' }, principal: { type: 'string' } }, + indexes: [{ fields: ['topic', 'principal'], unique: true }], + }, + { + name: 'notification_preference', + fields: { user_id: { type: 'string' }, topic: { type: 'string' }, channel: { type: 'string' } }, + indexes: [{ fields: ['user_id', 'topic', 'channel'], unique: true }], + }, + ].map((o) => ({ ...o, fields: { organization_id: { type: 'string' }, ...o.fields } })); + + const converted = () => NINE_KEY_CORPUS.map((o) => applyConversionsToStoredItem('object', o)); + + /** What the driver asks the database for, per table — serialized, so "identical" means bytes. */ + const expectedBytes = (objects: Array<{ name: string; fields: Record; indexes: any[] }>) => + JSON.stringify( + objects.map((o) => + expectedIndexes({ + table: o.name, + fields: o.fields, + tenantField: 'organization_id', + declaredIndexes: o.indexes, + physicalColumns: new Set(['id', ...Object.keys(o.fields)]), + }), + ), + ); + + it('the conversion respells every corpus index to global, and only that', () => { + const after = converted(); + expect(after.map((o) => o.indexes.map((i: any) => i.unique))).toEqual(NINE_KEY_CORPUS.map(() => ['global'])); + // Nothing but the scope word moved: same names, fields and columns. + expect(after.map((o) => ({ ...o, indexes: o.indexes.map(({ unique: _u, ...rest }: any) => rest) }))).toEqual( + NINE_KEY_CORPUS.map((o) => ({ ...o, indexes: o.indexes.map(({ unique: _u, ...rest }: any) => rest) })), + ); + }); - const uniques = await uniqueIndexColumns('domain'); - expect(Object.values(uniques)).toContainEqual(['host']); + it('expected-index output is byte-identical before and after the conversion', () => { + const before = expectedBytes(NINE_KEY_CORPUS); + expect(before).toContain('"unique":true'); // non-vacuous: the corpus does declare uniques + expect(expectedBytes(converted())).toBe(before); + }); + + it('a database built from the bare spelling shows an EMPTY drift plan for the converted metadata', async () => { + await driver.initObjects(NINE_KEY_CORPUS as any); + expect(await driver.detectManagedDrift(converted() as any)).toEqual([]); + + // Control: one index stated per organization IS index drift here, so the + // empty plan above is the detector reading, not the detector blind. + const moved = converted().map((o) => + o.name === 'sys_presence' ? { ...o, indexes: [{ fields: ['session_id'], unique: 'organization' }] } : o, + ); + const drift = await driver.detectManagedDrift(moved as any); + expect(drift.filter((d) => d.table === 'sys_presence').length).toBeGreaterThan(0); + }); }); // ── Legacy migration: drop the global index, create the composite ───────── diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index fd1b6670d9c..3d74420ee5c 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -515,6 +515,24 @@ const RUNTIME_OBJECT_ADVISORY_VOLUME = 'the save response and rendered by Studio\'s designer), not refusal risk. The object door opened to ' + 'the gating object rules alone; crossing an advisory one is a separate UX decision.'; +/** + * A gating rule whose whole finding the runtime write door already refuses ONE + * STEP EARLIER, in its own parse (#5082). + * + * `saveMetaItem` validates the body against the type's schema + * (`getMetadataTypeSchema` — `ObjectSchema` for an `object` write) and throws + * `INVALID_METADATA` / 422 before the authoring gate is reached. Where that + * schema refuses exactly what the rule reports — with the same prescription — + * a runtime crossing could never fire: every body it would judge has already + * been refused, so the entry would be wiring that reads as coverage and runs + * nothing. + */ +const RUNTIME_REFUSED_BY_THE_DOOR_PARSE = + 'Refused one step earlier at this surface: the runtime write door parses an object body against ' + + 'ObjectSchema before the authoring gate runs, and IndexSchema.unique refuses bare `true` there ' + + 'with the same prescription (INVALID_METADATA / 422), so a crossing could never fire. The rule ' + + 'gates `os lint`, which never parses.'; + /** * `ExprIssue` is the one rule finding that carries no rule id of its own — it * predates the `{ rule, path, hint }` shape every other rule settled on. Given @@ -1781,20 +1799,21 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ }, // 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). + // spelling alone, no tenancy inference. 17.x warned; protocol 18 refuses the + // spelling (#5082, ADR-0120 D7), so the rule is GATING and runs on all three + // commands — `lintDataModel` stopped calling it in the same change, so + // `os lint` reports it here, once. Under `os validate` / `os build` the + // parse (`IndexSchema.unique`) refuses the spelling first with the same + // prescription; under `os lint`, which never parses, this entry is the + // refusal. { name: 'lintUnscopedDeclaredIndexes', - tier: 'advisory', + tier: 'gating', input: 'parsed', - commands: ['validate', 'build'], + commands: ALL, source: 'packages/lint/src/data-model-rules.ts', surfaces: CLI_ONLY, - surfaceReason: RUNTIME_OBJECT_ADVISORY_VOLUME, - 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.', + surfaceReason: RUNTIME_REFUSED_BY_THE_DOOR_PARSE, run: (stack) => lintUnscopedDeclaredIndexes(Array.isArray(stack.objects) ? (stack.objects as unknown[]) : []).map((f) => ({ severity: f.severity === 'suggestion' ? ('info' as const) : f.severity, diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index d1494490b69..cf7676d3a9d 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -387,11 +387,12 @@ function fieldUniqueScope(u: unknown): 'organization' | 'global' { } /** - * 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). + * Which boundary does a DECLARED-index `unique` ask for? `'organization'` asks + * for the NULL-safe organization key part to be prepended at registration + * (ADR-0120 D1/D3); `'global'` is the verbatim column list. Bare `true` — the + * positional spelling of `'global'`, refused since protocol 18 (#5082) — still + * reads as `'global'` here, because this rule also runs over the unparsed + * stack `os lint` sees, where R11 reports the spelling itself. */ function indexUniqueScope(u: unknown): 'organization' | 'global' { return u === 'organization' ? 'organization' : 'global'; @@ -411,13 +412,20 @@ function indexUniqueScope(u: unknown): 'organization' | 'global' { * checkable at authoring time, which is what makes this the first gate in the * #4986 saga that can actually run here. * - * 17.x: warning. Protocol 18 rejects the spelling at validate/publish (#5082). - * Advisory — never fails a build in 17.x. + * Protocol 18 (#5082, ADR-0120 D7): an ERROR. 17.x warned; the spelling is + * now refused, and this rule is one of its two refusal channels. The other is + * the schema itself — `IndexSchema.unique` refuses bare `true` with the same + * prescription, at every door that parses (`os validate` / `os build`, + * `ObjectSchema.create`, the runtime save door). This rule is what refuses it + * where nothing parses: `os lint` is the cheap pre-flight and runs every rule + * over the NORMALIZED, unparsed stack. * * Concrete harm: #8323 measured the cross-tenant 409-vs-201 oracle end to end. * - * Wiring: own AUTHORING_RULES entry (validate/build), and `lintDataModel` - * calls it for `os lint` — each command reports each finding exactly once. + * Wiring: one AUTHORING_RULES entry on all three commands (a gating rule must + * run on all three). `lintDataModel` no longer calls it — the registry entry + * is what reports it under `os lint`, so each command reports each finding + * exactly once. */ export function lintUnscopedDeclaredIndexes(objects: any[]): LocatedLintIssue[] { const issues: LocatedLintIssue[] = []; @@ -436,20 +444,20 @@ export function lintUnscopedDeclaredIndexes(objects: any[]): LocatedLintIssue[] const cols = colList.join(', '); const indexLabel = typeof idx?.name === 'string' && idx.name.trim() ? ` '${idx.name.trim()}'` : ''; issues.push({ - severity: 'warning', + severity: 'error', rule: UNIQUE_UNSCOPED_DECLARED_INDEX, where: indexWhere(obj, idx, j, colList), 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, and stored metadata that still carries it converts to ` + - `\`unique: 'global'\`, which builds the same physical index.`, + `unstated (ADR-0120). Protocol 18 refuses this spelling on a declared index: it built the index over ` + + `exactly its \`fields\`, i.e. installation-wide, while reading like "unique per organization". ` + + `Stored metadata that still carries it converts to \`unique: 'global'\`, which builds the same physical index.`, path: `objects[${i}].indexes[${j}]`, fix: - `State the scope: \`unique: 'global'\` (installation-wide — exactly today's behavior) or ` + + `State the scope: \`unique: 'global'\` (installation-wide — the exact index bare \`true\` built) or ` + `\`unique: 'organization'\` (one holder per organization — the driver prepends the NULL-safe ` + - `organization key part at registration).`, + `organization key part at registration). Run \`os migrate meta --from 17\` to list the mechanical ` + + `edits for existing sources; apply them by hand.`, }); } } @@ -570,7 +578,8 @@ export function lintUniqueDeclarations(objects: any[]): LocatedLintIssue[] { * predating the vocabulary that can now say so. * * Why this is worth a nudge rather than left alone. The legacy spelling - * `{ fields: ['organization_id', 'name'], unique: true }` says "per + * `{ fields: ['organization_id', 'name'], unique: 'global' }` (bare `true` + * before protocol 18 — the conversion respells it, zero drift) says "per * organization" to a reader and materializes as a plain composite — and SQL * UNIQUE is NULL-distinct, so on every row where the organization column is * NULL it enforces **nothing** (#5030, measured). On a single-organization @@ -645,11 +654,13 @@ export function lintLegacyOrganizationComposites(objects: any[]): LocatedLintIss * metadata-generation scorer. */ export function lintDataModel(objects: any[]): LintIssue[] { - // R10/R11/R12 live in their own exported functions so `os validate`/`os build` + // R10/R12 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/D5b/D5c) — here `os lint` picks all three up. + // (#3991, ADR-0120 D5b/D5c) — here `os lint` picks both up. R11 is not + // called here since it became an error at protocol 18 (#5082): a gating + // rule runs on all three commands through its own registry entry, which is + // what reports it under `os lint`, exactly once. const issues: LintIssue[] = [ - ...lintUnscopedDeclaredIndexes(objects), ...lintUniqueDeclarations(objects), ...lintLegacyOrganizationComposites(objects), ]; diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index 1f29cfa5524..e7b3db3774e 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -19,11 +19,15 @@ * authoring lineages — a lower bound, since every measured population is * authored config-file metadata — with a post-launch replay of stored * overlay rows as the standing audit; - * - the six ADVISORY-tier object rules do NOT ride. They cannot refuse a + * - the ADVISORY-tier object rules do NOT ride. They cannot refuse a * write at all, and the measured ~8-advisories-per-object-write designer * noise they would add is a UX decision with its own card, not a * `runtimeTypes` edit. The fence is pinned below BY NAME so a later - * widening moves this line consciously rather than by drift. + * widening moves this line consciously rather than by drift. The + * adjudication fenced six; one, `lintUnscopedDeclaredIndexes`, left the + * advisory tier at protocol 18 (#5082 — bare `unique: true` on a declared + * index became an error) and is pinned on its own below: still off this + * door, because the door's own parse refuses the spelling first. * * The six refusal cases are the adjudication's own non-vacuity controls — the * six synthetic broken bodies the measurement round pushed through the gate @@ -50,12 +54,15 @@ const CROSSED = [ 'validateRuleSchemaFormats', ] as const; -/** The six advisory rules the adjudication fenced OUT, by name. */ +/** + * The advisory rules the adjudication fenced OUT, by name — five of its six. + * The sixth, `lintUnscopedDeclaredIndexes`, became gating at protocol 18 + * (#5082) and has its own case below. + */ const FENCED = [ 'validateRecordTitle', 'validateSemanticRoles', 'lintLivenessProperties', - 'lintUnscopedDeclaredIndexes', 'lintUniqueDeclarations', 'lintLegacyOrganizationComposites', ] as const; @@ -153,7 +160,7 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () ]); }); - it('the six advisory-tier object rules do NOT ride — the Q2 fence, by name', () => { + it('the fenced advisory-tier object rules do NOT ride — the Q2 fence, by name', () => { const atDoor = new Set(runtimeAuthoringRulesFor('object').map((r) => r.name)); for (const name of FENCED) { expect(atDoor.has(name), `${name} reached the object write door — the #4716 adjudication ` @@ -161,7 +168,7 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () + `Crossing it is a UX/volume decision with its own card, not a runtimeTypes edit.`).toBe(false); const entry = AUTHORING_RULES.find((r) => r.name === name); expect(entry, `${name} left AUTHORING_RULES — re-point this fence or retire it`).toBeDefined(); - expect(entry!.tier, `${name} changed tier — this fence pins the ADVISORY six; a severity ` + expect(entry!.tier, `${name} changed tier — this fence pins the ADVISORY rules; a severity ` + `change needs its own PR and re-opens the crossing question for the rule`).toBe('advisory'); // [#19542] The fence is about the OBJECT door, and until this card every // fenced rule happened to be off the runtime surface ENTIRELY, so "is @@ -208,6 +215,22 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', () } }); + it('`lintUnscopedDeclaredIndexes` left the fence as GATING (#5082) and still does not ride: the door parse refuses first', () => { + // Protocol 18 refuses bare `unique: true` on a declared index (ADR-0120 + // D7), so the rule that reports it emits `error` and is gating — the + // severity change the fence above says needs its own PR. It is still NOT + // at the object door, for a reason that is not the advisory-volume one: + // `saveMetaItem` parses the body against ObjectSchema before the + // authoring gate runs, and `IndexSchema.unique` refuses the spelling there + // with the same prescription, so a crossing could never fire. + const entry = AUTHORING_RULES.find((r) => r.name === 'lintUnscopedDeclaredIndexes'); + expect(entry, 'lintUnscopedDeclaredIndexes left AUTHORING_RULES').toBeDefined(); + expect(entry!.tier).toBe('gating'); + expect(entry!.surfaces).not.toContain('runtime-publish'); + expect(entry!.surfaceReason ?? '').toMatch(/IndexSchema\.unique refuses bare `true`/); + expect(runtimeAuthoringRulesFor('object').map((r) => r.name)).not.toContain('lintUnscopedDeclaredIndexes'); + }); + // ── [#15495] The MEMBER surface of the reference-integrity suite ── // // The roster case above pins which AUTHORING_RULES entries reach this door. diff --git a/packages/metadata-core/src/objects/sys-metadata-history.object.ts b/packages/metadata-core/src/objects/sys-metadata-history.object.ts index 29baceb4b8c..4a83d767cf0 100644 --- a/packages/metadata-core/src/objects/sys-metadata-history.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata-history.object.ts @@ -180,8 +180,8 @@ export const SysMetadataHistoryObject = ObjectSchema.create({ }, indexes: [ - { fields: ['organization_id', 'event_seq'], unique: true }, - { fields: ['organization_id', 'type', 'name', 'version'], unique: true }, + { fields: ['organization_id', 'event_seq'], unique: 'global' }, + { fields: ['organization_id', 'type', 'name', 'version'], unique: 'global' }, { fields: ['organization_id', 'type', 'name', 'recorded_at'] }, // ADR-0009: getByHash() lookup — execution-pinned types resolve a // historical body by content hash via this index. diff --git a/packages/metadata-core/src/objects/sys-metadata.object.ts b/packages/metadata-core/src/objects/sys-metadata.object.ts index fda98fc776f..4e156560669 100644 --- a/packages/metadata-core/src/objects/sys-metadata.object.ts +++ b/packages/metadata-core/src/objects/sys-metadata.object.ts @@ -226,7 +226,7 @@ export const SysMetadataObject = ObjectSchema.create({ { name: 'idx_sys_metadata_overlay_active', fields: ['type', 'name', 'organization_id', 'package_id'], - unique: true, + unique: 'global', }, { name: 'idx_sys_metadata_org_type', fields: ['organization_id', 'type'] }, { fields: ['type', 'scope'] }, diff --git a/packages/metadata-core/src/objects/sys-view-definition.object.ts b/packages/metadata-core/src/objects/sys-view-definition.object.ts index 93d94efbaaa..820ffffe353 100644 --- a/packages/metadata-core/src/objects/sys-view-definition.object.ts +++ b/packages/metadata-core/src/objects/sys-view-definition.object.ts @@ -164,7 +164,7 @@ export const SysViewDefinitionObject = ObjectSchema.create({ { name: 'idx_sys_view_def_active', fields: ['name', 'organization_id', 'owner'], - unique: true, + unique: 'global', }, // The switcher query: views for one object within a tenant. { name: 'idx_sys_view_def_object', fields: ['organization_id', 'object'] }, diff --git a/packages/metadata-protocol/src/migrations/overlay-index.ts b/packages/metadata-protocol/src/migrations/overlay-index.ts index d1e238be948..1d7a5ccea21 100644 --- a/packages/metadata-protocol/src/migrations/overlay-index.ts +++ b/packages/metadata-protocol/src/migrations/overlay-index.ts @@ -9,7 +9,7 @@ * * ```ts * { name: 'idx_sys_metadata_overlay_active', - * fields: ['type', 'name', 'organization_id', 'package_id'], unique: true } + * fields: ['type', 'name', 'organization_id', 'package_id'], unique: 'global' } * ``` * * and `syncDeclaredIndexes` materializes it as an UNRESTRICTED, NULL-distinct diff --git a/packages/metadata-protocol/src/migrations/view-definition-active-index.ts b/packages/metadata-protocol/src/migrations/view-definition-active-index.ts index 7ba2b9dc75b..7de61ee3a88 100644 --- a/packages/metadata-protocol/src/migrations/view-definition-active-index.ts +++ b/packages/metadata-protocol/src/migrations/view-definition-active-index.ts @@ -8,7 +8,7 @@ * `metadata-core`'s `sys-view-definition.object.ts` declares * * ```ts - * { name: 'idx_sys_view_def_active', fields: ['name', 'organization_id', 'owner'], unique: true } + * { name: 'idx_sys_view_def_active', fields: ['name', 'organization_id', 'owner'], unique: 'global' } * ``` * * and its comment has always promised uniqueness **among ACTIVE rows**. It diff --git a/packages/platform-objects/src/audit/sys-notification.object.ts b/packages/platform-objects/src/audit/sys-notification.object.ts index becf15150fc..5da270eb7ce 100644 --- a/packages/platform-objects/src/audit/sys-notification.object.ts +++ b/packages/platform-objects/src/audit/sys-notification.object.ts @@ -177,7 +177,7 @@ export const SysNotification = ObjectSchema.create({ // concurrent emit with the same dedup_key loses the insert and converges to // the winner (mirrors the delivery outbox). SQL treats NULLs as distinct, so // the (common) events with no dedup_key are unconstrained. - { fields: ['dedup_key'], unique: true }, + { fields: ['dedup_key'], unique: 'global' }, { fields: ['source_object', 'source_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 3398eaf68f8..a4343c57787 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -310,7 +310,7 @@ export const SysAccount = ObjectSchema.create({ // the column drop is gated behind the `os migrate` preflight // (`sys-account-issuer-retirement`), never behind this constraint failing // mid-apply. - { fields: ['provider_id', 'account_id'], unique: true }, + { fields: ['provider_id', 'account_id'], unique: 'global' }, ], enable: { diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 628b42328f0..811e7ad35c7 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -332,7 +332,7 @@ export const SysApiKey = ObjectSchema.create({ }, indexes: [ - { fields: ['key'], unique: true }, + { fields: ['key'], unique: 'global' }, { fields: ['user_id'] }, { fields: ['prefix'] }, { fields: ['revoked'] }, diff --git a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts index ae751b9aa3e..d1592a42a70 100644 --- a/packages/platform-objects/src/identity/sys-business-unit-member.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit-member.object.ts @@ -116,7 +116,7 @@ export const SysBusinessUnitMember = ObjectSchema.create({ }, indexes: [ - { fields: ['business_unit_id', 'user_id'], unique: true }, + { fields: ['business_unit_id', 'user_id'], unique: 'global' }, { fields: ['user_id'] }, { fields: ['is_primary'] }, ], diff --git a/packages/platform-objects/src/identity/sys-business-unit.object.ts b/packages/platform-objects/src/identity/sys-business-unit.object.ts index 82b3ff9066e..03f1ce69b29 100644 --- a/packages/platform-objects/src/identity/sys-business-unit.object.ts +++ b/packages/platform-objects/src/identity/sys-business-unit.object.ts @@ -259,7 +259,7 @@ export const SysBusinessUnit = ObjectSchema.create({ indexes: [ { fields: ['organization_id'] }, { fields: ['parent_business_unit_id'] }, - { fields: ['code', 'organization_id'], unique: true }, + { fields: ['code', 'organization_id'], unique: 'global' }, { fields: ['active'] }, ], diff --git a/packages/platform-objects/src/identity/sys-device-code.object.ts b/packages/platform-objects/src/identity/sys-device-code.object.ts index e98db7d7591..4d129b6e8d2 100644 --- a/packages/platform-objects/src/identity/sys-device-code.object.ts +++ b/packages/platform-objects/src/identity/sys-device-code.object.ts @@ -145,8 +145,8 @@ export const SysDeviceCode = ObjectSchema.create({ }, indexes: [ - { fields: ['device_code'], unique: true }, - { fields: ['user_code'], unique: true }, + { fields: ['device_code'], unique: 'global' }, + { fields: ['user_code'], unique: 'global' }, { fields: ['status'], unique: false }, ], diff --git a/packages/platform-objects/src/identity/sys-member.object.ts b/packages/platform-objects/src/identity/sys-member.object.ts index 9beac68ce1c..5da60848ae9 100644 --- a/packages/platform-objects/src/identity/sys-member.object.ts +++ b/packages/platform-objects/src/identity/sys-member.object.ts @@ -344,7 +344,7 @@ export const SysMember = ObjectSchema.create({ }, indexes: [ - { fields: ['organization_id', 'user_id'], unique: true }, + { fields: ['organization_id', 'user_id'], unique: 'global' }, { fields: ['user_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts index 726e76fba6c..3f433ea6d24 100644 --- a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts @@ -147,7 +147,7 @@ export const SysOauthAccessToken = ObjectSchema.create({ }, indexes: [ - { fields: ['token'], unique: true }, + { fields: ['token'], unique: 'global' }, { fields: ['client_id'] }, { fields: ['session_id'] }, { fields: ['user_id'] }, diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index b89dd6d5b3d..3c8dbf84e39 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -566,7 +566,7 @@ export const SysOauthApplication = ObjectSchema.create({ }, indexes: [ - { fields: ['client_id'], unique: true }, + { fields: ['client_id'], unique: 'global' }, { fields: ['user_id'] }, { fields: ['reference_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts index d8d91c84b7c..c1125c35c97 100644 --- a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts @@ -164,7 +164,7 @@ export const SysOauthRefreshToken = ObjectSchema.create({ }, indexes: [ - { fields: ['token'], unique: true }, + { fields: ['token'], unique: 'global' }, { fields: ['client_id'] }, { fields: ['session_id'] }, { fields: ['user_id'] }, diff --git a/packages/platform-objects/src/identity/sys-oauth-resource-sourced-bounds.test.ts b/packages/platform-objects/src/identity/sys-oauth-resource-sourced-bounds.test.ts index 1e71fd547bb..5663c97312f 100644 --- a/packages/platform-objects/src/identity/sys-oauth-resource-sourced-bounds.test.ts +++ b/packages/platform-objects/src/identity/sys-oauth-resource-sourced-bounds.test.ts @@ -71,7 +71,7 @@ describe('#12313 — sys_oauth_resource.identifier and its referrer carry a sour expect(SysOauthClientResource.name).toBe('sys_oauth_client_resource'); expect(identifier()).toBeTypeOf('object'); expect(resourceId()).toBeTypeOf('object'); - expect(SysOauthResource.indexes).toContainEqual({ fields: ['identifier'], unique: true }); + expect(SysOauthResource.indexes).toContainEqual({ fields: ['identifier'], unique: 'global' }); }); it('the referent declares the width its sole producer can store', () => { diff --git a/packages/platform-objects/src/identity/sys-oauth-resource.object.ts b/packages/platform-objects/src/identity/sys-oauth-resource.object.ts index 3f7735227c0..98ddf178ddb 100644 --- a/packages/platform-objects/src/identity/sys-oauth-resource.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-resource.object.ts @@ -157,7 +157,7 @@ export const SysOauthResource = ObjectSchema.create({ }, indexes: [ - { fields: ['identifier'], unique: true }, + { fields: ['identifier'], unique: 'global' }, ], enable: { diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 38336413c5b..3baca62dc3a 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -368,7 +368,7 @@ export const SysOrganization = ObjectSchema.create({ }, indexes: [ - { fields: ['slug'], unique: true }, + { fields: ['slug'], unique: 'global' }, { fields: ['name'] }, ], diff --git a/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts index 2a15c272416..92955d743c1 100644 --- a/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts @@ -161,7 +161,7 @@ export const SysScimConnectionBinding = ObjectSchema.create({ indexes: [ // UNIQUE mirrors @better-auth/scim's own declaration on connectionKey. - { fields: ['connection_key'], unique: true }, + { fields: ['connection_key'], unique: 'global' }, { fields: ['connection_id'] }, { fields: ['provisioning_domain_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts b/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts index 531f20abff1..fb15ba32470 100644 --- a/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-connection-credential.object.ts @@ -149,7 +149,7 @@ export const SysScimConnectionCredential = ObjectSchema.create({ indexes: [ // The digest is the verification lookup key — deterministic keyed HMAC, so // an indexed equality probe answers "which credential is this bearer". - { fields: ['token_digest'], unique: true }, + { fields: ['token_digest'], unique: 'global' }, { fields: ['connection_id'] }, { fields: ['organization_id'] }, { fields: ['user_id'] }, diff --git a/packages/platform-objects/src/identity/sys-scim-group-member.object.ts b/packages/platform-objects/src/identity/sys-scim-group-member.object.ts index cd6c05582be..df8191be574 100644 --- a/packages/platform-objects/src/identity/sys-scim-group-member.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group-member.object.ts @@ -105,7 +105,7 @@ export const SysScimGroupMember = ObjectSchema.create({ indexes: [ // UNIQUE mirrors @better-auth/scim's own declaration — what makes its // concurrent-add recovery work (same shape as sys_team_member). - { fields: ['membership_key'], unique: true }, + { fields: ['membership_key'], unique: 'global' }, { fields: ['group_id'] }, { fields: ['scim_user_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-scim-group.object.ts b/packages/platform-objects/src/identity/sys-scim-group.object.ts index 82e82b5b381..0ff770b8224 100644 --- a/packages/platform-objects/src/identity/sys-scim-group.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group.object.ts @@ -126,10 +126,10 @@ export const SysScimGroup = ObjectSchema.create({ indexes: [ // UNIQUE mirrors @better-auth/scim's own declarations. - { fields: ['display_name_key'], unique: true }, + { fields: ['display_name_key'], unique: 'global' }, // Nullable — repeated NULLs are admitted when the IdP sends no externalId. - { fields: ['external_id_key'], unique: true }, - { fields: ['order_key'], unique: true }, + { fields: ['external_id_key'], unique: 'global' }, + { fields: ['order_key'], unique: 'global' }, { fields: ['connection_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts b/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts index 48144c41e4f..02e1204b841 100644 --- a/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts @@ -100,7 +100,7 @@ export const SysScimIdentityTombstone = ObjectSchema.create({ indexes: [ // UNIQUE mirrors @better-auth/scim's own declaration. - { fields: ['external_id_key'], unique: true }, + { fields: ['external_id_key'], unique: 'global' }, { fields: ['connection_id'] }, { fields: ['user_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts b/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts index 6bbbce830da..570153ab931 100644 --- a/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts @@ -147,7 +147,7 @@ export const SysScimProjectionGrant = ObjectSchema.create({ indexes: [ // UNIQUE mirrors @better-auth/scim's own declaration. - { fields: ['grant_key'], unique: true }, + { fields: ['grant_key'], unique: 'global' }, { fields: ['scim_user_id'] }, { fields: ['user_id'] }, { fields: ['connection_id'] }, diff --git a/packages/platform-objects/src/identity/sys-scim-subject.object.ts b/packages/platform-objects/src/identity/sys-scim-subject.object.ts index d0cfbc29ddc..b3f5d61380c 100644 --- a/packages/platform-objects/src/identity/sys-scim-subject.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-subject.object.ts @@ -85,7 +85,7 @@ export const SysScimSubject = ObjectSchema.create({ indexes: [ // UNIQUE mirrors @better-auth/scim's own declaration — one row per user. - { fields: ['user_id'], unique: true }, + { fields: ['user_id'], unique: 'global' }, ], enable: { diff --git a/packages/platform-objects/src/identity/sys-scim-user.object.ts b/packages/platform-objects/src/identity/sys-scim-user.object.ts index 838bde52829..a07ba11faaa 100644 --- a/packages/platform-objects/src/identity/sys-scim-user.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-user.object.ts @@ -213,12 +213,12 @@ export const SysScimUser = ObjectSchema.create({ indexes: [ // UNIQUE mirrors @better-auth/scim's own declarations. - { fields: ['connection_user_key'], unique: true }, - { fields: ['user_name_key'], unique: true }, + { fields: ['connection_user_key'], unique: 'global' }, + { fields: ['user_name_key'], unique: 'global' }, // Nullable — repeated NULLs are admitted on sqlite / postgres / mysql when // the IdP sends no externalId. - { fields: ['external_id_key'], unique: true }, - { fields: ['order_key'], unique: true }, + { fields: ['external_id_key'], unique: 'global' }, + { fields: ['order_key'], unique: 'global' }, { fields: ['connection_id'] }, { fields: ['user_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index 4e46fda0773..19a616254b3 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -302,7 +302,7 @@ export const SysSession = ObjectSchema.create({ }, indexes: [ - { fields: ['token'], unique: true }, + { fields: ['token'], unique: 'global' }, { fields: ['user_id'], unique: false }, { fields: ['expires_at'], unique: false }, ], diff --git a/packages/platform-objects/src/identity/sys-sso-provider.object.ts b/packages/platform-objects/src/identity/sys-sso-provider.object.ts index 2d94cf90507..3f3273d6666 100644 --- a/packages/platform-objects/src/identity/sys-sso-provider.object.ts +++ b/packages/platform-objects/src/identity/sys-sso-provider.object.ts @@ -340,7 +340,7 @@ export const SysSsoProvider = ObjectSchema.create({ }, indexes: [ - { fields: ['provider_id'], unique: true }, + { fields: ['provider_id'], unique: 'global' }, { fields: ['domain'] }, { fields: ['user_id'] }, ], diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index f6cb5eaf59e..66fca16a9ff 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -148,12 +148,12 @@ export const SysTeamMember = ObjectSchema.create({ }, indexes: [ - { fields: ['team_id', 'user_id'], unique: true }, + { fields: ['team_id', 'user_id'], unique: 'global' }, { fields: ['user_id'] }, // UNIQUE mirrors better-auth's own declaration — the constraint is what // makes its concurrent-add recovery work. Nullable columns admit repeated // NULLs on sqlite / postgres / mysql, so pre-upgrade rows are unaffected. - { fields: ['membership_key'], unique: true }, + { fields: ['membership_key'], unique: 'global' }, ], enable: { diff --git a/packages/platform-objects/src/identity/sys-team.object.ts b/packages/platform-objects/src/identity/sys-team.object.ts index 2c7e1140e86..9a3f52ffb7d 100644 --- a/packages/platform-objects/src/identity/sys-team.object.ts +++ b/packages/platform-objects/src/identity/sys-team.object.ts @@ -187,7 +187,7 @@ export const SysTeam = ObjectSchema.create({ indexes: [ { fields: ['organization_id'] }, - { fields: ['name', 'organization_id'], unique: true }, + { fields: ['name', 'organization_id'], unique: 'global' }, ], enable: { diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index d80ca0ed48e..7af88eeff9c 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -239,7 +239,7 @@ export const SysTwoFactor = ObjectSchema.create({ }, indexes: [ - { fields: ['user_id'], unique: true }, + { fields: ['user_id'], unique: 'global' }, ], enable: { diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 0b9e8e738fb..2fb2100164a 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -1011,11 +1011,11 @@ export const SysUser = ObjectSchema.create({ }, indexes: [ - { fields: ['email'], unique: true }, + { fields: ['email'], unique: 'global' }, { fields: ['created_at'], unique: false }, // #2766 V1.5 — phone sign-in identifier; unique when present (null for // email-only accounts), also the upsert match key for identity import. - { fields: ['phone_number'], unique: true }, + { fields: ['phone_number'], unique: 'global' }, ], enable: { diff --git a/packages/platform-objects/src/system/sys-migration-journal.object.ts b/packages/platform-objects/src/system/sys-migration-journal.object.ts index 9e2a0f483aa..45288286637 100644 --- a/packages/platform-objects/src/system/sys-migration-journal.object.ts +++ b/packages/platform-objects/src/system/sys-migration-journal.object.ts @@ -176,7 +176,7 @@ export const SysMigrationJournal = ObjectSchema.create({ // ERROR rather than a silently double-recorded event. A resumed run that // miscomputed its next sequence must fail loudly — a journal that quietly // accepts two "chunk 7 done" rows cannot be trusted to say what committed. - { fields: ['run_id', 'seq'], unique: true }, + { fields: ['run_id', 'seq'], unique: 'global' }, // The recovery scan's access path: all events for a run, and the // open-run sweep that looks for run_started without run_done. { fields: ['run_id', 'kind'], unique: false }, diff --git a/packages/plugins/plugin-auth/README.md b/packages/plugins/plugin-auth/README.md index df3f832daab..b5d530a6d5d 100644 --- a/packages/plugins/plugin-auth/README.md +++ b/packages/plugins/plugin-auth/README.md @@ -214,7 +214,7 @@ export const AuthUser = ObjectSchema.create({ // ... other fields }, indexes: [ - { fields: ['email'], unique: true } + { fields: ['email'], unique: 'global' } ] }); ``` diff --git a/packages/plugins/plugin-auth/src/account-identity-preflight.ts b/packages/plugins/plugin-auth/src/account-identity-preflight.ts index 4a1b8e246f7..5a757b06f9a 100644 --- a/packages/plugins/plugin-auth/src/account-identity-preflight.ts +++ b/packages/plugins/plugin-auth/src/account-identity-preflight.ts @@ -319,7 +319,7 @@ export function formatAccountIdentityPreflightReport( * provider's account bindings. No key separates them, and after the column drop * nothing can.** * - * `sys_sso_provider` declares `{ fields: ['provider_id'], unique: true }`, so + * `sys_sso_provider` declares `{ fields: ['provider_id'], unique: 'global' }`, so * within one environment `provider_id → issuer` is a function and * `(provider_id, account_id)` determines exactly what `(issuer, account_id)` * determined — for as long as that function holds. Re-pointing breaks it: rows diff --git a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts index e4966389f16..67270085094 100644 --- a/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-position-permission-set.object.ts @@ -90,7 +90,7 @@ export const SysPositionPermissionSet = ObjectSchema.create({ }, indexes: [ - { fields: ['position_id', 'permission_set_id'], unique: true }, + { fields: ['position_id', 'permission_set_id'], unique: 'global' }, { fields: ['position_id'] }, { fields: ['permission_set_id'] }, ], diff --git a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts index dfa4159edc9..77b6c486454 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-permission-set.object.ts @@ -210,7 +210,7 @@ export const SysUserPermissionSet = ObjectSchema.create({ }, indexes: [ - { fields: ['user_id', 'permission_set_id', 'organization_id'], unique: true }, + { fields: ['user_id', 'permission_set_id', 'organization_id'], unique: 'global' }, { fields: ['user_id'] }, { fields: ['organization_id'] }, { fields: ['permission_set_id'] }, diff --git a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts index 8b19ca3064c..d2332104435 100644 --- a/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts +++ b/packages/plugins/plugin-security/src/objects/sys-user-position.object.ts @@ -187,7 +187,7 @@ export const SysUserPosition = ObjectSchema.create({ }, indexes: [ - { fields: ['user_id', 'position', 'organization_id'], unique: true }, + { fields: ['user_id', 'position', 'organization_id'], unique: 'global' }, { fields: ['user_id'] }, { fields: ['position'] }, { fields: ['business_unit_id'] }, diff --git a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts index f54ae5a647b..e9712d736fa 100644 --- a/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts +++ b/packages/plugins/plugin-sharing/src/objects/sys-share-link.object.ts @@ -275,7 +275,7 @@ export const SysShareLink = ObjectSchema.create({ indexes: [ // Hot path: resolveToken — one row lookup per public request. - { fields: ['token'], unique: true }, + { fields: ['token'], unique: 'global' }, // Management UI: "all links for this record". { fields: ['object_name', 'record_id'] }, // "Active links I issued". diff --git a/packages/services/service-automation/src/sys-flow-credential.object.ts b/packages/services/service-automation/src/sys-flow-credential.object.ts index 8fadd21af27..31501f8d80f 100644 --- a/packages/services/service-automation/src/sys-flow-credential.object.ts +++ b/packages/services/service-automation/src/sys-flow-credential.object.ts @@ -114,7 +114,7 @@ export const SysFlowCredential = ObjectSchema.create({ indexes: [ // One credential per position per state — the channel's upsert key. - { fields: ['flow_name', 'state', 'position'], unique: true }, + { fields: ['flow_name', 'state', 'position'], unique: 'global' }, ], enable: { diff --git a/packages/services/service-messaging/src/objects/http-delivery.object.ts b/packages/services/service-messaging/src/objects/http-delivery.object.ts index edac0512a21..1e016913fe6 100644 --- a/packages/services/service-messaging/src/objects/http-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/http-delivery.object.ts @@ -268,7 +268,7 @@ export const HttpDelivery = ObjectSchema.create({ }, indexes: [ - { fields: ['source', 'dedup_key'], unique: true }, + { fields: ['source', 'dedup_key'], unique: 'global' }, // Hot path: claim query { fields: ['status', 'partition_key', 'next_retry_at'] }, // Reaper: scan stale in_flight rows by claimed_at diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index 49299c2b88e..a8e05e2f416 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -179,7 +179,7 @@ export const NotificationDelivery = ObjectSchema.create({ indexes: [ // Dedup: one delivery per (event, recipient, channel). - { fields: ['notification_id', 'recipient_id', 'channel'], unique: true }, + { fields: ['notification_id', 'recipient_id', 'channel'], unique: 'global' }, // The hot claim query. { fields: ['status', 'partition_key', 'next_attempt_at'] }, // Stale-in_flight reaper. diff --git a/packages/services/service-messaging/src/objects/notification-receipt.object.ts b/packages/services/service-messaging/src/objects/notification-receipt.object.ts index ad01bbca68d..c9d5f1a930e 100644 --- a/packages/services/service-messaging/src/objects/notification-receipt.object.ts +++ b/packages/services/service-messaging/src/objects/notification-receipt.object.ts @@ -111,7 +111,7 @@ export const NotificationReceipt = ObjectSchema.create({ }, indexes: [ - { fields: ['notification_id', 'user_id', 'channel'], unique: true }, + { fields: ['notification_id', 'user_id', 'channel'], unique: 'global' }, { fields: ['user_id', 'state'] }, ], diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.test.ts b/packages/services/service-realtime/src/objects/sys-presence.object.test.ts index 63dd48b448d..3a3156060cf 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.test.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.test.ts @@ -69,7 +69,7 @@ describe('SysPresence object definition', () => { // shape is now exactly what the author wrote. expect(SysPresence.indexes).toEqual([ { fields: ['user_id'], unique: false }, - { fields: ['session_id'], unique: true }, + { fields: ['session_id'], unique: 'global' }, { fields: ['status'], unique: false }, ]); }); diff --git a/packages/services/service-realtime/src/objects/sys-presence.object.ts b/packages/services/service-realtime/src/objects/sys-presence.object.ts index 76a38052ade..8cc70f73c0e 100644 --- a/packages/services/service-realtime/src/objects/sys-presence.object.ts +++ b/packages/services/service-realtime/src/objects/sys-presence.object.ts @@ -135,7 +135,7 @@ export const SysPresence = ObjectSchema.create({ indexes: [ { fields: ['user_id'], unique: false }, - { fields: ['session_id'], unique: true }, + { fields: ['session_id'], unique: 'global' }, { fields: ['status'], unique: false }, ], diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index cf260910f15..9d65eb66ae8 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -97,7 +97,6 @@ "TemplateExpressionInputSchema (const)", "TypedExpressionDialect (type)", "VISIBILITY_ALIAS_KEYS (const)", - "VISIBILITY_STRICT_OPTIONS (const)", "ValueDomain (type)", "ValueDomainSchema (const)", "applyProtection (function)", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 33e5c6b4bba..15d4ff87156 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -92,7 +92,6 @@ "TemplateExpressionInputSchema": "src/shared/expression.zod.ts#TemplateExpressionInputSchema (const)", "TypedExpressionDialect": "src/shared/expression.zod.ts#TypedExpressionDialect (type)", "VISIBILITY_ALIAS_KEYS": "src/shared/visibility.ts#VISIBILITY_ALIAS_KEYS (const)", - "VISIBILITY_STRICT_OPTIONS": "src/shared/visibility.ts#VISIBILITY_STRICT_OPTIONS (const)", "ValueDomain": "src/shared/value-domain.zod.ts#ValueDomain (type)", "ValueDomainSchema": "src/shared/value-domain.zod.ts#ValueDomainSchema (const)", "applyProtection": "src/shared/protection.zod.ts#applyProtection (function)", diff --git a/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts b/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts index 675b96c24a6..8a56e608483 100644 --- a/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts +++ b/packages/spec/src/compose-stacks-merge-collection-refusal.test.ts @@ -119,7 +119,7 @@ describe("composeStacks objectConflict: 'merge' — a collection both objects de it("refuses two objects declaring different 'indexes' (strict-parsed inputs)", () => { const a = defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { indexes: [{ fields: ['title'] }] })] }); - const b = defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', { indexes: [{ fields: ['title'], unique: true }] })] }); + const b = defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', { indexes: [{ fields: ['title'], unique: 'global' }] })] }); const msg = refusal(() => composeStacks([a, b], { objectConflict: 'merge' })); expect(msg).toContain(REFUSED('indexes', A0, B1)); expect(msg).toContain(FIX('indexes')); @@ -148,7 +148,7 @@ describe("composeStacks objectConflict: 'merge' — a collection both objects de const idx = [{ fields: ['title'] }]; const a = defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { indexes: idx })] }); const b = defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', { indexes: [{ fields: ['title'] }] })] }); - const c = defineStack({ manifest: mf('com.example.c'), objects: [obj('shared', { indexes: [{ fields: ['title'], unique: true }] })] }); + const c = defineStack({ manifest: mf('com.example.c'), objects: [obj('shared', { indexes: [{ fields: ['title'], unique: 'global' }] })] }); const msg = refusal(() => composeStacks([a, b, c], { objectConflict: 'merge' })); expect(msg).toContain(REFUSED('indexes', A0, "'com.example.c' (stack #2)")); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index d707d83fdba..f5444c3f804 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -6985,6 +6985,162 @@ const datasetCountMeasureEmptyFieldRemoved: MetadataConversion = { }, }; +/** + * A declared index's bare `unique: true` → `unique: 'global'` (protocol 18, + * #5082 — ADR-0120 D2, the conversion half of D7's protocol-18 wave). + * + * ADR-0120 D1 made uniqueness scope an explicit vocabulary on both surfaces: + * `'global'` (one holder across the whole installation — materialized over + * exactly the listed `fields`) and `'organization'` (one holder per + * organization — the driver prepends the NULL-safe organization key part at + * registration). On a declared index, bare `true` was the one spelling whose + * scope was encoded by POSITION: it set neither driver flag and materialized + * verbatim, i.e. it meant `'global'`, while reading like "unique per + * organization" to anyone who knew the field-level meaning (the #4986 trap). + * 17.x warned (lint `unique/unscoped-declared-index`); protocol 18 refuses it + * at the parse (`IndexSchema.unique`). + * + * **Lossless by construction — `'global'` IS what bare `true` built.** Every + * driver resolves the two identically: `driver-sql`'s `normalizeDeclaredIndex` + * takes both verbatim and names the index from the same boolean + * (`isUniqueScopeDeclared`), the memory driver's declared-index constraint and + * the Mongo/Turso index sync read the same truthiness. So the physical index is + * byte-identical and schema drift sees nothing (ADR-0120 matrix invariant 2: + * the S4/S5 corpus — the nine engine-owned idempotency/dedup keys among them — + * keeps its expected-index output byte for byte; `driver-sql`'s + * `sql-driver-unique-tenancy.test.ts` replays this conversion over that corpus + * and asserts it). + * + * **Field-level `unique: true` is NOT converted** (ADR-0120 D1): there it has + * one documented meaning — per organization — and stays valid indefinitely. + * Only `indexes[]` entries are walked, on `objects[]` and on + * `objectExtensions[]` (both embed `IndexSchema`). `false`, `'global'` and + * `'organization'` pass through untouched, so the transform is idempotent: + * a second pass finds no bare `true` left. + * + * **Retired from the load path** — the authoring funnel does NOT replay it, so + * a live author meets `IndexSchema`'s refusal and its prescription (state + * `'global'` or `'organization'`) instead of being converted silently: the + * whole point of D1 is that the author STATES the scope. Data at rest has no + * author to teach, so the stored-row seam (`applyConversionsToStoredItem`), the + * artifact door inside its declared-floor window, and `os migrate meta --from + * 17` all replay it — a `sys_metadata` row or a built artifact carrying the old + * spelling converts to the index it always built, and never flags + * `metadata_spec_invalid`. + */ +const declaredIndexUniqueScope: MetadataConversion = { + id: 'declared-index-unique-scope', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'object.indexes[].unique / objectExtensions[].indexes[].unique', + summary: + "declared-index bare `unique: true` → `unique: 'global'` (ADR-0120 D2 — the scope is " + + 'stated, never positional; `\'global\'` is exactly the index bare `true` built, so the ' + + 'physical index is byte-identical; field-level `unique: true` is not converted)', + apply(stack, emit) { + // `indexes` is an ARRAY one level down, so drill in and copy-on-write: an + // owner whose indexes carry no bare `true` keeps its identity (pattern of + // `object-index-type-partial-removed`). + const respell = (owner: Dict, path: string): Dict => { + const indexes = owner.indexes; + if (!Array.isArray(indexes)) return owner; + let changed = false; + const next = indexes.map((idx, i) => { + if (!isDict(idx) || idx.unique !== true) return idx; + changed = true; + emit({ from: 'true', to: 'global', path: `${path}.indexes[${i}].unique` }); + return { ...idx, unique: 'global' }; + }); + return changed ? { ...owner, indexes: next } : owner; + }; + let out = mapCollection(stack, 'objects', respell); + out = mapCollection(out, 'objectExtensions', respell); + return out; + }, + fixture: { + before: { + objects: [ + { + // S4 — a platform-wide composite dedup key. + name: 'http_delivery', + label: 'HTTP Delivery', + indexes: [ + { fields: ['source', 'dedup_key'], unique: true }, + // A non-unique index passes through untouched. + { fields: ['status'] }, + ], + }, + { + // S5 — an engine idempotency key written by sudo (organization NULL). + name: 'sys_notification', + label: 'Notification', + // Field-level bare `true` is per-organization and stays valid (D1). + fields: { recipient_key: { type: 'text', unique: true } }, + indexes: [ + { name: 'idx_notification_dedup', fields: ['dedup_key'], unique: true }, + { fields: ['recipient_key', 'read'], unique: false }, + ], + }, + { + // Both stated scopes pass through untouched. + name: 'crm_case', + label: 'Case', + indexes: [ + { fields: ['department', 'code'], unique: 'organization' }, + { fields: ['external_ref'], unique: 'global' }, + ], + }, + ], + objectExtensions: [ + { + extend: 'crm_case', + indexes: [{ fields: ['legacy_ref'], unique: true }], + }, + ], + }, + after: { + objects: [ + { + name: 'http_delivery', + label: 'HTTP Delivery', + indexes: [ + { fields: ['source', 'dedup_key'], unique: 'global' }, + { fields: ['status'] }, + ], + }, + { + name: 'sys_notification', + label: 'Notification', + fields: { recipient_key: { type: 'text', unique: true } }, + indexes: [ + { name: 'idx_notification_dedup', fields: ['dedup_key'], unique: 'global' }, + { fields: ['recipient_key', 'read'], unique: false }, + ], + }, + { + name: 'crm_case', + label: 'Case', + indexes: [ + { fields: ['department', 'code'], unique: 'organization' }, + { fields: ['external_ref'], unique: 'global' }, + ], + }, + ], + objectExtensions: [ + { + extend: 'crm_case', + indexes: [{ fields: ['legacy_ref'], unique: 'global' }], + }, + ], + }, + // Three notices: one per declared index carrying bare `true` (two on + // objects, one on the extension). The field-level `true`, the `false`, the + // two stated scopes and the non-unique index emit none. + expectedNotices: 3, + }, +}; + /** * `element:filter` — the whole element retired (protocol 18, #9220, ADR-0049 * enforce-or-remove at ELEMENT grain). @@ -14779,6 +14935,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: dashboardRefreshIntervalToRefreshIntervalSeconds, order: 24 }, { conversion: dashboardWidgetChartConfigStructureRemoved, order: 33 }, { conversion: datasetCountMeasureEmptyFieldRemoved, order: 54 }, + { conversion: declaredIndexUniqueScope, order: 61 }, { conversion: elementFilterRemoved, order: 4 }, { conversion: elementFormRemoved, order: 5 }, { conversion: elementInputTargetVariableRemoved, order: 3 }, diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 17002393fb6..3282147dfa9 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -566,16 +566,17 @@ export { AddressSchema }; * ⚠️ **Field-surface only — the parenthetical below is FALSE on a declared * index, and that is why this map is not shared.** "`'organization'` … the * explicit spelling of true" holds here (`FieldSchema.unique`), where bare - * `true` resolves per-organization. On `IndexSchema.unique` bare `true` is the - * positional spelling of `'global'` (the #4986 trap, retired at protocol 18 by - * #5082) — so a shared message read at the one moment an author is looking for - * the accepted spelling prescribed a value that CHANGES materialization on an - * index that may already exist, which is the unannounced reinterpretation the - * #8323 ruling (maintainer, 2026-08-13) exists to prevent. `object.zod.ts` - * therefore carries its own sibling map, `declaredIndexUniqueScopeError`, - * pinned equivalent to this one on accept/reject by - * `unique-scope-message.test.ts`. Keep the two vocabularies in step; only the - * parentheticals may differ. + * `true` resolves per-organization. On `IndexSchema.unique` bare `true` was the + * positional spelling of `'global'` (the #4986 trap) and is REFUSED since + * protocol 18 (#5082) — so a shared message read at the one moment an author + * is looking for the accepted spelling prescribed a value that CHANGES + * materialization on an index that may already exist, which is the + * unannounced reinterpretation the #8323 ruling (maintainer, 2026-08-13) + * exists to prevent. `object.zod.ts` therefore carries its own sibling map, + * `declaredIndexUniqueScopeError`, pinned against this one on accept/reject by + * `unique-scope-message.test.ts`: the index surface accepts this vocabulary + * minus bare `true`, and nothing else may differ. Keep the two vocabularies in + * step; only the parentheticals and the bare-`true` row may differ. * * ⚠️ **One of the two hand-written `$ZodErrorMap`s in `packages/spec`, and the * pair stays a pair.** This docblock used to say "pattern of @@ -654,13 +655,14 @@ const uniqueScopeError: z.core.$ZodErrorMap = (issue) => { * * ⚠️ **This schema is the FIELD surface's.** The vocabulary above is shared * with `IndexSchema.unique`, but the *meaning of bare `true`* is not: on a - * declared index it is the positional spelling of `'global'`, not of - * `'organization'` (the #4986 trap; #5082 retires it at protocol 18). The - * index surface therefore declares its own structurally identical union with - * its own rejection text in `object.zod.ts` — accepting and rejecting exactly - * what this one does, pinned by `unique-scope-message.test.ts`. Widening or - * narrowing the member list here is a change to BOTH surfaces: make it in both - * places or the pin fails. + * declared index it was the positional spelling of `'global'`, not of + * `'organization'` (the #4986 trap), and since protocol 18 (#5082) the index + * surface REFUSES it. The index surface therefore declares its own union with + * its own rejection text in `object.zod.ts` — this vocabulary minus bare + * `true`, and otherwise accepting and rejecting exactly what this one does, + * pinned by `unique-scope-message.test.ts`. Widening or narrowing the member + * list here is a change to BOTH surfaces: make it in both places or the pin + * fails. */ export const UniqueScopeSchema = lazySchema(() => z.union([z.boolean(), z.literal('global'), z.literal('organization')], { @@ -699,10 +701,11 @@ export function isUniqueDeclared(unique: unknown): boolean { * `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. + * prepend the NULL-safe organization key part at registration, while + * `'global'` stays verbatim (bare `true`, its positional spelling, is refused + * on a declared index since protocol 18, and stored metadata converts it to + * `'global'`). Single source of truth so SQL and Mongo index sync cannot drift + * on the distinction. */ export function isOrganizationUnique(unique: unknown): boolean { return unique === 'organization'; diff --git a/packages/spec/src/data/object-strictness-batch20.test.ts b/packages/spec/src/data/object-strictness-batch20.test.ts index 7228fd54575..cb791d26948 100644 --- a/packages/spec/src/data/object-strictness-batch20.test.ts +++ b/packages/spec/src/data/object-strictness-batch20.test.ts @@ -528,7 +528,10 @@ describe('批 20, unknown keys refused — curation is anchored to the sibling c describe('批 20 — `IndexSchema` is closed (the held 14th site, once the console index editor converged on it)', () => { it('declared keys still parse, at every spelling ADR-0120 declares', () => { accept(IndexSchema, { fields: ['name'] }); - accept(IndexSchema, { name: 'idx_probe', fields: ['name'], unique: true }); + // Bare `true` is no longer one of them on a declared index (refused at + // protocol 18 — `unique-scope-message.test.ts` pins the prescription); + // `false` is, with a name beside it. + accept(IndexSchema, { name: 'idx_probe', fields: ['name'], unique: false }); accept(IndexSchema, { fields: ['code'], unique: 'global' }); accept(IndexSchema, { fields: ['code'], unique: 'organization' }); // …and through the carrier, which is how the console PUTs it. diff --git a/packages/spec/src/data/object.test.ts b/packages/spec/src/data/object.test.ts index 5633632b74b..7d3c3ca5408 100644 --- a/packages/spec/src/data/object.test.ts +++ b/packages/spec/src/data/object.test.ts @@ -400,7 +400,7 @@ describe('IndexSchema', () => { const index = { name: 'idx_email_status', fields: ['email', 'status'], - unique: true, + unique: 'global', }; expect(() => IndexSchema.parse(index)).not.toThrow(); @@ -711,12 +711,12 @@ describe('ObjectSchema', () => { { name: 'idx_email', fields: ['email'], - unique: true, + unique: 'global', }, { name: 'idx_username', fields: ['username'], - unique: true, + unique: 'global', }, { fields: ['email', 'username'], @@ -813,7 +813,7 @@ describe('ObjectSchema', () => { { name: 'idx_account_number', fields: ['account_number'], - unique: true, + unique: 'global', }, ], enable: { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index e8f4613a526..0bfe6efcf46 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -381,30 +381,61 @@ export const ObjectCapabilities = strictObject({ * rename onto the retired `partial` tombstone would be the campaign's * finding 7 (a suggestion pointing into a second rejection). */ +/** + * The refusal of a bare `unique: true` on a DECLARED INDEX (ADR-0120 D1 / D5a, + * retired at protocol 18 per D7). + * + * Bare `true` was the one spelling on this key whose scope was encoded by + * POSITION: on a declared index it set neither driver flag and materialized + * over exactly `fields` — installation-wide — while reading like "unique per + * organization" to anyone who knew the field-level meaning (the #4986 trap). + * 17.x warned (lint `unique/unscoped-declared-index`); protocol 18 refuses it, + * so the scope is always STATED. Field-level `unique: true` is untouched — it + * has one documented meaning there and stays valid (D1). + * + * Stored and built metadata never meets this refusal: the protocol-18 ADR-0087 + * conversion `declared-index-unique-scope` rewrites a declared index's bare + * `true` to `'global'` — byte-identical physical index, zero drift — on every + * data-at-rest seam (`applyConversionsToStoredItem`, the artifact door inside + * its declared-floor window, `os migrate meta`). It is retired from the + * authoring funnel, so a live author is refused here and taught the explicit + * spelling instead of being converted silently. + * + * ⛔ Runtime text — no tracker numbers (`check:doc-authoring`). + */ +const DECLARED_INDEX_BARE_TRUE_RETIRED = + '`indexes[].unique: true` was retired at protocol 18 (ADR-0120 D1) — on a declared index ' + + 'bare `true` never stated a scope: it built the index over exactly `fields`, one holder ' + + 'across the whole installation, while reading like "unique per organization". State the ' + + "scope: `unique: 'global'` (installation-wide — the exact index bare `true` built, so " + + "nothing on disk changes) or `unique: 'organization'` (one holder per organization — the " + + 'driver prepends the NULL-safe organization key part to `fields` at registration). ' + + 'Field-level `unique: true` is unaffected. ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'; + /** * Prescriptive rejection for a mis-spelled `unique` scope **on a DECLARED * INDEX** — the sibling of `field.zod.ts`'s `uniqueScopeError`, and the reason * the two are not one map. * - * Same vocabulary (`boolean | 'global' | 'organization'`), same near-miss - * table, same `invalid_union` channel. The difference is the one clause an - * author acts on: **what bare `true` positionally means here.** At field level - * `true` resolves per-organization, so naming `'organization'` "the explicit - * spelling of true" is a true and useful hint. On this surface `true` sets - * neither driver flag (`isGlobalUnique` / `isOrganizationUnique` are both - * false) and the index materializes over exactly `fields` — i.e. `'global'` is - * what `true` spells. The shared text therefore told an author who had just - * been refused on this key to write `'organization'` for what they already had, - * which asks the driver to prepend the NULL-safe organization key part at - * registration — a materialization change, silently, on an index that may - * already exist on deployed databases. That is precisely the unannounced index - * reinterpretation the #8323 ruling (maintainer, 2026-08-13) rejects and the - * #5082 protocol-18 sequencing is there to stage. - * - * ⛔ Message text only. The accepted and rejected sets are byte-identical to - * `UniqueScopeSchema`'s and must stay so — `unique-scope-message.test.ts` pins - * both surfaces against the same value table, so a member added or dropped on - * either side fails there rather than diverging quietly. + * Same near-miss table, same `invalid_union` channel. The two surfaces differ + * on exactly one member, and it is the one an author acts on: **bare `true`.** + * At field level `true` resolves per-organization, so naming `'organization'` + * "the explicit spelling of true" is a true and useful hint. On this surface + * `true` used to set neither driver flag and materialize over exactly + * `fields` — i.e. `'global'` is what it spelled — and since protocol 18 it is + * REFUSED here with {@link DECLARED_INDEX_BARE_TRUE_RETIRED}, which names both + * replacements and which one keeps the index it built. Telling an author who + * had just been refused on this key to write `'organization'` for what they + * already had would ask the driver to prepend the NULL-safe organization key + * part at registration — a materialization change, silently, on an index that + * may already exist on deployed databases: the unannounced index + * reinterpretation the #8323 ruling (maintainer, 2026-08-13) rejects. + * + * ⛔ The accepted set is `UniqueScopeSchema`'s MINUS bare `true`, and nothing + * else may differ — `unique-scope-message.test.ts` pins both surfaces against + * one value table whose only split row is `true`, so a member added or dropped + * on either side fails there rather than diverging quietly. * * Declared before `IndexSchema` because `OS_EAGER_SCHEMAS=1` evaluates the * factory at module load (TDZ) — same constraint as the field-surface map. @@ -412,31 +443,36 @@ export const ObjectCapabilities = strictObject({ const declaredIndexUniqueScopeError: z.core.$ZodErrorMap = (issue) => { if (issue.code !== 'invalid_union') return undefined; const input = (issue as { input?: unknown }).input; + if (input === true) return DECLARED_INDEX_BARE_TRUE_RETIRED; 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' ` + + `Invalid unique scope ${spelled}. Allowed: false, 'organization' ` + `(one holder per organization — the driver prepends the NULL-safe ` + `organization key part to \`fields\` at registration), or 'global' ` + `(one holder across the whole installation — materialized over exactly ` + - `\`fields\`, and the positional meaning of bare true on a declared index: ` + - `bare true is warned by lint unique/unscoped-declared-index in 17.x and ` + - `rejected at protocol 18).${nearMiss}` + `\`fields\`). Bare true is not accepted on a declared index: state the ` + + `scope.${nearMiss}` ); }; /** - * `UniqueScopeSchema`'s declared-index twin: the same union, refused in the - * index surface's own words. See `declaredIndexUniqueScopeError` above for why - * the message cannot be shared, and `field.zod.ts`'s `UniqueScopeSchema` for - * the scope vocabulary itself (ADR-0120 D1) — the member list is duplicated - * deliberately and pinned equivalent, never re-derived. + * `UniqueScopeSchema`'s declared-index twin: the same vocabulary minus bare + * `true` (retired at protocol 18 — see {@link DECLARED_INDEX_BARE_TRUE_RETIRED}), + * refused in the index surface's own words. See `declaredIndexUniqueScopeError` + * above for why the message cannot be shared, and `field.zod.ts`'s + * `UniqueScopeSchema` for the scope vocabulary itself (ADR-0120 D1) — the + * member list is written out deliberately and pinned against the field's, + * never re-derived. + * + * `false` stays a member: "not unique" states no scope and needs none, and it + * is the key's default. */ const DeclaredIndexUniqueScopeSchema = lazySchema(() => - z.union([z.boolean(), z.literal('global'), z.literal('organization')], { + z.union([z.literal(false), z.literal('global'), z.literal('organization')], { error: declaredIndexUniqueScopeError, }), ); @@ -477,17 +513,21 @@ export const IndexSchema = lazySchema(() => strictObject({ // 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. + // - bare `true` — REFUSED since protocol 18 (#5082, ADR-0120 D7). It + // was the positional spelling of `'global'`, the one spelling whose + // meaning was encoded by position (the #4986 trap): 17.x warned + // through lint `unique/unscoped-declared-index`, and the parse now + // refuses it with a prescription naming both words. Stored and built + // metadata converts to `'global'` instead (the ADR-0087 conversion + // `declared-index-unique-scope`, byte-identical physical index). State + // the scope. + // - `false` (or omitted) — not unique; states no scope and needs none. // // 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: DeclaredIndexUniqueScopeSchema.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) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'").meta({ title: 'Unique' }), + unique: DeclaredIndexUniqueScopeSchema.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; false/omitted = not unique. Bare true is refused (retired at protocol 18 — it was the positional spelling of 'global'): state the scope. 'tenant'/'org' are rejected — the word is 'organization'").meta({ title: 'Unique' }), // ── Tombstones (ADR-0049 / ADR-0087) ───────────────────────────────── // Kept LAST in the shape on purpose — see the #5606 note in the block diff --git a/packages/spec/src/data/unique-scope-message.test.ts b/packages/spec/src/data/unique-scope-message.test.ts index b98ff11af50..ff9bfc304e8 100644 --- a/packages/spec/src/data/unique-scope-message.test.ts +++ b/packages/spec/src/data/unique-scope-message.test.ts @@ -11,10 +11,12 @@ import { IndexSchema } from './object.zod'; * * - `FieldSchema.unique` — bare `true` resolves per-organization, so * `'organization'` genuinely IS its explicit spelling. - * - `IndexSchema.unique` — bare `true` sets neither driver flag and the - * index materializes over exactly `fields`, i.e. `'global'` is what it - * spells. #5082 retires the positional form at protocol 18; 17.x warns - * through lint `unique/unscoped-declared-index`. + * - `IndexSchema.unique` — bare `true` set neither driver flag and the + * index materialized over exactly `fields`, i.e. `'global'` is what it + * spelled. Since protocol 18 (#5082) the positional form is REFUSED here, + * with a prescription naming both stated scopes; 17.x warned through lint + * `unique/unscoped-declared-index`, and stored metadata converts it to + * `'global'` (the ADR-0087 conversion `declared-index-unique-scope`). * * One shared rejection message could only be right on one of them, and it was * written for the field surface. Read at the one moment it is most likely to be @@ -30,10 +32,13 @@ import { IndexSchema } from './object.zod'; * is re-breakable: * * 1. the two surfaces say DIFFERENT things about bare `true` (the fix), and - * 2. they accept and reject exactly the same values, with identical parse - * results and an identical rejection envelope (the constraint — #8323 - * forbids reinterpreting declared indexes, so a message repair may not - * move a single value across the accept/reject line). + * 2. they accept and reject exactly the same values — with identical parse + * results and an identical rejection envelope — on every row but ONE: + * bare `true`, which the field accepts (D1: valid indefinitely) and the + * declared index refuses since protocol 18 (D7). #8323 forbids + * reinterpreting declared indexes, so no other value may cross the + * accept/reject line, and the one that does crosses it as a REFUSAL with + * a prescription, never as a silent change of meaning. * * (2) is what makes the duplicated union in `object.zod.ts` safe: the member * lists are written twice on purpose, so drift fails here rather than shipping. @@ -88,23 +93,17 @@ describe('unique scope rejection message — the two surfaces disagree about bar expect(uniqueIssue(parseField('nonsense_scope')).message).toBe(FIELD_MESSAGE); }); - it('the DECLARED-INDEX surface names `global` as the positional meaning of bare true', () => { + it('the DECLARED-INDEX surface never calls `organization` the spelling of true, and says bare true is not accepted', () => { const message = uniqueIssue(parseIndex('nonsense_scope')).message; // The defect, stated as an assertion: this claim is false here. expect(message, "'organization' is NOT the explicit spelling of true on a declared index") .not.toContain('the explicit spelling of true'); - // What the author needs instead, attached to the scope it is true of. + // The accepted set, read off the message: no `true` in it. + expect(message).toContain("Allowed: false, 'organization'"); expect(message).toContain("'global'"); - expect(message).toContain('the positional meaning of bare true on a declared index'); - - // The migration the author is standing in front of: the 17.x warning - // channel and the protocol-18 rejection, named where they are read. The - // tracker id that used to sit beside them is gone — it resolved to nothing - // for the author reading this refusal. - expect(message).toContain('unique/unscoped-declared-index'); - expect(message).toContain('protocol 18'); + expect(message).toContain('Bare true is not accepted on a declared index: state the scope.'); expect(message).not.toMatch(/#\d{3,5}\b/); // And the organization scope described by what it DOES here, not by an @@ -113,6 +112,31 @@ describe('unique scope rejection message — the two surfaces disagree about bar expect(message).toContain('NULL-safe'); }); + it('bare `true` on a DECLARED INDEX is refused with the protocol-18 prescription (ADR-0120 D5a)', () => { + const issue = uniqueIssue(parseIndex(true)); + // Same envelope as every other refusal on this key. + expect(issue.code).toBe('invalid_union'); + expect(issue.path).toEqual(['unique']); + // The prescription: the retired spelling, both stated scopes and which one + // keeps the index bare `true` built, and the migration command. Asserted + // by phrase, not byte — the scopes and the command are what an author + // (and an upgrading agent grepping the refusal) acts on. + expect(issue.message).toMatch(/^`indexes\[\]\.unique: true` was retired at protocol 18 \(ADR-0120 D1\)/); + expect(issue.message).toContain("`unique: 'global'` (installation-wide — the exact index bare `true` built"); + expect(issue.message).toContain("`unique: 'organization'` (one holder per organization"); + expect(issue.message).toContain('Field-level `unique: true` is unaffected.'); + expect(issue.message).toContain( + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.', + ); + expect(issue.message).not.toMatch(/#\d{3,5}\b/); + }); + + it('bare `true` on a FIELD is still accepted and still means per-organization (ADR-0120 D1 — not retired)', () => { + const field = parseField(true); + expect(field.success).toBe(true); + if (field.success) expect(field.data.unique).toBe(true); + }); + it('the contrast is real — the two surfaces do not emit the same text', () => { const field = uniqueIssue(parseField('nonsense_scope')).message; const index = uniqueIssue(parseIndex('nonsense_scope')).message; @@ -121,8 +145,11 @@ describe('unique scope rejection message — the two surfaces disagree about bar // learns the whole accepted set from the first sentence. for (const message of [field, index]) { expect(message).toContain("Invalid unique scope 'nonsense_scope'."); - expect(message).toContain("Allowed: true/false, 'organization'"); + expect(message).toMatch(/Allowed: (true\/)?false, 'organization'/); } + // The one member the two surfaces disagree on, as each states it. + expect(field).toContain("Allowed: true/false, 'organization'"); + expect(index).toContain("Allowed: false, 'organization'"); }); it.each(['tenant', 'org'])( @@ -134,41 +161,46 @@ describe('unique scope rejection message — the two surfaces disagree about bar ); }); -describe('unique scope — message text only: the accept/reject line does not move, and bare `true` keeps its meaning', () => { +describe('unique scope — the accept/reject line moves for bare `true` on a declared index and for nothing else', () => { // Every value an author can write on this key, accepted or refused. The // vocabulary (ADR-0120 D1) plus the two rejected words plus the shapes a - // wrong type arrives as. - const VALUES: Array<{ label: string; value: unknown; accepted: boolean }> = [ - { label: 'true', value: true, accepted: true }, - { label: 'false', value: false, accepted: true }, - { label: "'global'", value: 'global', accepted: true }, - { label: "'organization'", value: 'organization', accepted: true }, - { label: "'tenant'", value: 'tenant', accepted: false }, - { label: "'org'", value: 'org', accepted: false }, - { label: "'nonsense_scope'", value: 'nonsense_scope', accepted: false }, - { label: "'TRUE'", value: 'TRUE', accepted: false }, - { label: "'Global'", value: 'Global', accepted: false }, - { label: "''", value: '', accepted: false }, - { label: '1', value: 1, accepted: false }, - { label: '0', value: 0, accepted: false }, - { label: 'null', value: null, accepted: false }, - { label: '[]', value: [], accepted: false }, - { label: '{}', value: {}, accepted: false }, + // wrong type arrives as. `index` differs from `field` on exactly one row — + // bare `true`, retired on the declared-index surface at protocol 18 — and a + // second split row is the drift this table exists to catch. + const VALUES: Array<{ label: string; value: unknown; field: boolean; index: boolean }> = [ + { label: 'true', value: true, field: true, index: false }, + { label: 'false', value: false, field: true, index: true }, + { label: "'global'", value: 'global', field: true, index: true }, + { label: "'organization'", value: 'organization', field: true, index: true }, + { label: "'tenant'", value: 'tenant', field: false, index: false }, + { label: "'org'", value: 'org', field: false, index: false }, + { label: "'nonsense_scope'", value: 'nonsense_scope', field: false, index: false }, + { label: "'TRUE'", value: 'TRUE', field: false, index: false }, + { label: "'Global'", value: 'Global', field: false, index: false }, + { label: "''", value: '', field: false, index: false }, + { label: '1', value: 1, field: false, index: false }, + { label: '0', value: 0, field: false, index: false }, + { label: 'null', value: null, field: false, index: false }, + { label: '[]', value: [], field: false, index: false }, + { label: '{}', value: {}, field: false, index: false }, ]; - it.each(VALUES)('$label is treated identically on both surfaces', ({ value, accepted }) => { + it('the two surfaces split on exactly one row, and it is bare `true`', () => { + expect(VALUES.filter((v) => v.field !== v.index).map((v) => v.label)).toEqual(['true']); + }); + + it.each(VALUES)('$label: field accepts=$field, declared index accepts=$index', ({ value, field: fieldOk, index: indexOk }) => { const field = parseField(value); const index = parseIndex(value); - expect(field.success).toBe(accepted); - expect(index.success).toBe(accepted); + expect(field.success).toBe(fieldOk); + expect(index.success).toBe(indexOk); - if (field.success && index.success) { - // The parse RESULT, not just the verdict: a scope must survive the round - // trip as itself on both surfaces (no coercion, no normalisation). - expect(field.data.unique).toStrictEqual(value); - expect(index.data.unique).toStrictEqual(value); - } + // The parse RESULT, not just the verdict: a scope must survive the round + // trip as itself on every surface that accepts it (no coercion, no + // normalisation). + if (field.success) expect(field.data.unique).toStrictEqual(value); + if (index.success) expect(index.data.unique).toStrictEqual(value); }); it('the refusal envelope is unchanged on both surfaces (code and path)', () => { diff --git a/packages/spec/src/data/unique-scope.test.ts b/packages/spec/src/data/unique-scope.test.ts index 7c78f12b0d1..26dc4388e9d 100644 --- a/packages/spec/src/data/unique-scope.test.ts +++ b/packages/spec/src/data/unique-scope.test.ts @@ -64,12 +64,19 @@ describe('UniqueScope (ADR-0120) — bare `true` on a field is unique per organi }); describe('IndexSchema.unique', () => { - it("accepts true / false / 'global' / 'organization'", () => { - for (const unique of [true, false, 'global', 'organization'] as const) { + it("accepts false / 'global' / 'organization'", () => { + for (const unique of [false, 'global', 'organization'] as const) { expect(IndexSchema.parse({ fields: ['a'], unique }).unique).toBe(unique); } }); + it('refuses bare true — the positional spelling retired at protocol 18 (ADR-0120 D1/D7)', () => { + // The prescription itself is pinned in `unique-scope-message.test.ts`; + // this is the accept-set half, beside its three surviving members. + const result = IndexSchema.safeParse({ fields: ['a'], unique: true }); + expect(result.success).toBe(false); + }); + it('defaults to false', () => { expect(IndexSchema.parse({ fields: ['a'] }).unique).toBe(false); }); diff --git a/packages/spec/src/migrations/entries/semantic/18.declared-index-bare-unique-true-retired.ts b/packages/spec/src/migrations/entries/semantic/18.declared-index-bare-unique-true-retired.ts new file mode 100644 index 00000000000..9d18affab3e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.declared-index-bare-unique-true-retired.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0120 D1 / D2 / D5a, staged by D7 to protocol 18: the declared index's +// positional `unique: true` is refused, and the chain respells it `'global'`. +export const entry: SemanticMigration = { + id: 'declared-index-bare-unique-true-retired', + surface: '`indexes[].unique: true` on a declared index (`objects[]` and `objectExtensions[]`) — ' + + 'the bare boolean, the one `unique` spelling whose scope was positional', + replacement: 'a stated scope: `unique: \'global\'` (one holder across the whole installation — ' + + 'exactly the index bare `true` built, which is what the chain writes) or ' + + '`unique: \'organization\'` (one holder per organization — the driver prepends the NULL-safe ' + + 'organization key part `COALESCE(organization_id, \'__global__\')` to `fields` at ' + + 'registration). `unique: false` / omitted is unchanged, and field-level `unique: true` is ' + + 'unchanged and stays valid (it means per organization there)', + reason: + 'The mechanical rewrite keeps every index exactly as it was built — `\'global\'` IS the verbatim ' + + 'column list bare `true` materialized, so nothing on disk changes. What the chain cannot know ' + + 'is what the author MEANT. On a declared index bare `true` read like "unique per ' + + 'organization" to anyone who knew the field-level meaning, and silently built an ' + + 'installation-wide constraint instead: an index meant per organization has been refusing a ' + + 'second organization\'s value all along, and its refusal told that organization somebody ' + + 'else holds it. Each respelled index is therefore a decision the owner makes once: keep ' + + '`\'global\'` for a genuinely installation-wide key (a hostname, an external provider id, an ' + + 'engine dedup key), or move it to `\'organization\'` so each organization may hold the value ' + + 'once — a change to the physical index that `os migrate plan` shows before anything is ' + + 'applied.', + acceptanceCriteria: + 'No declared index in the sources carries `unique: true`: `os validate` passes, and every ' + + 'stored `object` row reads back with `\'global\'` where it held bare `true`. `os migrate plan` ' + + 'against the existing database shows no index operation for an index kept at `\'global\'`. ' + + 'Each index moved to `\'organization\'` appears in that plan as a planned index change.', + conversionIds: ['declared-index-unique-scope'], +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts index 39abe4e20db..e47c66bae32 100644 --- a/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.sys-account-issuer-retired.ts @@ -30,7 +30,7 @@ export const entry: SemanticMigration = { + 'sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the ' + '`sys_user` row rather than at the account — four checklist items rediscovered that ' + 'independently. Its discriminating power here was near zero: `sys_sso_provider` declares ' - + '`{ fields: [\'provider_id\'], unique: true }`, so `provider_id → issuer` is a function ' + + '`{ fields: [\'provider_id\'], unique: \'global\' }`, so `provider_id → issuer` is a function ' + 'within an environment.', acceptanceCriteria: 'BEFORE the column is dropped, `os migrate account-issuer` reads zero on the deployment: no ' diff --git a/packages/spec/src/migrations/entries/semantic/18.visibility-strict-options-unexported.ts b/packages/spec/src/migrations/entries/semantic/18.visibility-strict-options-unexported.ts new file mode 100644 index 00000000000..2b371f1ecb7 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.visibility-strict-options-unexported.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// A TS/API surface, never stored in stack metadata: there is no source for the +// chain to rewrite, so this entry is the whole ADR-0087 registration. +export const entry: SemanticMigration = { + id: 'visibility-strict-options-unexported', + surface: + '`VISIBILITY_STRICT_OPTIONS` (const) on `@objectstack/spec/shared` — the shared ' + + '`strictObject` options of the visibility-carrying view/page shapes (ADR-0089 D3a)', + replacement: + '(removed from the public surface — no replacement export. It was an internal option bag ' + + 'for this package\'s own schemas; the visibility contract it configures is unchanged and ' + + 'still published through the schemas that use it — `FormFieldSchema`, `FormSectionSchema` ' + + 'and the page component — together with `normalizeVisibleWhen` and ' + + '`VISIBILITY_ALIAS_KEYS`, which stay exported.)', + reason: + 'ADR-0049 enforce-or-remove applied to an export. The const was barrel-exported while its ' + + 'type, `StrictObjectOptions`, is deliberately unpublished, so no consumer could annotate it, ' + + 'spread it into a typed option bag or name it in a parameter — a published value with no ' + + 'usable contract and zero measured pull outside this package. Publishing the type instead ' + + 'was weighed and not adopted: no consumer ever asked for it, and it would turn the ' + + 'strict-object template\'s internals into public API.', + acceptanceCriteria: + 'No code imports `VISIBILITY_STRICT_OPTIONS` from `@objectstack/spec`, `@objectstack/spec/shared` ' + + 'or any other entry (TS2305 after upgrade). Every visibility-carrying shape parses and ' + + 'refuses exactly as before — the options object is unchanged, only where it is exported ' + + 'from moved. No authored metadata document ever carried it, so `os migrate meta` has ' + + 'nothing to visit.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index fe36dead7c8..6175c414080 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5444,6 +5444,23 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'rewrite into a column, so the semantic entry `dataset-member-field-expression-refused` ' + 'carries the rest.', }, + { + id: 'declared-index-bare-unique-true-retired', + order: 86, + text: + 'It also makes a declared index state its uniqueness scope (ADR-0120 D1, staged to this ' + + 'protocol by D7). On `indexes[].unique`, bare `true` was the one spelling whose scope was ' + + 'positional: it built the index over exactly `fields`, one holder across the whole ' + + 'installation, while reading like "unique per organization" to an author who knew the ' + + 'field-level meaning. The parse now refuses it with a prescription naming both words — ' + + '`\'global\'` (installation-wide, the index bare `true` built) and `\'organization\'` (one ' + + 'holder per organization). Field-level `unique: true` is untouched. The D2 conversion ' + + '`declared-index-unique-scope` rewrites a declared index\'s bare `true` to `\'global\'`, ' + + 'which is lossless and drift-free by construction, retired from the load path so authors ' + + 'are refused at the door while stored rows, built artifacts and `os migrate meta` replay ' + + 'it. Its D3 record is the semantic entry `declared-index-bare-unique-true-retired`: ' + + 'whether each respelled index was really meant installation-wide is the author\'s call.', + }, { id: 'deployment-plumbing-organization-columns-retired', order: 86, @@ -10665,6 +10682,37 @@ const step18: MigrationStep = { 'datasource meant to connect anonymously carries no `credentialsRef`; no datasource ' + 'parse reports this URL-branch refusal.', }, + // ADR-0120 D1 / D2 / D5a, staged by D7 to protocol 18: the declared index's + // positional `unique: true` is refused, and the chain respells it `'global'`. + { + id: 'declared-index-bare-unique-true-retired', + surface: '`indexes[].unique: true` on a declared index (`objects[]` and `objectExtensions[]`) — ' + + 'the bare boolean, the one `unique` spelling whose scope was positional', + replacement: 'a stated scope: `unique: \'global\'` (one holder across the whole installation — ' + + 'exactly the index bare `true` built, which is what the chain writes) or ' + + '`unique: \'organization\'` (one holder per organization — the driver prepends the NULL-safe ' + + 'organization key part `COALESCE(organization_id, \'__global__\')` to `fields` at ' + + 'registration). `unique: false` / omitted is unchanged, and field-level `unique: true` is ' + + 'unchanged and stays valid (it means per organization there)', + reason: + 'The mechanical rewrite keeps every index exactly as it was built — `\'global\'` IS the verbatim ' + + 'column list bare `true` materialized, so nothing on disk changes. What the chain cannot know ' + + 'is what the author MEANT. On a declared index bare `true` read like "unique per ' + + 'organization" to anyone who knew the field-level meaning, and silently built an ' + + 'installation-wide constraint instead: an index meant per organization has been refusing a ' + + 'second organization\'s value all along, and its refusal told that organization somebody ' + + 'else holds it. Each respelled index is therefore a decision the owner makes once: keep ' + + '`\'global\'` for a genuinely installation-wide key (a hostname, an external provider id, an ' + + 'engine dedup key), or move it to `\'organization\'` so each organization may hold the value ' + + 'once — a change to the physical index that `os migrate plan` shows before anything is ' + + 'applied.', + acceptanceCriteria: + 'No declared index in the sources carries `unique: true`: `os validate` passes, and every ' + + 'stored `object` row reads back with `\'global\'` where it held bare `true`. `os migrate plan` ' + + 'against the existing database shows no index operation for an index kept at `\'global\'`. ' + + 'Each index moved to `\'organization\'` appears in that plan as a planned index change.', + conversionIds: ['declared-index-unique-scope'], + }, { id: 'device-request-response-interval-unit-in-key', // No backticks in `surface` — build-upgrade-guide.ts renders it inside a @@ -18749,7 +18797,7 @@ const step18: MigrationStep = { + 'sign-in failed `INVALID_EMAIL_OR_PASSWORD` behind a "User not found" warn pointing at the ' + '`sys_user` row rather than at the account — four checklist items rediscovered that ' + 'independently. Its discriminating power here was near zero: `sys_sso_provider` declares ' - + '`{ fields: [\'provider_id\'], unique: true }`, so `provider_id → issuer` is a function ' + + '`{ fields: [\'provider_id\'], unique: \'global\' }`, so `provider_id → issuer` is a function ' + 'within an environment.', acceptanceCriteria: 'BEFORE the column is dropped, `os migrate account-issuer` reads zero on the deployment: no ' @@ -22402,6 +22450,33 @@ const step18: MigrationStep = { + 'fraction are still refused. A view that must keep 25 rows per page declares ' + '`pagination: { pageSize: 25 }` and shows 25 rows on its first page.', }, + // A TS/API surface, never stored in stack metadata: there is no source for the + // chain to rewrite, so this entry is the whole ADR-0087 registration. + { + id: 'visibility-strict-options-unexported', + surface: + '`VISIBILITY_STRICT_OPTIONS` (const) on `@objectstack/spec/shared` — the shared ' + + '`strictObject` options of the visibility-carrying view/page shapes (ADR-0089 D3a)', + replacement: + '(removed from the public surface — no replacement export. It was an internal option bag ' + + 'for this package\'s own schemas; the visibility contract it configures is unchanged and ' + + 'still published through the schemas that use it — `FormFieldSchema`, `FormSectionSchema` ' + + 'and the page component — together with `normalizeVisibleWhen` and ' + + '`VISIBILITY_ALIAS_KEYS`, which stay exported.)', + reason: + 'ADR-0049 enforce-or-remove applied to an export. The const was barrel-exported while its ' + + 'type, `StrictObjectOptions`, is deliberately unpublished, so no consumer could annotate it, ' + + 'spread it into a typed option bag or name it in a parameter — a published value with no ' + + 'usable contract and zero measured pull outside this package. Publishing the type instead ' + + 'was weighed and not adopted: no consumer ever asked for it, and it would turn the ' + + 'strict-object template\'s internals into public API.', + acceptanceCriteria: + 'No code imports `VISIBILITY_STRICT_OPTIONS` from `@objectstack/spec`, `@objectstack/spec/shared` ' + + 'or any other entry (TS2305 after upgrade). Every visibility-carrying shape parses and ' + + 'refuses exactly as before — the options object is unchanged, only where it is exported ' + + 'from moved. No authored metadata document ever carried it, so `os migrate meta` has ' + + 'nothing to visit.', + }, { id: 'wait-node-event-config-required', surface: diff --git a/packages/spec/src/shared/editability-boundary.ts b/packages/spec/src/shared/editability-boundary.ts index 4d4a9a0d572..d4d6618d76b 100644 --- a/packages/spec/src/shared/editability-boundary.ts +++ b/packages/spec/src/shared/editability-boundary.ts @@ -60,7 +60,7 @@ import type { StrictObjectOptions } from './strict-object'; import type { KeySetGuidance } from './suggestions.zod'; -import { VISIBILITY_STRICT_OPTIONS } from './visibility'; +import { VISIBILITY_STRICT_OPTIONS } from './visibility-strict-options'; /** * The editability vocabulary an author reaches for on a shape that gates diff --git a/packages/spec/src/shared/visibility-strict-options.ts b/packages/spec/src/shared/visibility-strict-options.ts new file mode 100644 index 00000000000..5072b1d3297 --- /dev/null +++ b/packages/spec/src/shared/visibility-strict-options.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { StrictObjectOptions } from './strict-object'; + +/** + * # The visibility family's shared `strictObject` options (ADR-0089 D3a) + * + * Moved out of `./visibility` and deliberately NOT re-exported from the + * `shared` barrel (protocol 18, the folded export item of the ADR-0120 wave). + * It sat on `@objectstack/spec`'s public API while its own type, + * `StrictObjectOptions` (`./strict-object`), is unbarrelled on purpose — a + * published const no consumer could annotate, spread into a typed option bag, + * or name in a parameter, with zero measured pull outside this package. Its + * two importers — `ui/view.zod.ts` (for `FormFieldSchema`) and + * `./editability-boundary` (whose `VISIBILITY_ONLY_STRICT_OPTIONS` carries it + * to `FormSectionSchema` and `PageComponentSchema`) — take it from here, + * exactly as they take `StrictObjectOptions` itself. The removal is registered + * with ADR-0087 (the D3 semantic entry `visibility-strict-options-unexported`) + * and counted by `check:api-surface`. + */ + +/** + * A key that is (or is a likely mis-spelling of) the visibility predicate. + * + * **Deliberately a pattern and not a list.** The keys this has to answer are the + * ones nobody enumerated: `visibleWhenn`, `visibleIf`, `hiddenWhen`, `conceal`, + * `showWhen`, a `visibility` pasted onto a view form. Enumerating them is the + * shape of guess this family exists to stop the author making. + * + * It also matches the CANONICAL `visibleWhen` and both deprecated aliases, and + * that is harmless by construction: a key the shape declares is recognised, so + * it never reaches the `unrecognized_keys` path this pattern is consulted from. + * It is the reason the audit asks a pattern about {@link + * VISIBILITY_STRICT_OPTIONS}'s `examples` rather than about the shape's declared + * keys — see `KeySetGuidance`. + */ +const VISIBILITY_KEY_PATTERN = /vis|conceal|hidden|show.?when/i; + +/** + * The shared `strictObject` options for every `.strict()` view/page schema that + * carries a conditional-visibility predicate (ADR-0089 D3a). + * + * With the shape closed, a key these schemas do not declare — a stale + * `visibleOn` past removal, a `visibleWhen` typo, or a wrong-layer paste — is a + * **loud parse error** instead of a silent strip (ADR-0049 enforce-or-remove, + * ADR-0078 no-silently-inert). The rejection is *fixable*: it names the + * offending key(s), points a visibility-shaped key at the canonical + * `visibleWhen`, and — new with the fold below — suggests the closest declared + * key for everything else. + * + * ```ts + * strictObject(VISIBILITY_STRICT_OPTIONS, { ..., visibleWhen: Expr.optional() }) + * .transform(normalizeVisibleWhen) + * ``` + * + * ## Folded out of a hand-written `$ZodErrorMap` (#6619, #6416 direction 2) + * + * This used to be `strictVisibilityError`, a bespoke map that re-implemented + * the front matter, the prescription branch and the trailing history sentence + * by hand. Being hand-written is what put it out of reach of #5955 (the shared + * template's reorder) and #5593 (the `strictObject` migration) — and, the + * reason this card was worth doing, out of reach of `alias-integrity.test.ts`: + * a hand-rolled map registers in no registry, so its prescription was + * **unmeasured rather than clean**. Declared here, it is judged with every + * other table in the package. + * + * The emission ORDER the pins in `ui/view.test.ts` encode is the template's own + * and unchanged: front matter → fix channels → the explanatory sentence last. + * What changed in the bytes is that the prescription is now rendered as the + * template's `\n • ` bullet rather than joined inline with a space, which is + * how every other closed surface in this package already reads it. + * + * ## `surface` here is a FAMILY default that every consumer overrides (#8202) + * + * The three shapes on this table each name themselves — `'this form field'`, + * `'this form section'`, `'this page component'` — by spreading these options + * and setting `surface` at their own call site. The string below is what they + * shared until #8202, and it is kept only as the family's name: a table shared + * by three shapes cannot carry one shape's name, which is the same placement + * rule #8199 drew for the boundary prescription, read from the `surface` end. + * + * Why the shapes stopped sharing it: while all three answered a key identically + * the shared string cost nothing. Since #8199 they do not — on a field + * `disabled` gets a rename pointer toward `readonly`, on a section or component + * it gets the editability-boundary prescription telling the author to move the + * key to the fields inside. Those two answers contradict each other by design, + * and the contradiction only reads correctly if the message says which shape + * the author is on. + * + * A consumer that forgets to override inherits this string silently, so the + * inheritance is pinned rather than trusted: `editability-boundary.test.ts` + * asserts no live declaration on this family still reports it. + */ +export const VISIBILITY_STRICT_OPTIONS: StrictObjectOptions = { + surface: 'this view/page schema', + history: + 'Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ' + + 'a mis-layered or stale key is now a loud parse error.', + guidanceSets: [ + { + name: 'VISIBILITY_KEY_PATTERN', + keys: VISIBILITY_KEY_PATTERN, + // The spellings the pattern is FOR — the audit asserts each really + // matches and that none is a key the shape declares, which is the + // answerable half of the dead-entry claim for an open family. + examples: ['visibleWhenn', 'visibleIf', 'hiddenWhen', 'conceal', 'showWhen'], + prescription: + 'If this is the conditional-visibility predicate, the canonical key is ' + + '`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' + + 'component) are still accepted as deprecated aliases.', + }, + ], +}; diff --git a/packages/spec/src/shared/visibility.ts b/packages/spec/src/shared/visibility.ts index 300b85f5eaa..dd89ba74370 100644 --- a/packages/spec/src/shared/visibility.ts +++ b/packages/spec/src/shared/visibility.ts @@ -1,7 +1,5 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { StrictObjectOptions } from './strict-object'; - /** * # Conditional-visibility predicate normalization (ADR-0089) * @@ -67,95 +65,9 @@ export function normalizeVisibleWhen( return { ...rest, visibleWhen: canonical } as Omit; } -/** - * A key that is (or is a likely mis-spelling of) the visibility predicate. - * - * **Deliberately a pattern and not a list.** The keys this has to answer are the - * ones nobody enumerated: `visibleWhenn`, `visibleIf`, `hiddenWhen`, `conceal`, - * `showWhen`, a `visibility` pasted onto a view form. Enumerating them is the - * shape of guess this family exists to stop the author making. - * - * It also matches the CANONICAL `visibleWhen` and both deprecated aliases, and - * that is harmless by construction: a key the shape declares is recognised, so - * it never reaches the `unrecognized_keys` path this pattern is consulted from. - * It is the reason the audit asks a pattern about {@link - * VISIBILITY_STRICT_OPTIONS}'s `examples` rather than about the shape's declared - * keys — see `KeySetGuidance`. - */ -const VISIBILITY_KEY_PATTERN = /vis|conceal|hidden|show.?when/i; - -/** - * The shared `strictObject` options for every `.strict()` view/page schema that - * carries a conditional-visibility predicate (ADR-0089 D3a). - * - * With the shape closed, a key these schemas do not declare — a stale - * `visibleOn` past removal, a `visibleWhen` typo, or a wrong-layer paste — is a - * **loud parse error** instead of a silent strip (ADR-0049 enforce-or-remove, - * ADR-0078 no-silently-inert). The rejection is *fixable*: it names the - * offending key(s), points a visibility-shaped key at the canonical - * `visibleWhen`, and — new with the fold below — suggests the closest declared - * key for everything else. - * - * ```ts - * strictObject(VISIBILITY_STRICT_OPTIONS, { ..., visibleWhen: Expr.optional() }) - * .transform(normalizeVisibleWhen) - * ``` - * - * ## Folded out of a hand-written `$ZodErrorMap` (#6619, #6416 direction 2) - * - * This used to be `strictVisibilityError`, a bespoke map that re-implemented - * the front matter, the prescription branch and the trailing history sentence - * by hand. Being hand-written is what put it out of reach of #5955 (the shared - * template's reorder) and #5593 (the `strictObject` migration) — and, the - * reason this card was worth doing, out of reach of `alias-integrity.test.ts`: - * a hand-rolled map registers in no registry, so its prescription was - * **unmeasured rather than clean**. Declared here, it is judged with every - * other table in the package. - * - * The emission ORDER the pins in `ui/view.test.ts` encode is the template's own - * and unchanged: front matter → fix channels → the explanatory sentence last. - * What changed in the bytes is that the prescription is now rendered as the - * template's `\n • ` bullet rather than joined inline with a space, which is - * how every other closed surface in this package already reads it. - * - * ## `surface` here is a FAMILY default that every consumer overrides (#8202) - * - * The three shapes on this table each name themselves — `'this form field'`, - * `'this form section'`, `'this page component'` — by spreading these options - * and setting `surface` at their own call site. The string below is what they - * shared until #8202, and it is kept only as the family's name: a table shared - * by three shapes cannot carry one shape's name, which is the same placement - * rule #8199 drew for the boundary prescription, read from the `surface` end. - * - * Why the shapes stopped sharing it: while all three answered a key identically - * the shared string cost nothing. Since #8199 they do not — on a field - * `disabled` gets a rename pointer toward `readonly`, on a section or component - * it gets the editability-boundary prescription telling the author to move the - * key to the fields inside. Those two answers contradict each other by design, - * and the contradiction only reads correctly if the message says which shape - * the author is on. - * - * A consumer that forgets to override inherits this string silently, so the - * inheritance is pinned rather than trusted: `editability-boundary.test.ts` - * asserts no live declaration on this family still reports it. - */ -export const VISIBILITY_STRICT_OPTIONS: StrictObjectOptions = { - surface: 'this view/page schema', - history: - 'Before ADR-0089 D3a these were dropped silently, shipping inert metadata; ' - + 'a mis-layered or stale key is now a loud parse error.', - guidanceSets: [ - { - name: 'VISIBILITY_KEY_PATTERN', - keys: VISIBILITY_KEY_PATTERN, - // The spellings the pattern is FOR — the audit asserts each really - // matches and that none is a key the shape declares, which is the - // answerable half of the dead-entry claim for an open family. - examples: ['visibleWhenn', 'visibleIf', 'hiddenWhen', 'conceal', 'showWhen'], - prescription: - 'If this is the conditional-visibility predicate, the canonical key is ' - + '`visibleWhen` (ADR-0089) — `visibleOn` (view form) and `visibility` (page ' - + 'component) are still accepted as deprecated aliases.', - }, - ], -}; +// `VISIBILITY_STRICT_OPTIONS` — the shared `strictObject` options for the +// visibility-carrying view/page shapes — lives in `./visibility-strict-options`, +// beside its type (`StrictObjectOptions`, `./strict-object`) and OUTSIDE the +// `shared` barrel: a published const no consumer could annotate, spread or type +// a parameter with is not public API (the protocol-18 export batch; ADR-0087 +// registers the removal). diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 822880f5e3e..3d82116ad63 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -54,7 +54,8 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { closedObject, strictObject, strictObjectError } from '../shared/strict-object'; import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; -import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; +import { normalizeVisibleWhen } from '../shared/visibility'; +import { VISIBILITY_STRICT_OPTIONS } from '../shared/visibility-strict-options'; import { SELECT_OPTION_EDITABILITY_GUIDANCE, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; // [#13855] The section → field-group reference form, shared with the // `record:details` section shape (component.zod.ts) so one mixing rule serves diff --git a/scripts/adr-anchors/packages__lint__src__data-model-rules.ts.json b/scripts/adr-anchors/packages__lint__src__data-model-rules.ts.json index 0e15f219497..7bbb1d9ede9 100644 --- a/scripts/adr-anchors/packages__lint__src__data-model-rules.ts.json +++ b/scripts/adr-anchors/packages__lint__src__data-model-rules.ts.json @@ -3,5 +3,5 @@ "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). `unique/legacy-organization-composite` (D5c) is the S6 respelling NUDGE and stays ADVISORY forever: the legacy hand-written composite is valid indefinitely and forces ZERO drift, so this rule must never gain an auto-fix or an `error` severity — opting in is a physical tightening that goes through the D4 duplicate pre-flight." + "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` — an ERROR and a gating rule on all three commands since protocol 18 (#5082): the schema refuses the spelling wherever a door parses, and this rule is the refusal under `os lint`, which never parses. `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). `unique/legacy-organization-composite` (D5c) is the S6 respelling NUDGE and stays ADVISORY forever: the legacy hand-written composite is valid indefinitely and forces ZERO drift, so this rule must never gain an auto-fix or an `error` severity — opting in is a physical tightening that goes through the D4 duplicate pre-flight." } diff --git a/scripts/adr-anchors/packages__spec__src__conversions__registry.ts.json b/scripts/adr-anchors/packages__spec__src__conversions__registry.ts.json new file mode 100644 index 00000000000..d0d06bf9284 --- /dev/null +++ b/scripts/adr-anchors/packages__spec__src__conversions__registry.ts.json @@ -0,0 +1,8 @@ +{ + "file": "packages/spec/src/conversions/registry.ts", + "adrs": [ + "ADR-0120", + "ADR-0087" + ], + "invariant": "`declared-index-unique-scope` (ADR-0120 D2, toMajor 18) rewrites a DECLARED index's bare `unique: true` to `'global'` — and nothing else: field-level `unique: true` is never converted (D1: it means per-organization and stays valid), and `false` / `'global'` / `'organization'` pass through untouched. It is lossless only because `'global'` IS the verbatim column list bare `true` built — the S4/S5 nine-key corpus is pinned byte-identical before and after in driver-sql's `sql-driver-unique-tenancy.test.ts`. ⛔ Never re-target it at `'organization'` (the #4986 trap in mirror image, a silent physical change) and never take it off `retiredFromLoadPath`: the authoring funnel must REFUSE the bare spelling so the author states the scope, while data-at-rest seams replay it." +} diff --git a/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json b/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json index f92693d230a..1ba4c2ea3f5 100644 --- a/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json +++ b/scripts/adr-anchors/packages__spec__src__data__object.zod.ts.json @@ -3,5 +3,5 @@ "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)." + "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` = REFUSED since protocol 18 (#5082, ADR-0120 D7) with a prescription naming both words — it was the positional spelling of `'global'`; stored and built metadata converts it to `'global'` through the ADR-0087 conversion `declared-index-unique-scope` (byte-identical physical index), retired from the authoring funnel so a live author is refused, never converted silently. Field-level `unique: true` stays valid (D1) — never narrow `UniqueScopeSchema` in step with this one. 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)." } diff --git a/skills/objectstack-data/rules/indexing.md b/skills/objectstack-data/rules/indexing.md index c73464928fb..1e02314ba24 100644 --- a/skills/objectstack-data/rules/indexing.md +++ b/skills/objectstack-data/rules/indexing.md @@ -59,9 +59,9 @@ exactly two, and the same words work on a field and on a declared index: // ✅ platform-wide — a hostname, an external id, an engine dedup key { fields: ['source', 'dedup_key'], unique: 'global' } -// ❌ scope unstated — this is the DEPRECATED spelling of 'global'. +// ❌ scope unstated — REFUSED since protocol 18 (it spelled 'global' by position). // It reads like "per organization" and does the opposite. -// `os lint` reports unique/unscoped-declared-index; protocol 18 rejects it. +// `os validate` refuses it at the schema; `os lint`: unique/unscoped-declared-index. { fields: ['code'], unique: true } ``` @@ -73,7 +73,7 @@ Notes an author has to know: because SQL `UNIQUE` treats every `NULL` as distinct. - **On a FIELD, `unique: true` means `'organization'`** and stays valid forever; `'organization'` is just the preferred spelling in new code. Only on a - *declared index* is bare `true` deprecated. + *declared index* is bare `true` refused (stored metadata converts it to `'global'`). - **You never write the posture.** The same declaration is correct under every tenancy posture — state the business boundary, not the deployment shape. - **`'tenant'` and `'org'` are rejected.** The word is `'organization'`.