Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/unique-scope-message-per-surface.md
Original file line numberDiff line numberDiff line change
@@ -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.
53 changes: 39 additions & 14 deletions packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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')], {
Expand Down
64 changes: 62 additions & 2 deletions packages/spec/src/data/object.zod.ts
Original file line numberDiff line numberDiff line change
@@ -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';
Expand DownExpand Up@@ -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:
Expand DownExpand Up@@ -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
Expand Down
Loading
Loading