From d4c39a306ffc427a2940dfbdbeb9d5466ef7ef31 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:08:49 +0000 Subject: [PATCH 1/3] refactor(spec): narrow metadata-url-spelling exports to the verdict surface (#8424) Per the amended #8424 spec-seat ruling (option A): unmappedDeclaredTypeSpelling, restPluralOfMetaType and DECLARED_META_TYPES become module-internal; the module gains one purpose-shaped export, metaUrlSpellingRefusal(urlType) -> { declared, hint } | null. metadata-protocol's request-boundary refusal now consumes the composed verdict; its 400 INVALID_REQUEST wording is byte-identical. Tests re-pin the same invariants through the public surface, with a registry-quantified refusal pin replacing the set-membership pin. Co-Authored-By: Claude --- .../spec-meta-url-spelling-verdict-surface.md | 36 +++++++++ packages/metadata-protocol/src/protocol.ts | 16 ++-- .../src/shared/metadata-url-spelling.test.ts | 75 +++++++++++++------ .../spec/src/shared/metadata-url-spelling.ts | 56 +++++++++++++- 4 files changed, 152 insertions(+), 31 deletions(-) create mode 100644 .changeset/spec-meta-url-spelling-verdict-surface.md diff --git a/.changeset/spec-meta-url-spelling-verdict-surface.md b/.changeset/spec-meta-url-spelling-verdict-surface.md new file mode 100644 index 0000000000..22378867eb --- /dev/null +++ b/.changeset/spec-meta-url-spelling-verdict-surface.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": minor +--- + +refactor(spec): narrow the `@objectstack/spec/shared` metadata-url-spelling surface to the verdict (#8424) + +**BREAKING** — three exports are removed from `@objectstack/spec/shared` and one +purpose-shaped export is added, per the #8424 spec-seat ruling (amended, option A): + +| removed export | successor | +|:--|:--| +| `unmappedDeclaredTypeSpelling(type)` | `metaUrlSpellingRefusal(type)?.declared` | +| `restPluralOfMetaType(type)` | `metaUrlSpellingRefusal(type)?.hint` (the declared type's canonical REST plural) | +| `DECLARED_META_TYPES` | none — it looked like a live registry and is not one; the refusal verdict is the supported question | + +FROM → TO: replace +`unmappedDeclaredTypeSpelling(t)` / `restPluralOfMetaType(t)` compositions with a +single `metaUrlSpellingRefusal(t)` call — it returns +`{ declared, hint } | null`, i.e. the composed #7894 boundary verdict ("this +spelling is an unrecognised plural of declared type `declared`; the accepted +spellings are `declared` and `hint`"), which is the one measured out-of-package +use. `META_URL_TO_SINGULAR` and `canonicalMetaUrlType` are unchanged. + +Graded `minor`, not `major`: every publishable package sits in the Changesets +`fixed` lockstep group during the launch window +(`scripts/check-changeset-no-major.mjs`), so a `major` here would promote the +whole ~70-package stack for a three-symbol surface trim. The removed exports +have never been published: they landed on `main` 2026-08-13 (PR #8420) and the +last published version is `17.0.0-rc.6` (2026-08-10), so no released consumer +can hold an import of them. + +No runtime behaviour changes: the `/meta` boundary's 400 `INVALID_REQUEST` +refusal wording and status are byte-identical (#7894's pins are the proof); +`metadata-protocol` now consumes the composed verdict instead of the parts. + + diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 8b84b9734e..2f3505cb90 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -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'; @@ -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(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}'.`, ); diff --git a/packages/spec/src/shared/metadata-url-spelling.test.ts b/packages/spec/src/shared/metadata-url-spelling.test.ts index 8cb3776c4c..34ffd136b9 100644 --- a/packages/spec/src/shared/metadata-url-spelling.test.ts +++ b/packages/spec/src/shared/metadata-url-spelling.test.ts @@ -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 @@ -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(); } }); @@ -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(); } }); @@ -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', () => { @@ -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', () => { @@ -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); } }); @@ -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. `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. }); }); diff --git a/packages/spec/src/shared/metadata-url-spelling.ts b/packages/spec/src/shared/metadata-url-spelling.ts index 16040ebe5a..d7cbf06686 100644 --- a/packages/spec/src/shared/metadata-url-spelling.ts +++ b/packages/spec/src/shared/metadata-url-spelling.ts @@ -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 */ @@ -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`; @@ -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 = new Set( +const DECLARED_META_TYPES: ReadonlySet = new Set( DEFAULT_METADATA_TYPE_REGISTRY.map((e) => e.type), ); @@ -192,8 +207,11 @@ 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)) { @@ -201,3 +219,35 @@ export function unmappedDeclaredTypeSpelling(type: string): string | null { } 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) }; +} From 02ee314528177c7d64f64dd2b80aec14ce18253e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:28:00 +0000 Subject: [PATCH 2/3] chore(spec): regenerate api-surface + export-origins to the three-symbol spelling surface (#8424) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- packages/spec/api-surface/shared.json | 6 ++---- packages/spec/export-origins/shared.json | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index 421c00b6c1..b474f16a56 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -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)", @@ -109,6 +108,7 @@ "keySetMatches (function)", "lazySchema (function)", "levenshteinDistance (function)", + "metaUrlSpellingRefusal (function)", "normalizeMetadataCollection (function)", "normalizePluginMetadata (function)", "normalizeStackInput (function)", @@ -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)" ] } diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 0fa624ecb9..d19e16792a 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -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)", @@ -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)", @@ -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)" } } From 1a097a4e1a9ce9a7cff9b8b378a7ae58515093b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 02:48:09 +0000 Subject: [PATCH 3/3] =?UTF-8?q?docs(changeset):=20grade=20the=20surface=20?= =?UTF-8?q?trim=20accurately=20=E2=80=94=20no=20breaking=20declaration,=20?= =?UTF-8?q?nothing=20published=20carried=20the=20removed=20exports=20(#842?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adr-0087 gate measured the first draft honestly: 'unpublished' is a mechanical private:true category, and a declared-breaking changeset carrying a FROM->TO prescription cannot claim no-migration-prescription. The accurate fix is upstream of the marker: no released version ever carried the three exports (they merged 2026-08-13; last publish is 17.0.0-rc.6 of 2026-08-10), so the changeset should never have declared BREAKING - relative to every published artifact this release only adds spelling exports. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- .../spec-meta-url-spelling-verdict-surface.md | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/.changeset/spec-meta-url-spelling-verdict-surface.md b/.changeset/spec-meta-url-spelling-verdict-surface.md index 22378867eb..8462fc65d4 100644 --- a/.changeset/spec-meta-url-spelling-verdict-surface.md +++ b/.changeset/spec-meta-url-spelling-verdict-surface.md @@ -4,33 +4,27 @@ refactor(spec): narrow the `@objectstack/spec/shared` metadata-url-spelling surface to the verdict (#8424) -**BREAKING** — three exports are removed from `@objectstack/spec/shared` and one -purpose-shaped export is added, per the #8424 spec-seat ruling (amended, option A): +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`"). -| removed export | successor | -|:--|:--| -| `unmappedDeclaredTypeSpelling(type)` | `metaUrlSpellingRefusal(type)?.declared` | -| `restPluralOfMetaType(type)` | `metaUrlSpellingRefusal(type)?.hint` (the declared type's canonical REST plural) | -| `DECLARED_META_TYPES` | none — it looked like a live registry and is not one; the refusal verdict is the supported question | +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). -FROM → TO: replace -`unmappedDeclaredTypeSpelling(t)` / `restPluralOfMetaType(t)` compositions with a -single `metaUrlSpellingRefusal(t)` call — it returns -`{ declared, hint } | null`, i.e. the composed #7894 boundary verdict ("this -spelling is an unrecognised plural of declared type `declared`; the accepted -spellings are `declared` and `hint`"), which is the one measured out-of-package -use. `META_URL_TO_SINGULAR` and `canonicalMetaUrlType` are unchanged. - -Graded `minor`, not `major`: every publishable package sits in the Changesets -`fixed` lockstep group during the launch window -(`scripts/check-changeset-no-major.mjs`), so a `major` here would promote the -whole ~70-package stack for a three-symbol surface trim. The removed exports -have never been published: they landed on `main` 2026-08-13 (PR #8420) and the -last published version is `17.0.0-rc.6` (2026-08-10), so no released consumer -can hold an import of them. +**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); -`metadata-protocol` now consumes the composed verdict instead of the parts. - - +refusal wording and status are byte-identical (#7894's pins are the proof).