Skip to content

Commit f34f56b

Browse files
claude[bot]claude
andauthored
fix(spec): give the declared-index unique surface its own rejection message (#11213)
* fix(spec): give the declared-index unique surface its own rejection message UniqueScopeSchema's shared rejection text called 'organization' "the explicit spelling of true" on both surfaces. True at field level; false on a declared index, where bare true is the positional spelling of 'global'. Following the message there changes materialization on an index that may already exist — the unannounced reinterpretation #8323 rejects and #5082 stages. object.zod.ts now carries a sibling error map and a structurally identical union. Message text only: accepted values, parse results, defaults, scope semantics and the invalid_union/path envelope are unchanged on both surfaces, pinned by unique-scope-message.test.ts. Fixes #10928 * test(spec): type the unique-scope helper over BOTH surfaces check:test-typecheck refused the new file: uniqueIssue() was typed to ReturnType<typeof parseField>, so the index half of every shared assertion was unwritable (4x TS2345). Spelled as the union of the two surfaces instead — which is what the file is about. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c815c50 commit f34f56b

4 files changed

Lines changed: 345 additions & 16 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
Give the declared-index `unique` surface its own rejection message, so the
6+
platform stops prescribing a silent scope change (#10928).
7+
8+
`UniqueScopeSchema` is shared by `FieldSchema.unique` and `IndexSchema.unique`,
9+
but its rejection text was written from the field-level viewpoint only:
10+
11+
```
12+
Invalid unique scope 'nonsense_scope'. Allowed: true/false, 'organization'
13+
(one holder per organization — the explicit spelling of true), or 'global'
14+
(one holder across the whole installation).
15+
```
16+
17+
The parenthetical is true at field level, where bare `true` resolves
18+
per-organization. It is **false on a declared index**, where bare `true` sets
19+
neither driver flag (`isGlobalUnique` / `isOrganizationUnique`) and the index
20+
materializes over exactly `fields` — there `'global'` is what `true` spells, and
21+
`IndexSchema.unique`'s own `describe()` already said so.
22+
23+
That message is read at the one moment it is most likely to be obeyed: the
24+
author has just been refused on this very key and is looking for the accepted
25+
spelling. An author holding a working `unique: true` on a declared index was
26+
told `'organization'` is what it spells; taking that advice asks the driver to
27+
prepend the NULL-safe organization key part at registration — a materialization
28+
change, silently, on an index that may already exist on a deployed database.
29+
That is the unannounced index reinterpretation ruled out by #8323 (maintainer,
30+
2026-08-13) and staged by #5082, reaching authors through the platform's own
31+
error text rather than at review time.
32+
33+
`object.zod.ts` now declares its own structurally identical union with a
34+
sibling error map. On a declared index the refusal reads:
35+
36+
```
37+
Invalid unique scope 'nonsense_scope'. Allowed: true/false, 'organization'
38+
(one holder per organization — the driver prepends the NULL-safe organization
39+
key part to `fields` at registration), or 'global' (one holder across the whole
40+
installation — materialized over exactly `fields`, and the positional meaning of
41+
bare true on a declared index: bare true is warned by lint
42+
unique/unscoped-declared-index in 17.x and rejected at protocol 18, #5082).
43+
```
44+
45+
The field-level message is unchanged — the hint is correct there and that is the
46+
common surface.
47+
48+
**Message text only.** No accepted value, parse result, default, or scope
49+
semantics changes on either surface, and the refusal envelope (`invalid_union`
50+
on path `unique`) is identical to before — as #8323 requires. The new
51+
`unique-scope-message.test.ts` pins both halves: the two surfaces say different
52+
things about bare `true`, and they accept and reject exactly the same value
53+
table with identical parse results, so the deliberately duplicated member list
54+
cannot drift.

packages/spec/src/data/field.zod.ts

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -368,25 +368,40 @@ export { AddressSchema };
368368
*/
369369
/**
370370
* Prescriptive rejection for a mis-spelled `unique` scope (ADR-0120
371-
* §Terminology): the error must carry the vocabulary and, for the two
372-
* predictable near-misses (`'tenant'`, `'org'`), name `'organization'`
373-
* explicitly — a typo must be a loud, fixable parse error, never a silent
374-
* scope change. Declared before `UniqueScopeSchema` because
371+
* §Terminology) **on the FIELD surface**: the error must carry the vocabulary
372+
* and, for the two predictable near-misses (`'tenant'`, `'org'`), name
373+
* `'organization'` explicitly — a typo must be a loud, fixable parse error,
374+
* never a silent scope change. Declared before `UniqueScopeSchema` because
375375
* `OS_EAGER_SCHEMAS=1` evaluates the factory at module load (TDZ).
376376
*
377-
* ⚠️ **The last hand-written `$ZodErrorMap` in `packages/spec`, and it stays
378-
* one.** This docblock used to say "pattern of `strictCapabilitiesError`";
379-
* #6805 folded that sibling into the shared `strictObject` template and the
380-
* pointer would have gone stale, so it is replaced by the reason this map is
381-
* NOT following it. The fold's channel is `unrecognized_keys` — an unknown
382-
* KEY, answered from a per-key `guidance` table. This map answers
383-
* `invalid_union`, a VALUE-level verdict on a key the schema declares, which
384-
* `strictObject` does not address at any level. Folding it would be a category
385-
* error, and `alias-integrity.test.ts`'s class pin
377+
* ⚠️ **Field-surface only — the parenthetical below is FALSE on a declared
378+
* index, and that is why this map is not shared.** "`'organization'` … the
379+
* explicit spelling of true" holds here (`FieldSchema.unique`), where bare
380+
* `true` resolves per-organization. On `IndexSchema.unique` bare `true` is the
381+
* positional spelling of `'global'` (the #4986 trap, retired at protocol 18 by
382+
* #5082) — so a shared message read at the one moment an author is looking for
383+
* the accepted spelling prescribed a value that CHANGES materialization on an
384+
* index that may already exist, which is the unannounced reinterpretation the
385+
* #8323 ruling (maintainer, 2026-08-13) exists to prevent. `object.zod.ts`
386+
* therefore carries its own sibling map, `declaredIndexUniqueScopeError`,
387+
* pinned equivalent to this one on accept/reject by
388+
* `unique-scope-message.test.ts`. Keep the two vocabularies in step; only the
389+
* parentheticals may differ.
390+
*
391+
* ⚠️ **One of the two hand-written `$ZodErrorMap`s in `packages/spec`, and the
392+
* pair stays a pair.** This docblock used to say "pattern of
393+
* `strictCapabilitiesError`"; #6805 folded that sibling into the shared
394+
* `strictObject` template and the pointer would have gone stale, so it is
395+
* replaced by the reason this map is NOT following it. The fold's channel is
396+
* `unrecognized_keys` — an unknown KEY, answered from a per-key `guidance`
397+
* table. This map answers `invalid_union`, a VALUE-level verdict on a key the
398+
* schema declares, which `strictObject` does not address at any level. Folding
399+
* it would be a category error, and `alias-integrity.test.ts`'s class pin
386400
* (`NO module outside the shared helpers writes its own unrecognized_keys
387401
* map`) is scoped by `issue.code` precisely so this site is out of class by
388402
* measurement rather than by an exemption — that pin reads this file as a live
389-
* control.
403+
* control, and the index-surface sibling is out of class by the same
404+
* measurement rather than by an added exemption.
390405
*/
391406
const uniqueScopeError: z.core.$ZodErrorMap = (issue) => {
392407
if (issue.code !== 'invalid_union') return undefined;
@@ -447,6 +462,16 @@ const uniqueScopeError: z.core.$ZodErrorMap = (issue) => {
447462
* accepted and are NOT aliases — "tenant" is overloaded across deployment
448463
* topologies and the platform spells the noun out (`organization_id`). The
449464
* parse error names `'organization'` so the fix ships inside the rejection.
465+
*
466+
* ⚠️ **This schema is the FIELD surface's.** The vocabulary above is shared
467+
* with `IndexSchema.unique`, but the *meaning of bare `true`* is not: on a
468+
* declared index it is the positional spelling of `'global'`, not of
469+
* `'organization'` (the #4986 trap; #5082 retires it at protocol 18). The
470+
* index surface therefore declares its own structurally identical union with
471+
* its own rejection text in `object.zod.ts` — accepting and rejecting exactly
472+
* what this one does, pinned by `unique-scope-message.test.ts`. Widening or
473+
* narrowing the member list here is a change to BOTH surfaces: make it in both
474+
* places or the pin fails.
450475
*/
451476
export const UniqueScopeSchema = lazySchema(() =>
452477
z.union([z.boolean(), z.literal('global'), z.literal('organization')], {

packages/spec/src/data/object.zod.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { z } from 'zod';
4-
import { FieldSchema, UniqueScopeSchema } from './field.zod';
4+
import { FieldSchema } from './field.zod';
55
import { ValidationRuleSchema } from './validation.zod';
66
import { ActionSchema } from '../ui/action.zod';
77
import { ObjectListViewSchema } from '../ui/view.zod';
@@ -374,6 +374,66 @@ export const ObjectCapabilities = strictObject({
374374
* rename onto the retired `partial` tombstone would be the campaign's
375375
* finding 7 (a suggestion pointing into a second rejection).
376376
*/
377+
/**
378+
* Prescriptive rejection for a mis-spelled `unique` scope **on a DECLARED
379+
* INDEX** — the sibling of `field.zod.ts`'s `uniqueScopeError`, and the reason
380+
* the two are not one map.
381+
*
382+
* Same vocabulary (`boolean | 'global' | 'organization'`), same near-miss
383+
* table, same `invalid_union` channel. The difference is the one clause an
384+
* author acts on: **what bare `true` positionally means here.** At field level
385+
* `true` resolves per-organization, so naming `'organization'` "the explicit
386+
* spelling of true" is a true and useful hint. On this surface `true` sets
387+
* neither driver flag (`isGlobalUnique` / `isOrganizationUnique` are both
388+
* false) and the index materializes over exactly `fields` — i.e. `'global'` is
389+
* what `true` spells. The shared text therefore told an author who had just
390+
* been refused on this key to write `'organization'` for what they already had,
391+
* which asks the driver to prepend the NULL-safe organization key part at
392+
* registration — a materialization change, silently, on an index that may
393+
* already exist on deployed databases. That is precisely the unannounced index
394+
* reinterpretation the #8323 ruling (maintainer, 2026-08-13) rejects and the
395+
* #5082 protocol-18 sequencing is there to stage.
396+
*
397+
* ⛔ Message text only. The accepted and rejected sets are byte-identical to
398+
* `UniqueScopeSchema`'s and must stay so — `unique-scope-message.test.ts` pins
399+
* both surfaces against the same value table, so a member added or dropped on
400+
* either side fails there rather than diverging quietly.
401+
*
402+
* Declared before `IndexSchema` because `OS_EAGER_SCHEMAS=1` evaluates the
403+
* factory at module load (TDZ) — same constraint as the field-surface map.
404+
*/
405+
const declaredIndexUniqueScopeError: z.core.$ZodErrorMap = (issue) => {
406+
if (issue.code !== 'invalid_union') return undefined;
407+
const input = (issue as { input?: unknown }).input;
408+
const spelled = typeof input === 'string' ? `'${input}'` : String(input);
409+
const nearMiss =
410+
input === 'tenant' || input === 'org'
411+
? ` ${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).`
412+
: '';
413+
return (
414+
`Invalid unique scope ${spelled}. Allowed: true/false, 'organization' ` +
415+
`(one holder per organization — the driver prepends the NULL-safe ` +
416+
`organization key part to \`fields\` at registration), or 'global' ` +
417+
`(one holder across the whole installation — materialized over exactly ` +
418+
`\`fields\`, and the positional meaning of bare true on a declared index: ` +
419+
`bare true is warned by lint unique/unscoped-declared-index in 17.x and ` +
420+
`rejected at protocol 18, #5082).${nearMiss}`
421+
);
422+
};
423+
424+
/**
425+
* `UniqueScopeSchema`'s declared-index twin: the same union, refused in the
426+
* index surface's own words. See `declaredIndexUniqueScopeError` above for why
427+
* the message cannot be shared, and `field.zod.ts`'s `UniqueScopeSchema` for
428+
* the scope vocabulary itself (ADR-0120 D1) — the member list is duplicated
429+
* deliberately and pinned equivalent, never re-derived.
430+
*/
431+
const DeclaredIndexUniqueScopeSchema = lazySchema(() =>
432+
z.union([z.boolean(), z.literal('global'), z.literal('organization')], {
433+
error: declaredIndexUniqueScopeError,
434+
}),
435+
);
436+
377437
export const IndexSchema = lazySchema(() => strictObject({
378438
surface: 'this index',
379439
history:
@@ -420,7 +480,7 @@ export const IndexSchema = lazySchema(() => strictObject({
420480
// `fields: ['organization_id', 'code']`" survives as valid legacy input,
421481
// but new code says `unique: 'organization'` — the hand-written composite
422482
// is NOT NULL-safe (#5030).
423-
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'"),
483+
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'"),
424484

425485
// ── Tombstones (ADR-0049 / ADR-0087) ─────────────────────────────────
426486
// Kept LAST in the shape on purpose — see the #5606 note in the block

0 commit comments

Comments
 (0)