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
30 changes: 30 additions & 0 deletions .changeset/spec-meta-url-spelling-verdict-surface.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
---
"@objectstack/spec": minor
---

refactor(spec): narrow the `@objectstack/spec/shared` metadata-url-spelling surface to the verdict (#8424)

Per the #8424 spec-seat ruling (amended, option A), the `/meta` URL-spelling
module's surface is three symbols with three roles: `META_URL_TO_SINGULAR` (the
spelling contract), `canonicalMetaUrlType` (the fold), and the new
`metaUrlSpellingRefusal(urlType)` → `{ declared, hint } | null` (the composed
#7894 boundary refusal verdict: "this spelling is an unrecognised plural of
declared type `declared`; the accepted spellings are `declared` and `hint`").

Three helpers that briefly rode the surface become module-internal:
`unmappedDeclaredTypeSpelling` and `restPluralOfMetaType` (their one
out-of-package consumer, `metadata-protocol`'s 400 refusal, now consumes the
composed verdict — `metaUrlSpellingRefusal(t)?.declared` / `?.hint` replace the
two calls) and `DECLARED_META_TYPES` (no consumer; it read like a live registry
of registered types and is not one).

**Not a breaking change for any released consumer, hence no breaking
declaration**: the three internalized exports landed on `main` 2026-08-13
(PR #8420) and no version has published since `17.0.0-rc.6` (2026-08-10), so no
released artifact ever carried them — relative to every published version this
release only *adds* spelling exports. Even had they been published, the
launch-window convention (`scripts/check-changeset-no-major.mjs`) would ship the
trim as `minor`.

No runtime behaviour changes: the `/meta` boundary's 400 `INVALID_REQUEST`
refusal wording and status are byte-identical (#7894's pins are the proof).
16 changes: 9 additions & 7 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -61,7 +61,7 @@ import {
type QueryAliasConflict, type QueryAliasSlot,
type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed,
} from '@objectstack/spec/data';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, unmappedDeclaredTypeSpelling, restPluralOfMetaType } from '@objectstack/spec/shared';
import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal } from '@objectstack/spec/shared';
import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec';
import { type FormView, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui';
import { METADATA_FORM_REGISTRY, CORE_SERVICE_PROVIDER, serviceUnavailableMessage, inProcessServiceMessage } from '@objectstack/spec/system';
Expand DownExpand Up@@ -191,17 +191,19 @@ function canonicalMetaType(type: string): string {
* `stripServedSystemColumns`, where the type is already canonical and a throw
* would be a bug rather than a refusal.
*
* The refusal is deliberately narrow: {@link unmappedDeclaredTypeSpelling}
* fires only for a spelling whose singular is a type the platform itself
* The refusal is deliberately narrow: {@link metaUrlSpellingRefusal} returns a
* verdict only for a spelling whose singular is a type the platform itself
* DECLARES, so a plugin-registered runtime kind can never trip it. See that
* function for why the rule is static rather than a live-registry lookup.
* function for why the rule is static rather than a live-registry lookup —
* and (#8424) for why this boundary consumes the composed VERDICT rather than
* the predicate parts: the spelling contract stays whole at its producer.
*/
function canonicalizeMetaRequestType<T extends { type: string }>(request: T): T {
const declared = unmappedDeclaredTypeSpelling(request.type);
if (declared) {
const refusal = metaUrlSpellingRefusal(request.type);
if (refusal) {
const err = new Error(
`[invalid_request] '${request.type}' is not a recognised spelling of metadata type `
+ `'${declared}'. Address it as '${declared}' or '${restPluralOfMetaType(declared)}'. `
+ `'${refusal.declared}'. Address it as '${refusal.declared}' or '${refusal.hint}'. `
+ `Refused rather than treated as a plugin-registered type, because forwarding an unrecognised `
+ `spelling of a declared type would create a second namespace under type='${request.type}'.`,
);
Expand Down
6 changes: 2 additions & 4 deletions packages/spec/api-surface/shared.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,6 @@
"CorsConfigSchema (const)",
"CronExpressionInput (type)",
"CronExpressionInputSchema (const)",
"DECLARED_META_TYPES (const)",
"EXTERNAL_ERROR_CODES (const)",
"EXTERNAL_ERROR_HTTP_STATUS (const)",
"EventName (type)",
Expand DownExpand Up@@ -109,6 +108,7 @@
"keySetMatches (function)",
"lazySchema (function)",
"levenshteinDistance (function)",
"metaUrlSpellingRefusal (function)",
"normalizeMetadataCollection (function)",
"normalizePluginMetadata (function)",
"normalizeStackInput (function)",
Expand All@@ -117,12 +117,10 @@
"pluralToSingular (function)",
"renderDiffMessage (function)",
"resilientFetch (function)",
"restPluralOfMetaType (function)",
"safeParsePretty (function)",
"singularToPlural (function)",
"strictUnknownKeyError (function)",
"suggestFieldType (function)",
"tmpl (function)",
"unmappedDeclaredTypeSpelling (function)"
"tmpl (function)"
]
}
6 changes: 2 additions & 4 deletions packages/spec/export-origins/shared.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,6 @@
"CorsConfigSchema": "src/shared/http.zod.ts#CorsConfigSchema (const)",
"CronExpressionInput": "src/shared/expression.zod.ts#CronExpressionInput (type)",
"CronExpressionInputSchema": "src/shared/expression.zod.ts#CronExpressionInputSchema (const)",
"DECLARED_META_TYPES": "src/shared/metadata-url-spelling.ts#DECLARED_META_TYPES (const)",
"EXTERNAL_ERROR_CODES": "src/shared/external-errors.ts#EXTERNAL_ERROR_CODES (const)",
"EXTERNAL_ERROR_HTTP_STATUS": "src/shared/external-errors.ts#EXTERNAL_ERROR_HTTP_STATUS (const)",
"EventName": "src/shared/identifiers.zod.ts#EventName (type)",
Expand DownExpand Up@@ -109,6 +108,7 @@
"keySetMatches": "src/shared/suggestions.zod.ts#keySetMatches (function)",
"lazySchema": "src/shared/lazy-schema.ts#lazySchema (function)",
"levenshteinDistance": "src/shared/suggestions.zod.ts#levenshteinDistance (function)",
"metaUrlSpellingRefusal": "src/shared/metadata-url-spelling.ts#metaUrlSpellingRefusal (function)",
"normalizeMetadataCollection": "src/shared/metadata-collection.zod.ts#normalizeMetadataCollection (function)",
"normalizePluginMetadata": "src/shared/metadata-collection.zod.ts#normalizePluginMetadata (function)",
"normalizeStackInput": "src/shared/metadata-collection.zod.ts#normalizeStackInput (function)",
Expand All@@ -117,12 +117,10 @@
"pluralToSingular": "src/shared/metadata-collection.zod.ts#pluralToSingular (function)",
"renderDiffMessage": "src/shared/external-errors.ts#renderDiffMessage (function)",
"resilientFetch": "src/shared/resilient-fetch.ts#resilientFetch (function)",
"restPluralOfMetaType": "src/shared/metadata-url-spelling.ts#restPluralOfMetaType (function)",
"safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)",
"singularToPlural": "src/shared/metadata-collection.zod.ts#singularToPlural (function)",
"strictUnknownKeyError": "src/shared/suggestions.zod.ts#strictUnknownKeyError (function)",
"suggestFieldType": "src/shared/suggestions.zod.ts#suggestFieldType (function)",
"tmpl": "src/shared/expression.zod.ts#tmpl (function)",
"unmappedDeclaredTypeSpelling": "src/shared/metadata-url-spelling.ts#unmappedDeclaredTypeSpelling (function)"
"tmpl": "src/shared/expression.zod.ts#tmpl (function)"
}
}
75 changes: 54 additions & 21 deletions packages/spec/src/shared/metadata-url-spelling.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,19 +10,37 @@
* every legitimate change and would teach the next author to re-bless it
* without reading. These assertions are quantified over the registry instead —
* declare a new type and they cover it automatically.
*
* #8424 — everything here goes through the module's PUBLIC surface
* (`META_URL_TO_SINGULAR` · `canonicalMetaUrlType` · `metaUrlSpellingRefusal`).
* The helpers behind it are module-internal by ruling, so the tests pin the
* behaviour a consumer can actually reach, and the quantified cases derive
* expected spellings from an independent mirror (see `expectedRestPlural`).
*/

import { describe, expect, it } from 'vitest';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '../kernel/metadata-plugin.zod';
import { PLURAL_TO_SINGULAR } from './metadata-collection.zod';
import {
DECLARED_META_TYPES,
META_URL_TO_SINGULAR,
canonicalMetaUrlType,
restPluralOfMetaType,
unmappedDeclaredTypeSpelling,
metaUrlSpellingRefusal,
} from './metadata-url-spelling';

/**
* Independent mirror of the module-internal pluralization rule (#8424). The
* real rule is deliberately no longer exported, so the registry-quantified
* invariants below derive the expected spelling themselves. The duplication is
* the point: if the module's rule ever drifts from this mirror, the map stops
* containing the mirror's spelling (or a verdict's `hint` stops matching) and
* these assertions go red — drift becomes visible instead of silent.
*/
function expectedRestPlural(type: string): string {
if (/[^aeiou]y$/.test(type)) return `${type.slice(0, -1)}ies`;
if (/(s|x|z|ch|sh)$/.test(type)) return `${type}es`;
return `${type}s`;
}

describe('#7894 INVARIANT 1 — no spelling that worked before may stop working', () => {
it('folds every manifest spelling to exactly the singular it folded to before', () => {
// The manifest map is the complete population of spellings that resolved at
Expand All@@ -39,7 +57,7 @@ describe('#7894 INVARIANT 1 — no spelling that worked before may stop working'

it('refuses none of them', () => {
for (const plural of Object.keys(PLURAL_TO_SINGULAR)) {
expect(unmappedDeclaredTypeSpelling(plural), `${plural} works today and must not be refused`).toBeNull();
expect(metaUrlSpellingRefusal(plural), `${plural} works today and must not be refused`).toBeNull();
}
});

Expand All@@ -62,18 +80,20 @@ describe('#7894 INVARIANT 2 — no unmapped spelling of a DECLARED type may answ
it('maps the REST plural of every declared registry type', () => {
// This is the limb that makes the defect non-recurring: it is quantified
// over the registry, so a newly declared type arrives already mapped and
// can never fall through to the permissive plugin path.
// can never fall through to the permissive plugin path. The expected
// plural comes from the independent mirror, so this also cross-checks the
// module-internal pluralizer against a second spelling of the same rule.
for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
const plural = restPluralOfMetaType(entry.type);
const plural = expectedRestPlural(entry.type);
expect(canonicalMetaUrlType(plural), `${plural} must fold to ${entry.type}`).toBe(entry.type);
expect(unmappedDeclaredTypeSpelling(plural)).toBeNull();
expect(metaUrlSpellingRefusal(plural)).toBeNull();
}
});

it('leaves every declared singular as its own canonical form', () => {
for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
expect(canonicalMetaUrlType(entry.type)).toBe(entry.type);
expect(unmappedDeclaredTypeSpelling(entry.type)).toBeNull();
expect(metaUrlSpellingRefusal(entry.type)).toBeNull();
}
});

Expand All@@ -82,15 +102,18 @@ describe('#7894 INVARIANT 2 — no unmapped spelling of a DECLARED type may answ
// before the fix — i.e. was treated as a plugin type — and `field` was the
// live authorization hole.
for (const type of ['field', 'seed', 'external_catalog', 'translation']) {
const plural = restPluralOfMetaType(type);
const plural = expectedRestPlural(type);
expect(PLURAL_TO_SINGULAR[plural], `${plural} must stay OUT of the manifest map`).toBeUndefined();
expect(canonicalMetaUrlType(plural)).toBe(type);
}
});

it('handles the consonant-y irregular so `capability` is not spelled `capabilitys`', () => {
expect(restPluralOfMetaType('capability')).toBe('capabilities');
// The y→ies limb, pinned twice through the public surface: the map was
// BUILT with the real rule (so `capabilities` resolving proves the limb),
// and the verdict's `hint` is DERIVED with it at call time.
expect(canonicalMetaUrlType('capabilities')).toBe('capability');
expect(metaUrlSpellingRefusal('capabilitys')).toEqual({ declared: 'capability', hint: 'capabilities' });
});

it('addresses snake_case types in both snake and camel plural', () => {
Expand All@@ -110,10 +133,10 @@ describe('#7894 INVARIANT 2 — no unmapped spelling of a DECLARED type may answ
});

describe('#7894 — the refusal limb is narrow by construction', () => {
it('refuses an unrecognised plural of a declared type, naming that type', () => {
expect(unmappedDeclaredTypeSpelling('capabilitys')).toBe('capability');
expect(unmappedDeclaredTypeSpelling('objectes')).toBe('object');
expect(unmappedDeclaredTypeSpelling('fieldes')).toBe('field');
it('refuses an unrecognised plural of a declared type, naming that type and its spelling', () => {
expect(metaUrlSpellingRefusal('capabilitys')).toEqual({ declared: 'capability', hint: 'capabilities' });
expect(metaUrlSpellingRefusal('objectes')).toEqual({ declared: 'object', hint: 'objects' });
expect(metaUrlSpellingRefusal('fieldes')).toEqual({ declared: 'field', hint: 'fields' });
});

it('POSITIVE CONTROL — cannot refuse a plugin kind, whatever it is named', () => {
Expand All@@ -129,7 +152,7 @@ describe('#7894 — the refusal limb is narrow by construction', () => {
'theme', 'sharing_rule', 'webhook', 'rag_pipeline', 'analytics_cube', 'connector',
'my_plugin_kind', 'address', 'status', 'kudos', 'analysis', 'series',
]) {
expect(unmappedDeclaredTypeSpelling(kind), `${kind} must not be refused`).toBeNull();
expect(metaUrlSpellingRefusal(kind), `${kind} must not be refused`).toBeNull();
expect(canonicalMetaUrlType(kind)).toBe(kind);
}
});
Expand All@@ -139,16 +162,26 @@ describe('#7894 — the refusal limb is narrow by construction', () => {
// plugin kind by static means, so it still takes the plugin path. Pinned so
// the limitation is a stated fact rather than an unnoticed gap; closing it
// needs the live registered-type set at the boundary.
expect(unmappedDeclaredTypeSpelling('fieldz')).toBeNull();
expect(metaUrlSpellingRefusal('fieldz')).toBeNull();
});

it('every declared type is in DECLARED_META_TYPES and no plugin kind is', () => {
it('refuses a wrong plural of EVERY declared type, naming that type (#8424)', () => {
// Registry-quantified successor to the retired `DECLARED_META_TYPES`
// membership pin: a type is refusable-when-misspelled iff it is in the
// declared set, so quantifying the refusal over the registry pins the
// set's whole population through the public verdict. `<type>es` is a
// recognisable-but-wrong plural for every declared type except one whose
// REAL plural is the `es` form — for those the spelling is in the map and
// there is nothing wrong to spell, so they are skipped (their correct
// plural is covered by INVARIANT 2's first case).
for (const entry of DEFAULT_METADATA_TYPE_REGISTRY) {
expect(DECLARED_META_TYPES.has(entry.type)).toBe(true);
}
for (const kind of ['theme', 'webhook', 'connector', 'my_plugin_kind']) {
expect(DECLARED_META_TYPES.has(kind)).toBe(false);
const wrong = `${entry.type}es`;
if (wrong in META_URL_TO_SINGULAR) continue;
expect(metaUrlSpellingRefusal(wrong), `${wrong} must be refused as a misspelling of ${entry.type}`)
.toEqual({ declared: entry.type, hint: expectedRestPlural(entry.type) });
}
// And the plugin-kind half of the retired pin: no plugin kind is declared,
// which the POSITIVE CONTROL above already quantifies by behaviour.
});
});

Expand Down
56 changes: 53 additions & 3 deletions packages/spec/src/shared/metadata-url-spelling.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -65,6 +65,14 @@
* layers below keep reading the single canonical singular. Nothing here should
* ever be consulted by a predicate one layer down.
*
* ## The published surface is three symbols, by ruling (#8424)
*
* {@link META_URL_TO_SINGULAR} (the spelling contract) ·
* {@link canonicalMetaUrlType} (the fold) · {@link metaUrlSpellingRefusal}
* (the boundary refusal verdict). The helpers behind them are module-internal;
* see {@link metaUrlSpellingRefusal}'s doc for why the verdict is exported and
* the parts are not.
*
* @module
*/

Expand All@@ -80,8 +88,11 @@ import { PLURAL_TO_SINGULAR } from './metadata-collection.zod';
* carried for correctness of future types rather than for any type declared
* today. Anything more clever would be a spelling GUESSER, which is precisely
* what the boundary must not contain.
*
* Module-internal (#8424): the published surface carries the VERDICT
* ({@link metaUrlSpellingRefusal}), never the predicate parts.
*/
export function restPluralOfMetaType(type: string): string {
function restPluralOfMetaType(type: string): string {
if (/[^aeiou]y$/.test(type)) return `${type.slice(0, -1)}ies`;
if (/(s|x|z|ch|sh)$/.test(type)) return `${type}es`;
return `${type}s`;
Expand All@@ -99,8 +110,12 @@ function camelCaseOf(type: string): string {
* itself ships a contract for, so an unresolvable spelling of it is a caller
* error rather than a plugin the platform has not heard of. That distinction is
* the whole basis of {@link unmappedDeclaredTypeSpelling}.
*
* Module-internal (#8424) — deliberately so: this set LOOKS like a live
* registry of registered types and is not one (it is the static declared set),
* which is exactly the misreading a public export would invite.
*/
export const DECLARED_META_TYPES: ReadonlySet<string> = new Set(
const DECLARED_META_TYPES: ReadonlySet<string> = new Set(
DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type),
);

Expand DownExpand Up@@ -192,12 +207,47 @@ function singularCandidates(type: string): string[] {
* indistinguishable from a plugin kind by static means, so it still takes the
* plugin path. Closing that needs the live registered-type set at the boundary,
* which is a different change with a different risk profile.
*
* Module-internal (#8424): consumers get the composed verdict from
* {@link metaUrlSpellingRefusal}, never this predicate on its own.
*/
export function unmappedDeclaredTypeSpelling(type: string): string | null {
function unmappedDeclaredTypeSpelling(type: string): string | null {
if (type in META_URL_TO_SINGULAR) return null;
if (DECLARED_META_TYPES.has(type)) return null;
for (const candidate of singularCandidates(type)) {
if (DECLARED_META_TYPES.has(candidate)) return candidate;
}
return null;
}

/**
* The refusal VERDICT for a `/meta/:type` path segment (#7894 · #8424).
*
* Returns `null` when the spelling is not the platform's to refuse — it is
* canonical, a mapped plural, or a possible plugin kind. Returns the verdict
* when the spelling is an unrecognised plural of a type the platform itself
* DECLARES: `declared` is that type, `hint` its canonical REST-plural spelling,
* so the refusing boundary can name both accepted spellings without owning any
* spelling logic of its own.
*
* ## Why the surface exports the verdict and not the parts (#8424)
*
* The predicate ({@link unmappedDeclaredTypeSpelling}), the pluralizer
* ({@link restPluralOfMetaType}) and the declared set (`DECLARED_META_TYPES`)
* are module-internal on purpose. Every `@objectstack/spec` export is a
* compatibility commitment, and the one measured need outside this module —
* `metadata-protocol`'s 400 refusal at the request boundary — is "is this
* spelling refusable, and what does the refusal say". Exporting the parts
* would invite a consumer to recompose them in the wrong order (ask the
* declared set a live-registry question, derive a plural the map disagrees
* with); exporting the verdict makes the correct use the only expressible one.
* The spelling contract stays whole, at its producer (Prime Directive #8:
* derived, never re-derived downstream).
*/
export function metaUrlSpellingRefusal(
urlType: string,
): { declared: string; hint: string } | null {
const declared = unmappedDeclaredTypeSpelling(urlType);
if (declared === null) return null;
return { declared, hint: restPluralOfMetaType(declared) };
}
Loading