diff --git a/.changeset/unique-scope-message-per-surface.md b/.changeset/unique-scope-message-per-surface.md new file mode 100644 index 0000000000..1bcc933b81 --- /dev/null +++ b/.changeset/unique-scope-message-per-surface.md @@ -0,0 +1,54 @@ +--- +"@objectstack/spec": patch +--- + +Give the declared-index `unique` surface its own rejection message, so the +platform stops prescribing a silent scope change (#10928). + +`UniqueScopeSchema` is shared by `FieldSchema.unique` and `IndexSchema.unique`, +but its rejection text was written from the field-level viewpoint only: + +``` +Invalid unique scope 'nonsense_scope'. Allowed: true/false, 'organization' +(one holder per organization — the explicit spelling of true), or 'global' +(one holder across the whole installation). +``` + +The parenthetical is true at field level, where bare `true` resolves +per-organization. It is **false on a declared index**, where bare `true` sets +neither driver flag (`isGlobalUnique` / `isOrganizationUnique`) and the index +materializes over exactly `fields` — there `'global'` is what `true` spells, and +`IndexSchema.unique`'s own `describe()` already said so. + +That message is read at the one moment it is most likely to be obeyed: the +author has just been refused on this very key and is looking for the accepted +spelling. An author holding a working `unique: true` on a declared index was +told `'organization'` is what it spells; taking that advice 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 a deployed database. +That is the unannounced index reinterpretation ruled out by #8323 (maintainer, +2026-08-13) and staged by #5082, reaching authors through the platform's own +error text rather than at review time. + +`object.zod.ts` now declares its own structurally identical union with a +sibling error map. On a declared index the refusal reads: + +``` +Invalid unique scope 'nonsense_scope'. Allowed: true/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, #5082). +``` + +The field-level message is unchanged — the hint is correct there and that is the +common surface. + +**Message text only.** No accepted value, parse result, default, or scope +semantics changes on either surface, and the refusal envelope (`invalid_union` +on path `unique`) is identical to before — as #8323 requires. The new +`unique-scope-message.test.ts` pins both halves: the two surfaces say different +things about bare `true`, and they accept and reject exactly the same value +table with identical parse results, so the deliberately duplicated member list +cannot drift. diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 70bb2afde2..49b46f0b0c 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -368,25 +368,40 @@ export { AddressSchema }; */ /** * Prescriptive rejection for a mis-spelled `unique` scope (ADR-0120 - * §Terminology): the error must carry the vocabulary and, for the two - * predictable near-misses (`'tenant'`, `'org'`), name `'organization'` - * explicitly — a typo must be a loud, fixable parse error, never a silent - * scope change. Declared before `UniqueScopeSchema` because + * §Terminology) **on the FIELD surface**: the error must carry the vocabulary + * and, for the two predictable near-misses (`'tenant'`, `'org'`), name + * `'organization'` explicitly — a typo must be a loud, fixable parse error, + * never a silent scope change. Declared before `UniqueScopeSchema` because * `OS_EAGER_SCHEMAS=1` evaluates the factory at module load (TDZ). * - * ⚠️ **The last hand-written `$ZodErrorMap` in `packages/spec`, and it stays - * one.** This docblock used to say "pattern of `strictCapabilitiesError`"; - * #6805 folded that sibling into the shared `strictObject` template and the - * pointer would have gone stale, so it is replaced by the reason this map is - * NOT following it. The fold's channel is `unrecognized_keys` — an unknown - * KEY, answered from a per-key `guidance` table. This map answers - * `invalid_union`, a VALUE-level verdict on a key the schema declares, which - * `strictObject` does not address at any level. Folding it would be a category - * error, and `alias-integrity.test.ts`'s class pin + * ⚠️ **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. + * + * ⚠️ **One of the two hand-written `$ZodErrorMap`s in `packages/spec`, and the + * pair stays a pair.** This docblock used to say "pattern of + * `strictCapabilitiesError`"; #6805 folded that sibling into the shared + * `strictObject` template and the pointer would have gone stale, so it is + * replaced by the reason this map is NOT following it. The fold's channel is + * `unrecognized_keys` — an unknown KEY, answered from a per-key `guidance` + * table. This map answers `invalid_union`, a VALUE-level verdict on a key the + * schema declares, which `strictObject` does not address at any level. Folding + * it would be a category error, and `alias-integrity.test.ts`'s class pin * (`NO module outside the shared helpers writes its own unrecognized_keys * map`) is scoped by `issue.code` precisely so this site is out of class by * measurement rather than by an exemption — that pin reads this file as a live - * control. + * control, and the index-surface sibling is out of class by the same + * measurement rather than by an added exemption. */ const uniqueScopeError: z.core.$ZodErrorMap = (issue) => { if (issue.code !== 'invalid_union') return undefined; @@ -447,6 +462,16 @@ const uniqueScopeError: z.core.$ZodErrorMap = (issue) => { * accepted and are NOT aliases — "tenant" is overloaded across deployment * topologies and the platform spells the noun out (`organization_id`). The * parse error names `'organization'` so the fix ships inside the rejection. + * + * ⚠️ **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. */ export const UniqueScopeSchema = lazySchema(() => z.union([z.boolean(), z.literal('global'), z.literal('organization')], { diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index d65e7d2f5a..555c640285 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { z } from 'zod'; -import { FieldSchema, UniqueScopeSchema } from './field.zod'; +import { FieldSchema } from './field.zod'; import { ValidationRuleSchema } from './validation.zod'; import { ActionSchema } from '../ui/action.zod'; import { ObjectListViewSchema } from '../ui/view.zod'; @@ -374,6 +374,66 @@ export const ObjectCapabilities = strictObject({ * rename onto the retired `partial` tombstone would be the campaign's * finding 7 (a suggestion pointing into a second rejection). */ +/** + * 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. + * + * Declared before `IndexSchema` because `OS_EAGER_SCHEMAS=1` evaluates the + * factory at module load (TDZ) — same constraint as the field-surface map. + */ +const declaredIndexUniqueScopeError: z.core.$ZodErrorMap = (issue) => { + if (issue.code !== 'invalid_union') return undefined; + const input = (issue as { input?: unknown }).input; + const spelled = typeof input === 'string' ? `'${input}'` : String(input); + const nearMiss = + input === 'tenant' || input === 'org' + ? ` ${spelled} is not accepted and is not an alias — the per-organization scope is spelled 'organization' (ADR-0120: "tenant" is overloaded across deployment topologies, and the platform spells the word out).` + : ''; + return ( + `Invalid unique scope ${spelled}. Allowed: true/false, 'organization' ` + + `(one holder per organization — the 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, #5082).${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. + */ +const DeclaredIndexUniqueScopeSchema = lazySchema(() => + z.union([z.boolean(), z.literal('global'), z.literal('organization')], { + error: declaredIndexUniqueScopeError, + }), +); + export const IndexSchema = lazySchema(() => strictObject({ surface: 'this index', history: @@ -420,7 +480,7 @@ export const IndexSchema = lazySchema(() => strictObject({ // `fields: ['organization_id', 'code']`" survives as valid legacy input, // but new code says `unique: 'organization'` — the hand-written composite // is NOT NULL-safe (#5030). - unique: UniqueScopeSchema.optional().default(false).describe("Whether the index enforces uniqueness, and at which scope (ADR-0120). 'global' = materialized over exactly `fields`, no organization column injected — one holder across the whole installation; 'organization' = the driver prepends the NULL-safe organization key part (COALESCE(organization_id, '__global__')) at registration — one holder per organization; bare true = deprecated positional spelling of 'global' (warned in 17.x by lint unique/unscoped-declared-index, rejected at protocol 18, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'"), + 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, #5082) — state the scope. 'tenant'/'org' are rejected — the word is 'organization'"), // ── 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 new file mode 100644 index 0000000000..45f3608a7a --- /dev/null +++ b/packages/spec/src/data/unique-scope-message.test.ts @@ -0,0 +1,190 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { FieldSchema } from './field.zod'; +import { IndexSchema } from './object.zod'; + +/** + * The `unique` scope vocabulary is shared by two surfaces; the *meaning of bare + * `true`* is not, and that divergence is deliberate and load-bearing (ADR-0120 + * D1, the #4986 trap): + * + * - `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`. + * + * 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 + * obeyed — the author has just been refused on this very key and is looking for + * the accepted spelling — it told a declared-index author that `'organization'` + * is what their working `true` spells. Taking that advice asks the driver to + * prepend the NULL-safe organization key part at registration: a + * materialization change, silent, on an index that may already exist on a + * deployed database. That is the unannounced reinterpretation ruled out by + * #8323 (maintainer, 2026-08-13) and staged by #5082. + * + * This file pins the repair on both halves at once, because either half alone + * 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) 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. + */ + +/** Minimal valid field, `unique` supplied by the caller. */ +const parseField = (unique: unknown) => + FieldSchema.safeParse({ name: 'code', label: 'Code', type: 'text', unique }); + +/** Minimal valid declared index, `unique` supplied by the caller. */ +const parseIndex = (unique: unknown) => + IndexSchema.safeParse({ fields: ['code'], unique }); + +/** + * A parse from EITHER surface. Spelled as the union rather than as one of the + * two, because reading the same assertion off both is the whole point of this + * file — a helper typed to one surface silently makes the other half unwritable. + */ +type ScopeParse = ReturnType | ReturnType; + +/** The sole `unique` issue, or a failure the caller can read. */ +const uniqueIssue = (result: ScopeParse) => { + expect(result.success, 'expected this value to be REFUSED').toBe(false); + if (result.success) throw new Error('unreachable'); + const issues = result.error.issues.filter((i) => i.path.join('.') === 'unique'); + expect(issues, 'expected exactly one issue on `unique`').toHaveLength(1); + return issues[0]!; +}; + +/** + * The near-miss clause (ADR-0120 §Terminology). Shared verbatim by both + * surfaces — `'tenant'`/`'org'` are rejected words on either, and nothing about + * that answer is surface-dependent. + */ +const NEAR_MISS = (spelled: string) => + ` ${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).`; + +/** + * The field-surface message, pinned byte-for-byte as it shipped before the + * split. This half of the repair is "change nothing": the hint is TRUE here and + * is the common surface, so the fix must not cost it. A `toBe` rather than a + * `toContain` on purpose — a later edit that "harmonises" the two messages back + * together fails here, which is the regression this card is about. + */ +const FIELD_MESSAGE = + "Invalid unique scope 'nonsense_scope'. Allowed: true/false, 'organization' " + + '(one holder per organization — the explicit spelling of true), or \'global\' ' + + '(one holder across the whole installation).'; + +describe('unique scope rejection message — the two surfaces disagree about bare `true`', () => { + it('the FIELD surface keeps its "explicit spelling of true" hint, unchanged', () => { + expect(uniqueIssue(parseField('nonsense_scope')).message).toBe(FIELD_MESSAGE); + }); + + it('the DECLARED-INDEX surface names `global` as the positional meaning of bare true', () => { + 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. + 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 (#5082): the 17.x + // warning channel and the protocol-18 rejection, named where they are read. + expect(message).toContain('unique/unscoped-declared-index'); + expect(message).toContain('protocol 18'); + expect(message).toContain('#5082'); + + // And the organization scope described by what it DOES here, not by an + // equivalence to `true` that does not hold on this surface. + expect(message).toContain('one holder per organization'); + expect(message).toContain('NULL-safe'); + }); + + 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; + expect(index).not.toBe(field); + // Both still open with the vocabulary, so an author reading either one + // 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'"); + } + }); + + it.each(['tenant', 'org'])( + 'the rejected word %s gets the same near-miss clause on BOTH surfaces', + (word) => { + expect(uniqueIssue(parseField(word)).message).toContain(NEAR_MISS(`'${word}'`)); + expect(uniqueIssue(parseIndex(word)).message).toContain(NEAR_MISS(`'${word}'`)); + }, + ); +}); + +describe('unique scope — message text only: the accept/reject line does not move (#8323)', () => { + // 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 }, + ]; + + it.each(VALUES)('$label is treated identically on both surfaces', ({ value, accepted }) => { + const field = parseField(value); + const index = parseIndex(value); + + expect(field.success).toBe(accepted); + expect(index.success).toBe(accepted); + + 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); + } + }); + + it('the refusal envelope is unchanged on both surfaces (code and path)', () => { + for (const parse of [parseField, parseIndex]) { + for (const bad of ['nonsense_scope', 'tenant', 'org', 1, null]) { + const issue = uniqueIssue(parse(bad)); + expect(issue.code).toBe('invalid_union'); + expect(issue.path).toEqual(['unique']); + } + } + }); + + it('`unique` still defaults the same way on each surface', () => { + const field = FieldSchema.safeParse({ name: 'code', label: 'Code', type: 'text' }); + expect(field.success && field.data.unique).toBe(false); + + // Optional on a declared index, and its default is the same `false`. + const index = IndexSchema.safeParse({ fields: ['code'] }); + expect(index.success && index.data.unique).toBe(false); + }); +});