diff --git a/.changeset/metadata-item-name-grammar-enforced.md b/.changeset/metadata-item-name-grammar-enforced.md new file mode 100644 index 0000000000..c9dd52ccb4 --- /dev/null +++ b/.changeset/metadata-item-name-grammar-enforced.md @@ -0,0 +1,48 @@ +--- +"@objectstack/spec": minor +"@objectstack/metadata-protocol": minor +--- + +feat(spec,metadata-protocol): declare the metadata item-name grammar and refuse it loudly at the publish door (#12194, #12176 stage 1) + +**BREAKING** accept-set narrowing at the metadata write door, shipped as +`minor` under the repo's launch-window convention for breaking changes. + +Metadata item names — the `name` half of the `type`/`name` pair that keys +`sys_metadata` and the `/api/v1/meta` URL space — were entirely unconstrained: +the empty string, `//`, `'Views/All Leads'` and slash-compound spellings +(`views/all_leads`) were all accepted and stored, and a slash in the name +bypassed the unrecognised-metadata-type refusal entirely (`type=fieldz +name='a/b'` was accepted and stored while `type=fieldz name='a'` was 400). +Maintainer ruling 2026-08-25 (#12176): item names must not contain `/`. + +The grammar is now **declared in spec** (`MetadataItemNameSchema` / +`METADATA_ITEM_NAME_PATTERN`, `@objectstack/spec/shared`): lowercase +snake_case segments, optionally dot-qualified — the family +`^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$` — sourced from the existing +`ViewItemNameSchema` dotted declaration (same segment source, one grammar; the +view-item identity keeps requiring its dot). And it is **enforced at the +publish door** (`saveMetaItem` and `publishMetaItem` in +`@objectstack/metadata-protocol`): an off-grammar name is refused +`400 INVALID_REQUEST` with the grammar and the dotted prescription in the +message, and nothing is persisted. The slash bypass of +`refuseUnmintableMetaType` closes as a consequence. + +**What an author writes instead.** A flat snake_case name (`crm_lead`) and a +dotted qualified name (`crm_lead.pipeline`) both work exactly as before. A +name that spelled a sub-resource with a slash (`views/all_leads`) is +re-authored with a dot qualifier (`crm_lead.pipeline` — the qualified identity +whose prefix recovers the owner) or flattened with an underscore +(`views_all_leads`); containment is expressed by structure, never by a +separator inside the identity string. A translation item conventionally named +after its locale is named in snake_case (`zh_cn`) with the BCP-47 spelling in +its required `locale` field (`"zh-CN"`), which has been the item's real +identity key all along. + +Reads and `deleteMetaItem` deliberately stay open, so any pre-grammar residue +row remains listable and clearable. The in-repo stored corpus was measured at +**zero** slash-bearing item names (#12176 census, re-asserted at land time); +out-of-repo stored slash rows, if any exist, are reported by their deployment's +migrate run rather than rewritten silently. + + diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index c3e4e11841..46b68058c9 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1271,7 +1271,7 @@ List packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | -| **name** | `string` | ✅ | Item name | +| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem` (#12194). | | **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005, #8805), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `[no_draft]`. Absent = environment-wide. | | **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer, #7749) — never a caller-supplied header. | | **message** | `string` | optional | Optional human-readable note recorded with the publish history event. | @@ -1477,7 +1477,7 @@ List packages response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | -| **name** | `string` | ✅ | Item name | +| **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at the publish door (#12176). | | **item** | `any` | ✅ | Metadata item definition | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 49eb4e6576..680c1530f9 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,6 +1,6 @@ --- title: Protocol Reference -description: Every schema published by @objectstack/spec — 1588 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1589 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -29,11 +29,11 @@ counts are sums of the rows they head. Regenerate with | [Kernel Protocol](/docs/references/kernel) | 31 | 176 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | | [Security Protocol](/docs/references/security) | 5 | 27 | Permission sets, row-level security, sharing rules, tenancy posture. | -| [Shared Protocol](/docs/references/shared) | 8 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | +| [Shared Protocol](/docs/references/shared) | 8 | 32 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 288 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 152 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **199** | **1588** | 14 protocol modules | +| **Total** | **199** | **1589** | 14 protocol modules | --- @@ -285,7 +285,7 @@ Permission sets, row-level security, sharing rules, tenancy posture. ## Shared Protocol -**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 31 schemas** +**Source:** `packages/spec/src/shared/` · **Import:** `@objectstack/spec/shared` · **8 pages, 32 schemas** Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. @@ -295,7 +295,7 @@ Primitives used across every protocol — identifiers, HTTP, expressions, error | [`enums.zod.ts`](/docs/references/shared/enums) | `IsolationLevelEnum`, `MutationEventEnum`, `SortDirectionEnum`, `SortItem` | | [`expression.zod.ts`](/docs/references/shared/expression) | `CronExpressionInput`, `Expression`, `ExpressionDialect`, `ExpressionInput`, `ExpressionMeta`, `Predicate`, `PredicateInput`, `TemplateExpressionInput` | | [`http.zod.ts`](/docs/references/shared/http) | `CorsConfig`, `HttpMethod`, `HttpMethodSubset`, `HttpRequest`, `RateLimitConfig`, `StaticMount` | -| [`identifiers.zod.ts`](/docs/references/shared/identifiers) | `EventName`, `SnakeCaseIdentifier`, `SystemIdentifier` | +| [`identifiers.zod.ts`](/docs/references/shared/identifiers) | `EventName`, `MetadataItemName`, `SnakeCaseIdentifier`, `SystemIdentifier` | | [`mapping.zod.ts`](/docs/references/shared/mapping) | `FieldMapping` | | [`metadata-types.zod.ts`](/docs/references/shared/metadata-types) | `BaseMetadataRecord`, `MetadataFormat` | | [`protection.zod.ts`](/docs/references/shared/protection) | `Protection` | diff --git a/content/docs/references/shared/identifiers.mdx b/content/docs/references/shared/identifiers.mdx index 740ca79be9..c598d2630e 100644 --- a/content/docs/references/shared/identifiers.mdx +++ b/content/docs/references/shared/identifiers.mdx @@ -54,8 +54,8 @@ Enforces lowercase with underscores or dots to ensure: ## TypeScript Usage ```typescript -import { EventNameSchema, SnakeCaseIdentifierSchema, SystemIdentifierSchema } from '@objectstack/spec/shared'; -import type { EventName, SnakeCaseIdentifier, SystemIdentifier } from '@objectstack/spec/shared'; +import { EventNameSchema, MetadataItemNameSchema, SnakeCaseIdentifierSchema, SystemIdentifierSchema } from '@objectstack/spec/shared'; +import type { EventName, MetadataItemName, SnakeCaseIdentifier, SystemIdentifier } from '@objectstack/spec/shared'; // Validate data const result = EventNameSchema.parse(data); @@ -70,6 +70,15 @@ Event name (lowercase with dot notation for namespacing) **Type:** `string` +--- + +## MetadataItemName + +Metadata item name (lowercase snake_case segments, optionally dot-qualified) + +**Type:** `string` + + --- ## SnakeCaseIdentifier diff --git a/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts b/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts new file mode 100644 index 0000000000..ddeaff9be6 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.item-name-grammar.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #12194 — the metadata item-name grammar is enforced at the publish door. + * + * Stage 1 of #12176's maintainer-ruled retirement of compound-name addressing + * (2026-08-25): item names are lowercase snake_case segments, optionally + * dot-qualified (`METADATA_ITEM_NAME_PATTERN`, `@objectstack/spec/shared` — + * the same segment source as `ViewItemNameSchema`'s dot-required arity), and + * `saveMetaItem` / `publishMetaItem` refuse an off-grammar name loudly. + * + * What the #12176 census measured BEFORE this landed — every refusal case in + * this suite was an acceptance then: `''`, `'a//b'`, `'Views/All Leads'` and + * `'views/all_leads'` were all accepted and stored as item names, and a slash + * in the name BYPASSED the #8421 unrecognised-type refusal entirely + * (`type=fieldz name='a'` → 400 while `type=fieldz name='a/b'` was accepted + * and stored). This suite is that census's probe table, pinned. + * + * ## What this suite pins, in both directions + * + * A suite that only asserted "junk is refused" would be satisfied by a door + * that refuses everything, so: + * + * - flat snake_case still saves, and the DOTTED qualified form still saves — + * the ruling keeps the dot (`ViewItemNameSchema`'s convention becomes + * enforced-optional at this door, Q2 = B); + * - every junk shape is refused with the ADR-0112 envelope (`code` AND + * `status`, never a bare throw) and NOTHING is persisted; + * - the slash bypass of `refuseUnmintableMetaType` is closed: an + * unrecognised type + slash name is refused (grammar reason), and the + * unrecognised-type refusal itself still fires for grammatical names + * (anti-vacuity control); + * - the promotion door (`publishMetaItem`) enforces the same grammar; + * - reads and DELETE stay open for pre-grammar residue rows, or the + * accumulation would become one nobody can clear. + * + * Harness: the real `saveMetaItem` write path over a stub engine — the pinned + * shape `protocol.unrecognised-meta-type.test.ts` carries. A gate INSIDE + * `saveMetaItem` cannot be measured against a harness that mocks it. + */ +import { describe, expect, it } from 'vitest'; +// [#5619] The producer's OWN write-verb dispatch decisions, so the fake engine +// below cannot accept a call ObjectQL itself refuses. +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +interface Row { + id: string; + type: string; + name: string; + organization_id: string | null; + state: string; + /** A real `checksum` is required or the OCC parent-version check 409s */ + /** before `deleteMetaItem` reaches anything this suite is about. */ + checksum: string; + metadata: string; +} + +function makeProtocol(seedRows: Array> = []) { + const rows = new Map(); + let nextId = 0; + const keyOf = (w: { type?: unknown; name?: unknown; organization_id?: unknown; state?: unknown }) => + `${w.type}|${w.name}|${w.organization_id ?? '__env__'}|${w.state ?? 'active'}`; + for (const seed of seedRows) { + nextId += 1; + const row = { + id: `seed_${nextId}`, + organization_id: null, + state: 'active', + checksum: 'sha256:stored-head', + metadata: JSON.stringify({ name: seed.name, label: 'Stored' }), + ...seed, + } as Row; + rows.set(keyOf(row), row); + } + const deletes: Array | undefined> = []; + const engine: any = { + async findOne(_t: string, opts: { where: Record }) { + for (const row of rows.values()) { + if (opts.where.type !== undefined && row.type !== opts.where.type) continue; + if (opts.where.name !== undefined && row.name !== opts.where.name) continue; + return row; + } + return null; + }, + async find(_t: string, opts?: { where?: Record; limit?: number }) { + const where = opts?.where ?? {}; + // Every provided where key is matched, by nullish-normalised + // equality — the only shapes protocol.ts sends here are flat + // scalars ({ type, state, organization_id, package_id, name }). + // An operator object would match nothing rather than silently + // widening the result. + const matched = [...rows.values()].filter((row) => + Object.entries(where).every(([k, v]) => + v === undefined + || ((row as unknown as Record)[k] ?? null) === (v ?? null))); + // The caller's bound is held — applied AFTER the filter, by + // presence — so a bounded read through this double cannot see + // more rows than the real engine would return. + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; + }, + async insert(_t: string, data: Record) { + if (_t !== 'sys_metadata') return { id: 'side_effect_skip' }; + nextId += 1; + const row = { id: `r_${nextId}`, ...(data as any) } as Row; + rows.set(keyOf(data), row); + return { id: row.id }; + }, + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + return { id: null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + deletes.push(opts); + const id = (opts as any)?.where?.id; + for (const [key, row] of rows.entries()) if (row.id === id) rows.delete(key); + return { deleted: 1 }; + }, + async count() { return 0; }, + async transaction(fn: (ctx: unknown) => Promise) { return fn(undefined); }, + async execute() { return {}; }, + async getObjectSchema() { return undefined; }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + unregisterItem: () => {}, + listItems: () => [], + getItem: () => undefined, + getArtifactItem: () => undefined, + }, + }; + const protocol = new ObjectStackProtocolImplementation( + engine, + () => new Map(), + undefined, + ) as any; + return { protocol, rows, deletes }; +} + +const metaRows = (rows: Map) => [...rows.values()].filter((r) => r.type !== undefined); + +/** Spec-valid `view` body, so the ONLY variable under test is the NAME. */ +const VIEW_BODY = { + name: 'probe_item', + label: 'Probe', + object: 'task', + viewKind: 'list', + columns: [{ field: 'name', label: 'Name' }], +}; + +const GRAMMAR_MESSAGE = /is not a legal metadata item name/; + +describe('#12194 — the names that must keep working', () => { + it('accepts flat snake_case (`crm_lead`)', async () => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ type: 'view', name: 'crm_lead', item: VIEW_BODY }); + expect(result.success).toBe(true); + expect(metaRows(rows)).toHaveLength(1); + expect(metaRows(rows)[0]!.name).toBe('crm_lead'); + }); + + it('accepts the dotted qualified form (`crm_lead.pipeline`) — the ruling keeps the dot', async () => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ type: 'view', name: 'crm_lead.pipeline', item: VIEW_BODY }); + expect(result.success).toBe(true); + expect(metaRows(rows)).toHaveLength(1); + expect(metaRows(rows)[0]!.name).toBe('crm_lead.pipeline'); + }); + + it('accepts a multi-dot qualified name (`a.b.c` family)', async () => { + const { protocol, rows } = makeProtocol(); + const result = await protocol.saveMetaItem({ type: 'view', name: 'crm_lead.kanban.v2_board', item: VIEW_BODY }); + expect(result.success).toBe(true); + expect(metaRows(rows)).toHaveLength(1); + }); +}); + +describe('#12194 — the junk shapes the census measured ACCEPTED are now refused', () => { + // Each entry was accepted and stored on the pre-#12194 tree (census P2–P8). + // The refusal asserts the ADR-0112 envelope — `code` AND `status` — never a + // bare `.toThrow()`, which an unrelated 422 one layer down would satisfy. + const JUNK: Array<[label: string, name: string]> = [ + ['one slash (retired compound spelling)', 'views/all_leads'], + ['two slashes', 'a/b/c'], + ['empty section', '/all_leads'], + ['empty leaf', 'views/'], + ['double slash alone', '//'], + ['empty string', ''], + ['uppercase + whitespace + slash', 'Views/All Leads'], + ['double slash inside', 'a//b'], + ['leading dot', '.a'], + ['trailing dot', 'a.'], + ['double dot', 'a..b'], + ['uppercase', 'CRM_Lead'], + ['interior whitespace', 'crm lead'], + ['leading digit', '1crm'], + ]; + + it.each(JUNK)('refuses %s with the ADR-0112 envelope and persists nothing', async (_label, name) => { + const { protocol, rows } = makeProtocol(); + await expect( + protocol.saveMetaItem({ type: 'view', name, item: VIEW_BODY }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + expect(metaRows(rows)).toHaveLength(0); + }); + + it('names the grammar AND the dotted prescription in the refusal', async () => { + const { protocol } = makeProtocol(); + await expect( + protocol.saveMetaItem({ type: 'view', name: 'views/all_leads', item: VIEW_BODY }), + ).rejects.toThrow(GRAMMAR_MESSAGE); + await expect( + protocol.saveMetaItem({ type: 'view', name: 'views/all_leads', item: VIEW_BODY }), + ).rejects.toThrow(/crm_lead\.pipeline/); + }); +}); + +describe('#12194 — the slash bypass of the unrecognised-type refusal is CLOSED', () => { + it('refuses unrecognised type + slash name (the census P10 acceptance)', async () => { + // Pre-#12194: `refuseUnmintableMetaType` opened with + // `if (request.name.includes('/')) return;` — so this exact request was + // ACCEPTED and stored `type='fieldz' name='a/b'` (census P10, and the + // residue #8421's own docblock stated rather than hid). The grammar + // verdict now runs first, so the refusal FIRES — for the grammar + // reason — and nothing is persisted. + const { protocol, rows } = makeProtocol(); + await expect( + protocol.saveMetaItem({ type: 'fieldz', name: 'a/b', item: { name: 'x' } }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + await expect( + protocol.saveMetaItem({ type: 'fieldz', name: 'a/b', item: { name: 'x' } }), + ).rejects.toThrow(GRAMMAR_MESSAGE); + expect(metaRows(rows)).toHaveLength(0); + }); + + it('ANTI-VACUITY — unrecognised type + grammatical name still earns the TYPE refusal', async () => { + // The pair that proves the two verdicts compose rather than one + // swallowing the other: `a` passes the grammar, so the refusal that + // fires is #8421's own, with its own prescription. + const { protocol, rows } = makeProtocol(); + await expect( + protocol.saveMetaItem({ type: 'fieldz', name: 'a', item: { name: 'x' } }), + ).rejects.toThrow(/'fieldz' is not a metadata type/); + expect(metaRows(rows)).toHaveLength(0); + }); +}); + +describe('#12194 — the promotion door enforces the same grammar', () => { + it('refuses publishMetaItem for an off-grammar name, before draft resolution', async () => { + const { protocol } = makeProtocol(); + await expect( + protocol.publishMetaItem({ type: 'view', name: 'views/all_leads' }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + await expect( + protocol.publishMetaItem({ type: 'view', name: 'views/all_leads' }), + ).rejects.toThrow(GRAMMAR_MESSAGE); + }); + + it('ANTI-VACUITY — a grammatical name reaches draft resolution (fails as no_draft, not grammar)', async () => { + const { protocol } = makeProtocol(); + const failure = await protocol + .publishMetaItem({ type: 'view', name: 'crm_lead' }) + .then(() => null, (e: unknown) => e as Error); + expect(failure).not.toBeNull(); + expect(String(failure)).not.toMatch(GRAMMAR_MESSAGE); + }); +}); + +describe('#12194 — the refusal is scoped to the doors that MINT or PROMOTE', () => { + it('leaves READS of a residue slash name answering', async () => { + const { protocol } = makeProtocol([{ type: 'view', name: 'views/all_leads' }]); + await expect(protocol.getMetaItem({ type: 'view', name: 'views/all_leads' })) + .resolves.toBeDefined(); + }); + + it('leaves a residue slash-name row DELETABLE', async () => { + // Rows written before this grammar existed are real and nothing + // rewrites them on upgrade. Refusing delete would strand them + // permanently — the same scoping #8421 chose for residue types. + const { protocol, rows } = makeProtocol([{ type: 'view', name: 'views/all_leads' }]); + expect(metaRows(rows)).toHaveLength(1); + await expect( + protocol.deleteMetaItem({ type: 'view', name: 'views/all_leads' }), + ).resolves.toBeDefined(); + }); + + it('and the row cannot come back through the save door after the delete', async () => { + const { protocol } = makeProtocol([{ type: 'view', name: 'views/all_leads' }]); + await protocol.deleteMetaItem({ type: 'view', name: 'views/all_leads' }); + await expect( + protocol.saveMetaItem({ type: 'view', name: 'views/all_leads', item: VIEW_BODY }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 0368d23895..8543c01385 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -69,7 +69,7 @@ import { type QueryAliasConflict, type QueryAliasSlot, type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed, } from '@objectstack/spec/data'; -import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal } from '@objectstack/spec/shared'; +import { PLURAL_TO_SINGULAR, SINGULAR_TO_PLURAL, canonicalMetaUrlType, metaUrlSpellingRefusal, unrecognisedMetaTypeRefusal, METADATA_ITEM_NAME_PATTERN } from '@objectstack/spec/shared'; import { applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec'; import { type FormView, isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec/ui'; // [#11350] Emitted-specifier pin. This module's inferred public declarations @@ -13017,6 +13017,60 @@ export class ObjectStackProtocolImplementation implements return true; } + /** + * [#12194] The item-name grammar verdict — stage 1 of the #12176 + * maintainer-ruled retirement of compound `
/` addressing + * (2026-08-25). Refuse, on the doors that MINT or PROMOTE a `sys_metadata` + * row, an item name outside the declared grammar: lowercase snake_case + * segments, optionally dot-qualified (`METADATA_ITEM_NAME_PATTERN`, + * `@objectstack/spec/shared` — the one segment source, shared with + * `ViewItemNameSchema`'s dot-required arity). + * + * What this closes, measured on the #12176 census before this landed: + * `''`, `'//'`, `'a/b/c'`, `'Views/All Leads'` were all accepted and + * stored as item names, and a slash in the name BYPASSED + * {@link refuseUnmintableMetaType} entirely (`type=fieldz name='a'` → + * 400, `type=fieldz name='a/b'` → accepted and stored). The compound + * REST/dispatcher arities still fold `:section/:name` into one + * slash-joined string; a write arriving that way is now refused here with + * the dotted spelling as the prescription (their retirement is D3, + * #12195 — this door does not wait for it). + * + * Scoping, deliberate and parallel to {@link refuseUnmintableMetaType}: + * + * - **`saveMetaItem` and `publishMetaItem` only** — the door that mints + * a row and the door that promotes one to `active`. + * - **Read doors and `deleteMetaItem` stay open**: residue rows written + * before this grammar existed must remain listable and clearable, or + * the accumulation becomes one nobody can clear. + * - **`migrateStoredMetadata` is NOT exempt.** Its per-row rewrite calls + * this same door with the STORED name, so an off-grammar stored row's + * in-place rewrite is refused — recorded loudly as that row's + * `outcome: 'failed'` with this refusal's text on the migration + * report, never silently. That is the ruled shape: a stored slash row + * is D2 reopening as its own card, not something to rewrite quietly + * under its junk name. A future name-changing conversion writes under + * its NEW (grammatical) name and passes this door untouched. + * + * Static, answerable from the request alone — no store read — which is + * why it runs before the {@link refuseUnmintableMetaType} probe. + */ + private refuseUngrammaticalMetaItemName(request: { type: string, name: string }): void { + if (METADATA_ITEM_NAME_PATTERN.test(request.name)) return; + const err = new Error( + `[invalid_request] ${JSON.stringify(request.name)} is not a legal metadata item name. ` + + `Item names are lowercase snake_case segments, optionally dot-qualified — ` + + `/^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$/ — e.g. 'crm_lead' or 'crm_lead.pipeline'. ` + + `No slashes, spaces, uppercase, empty segments, or leading/trailing dots. ` + + `The compound '
/' spelling is retired: qualify with a dot ` + + `('crm_lead.pipeline') and express containment structurally, never with a ` + + `separator inside the identity string.`, + ); + (err as any).code = 'INVALID_REQUEST'; + (err as any).status = 400; + throw err; + } + /** * [#8421] The SECOND `/meta` verdict: refuse a `:type` segment that is not * a metadata type AT ALL, on the one entry point that MINTS a @@ -13048,25 +13102,22 @@ export class ObjectStackProtocolImplementation implements * permanently — turning the accumulation this card was filed about into * an accumulation nobody can clear. * - * ## …and why two shapes reaching THIS door are exempt (#8421 rework) - * - * Both were regressions in the first cut, both measured on the three - * consumer packages the first cut never ran: - * - * 1. **The COMPOUND arity puts an OBJECT name in the `:type` segment.** - * `/metadata/lead/views/all_leads` is `type='lead'`, - * `name='views/all_leads'` — one operation reaching one - * `saveMetaItem`, documented verbatim in the runtime dispatcher's own - * `/meta` branch and in `rest`'s `PUBLISHED_COMPOUND` route. `lead` is - * an object, i.e. RUNTIME DATA, and no static contract can enumerate - * the objects a deployment carries — so applying a static type verdict - * to that segment refuses every object name that is not coincidentally - * a metadata type. The maintainer's ruling is about metadata TYPE names - * like `fieldz`; this was not a narrowing anyone approved. - * ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is - * therefore still accepted, because at that arity `fieldz` is a claim - * about an object and the alternative is a live-registry check — option - * C, ruled out on this very card. + * ## …and why one shape reaching THIS door is exempt (#8421 rework, revised by #12194) + * + * The first cut had TWO exemptions, both regressions measured on the three + * consumer packages the first cut never ran. The FIRST — skip the verdict + * when the name contains a slash, because the compound arity puts an + * OBJECT name in the `:type` segment (`/metadata/lead/views/all_leads` is + * `type='lead'`, `name='views/all_leads'`, and `lead` is runtime data no + * static contract can enumerate) — is GONE (#12194): the item-name + * grammar verdict ({@link refuseUngrammaticalMetaItemName}) runs before + * this probe and refuses every slash-bearing name outright, so no request + * that needed the exemption can reach this door any more. That also + * closes the residue the exemption used to document: `PUT + * /meta/fieldz/a/b` was accepted and minted `type='fieldz'`; it is now + * refused at the grammar gate, and this verdict applies to every name + * that gets past it. + * * 2. **A namespace that ALREADY EXISTS is not being minted.** Two * production paths re-save a row taking its type from an existing * `sys_metadata` row: {@link migrateStoredMetadata} (`source: @@ -13090,9 +13141,9 @@ export class ObjectStackProtocolImplementation implements private async refuseUnmintableMetaType(request: { type: string, name: string }): Promise { const unrecognised = unrecognisedMetaTypeRefusal(request.type); if (!unrecognised) return; - // Exemption 1 — the compound arity. Cheap, and first: it is a statement - // about the REQUEST SHAPE and needs no store at all. - if (request.name.includes('/')) return; + // The old exemption 1 (skip when the name contains a slash) was + // removed by #12194 — the grammar verdict upstream refuses every + // slash-bearing name before this probe runs. See the header. // Exemption 2 — the namespace predates this write. if (await this.metaTypeNamespaceExists(unrecognised.type)) return; const err = new Error( @@ -13182,10 +13233,15 @@ export class ObjectStackProtocolImplementation implements } // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. request = canonicalizeMetaRequestType(request); + // [#12194] The item-name grammar verdict — static, request-only, so it + // runs before the store-backed type probe below. Closes the measured + // slash bypass: a slash-bearing name used to skip the #8421 refusal + // entirely. See {@link refuseUngrammaticalMetaItemName}. + this.refuseUngrammaticalMetaItemName(request); // [#8421] …and the second verdict, on the door that MINTS. Kept here // rather than inside the fold above because it is not answerable from // the request alone — see {@link refuseUnmintableMetaType} for the - // scoping, its two exemptions, and the measurements behind both. + // scoping, its exemption, and the measurements behind it. await this.refuseUnmintableMetaType(request); // What the history row, the audit row and the watch event record as the // origin of this write. Defaults to this method — the ordinary Studio / @@ -14822,6 +14878,13 @@ export class ObjectStackProtocolImplementation implements // rewrites it on upgrade). Different input class, different map; see // {@link canonicalMetaType}'s header for why the two are not one fold. request = canonicalizeMetaRequestType(request); + // [#12194] The item-name grammar verdict, same as `saveMetaItem`'s: + // the promotion door writes an `active` row under this name, so an + // off-grammar name is refused here too rather than promoted. With the + // save door closed no such draft can exist any more; for pre-grammar + // residue drafts the refusal (rather than a promotion) is the ruled + // direction, and `deleteMetaItem` stays open to clear them. + this.refuseUngrammaticalMetaItemName(request); // [#10219] Then resolve WHICH SCOPE's draft this publish means. The // caller states the scope it is IN; the draft may live env-wide. See // {@link resolveDraftOrgScopeForPublish} — the single-item twin of the diff --git a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts index 1f1d36ae72..3702dfeea4 100644 --- a/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts +++ b/packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts @@ -38,11 +38,14 @@ * works, or the accumulation this card was filed about would become * unremovable. * - * The last two describe blocks pin the two exemptions the first cut lacked, each - * paired with the refusal that must survive it: the COMPOUND arity (whose - * `:type` segment carries an OBJECT name) and an already-stored namespace (which - * is not being minted by definition). `protocol.stored-residue-resave.test.ts` - * carries the production paths that made the second one necessary. + * The last two describe blocks pin the door's remaining exemption and the fate + * of the one it used to have: an already-stored namespace (which is not being + * minted by definition — `protocol.stored-residue-resave.test.ts` carries the + * production paths that made it necessary) still passes, while the COMPOUND + * arity exemption (skip the verdict when the name contains a slash) is GONE — + * #12194's item-name grammar refuses every slash-bearing name BEFORE this + * verdict runs, so the request that needed it can no longer arrive + * (`protocol.item-name-grammar.test.ts` is that door's own suite). * * Harness: the real `saveMetaItem` write path over a stub engine, the shape * `protocol.code-only-types.test.ts` uses. A gate INSIDE `saveMetaItem` cannot @@ -293,42 +296,45 @@ describe('#8421 — the refusal is scoped to the door that MINTS', () => { }); }); -describe('#8421 — the COMPOUND arity carries an OBJECT name, not a type claim', () => { +describe('#8421/#12194 — the COMPOUND arity is refused at the grammar gate, not exempted here', () => { // `/metadata/lead/views/all_leads` → `type='lead'`, `name='views/all_leads'`. - // One operation, one `saveMetaItem`; the runtime dispatcher's `/meta` branch - // and `rest`'s `PUBLISHED_COMPOUND` route both document that shape verbatim. - // `lead` is an OBJECT — runtime data no static contract can enumerate — so - // a type verdict applied to that segment refuses every object name that is - // not coincidentally a metadata type. Measured as a regression of the first - // cut in two packages; the ruling this card implements is about metadata - // TYPE names like `fieldz`. + // Until #12194 this door EXEMPTED that shape (skip the type verdict when + // the name contains a slash) because `lead` is an OBJECT name no static + // contract can enumerate — and the exemption's residue was that + // `PUT /meta/fieldz/a/b` minted a namespace. The item-name grammar now + // refuses every slash-bearing name BEFORE this verdict runs (maintainer + // ruling 2026-08-25, #12176 stage 1), so the exemption is gone and the + // compound write is refused outright — for the GRAMMAR reason, with the + // dotted qualified spelling as the prescription. const VIEW_BODY = { name: 'all_leads', label: 'All Leads', columns: ['name'] }; - it('saves a sub-resource under an object name the static contract cannot know', async () => { + it('refuses the compound-arity write with the grammar prescription (was the exemption)', async () => { const { protocol, rows } = makeProtocol(); - const result = await protocol.saveMetaItem({ - type: 'lead', name: 'views/all_leads', item: VIEW_BODY, - }); - - expect(result.success).toBe(true); - // The compound name is ONE key — not split, not truncated to its last - // segment — which is what makes this the same operation the two - // transports document rather than a lookalike. - expect(metaRows(rows)).toHaveLength(1); - expect(metaRows(rows)[0]).toMatchObject({ type: 'lead', name: 'views/all_leads' }); + await expect( + protocol.saveMetaItem({ type: 'lead', name: 'views/all_leads', item: VIEW_BODY }), + ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + await expect( + protocol.saveMetaItem({ type: 'lead', name: 'views/all_leads', item: VIEW_BODY }), + ).rejects.toThrow(/is not a legal metadata item name/); + // And nothing is persisted — the acceptance this replaces stored the + // compound name as one opaque key. + expect(metaRows(rows)).toHaveLength(0); }); - it('and the exemption is the ARITY, not the name — `lead` alone is still refused', async () => { - // ANTI-VACUITY, and the line between the two fixes: at the simple arity - // the `:type` segment IS a type claim, so the same string that is a - // legal owner above is an illegal type here. Without this case the - // exemption above would be indistinguishable from "stop refusing". + it('and a grammatical name under the same string reaches the TYPE verdict — `lead` is still refused as a type', async () => { + // ANTI-VACUITY, and the line between the two verdicts: `all_leads` + // passes the grammar, so the refusal that fires here is #8421's own — + // the `:type` segment IS a type claim at this arity, and `lead` is not + // a metadata type. const { protocol, rows } = makeProtocol(); await expect( protocol.saveMetaItem({ type: 'lead', name: 'all_leads', item: VIEW_BODY }), ).rejects.toMatchObject({ code: 'INVALID_REQUEST', status: 400 }); + await expect( + protocol.saveMetaItem({ type: 'lead', name: 'all_leads', item: VIEW_BODY }), + ).rejects.toThrow(/'lead' is not a metadata type/); expect(metaRows(rows)).toHaveLength(0); }); }); diff --git a/packages/objectql/src/metadata-validation-sweep.test.ts b/packages/objectql/src/metadata-validation-sweep.test.ts index 1549e097fe..7bb0071afd 100644 --- a/packages/objectql/src/metadata-validation-sweep.test.ts +++ b/packages/objectql/src/metadata-validation-sweep.test.ts @@ -135,8 +135,13 @@ const FIXTURES: Record = { }, app: { valid: { name: 'sweep_app', label: 'Sweep' }, - invalid: { name: 'BadCaseApp', label: 'Bad' }, - invalidatedField: 'name', + // The invalid probe breaks `label`, not `name`: an ungrammatical item + // name is refused by the #12194 grammar door (INVALID_REQUEST 400) + // BEFORE the central Zod registry runs, so a bad name can no longer + // prove the schema gate this sweep exists to prove. The name-grammar + // refusal has its own pins in metadata-protocol. + invalid: { name: 'sweep_app_bad', label: 123 }, + invalidatedField: 'label', }, action: { valid: { name: 'sweep_action', label: 'Do it', type: 'script', target: 'do_it' }, diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 38ebf1a4a4..f15ab827b7 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -603,14 +603,17 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { it('validates types via the central spec registry (e.g. app)', async () => { // Every overlay-allowed built-in type now has a canonical Zod - // schema registered in `getMetadataTypeSchema()`. An app - // payload with a non-snake_case `name` must be rejected. + // schema registered in `getMetadataTypeSchema()`. The probe + // breaks `label`, not `name`: an ungrammatical item name is + // refused by the #12194 grammar door (INVALID_REQUEST 400) + // before the registry runs, so a bad name can no longer reach + // — and therefore cannot prove — the schema gate under test. let caught: any; try { await protocol.saveMetaItem({ type: 'app', - name: 'BadApp', - item: { name: 'BadApp', label: 'X' }, // name violates snake_case + name: 'bad_app', + item: { name: 'bad_app', label: 123 }, // label violates z.string() }); } catch (e) { caught = e; } diff --git a/packages/rest/src/meta-compound-save-force-parity.test.ts b/packages/rest/src/meta-compound-save-force-parity.test.ts index 12fce73fce..7a64e7fa82 100644 --- a/packages/rest/src/meta-compound-save-force-parity.test.ts +++ b/packages/rest/src/meta-compound-save-force-parity.test.ts @@ -5,6 +5,18 @@ * — the third row of the destructive-409 face inventory, closed by threading * the parameter rather than by rewording the sentence. * + * ## ⚠️ #12194 reversed the compound door's WRITE outcome + * + * Stage 1 of #12176 (maintainer ruling 2026-08-25): the item-name grammar + * refuses every slash-bearing name at `saveMetaItem`, BEFORE the destructive + * gate this file was written about. The compound door still folds + * `:section/:name` into `crm/task` and still threads `force` (the seam pins + * below stay true), but the fold's output is now refused `400 INVALID_REQUEST` + * with the dotted prescription — no `?force` spelling can acknowledge a + * grammar violation. The compound-door cases below pin that refusal; the + * single-segment twin keeps the full #11095 contract, and the twins now + * DIVERGE BY DESIGN at the write (the route retirement itself is D3, #12195). + * * ## The defect * * `saveMetaItem`'s Phase 3a-destructive gate raises ONE `409 @@ -285,42 +297,41 @@ const PUT_REMEDY = 're-submit with ?force=true to proceed.'; // 1. The compound door, REFUSED — and the refusal tells the truth now // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11095] compound-name PUT — the destructive refusal', () => { - it('refuses a data-dropping save with the ADR-0112 envelope, and writes NOTHING', async () => { +describe('[#11095 / #12194] compound-name PUT — refused at the item-name grammar gate', () => { + it('refuses the folded slash name with the ADR-0112 envelope, and writes NOTHING', async () => { const stack = boot(); const answer = await stack.compoundPut(); - expect(answer.status).toBe(409); - expect(answer.body?.code).toBe('DESTRUCTIVE_CHANGE'); + // The grammar gate answers BEFORE the destructive gate ever computes a + // diff: 400 (the caller's addressing mistake), not the 409 this file + // used to pin. `handleRouteError`'s body is FLAT — `code` at top level. + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); // THE POINT of a refusal case: "refused after writing" satisfies both // assertions above and is still the bug. expect(stack.compoundFields()).toEqual(STORED_FIELDS); }); - it('the findings reach the caller structurally as well as in the prose (#10886 non-effect)', async () => { + it('names the grammar and the dotted prescription — guidance, not a bare no', async () => { const stack = boot(); const answer = await stack.compoundPut(); - // `handleRouteError` threads `error.issues` onto a top-level `issues`. - // Row 2 of the face inventory says this door is NOT a sole carrier, and - // that claim is about THIS body. - expect(Array.isArray(answer.body?.issues)).toBe(true); - expect(answer.body.issues).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'field_removed', field: 'b' }), - ])); - expect(answer.body?.error).toContain("Field 'b' removed"); + expect(answer.body?.error).toContain('is not a legal metadata item name'); + expect(answer.body?.error).toContain('crm_lead.pipeline'); }); - it('⭐ prescribes `?force=true` — the sentence this card had to make true', async () => { + it('and does NOT prescribe `?force=true` — force cannot acknowledge a grammar violation', async () => { const stack = boot(); const answer = await stack.compoundPut(); - // Pre-fix this assertion ALSO passed: the clause was rendered on every - // face. What it could not do was survive the next case. - expect(answer.body?.error).toContain(PUT_REMEDY); + // The destructive 409's remedy clause must not ride on this refusal: + // re-submitting with the parameter changes nothing (pinned below), so + // prescribing it here would be the #11095 defect resurrected — a + // sentence the door cannot make true. + expect(answer.body?.error).not.toContain(PUT_REMEDY); }); }); @@ -328,25 +339,23 @@ describe('[#11095] compound-name PUT — the destructive refusal', () => { // 2. ⭐ The compound door, ACCEPTED — the case that fails without the fix // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11095] compound-name PUT — `?force=true` is honoured', () => { - it('⭐ doing what the refusal says WORKS: 200, and the store actually changed', async () => { +describe('[#11095 / #12194] compound-name PUT — no `?force` spelling bypasses the grammar gate', () => { + it('⭐ re-submitting with `?force=true` changes NOTHING — same refusal, store untouched', async () => { const stack = boot(); - // 1. Refused, and told to re-submit with the parameter. + // 1. Refused at the grammar gate. const refused = await stack.compoundPut(); - expect(refused.status).toBe(409); - expect(refused.body?.code).toBe('DESTRUCTIVE_CHANGE'); - expect(refused.body?.error).toContain(PUT_REMEDY); + expect(refused.status).toBe(400); + expect(refused.body?.code).toBe('INVALID_REQUEST'); - // 2. The caller does exactly that. Before this card the answer here was - // a byte-identical 409 — the whole defect, in one line. + // 2. `force` acknowledges a DESTRUCTIVE diff; it is not a bypass of the + // name grammar. The pre-#12194 direction here was 200 + the shrunk + // store — the acceptance this pin replaces. const forced = await stack.compoundPut({ force: 'true' }); - expect(forced.status).toBe(200); - expect(forced.body?.error).toBeUndefined(); - // 3. …and the acknowledged change LANDED. A 200 that wrote nothing - // would pass a status-only assertion and be a different bug. - expect(stack.compoundFields()).toEqual(SHRUNK_FIELDS); + expect(forced.status).toBe(400); + expect(forced.body?.code).toBe('INVALID_REQUEST'); + expect(stack.compoundFields()).toEqual(STORED_FIELDS); }); it('threads `force: true` into the protocol request, and only when asked', async () => { @@ -369,23 +378,23 @@ describe('[#11095] compound-name PUT — `?force=true` is honoured', () => { it.each([ { spelling: 'true' }, { spelling: '1' }, { spelling: 'yes' }, { spelling: 'on' }, { spelling: 'TRUE' }, - ])('accepts the `$spelling` spelling, byte-identically to the twin', async ({ spelling }) => { + ])('the `$spelling` spelling is refused the same way — the truthy table buys no bypass', async ({ spelling }) => { const stack = boot(); const answer = await stack.compoundPut({ force: spelling }); - expect(answer.status).toBe(200); - expect(stack.compoundFields()).toEqual(SHRUNK_FIELDS); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); + expect(stack.compoundFields()).toEqual(STORED_FIELDS); }); - it('an explicit opt-OUT is still an opt-out — `?force=false` refuses', async () => { + it('`?force=false` earns the SAME grammar refusal — the gate reads the name, never the flag', async () => { const stack = boot(); const answer = await stack.compoundPut({ force: 'false' }); - // Not "any value present means force": the truthy table is a table. - expect(answer.status).toBe(409); - expect(answer.body?.code).toBe('DESTRUCTIVE_CHANGE'); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); expect(stack.compoundFields()).toEqual(STORED_FIELDS); }); }); @@ -430,13 +439,21 @@ describe('[#11095 / #6877] a REPEATED `?force` is refused, never read as force-O expect(stack.compoundFields()).toEqual(STORED_FIELDS); }); - it('one occurrence encoded as an array still works — the guard unwraps, it does not blanket-refuse', async () => { + it('one occurrence encoded as an array still REACHES the door — the guard unwraps, it does not blanket-refuse', async () => { const stack = boot(); const answer = await stack.compoundPut({ force: ['true'] }); - expect(answer.status).toBe(200); - expect(stack.compoundFields()).toEqual(SHRUNK_FIELDS); + // The guard's own verdict would be the nested VALIDATION_ERROR before + // the protocol is called (`seen` empty, as the repeated cases above + // pin). A single array-encoded occurrence unwraps and travels: the + // request reaches `saveMetaItem` — recorded at the seam — where the + // #12194 grammar gate is what answers now. + expect(stack.seen).toHaveLength(1); + expect(stack.seen[0].force).toBe(true); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); + expect(stack.compoundFields()).toEqual(STORED_FIELDS); }); }); @@ -444,43 +461,42 @@ describe('[#11095 / #6877] a REPEATED `?force` is refused, never read as force-O // 4. ⭐ [#7019] The twins agree — the ruling this card inherits, executable // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11095 / #7019] the two `PUT` doors answer the same question the same way', () => { +describe('[#11095 / #7019 / #12194] the two `PUT` doors now DIVERGE by design at the write', () => { /** - * The single-segment door is UNTOUCHED by this card and is the control. Its - * behaviour is asserted here rather than assumed, so this pair of cases - * fails if either door moves — which is the only shape in which "the twins - * agree" is a pin rather than a comment. + * #7019's "one operation, two spellings" premise is what #12176 retired: + * the compound spelling is no longer a legal way to say the operation. The + * single-segment door keeps the FULL #11095 contract — destructive 409 + * with the remedy, honoured `?force=true` — and the compound door refuses + * before the destructive gate runs. Both directions are pinned so this + * fails if EITHER door moves. */ - it('refused identically at both doors, with the same code and status', async () => { + it('single door: destructive 409 with the remedy — compound door: grammar 400', async () => { const stack = boot(); const compound = await stack.compoundPut(); const single = await stack.singlePut(); - expect(compound.status).toBe(single.status); - expect(compound.status).toBe(409); - expect(compound.body?.code).toBe(single.body?.code); - expect(compound.body?.code).toBe('DESTRUCTIVE_CHANGE'); - // Both refusals prescribe the parameter, and now both mean it. - expect(compound.body?.error).toContain(PUT_REMEDY); + expect(single.status).toBe(409); + expect(single.body?.code).toBe('DESTRUCTIVE_CHANGE'); expect(single.body?.error).toContain(PUT_REMEDY); + expect(compound.status).toBe(400); + expect(compound.body?.code).toBe('INVALID_REQUEST'); expect(stack.compoundFields()).toEqual(STORED_FIELDS); expect(stack.singleFields()).toEqual(STORED_FIELDS); }); - it('⭐ ACCEPTED identically at both doors — the divergence this card closed', async () => { + it('⭐ `?force=true` is honoured ONLY where the name is legal', async () => { const stack = boot(); const compound = await stack.compoundPut({ force: 'true' }); const single = await stack.singlePut({ force: 'true' }); - // The one assertion that was FALSE before this card: these two statuses - // were 409 and 200. One name, spelled two ways, two different answers - // to "may I acknowledge this risk". - expect(compound.status).toBe(single.status); - expect(compound.status).toBe(200); - expect(stack.compoundFields()).toEqual(SHRUNK_FIELDS); + // The single door's #11095 fix stands: acknowledged, 200, landed. + expect(single.status).toBe(200); expect(stack.singleFields()).toEqual(SHRUNK_FIELDS); + // The compound door refuses the NAME before reading the flag. + expect(compound.status).toBe(400); + expect(stack.compoundFields()).toEqual(STORED_FIELDS); }); it('and the twin is UNTOUCHED — its request shape is what it always was', async () => { diff --git a/packages/rest/src/meta-compound-save-mode-parity.test.ts b/packages/rest/src/meta-compound-save-mode-parity.test.ts index 83954981cb..b3ff291fc1 100644 --- a/packages/rest/src/meta-compound-save-mode-parity.test.ts +++ b/packages/rest/src/meta-compound-save-mode-parity.test.ts @@ -5,6 +5,18 @@ * — the FIFTH divergence closed on this door pair, and the first one whose * harmful direction is a silent WRITE rather than a silent refusal. * + * ## ⚠️ #12194 reversed the compound door's WRITE outcome + * + * Stage 1 of #12176 (maintainer ruling 2026-08-25): the item-name grammar + * refuses every slash-bearing name at `saveMetaItem`, so the compound door's + * folded `crm/task` is now refused `400 INVALID_REQUEST` before the lifecycle + * split this file was written about is reached — `?mode=draft` cannot stage a + * slash-named draft any more. The route still folds and still threads `mode` + * (the seam pins below stay true); the single-segment twin keeps the full + * #11712 contract; the repeated-parameter guard still answers first. The + * compound-door cases pin the refusal; the twins now DIVERGE BY DESIGN at the + * write (the route retirement itself is D3, #12195). + * * ## The defect, as measured * * ADR-0005's per-item lifecycle stages a write when the caller sends @@ -296,31 +308,29 @@ const PUBLISHED = [SUBMITTED_LABEL, undefined]; // 1. ⭐ The compound door, `?mode=draft` — the case that fails without the fix // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11712] compound-name PUT — `?mode=draft` stages instead of publishing', () => { - it('⭐ leaves the LIVE row alone and stages the edit beside it', async () => { +describe('[#11712 / #12194] compound-name PUT — `?mode=draft` is refused at the grammar gate', () => { + it('⭐ refuses the folded slash name: live row untouched, NOTHING staged', async () => { const stack = boot(); const answer = await stack.compoundPut({ mode: 'draft' }); - expect(answer.status).toBe(200); - // ⛔ The status is NOT the pin. The unfixed door answers 200 here too — - // it accepts the parameter and ignores it. These two lines are the - // claim: the caller's edit is STAGED, and the live body is untouched. - expect(stack.compoundOutcome()).toEqual(STAGED); - // Pre-fix this read `['Edited label', undefined]`: the live row - // overwritten, nothing staged. A publish, answered 200, for a request - // that asked for a draft. + // The grammar gate answers before the lifecycle split is reached: a + // slash-named DRAFT is as refused as a slash-named publish, or the + // staging buffer would become the one channel that still mints slash + // rows (they would surface at promote time instead). + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); }); - it('⭐ says so in the answer too — `state` is the staged one, not the live one', async () => { + it('⭐ the refusal names the grammar and the dotted prescription', async () => { const stack = boot(); const answer = await stack.compoundPut({ mode: 'draft' }); - // The protocol's save answer carries the lifecycle state it wrote. - // Pre-fix this said `active` while reporting success — the silence the - // card is about, one field along from the store itself. - expect(answer.body?.state).toBe('draft'); + // `handleRouteError`'s body is FLAT — the message string is `error`. + expect(answer.body?.error).toContain('is not a legal metadata item name'); + expect(answer.body?.error).toContain('crm_lead.pipeline'); }); it('threads `mode: \'draft\'` into the protocol request, and only when asked', async () => { @@ -342,13 +352,14 @@ describe('[#11712] compound-name PUT — `?mode=draft` stages instead of publish expect(stack.seen[1].writeFace).toBe('meta-envelope'); }); - it('accepts the `DRAFT` spelling case-insensitively, byte-identically to the twin', async () => { + it('the `DRAFT` spelling is refused the same way — case-folding buys no bypass', async () => { const stack = boot(); const answer = await stack.compoundPut({ mode: 'DRAFT' }); - expect(answer.status).toBe(200); - expect(stack.compoundOutcome()).toEqual(STAGED); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); }); }); @@ -358,19 +369,22 @@ describe('[#11712] compound-name PUT — `?mode=draft` stages instead of publish // red-before case. // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11712] the compound door still publishes when nothing asked for a draft', () => { +describe('[#11712 / #12194] no `mode` spelling changes the compound refusal — the gate reads the name', () => { it.each([ { label: 'no `mode` at all', query: {} }, { label: 'an explicit `mode=publish`', query: { mode: 'publish' } }, { label: 'an unrecognised `mode=staged`', query: { mode: 'staged' } }, { label: 'an empty `mode=`', query: { mode: '' } }, - ])('$label goes live — legacy semantics, unchanged', async ({ query }) => { + ])('$label is refused identically — 400, store untouched', async ({ query }) => { const stack = boot(); const answer = await stack.compoundPut(query); - expect(answer.status).toBe(200); - expect(stack.compoundOutcome()).toEqual(PUBLISHED); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + // The request REACHED the door (the refusal is the protocol's, not the + // route's), and none of these spellings threaded a `mode`. expect(stack.seen[0].mode).toBeUndefined(); }); }); @@ -416,13 +430,21 @@ describe('[#11712 / #6877] a REPEATED `?mode` is refused, never read as publish- expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); }); - it('one occurrence encoded as an array still stages — the guard unwraps, it does not blanket-refuse', async () => { + it('one occurrence encoded as an array still REACHES the door — the guard unwraps, it does not blanket-refuse', async () => { const stack = boot(); const answer = await stack.compoundPut({ mode: ['draft'] }); - expect(answer.status).toBe(200); - expect(stack.compoundOutcome()).toEqual(STAGED); + // The guard's own verdict would be the nested VALIDATION_ERROR with + // `seen` empty (as the repeated cases above pin). A single + // array-encoded occurrence unwraps and travels: the request reaches + // `saveMetaItem` with `mode: 'draft'` threaded — recorded at the seam — + // where the #12194 grammar gate is what answers now. + expect(stack.seen).toHaveLength(1); + expect(stack.seen[0].mode).toBe('draft'); + expect(answer.status).toBe(400); + expect(answer.body?.code).toBe('INVALID_REQUEST'); + expect(stack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); }); it('the twin refuses a repeated `mode` the same way — it always did', async () => { @@ -481,41 +503,51 @@ describe('[#11712 / #11095] adding `mode` to the list did not disturb `force` or // 5. ⭐ [#7019] The twins agree — the ruling this card inherits, executable // ═══════════════════════════════════════════════════════════════════════════ -describe('[#11712 / #7019] the two `PUT` doors answer `?mode` the same way', () => { +describe('[#11712 / #7019 / #12194] the two `PUT` doors now DIVERGE by design at the write', () => { /** - * ⛔ Deliberately literal-free on both sides: these cases assert that the - * two doors AGREE, never what they agree on. §1 names the outcome; this - * section names only the equality, so a future move on EITHER door reddens - * here independently of whichever literal §1 happens to pin. (#11731 §4 is - * the precedent — "every classified refusal gets the same status at both - * doors", with the status read off the other door rather than written down.) - * - * The single-segment door is untouched by this card and is the control: its - * behaviour is asserted rather than assumed, which is the only shape in - * which "the twins agree" is a pin instead of a comment. + * #7019's "one operation, two spellings" premise is what #12176 retired: + * the compound spelling is no longer a legal way to say the operation. + * The single-segment twin keeps the FULL #11712 `?mode` contract (asserted + * per spelling, never assumed), while every compound write is refused at + * the grammar gate before `mode` matters. Both directions are pinned so + * this fails if EITHER door moves. One agreement survives: a REPEATED + * `mode` is the route guard's own 400 at both doors, because the guard + * runs before either door's verdict. */ it.each([ - { label: 'no `mode`', query: {} }, - { label: '`mode=draft`', query: { mode: 'draft' } }, - { label: '`mode=DRAFT`', query: { mode: 'DRAFT' } }, - { label: '`mode=publish`', query: { mode: 'publish' } }, - { label: '`mode=staged` (unrecognised)', query: { mode: 'staged' } }, - { label: 'a repeated `mode`', query: { mode: ['draft', 'draft'] } }, - ])('⭐ $label: same status, same lifecycle, same store outcome at both doors', async ({ query }) => { + { label: 'no `mode`', query: {}, singleOutcome: PUBLISHED }, + { label: '`mode=draft`', query: { mode: 'draft' }, singleOutcome: STAGED }, + { label: '`mode=DRAFT`', query: { mode: 'DRAFT' }, singleOutcome: STAGED }, + { label: '`mode=publish`', query: { mode: 'publish' }, singleOutcome: PUBLISHED }, + { label: '`mode=staged` (unrecognised)', query: { mode: 'staged' }, singleOutcome: PUBLISHED }, + ])('⭐ $label: single door keeps the #11712 contract, compound door refuses', async ({ query, singleOutcome }) => { const compoundStack = boot(); const singleStack = boot(); const compound = await compoundStack.compoundPut(query); const single = await singleStack.singlePut(query); - // 1. The answer. + expect(single.status).toBe(200); + expect(singleStack.singleOutcome()).toEqual(singleOutcome); + expect(compound.status).toBe(400); + expect(compound.body?.code).toBe('INVALID_REQUEST'); + expect(compoundStack.compoundOutcome()).toEqual([LIVE_LABEL, undefined]); + }); + + it('a REPEATED `mode` is still refused identically at both doors — the guard answers first', async () => { + const compoundStack = boot(); + const singleStack = boot(); + + const compound = await compoundStack.compoundPut({ mode: ['draft', 'draft'] }); + const single = await singleStack.singlePut({ mode: ['draft', 'draft'] }); + expect(compound.status).toBe(single.status); - expect(compound.body?.state).toBe(single.body?.state); + expect(compound.status).toBe(400); + // The guard's NESTED body at both doors — neither request reached a door. expect(compound.body?.error?.code).toBe(single.body?.error?.code); - // 2. What the write actually DID, as `[live label, staged label]`. - // Pre-fix, `mode=draft` read `['Edited label', undefined]` on the - // compound side and `['Live label', 'Edited label']` on the twin. - expect(compoundStack.compoundOutcome()).toEqual(singleStack.singleOutcome()); + expect(compound.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(compoundStack.seen).toHaveLength(0); + expect(singleStack.seen).toHaveLength(0); }); it('and the twin is UNTOUCHED — its request shape is what it always was', async () => { @@ -531,17 +563,16 @@ describe('[#11712 / #7019] the two `PUT` doors answer `?mode` the same way', () }); }); - it('both doors reach ONE store, so a draft staged at either is the same draft', async () => { + it('a refused compound write leaves the single door\'s staging untouched — one store, one refusal', async () => { const stack = boot(); - // Same fixture, both doors, both staging: the names differ, so the two - // drafts are two rows and neither door's staging leaks onto the other's - // live body. This is the claim "one generic `saveMetaItem`, reached by a - // name spelled in two segments" reduced to something executable. + // Same fixture, both doors: the compound attempt is refused at the + // grammar gate and must not disturb the twin's staging beside it in + // the same store. await stack.compoundPut({ mode: 'draft' }); await stack.singlePut({ mode: 'draft' }); - expect(stack.outcome(COMPOUND_NAME)).toEqual(STAGED); + expect(stack.outcome(COMPOUND_NAME)).toEqual([LIVE_LABEL, undefined]); expect(stack.outcome(SINGLE_NAME)).toEqual(STAGED); }); }); diff --git a/packages/rest/src/meta-published-overlay.test.ts b/packages/rest/src/meta-published-overlay.test.ts index 4d03dca65e..207d09e506 100644 --- a/packages/rest/src/meta-published-overlay.test.ts +++ b/packages/rest/src/meta-published-overlay.test.ts @@ -351,18 +351,29 @@ describe('[#8278] REST `/meta/:type/:name/published` resolves from the published expect(res.body).toEqual({ error: { code: 'NOT_FOUND', message: 'Not found' } }); }, 60_000); - it('§6 the COMPOUND arity resolves the runtime-published sub-resource', async () => { + it('§6 the COMPOUND arity resolves a STORED sub-resource row — reads stay open for residue', async () => { // `getPublished('lead', 'views/all_leads')` is the shape the SDK // documents and the shape this route's own comment names // (`lead/views/all_leads/published`). It reaches a DIFFERENT route // registration than §1, so the overlay consult has to be on both or // the fix covers only one of the two doors this card puts in scope. + // + // [#12194] The fixture used to be authored through `runtimePublish` — + // the item-name grammar now refuses a slash name at that door, and the + // READ door deliberately stays open for pre-grammar residue rows. So + // the row is seeded directly in the store, which is exactly what such + // a row now is: residue this route must keep serving until D2/D3 + // dispose of it. const { engine, rows } = makeStubEngine(); const metadata = new MetadataManager({}); const protocol = makeProtocol(engine, metadata); const viewBody = { name: 'all_leads', label: 'All Leads', columns: ['name'] }; - await runtimePublish(protocol, 'views/all_leads', viewBody, 'lead'); + await engine.insert('sys_metadata', { + type: 'lead', name: 'views/all_leads', + organization_id: null, package_id: 'app.projects', state: 'active', + metadata: JSON.stringify(viewBody), checksum: 'sha256:residue', version: 1, + }); expect(Array.from(rows.values()).filter((r) => r.state === 'active')).toHaveLength(1); diff --git a/packages/runtime/src/meta-compound-arity-mint-door.test.ts b/packages/runtime/src/meta-compound-arity-mint-door.test.ts index a2d0437c0e..b74dfac92b 100644 --- a/packages/runtime/src/meta-compound-arity-mint-door.test.ts +++ b/packages/runtime/src/meta-compound-arity-mint-door.test.ts @@ -1,27 +1,31 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * #8421 — the COMPOUND `/meta` arity survives the unrecognised-type refusal, - * pinned at the LIVE ROUTE. + * #8421/#12194 — the COMPOUND `/meta` arity write is refused at the item-name + * grammar gate, pinned at the LIVE ROUTE. * * `/metadata/lead/views/all_leads` is `type='lead'`, `name='views/all_leads'`: - * ONE operation reaching ONE `saveMetaItem`, the shape this dispatcher's own - * `/meta` branch documents verbatim ("compound names are how the client - * expresses sub-resources of a type"). The segment in the `:type` position is an - * OBJECT name — runtime data, which no static contract can enumerate — so a - * static type verdict applied there refuses every object name that is not - * coincidentally a metadata type. + * ONE operation reaching ONE `saveMetaItem`. Under #8421 that shape was + * EXEMPTED from the unrecognised-type refusal (the `:type` segment carries an + * OBJECT name no static contract can enumerate), and this file pinned the + * exemption at the wire. #12194 (stage 1 of #12176's maintainer-ruled + * retirement of compound-name addressing, 2026-08-25) reverses the pinned + * direction: the item-name grammar refuses every slash-bearing name BEFORE the + * type verdict runs, so the compound WRITE now answers `400 INVALID_REQUEST` + * with the dotted prescription — and this file pins that the refusal reaches + * the wire with the ADR-0112 envelope, not a 500. * * ## Why this file exists rather than one more case next door * * `domains/meta-save-capability-gate.test.ts` already drives this exact path, - * and it stayed GREEN through the regression: its caller holds no capabilities, - * so `PERMISSION_DENIED` answers before the protocol is ever resolved, and its - * `saveMetaItem` is a `vi.fn()` that could not have refused anything anyway. The - * site was masked, not unaffected — a 403 arriving first is not evidence about - * what the door behind it does. So every case here holds `manage_metadata` and - * drives the REAL `ObjectStackProtocolImplementation` over a real store, and - * then reads the stored ROW rather than the response body. + * and it stayed GREEN through the original regression: its caller holds no + * capabilities, so `PERMISSION_DENIED` answers before the protocol is ever + * resolved, and its `saveMetaItem` is a `vi.fn()` that could not have refused + * anything anyway. The site was masked, not unaffected — a 403 arriving first + * is not evidence about what the door behind it does. So every case here holds + * `manage_metadata` and drives the REAL `ObjectStackProtocolImplementation` + * over a real store, and then reads the stored ROW rather than the response + * body. * * ⚠️ `packages/runtime` resolves `@objectstack/metadata-protocol` through its * built `dist`, and stack traces are source-mapped back to `src` — so any @@ -30,11 +34,12 @@ * * ## Reverse verification, direction predicted BEFORE running * - * Deleting the compound exemption from `refuseUnmintableMetaType` (the - * `request.name.includes('/')` line) and rebuilding must turn the two compound - * cases RED — `400 INVALID_REQUEST`, no row — and leave the simple-arity - * refusal and the recognised-type control GREEN. Predicted 2 red / 3 green; - * measured 2 red / 3 green. + * On the pre-#12194 tree (grammar gate absent, compound exemption present) + * the two compound cases run the OTHER way — `200`, row stored under the + * slash key. Measured on `origin/main@22c42c9b` before the door change: this + * file's two compound pins were the acceptance direction; after the door + * change and a rebuild they pin the refusal. The simple-arity refusal and the + * recognised-type control are green on both trees. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -209,7 +214,7 @@ describe('#8421 — the compound `/meta` arity is not a metadata-type claim', () vi.spyOn(console, 'error').mockImplementation(() => {}); }); - it('saves a sub-resource addressed under an OBJECT name', async () => { + it('refuses the compound-arity write at the wire with the grammar envelope (#12194)', async () => { const { engine, dispatcher } = makeStack(); const res = responseOf(await dispatcher.handleMetadata( @@ -217,22 +222,28 @@ describe('#8421 — the compound `/meta` arity is not a metadata-type claim', () { name: 'all_leads', label: 'All Leads', columns: ['name'] }, )); - expect(res.status).toBe(200); - // The stored ROW, not the answer: the compound name is reassembled and - // used as ONE key — not split, not truncated to its last segment. - expect(metaRow(engine, 'lead', 'views/all_leads')).toBeDefined(); + // The refusal reaches the WIRE as the caller's mistake, not a server + // fault: 400 + code, with the grammar and the dotted prescription in + // the message — and the stored ROW proves nothing was minted under + // the slash key (the pre-#12194 direction stored it as one opaque key). + expect(res.status).toBe(400); + expect(res.body?.error?.code).toBe('INVALID_REQUEST'); + expect(String(res.body?.error?.message ?? '')).toMatch(/is not a legal metadata item name/); + expect(String(res.body?.error?.message ?? '')).toMatch(/crm_lead\.pipeline/); + expect(metaRow(engine, 'lead', 'views/all_leads')).toBeUndefined(); expect(metaRow(engine, 'lead', 'all_leads')).toBeUndefined(); }); - it('…on a deeper compound name too', async () => { + it('…and a deeper compound name is refused the same way', async () => { const { engine, dispatcher } = makeStack(); const res = responseOf(await dispatcher.handleMetadata( '/lead/views/all_leads/columns', ctx(), 'PUT', { name: 'columns', label: 'Columns' }, )); - expect(res.status).toBe(200); - expect(metaRow(engine, 'lead', 'views/all_leads/columns')).toBeDefined(); + expect(res.status).toBe(400); + expect(res.body?.error?.code).toBe('INVALID_REQUEST'); + expect(metaRow(engine, 'lead', 'views/all_leads/columns')).toBeUndefined(); }); it('ANTI-VACUITY — the same object name at the SIMPLE arity is still refused', async () => { diff --git a/packages/runtime/src/meta-write-org-scope.test.ts b/packages/runtime/src/meta-write-org-scope.test.ts index 2e3da51377..1ee53bded2 100644 --- a/packages/runtime/src/meta-write-org-scope.test.ts +++ b/packages/runtime/src/meta-write-org-scope.test.ts @@ -569,7 +569,11 @@ describe('#10503 the dispatcher /metadata transport decides org scope on the FOL plural: 'translations', singular: 'translation', item: { - name: 'zh-CN', + // [#12194] The addressing name is snake_case — the item-name + // grammar refuses `zh-CN` as an ADDRESSING key (uppercase + + // dash). The BCP-47 spelling lives in `locale`, which is this + // type's required identity; the name was always free to choose. + name: 'zh_cn', label: 'Chinese (Simplified)', locale: 'zh-CN', messages: { greeting: '你好' }, diff --git a/packages/spec/api-surface/shared.json b/packages/spec/api-surface/shared.json index 5253a74634..2767daa95e 100644 --- a/packages/spec/api-surface/shared.json +++ b/packages/spec/api-surface/shared.json @@ -48,11 +48,14 @@ "KeySetGuidance (interface)", "MAP_SUPPORTED_FIELDS (const)", "METADATA_ALIASES (const)", + "METADATA_ITEM_NAME_PATTERN (const)", "META_URL_TO_SINGULAR (const)", "MapSupportedField (type)", "MetadataCollectionInput (type)", "MetadataFormat (type)", "MetadataFormatSchema (const)", + "MetadataItemName (type)", + "MetadataItemNameSchema (const)", "MutationEvent (type)", "MutationEventEnum (const)", "NormalizeStackInputOptions (interface)", @@ -68,6 +71,7 @@ "PredicateSchema (const)", "Protection (type)", "ProtectionSchema (const)", + "QUALIFIED_ITEM_NAME_PATTERN (const)", "RateLimitConfig (type)", "RateLimitConfigParsed (type)", "RateLimitConfigSchema (const)", diff --git a/packages/spec/export-origins/shared.json b/packages/spec/export-origins/shared.json index 71548df970..d20ab3a4d4 100644 --- a/packages/spec/export-origins/shared.json +++ b/packages/spec/export-origins/shared.json @@ -48,11 +48,14 @@ "KeySetGuidance": "src/shared/suggestions.zod.ts#KeySetGuidance (interface)", "MAP_SUPPORTED_FIELDS": "src/shared/metadata-collection.zod.ts#MAP_SUPPORTED_FIELDS (const)", "METADATA_ALIASES": "src/shared/metadata-collection.zod.ts#METADATA_ALIASES (const)", + "METADATA_ITEM_NAME_PATTERN": "src/shared/identifiers.zod.ts#METADATA_ITEM_NAME_PATTERN (const)", "META_URL_TO_SINGULAR": "src/meta-spelling/meta-url-data.generated.ts#META_URL_TO_SINGULAR (const)", "MapSupportedField": "src/shared/metadata-collection.zod.ts#MapSupportedField (type)", "MetadataCollectionInput": "src/shared/metadata-collection.zod.ts#MetadataCollectionInput (type)", "MetadataFormat": "src/shared/metadata-types.zod.ts#MetadataFormat (type)", "MetadataFormatSchema": "src/shared/metadata-types.zod.ts#MetadataFormatSchema (const)", + "MetadataItemName": "src/shared/identifiers.zod.ts#MetadataItemName (type)", + "MetadataItemNameSchema": "src/shared/identifiers.zod.ts#MetadataItemNameSchema (const)", "MutationEvent": "src/shared/enums.zod.ts#MutationEvent (type)", "MutationEventEnum": "src/shared/enums.zod.ts#MutationEventEnum (const)", "NormalizeStackInputOptions": "src/shared/metadata-collection.zod.ts#NormalizeStackInputOptions (interface)", @@ -68,6 +71,7 @@ "PredicateSchema": "src/shared/expression.zod.ts#PredicateSchema (const)", "Protection": "src/shared/protection.zod.ts#Protection (type)", "ProtectionSchema": "src/shared/protection.zod.ts#ProtectionSchema (const)", + "QUALIFIED_ITEM_NAME_PATTERN": "src/shared/identifiers.zod.ts#QUALIFIED_ITEM_NAME_PATTERN (const)", "RateLimitConfig": "src/shared/http.zod.ts#RateLimitConfig (type)", "RateLimitConfigParsed": "src/shared/http.zod.ts#RateLimitConfigParsed (type)", "RateLimitConfigSchema": "src/shared/http.zod.ts#RateLimitConfigSchema (const)", diff --git a/packages/spec/json-schema.manifest/shared.json b/packages/spec/json-schema.manifest/shared.json index 0554b3fbbb..f29ff66bb5 100644 --- a/packages/spec/json-schema.manifest/shared.json +++ b/packages/spec/json-schema.manifest/shared.json @@ -19,6 +19,7 @@ "shared/HttpRequest", "shared/IsolationLevelEnum", "shared/MetadataFormat", + "shared/MetadataItemName", "shared/MutationEventEnum", "shared/ObjectName", "shared/Predicate", diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index f666799577..68f0a65983 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -12,6 +12,7 @@ import { import { MetadataCacheRequestSchema, MetadataCacheResponseSchema } from './http-cache.zod'; import { QuerySchema, QUERY_DISTINCT_REMOVED } from '../data/query.zod'; import { retiredKey } from '../shared/retired-key'; +import { MetadataItemNameSchema } from '../shared/identifiers.zod'; import { DroppedFieldsEventSchema } from '../data/data-engine.zod'; import { AnalyticsQueryRequestSchema, @@ -566,10 +567,21 @@ export const RuntimeAuthoringIssueSchema = lazySchema(() => z.object({ /** * Save Metadata Item Request * Create or update a metadata item + * + * `name` carries the enforced item-name grammar (#12194 — lowercase + * snake_case segments, optionally dot-qualified; `shared/identifiers.zod.ts` + * is the single source). The implementation refuses an off-grammar name at + * the door with `400 INVALID_REQUEST`, so declared = enforced. The read and + * delete request shapes deliberately stay `z.string()`: pre-grammar residue + * rows must remain listable and clearable. */ export const SaveMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), - name: z.string().describe('Item name'), + name: MetadataItemNameSchema.describe( + 'Item name — lowercase snake_case segments, optionally dot-qualified ' + + '(`crm_lead`, `crm_lead.pipeline`). Slash-compound names are refused at ' + + 'the publish door (#12176).', + ), item: z.unknown().describe('Metadata item definition'), })); @@ -679,7 +691,11 @@ export const SaveMetaItemResponseSchema = lazySchema(() => z.object({ */ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ type: z.string().describe('Metadata type name'), - name: z.string().describe('Item name'), + name: MetadataItemNameSchema.describe( + 'Item name — lowercase snake_case segments, optionally dot-qualified ' + + '(`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the ' + + 'same grammar as `saveMetaItem` (#12194).', + ), organizationId: z.string().optional().describe( 'Organization (tenant) scope for the promotion. The implementation resolves ' + 'the draft through the org partition (ADR-0005, #8805), so a draft ' diff --git a/packages/spec/src/migrations/entries/semantic/18.metadata-item-name-grammar-enforced.ts b/packages/spec/src/migrations/entries/semantic/18.metadata-item-name-grammar-enforced.ts new file mode 100644 index 0000000000..cb85a03489 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.metadata-item-name-grammar-enforced.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'metadata-item-name-grammar-enforced', + surface: 'metadata item names (the `name` half of the `type`/`name` addressing pair — ' + + '`saveMetaItem` / `publishMetaItem`, `PUT /api/v1/meta/:type/:name` and the compound ' + + '`:type/:section/:name` fold)', + replacement: 'lowercase snake_case segments, optionally dot-qualified — ' + + 'the pattern family of METADATA_ITEM_NAME_PATTERN, i.e. one or more [a-z][a-z0-9_]* ' + + 'segments joined by single dots (`crm_lead`, `crm_lead.pipeline`). A name that spelled ' + + 'a sub-resource with a slash (`views/all_leads`) is re-authored with a dot qualifier ' + + '(`crm_lead.pipeline` — the `ViewItemNameSchema` convention, now enforced with the ' + + 'qualifier optional) or flattened with an underscore (`views_all_leads`); containment ' + + 'is expressed by structure, never by a separator inside the identity string.', + reason: + 'Maintainer ruling (2026-08-25): metadata item names must not contain `/` — ' + + 'identity-with-separator is the measured root cause of a defect family (URL arity ' + + 'mismatches, dual-arity route-mount obligations, route shadowing, a two-rule URL ' + + 'spelling split in one SDK file). The grammar was entirely unconstrained at the door: ' + + 'the empty string, `//` and `Views/All Leads` were all accepted and stored as item ' + + 'names, and a slash in the name bypassed the unrecognised-metadata-type refusal ' + + '(`type=fieldz name=a/b` was accepted while `type=fieldz name=a` was 400). Whether a ' + + 'stored slash-name (out-of-repo deployments only — the in-repo census measured zero) ' + + 'should be renamed, and to what, is a judgment the chain cannot make, so no mechanical ' + + 'conversion ships with the narrowing.', + acceptanceCriteria: + 'Every write through `saveMetaItem` / `publishMetaItem` whose name is lowercase ' + + 'snake_case segments optionally joined by single dots succeeds exactly as before, flat ' + + 'and dotted alike. Any other name — slash, empty, whitespace, uppercase, ' + + 'leading/trailing/double dots — is refused `400 INVALID_REQUEST` with the grammar and ' + + 'the dotted prescription in the message, and nothing is persisted. Reads and ' + + '`deleteMetaItem` still answer for pre-grammar residue rows, so any stored junk name ' + + 'remains listable and clearable.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index ffec98a792..383b4cafcb 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6125,6 +6125,38 @@ const step18: MigrationStep = { '`persistence.key`; `initialData` record values containing literal `${…}` keep parsing ' + 'byte-identically.', }, + { + id: 'metadata-item-name-grammar-enforced', + surface: 'metadata item names (the `name` half of the `type`/`name` addressing pair — ' + + '`saveMetaItem` / `publishMetaItem`, `PUT /api/v1/meta/:type/:name` and the compound ' + + '`:type/:section/:name` fold)', + replacement: 'lowercase snake_case segments, optionally dot-qualified — ' + + 'the pattern family of METADATA_ITEM_NAME_PATTERN, i.e. one or more [a-z][a-z0-9_]* ' + + 'segments joined by single dots (`crm_lead`, `crm_lead.pipeline`). A name that spelled ' + + 'a sub-resource with a slash (`views/all_leads`) is re-authored with a dot qualifier ' + + '(`crm_lead.pipeline` — the `ViewItemNameSchema` convention, now enforced with the ' + + 'qualifier optional) or flattened with an underscore (`views_all_leads`); containment ' + + 'is expressed by structure, never by a separator inside the identity string.', + reason: + 'Maintainer ruling (2026-08-25): metadata item names must not contain `/` — ' + + 'identity-with-separator is the measured root cause of a defect family (URL arity ' + + 'mismatches, dual-arity route-mount obligations, route shadowing, a two-rule URL ' + + 'spelling split in one SDK file). The grammar was entirely unconstrained at the door: ' + + 'the empty string, `//` and `Views/All Leads` were all accepted and stored as item ' + + 'names, and a slash in the name bypassed the unrecognised-metadata-type refusal ' + + '(`type=fieldz name=a/b` was accepted while `type=fieldz name=a` was 400). Whether a ' + + 'stored slash-name (out-of-repo deployments only — the in-repo census measured zero) ' + + 'should be renamed, and to what, is a judgment the chain cannot make, so no mechanical ' + + 'conversion ships with the narrowing.', + acceptanceCriteria: + 'Every write through `saveMetaItem` / `publishMetaItem` whose name is lowercase ' + + 'snake_case segments optionally joined by single dots succeeds exactly as before, flat ' + + 'and dotted alike. Any other name — slash, empty, whitespace, uppercase, ' + + 'leading/trailing/double dots — is refused `400 INVALID_REQUEST` with the grammar and ' + + 'the dotted prescription in the message, and nothing is persisted. Reads and ' + + '`deleteMetaItem` still answer for pre-grammar residue rows, so any stored junk name ' + + 'remains listable and clearable.', + }, { id: 'metadata-plugin-additional-types-retired', surface: 'metadata plugin `config.additionalTypes` (on `MetadataPluginConfig`)', diff --git a/packages/spec/src/shared/identifiers.zod.ts b/packages/spec/src/shared/identifiers.zod.ts index b004bd172d..035cfe6f50 100644 --- a/packages/spec/src/shared/identifiers.zod.ts +++ b/packages/spec/src/shared/identifiers.zod.ts @@ -79,9 +79,83 @@ export const SnakeCaseIdentifierSchema = lazySchema(() => z }) .describe('Snake case identifier (lowercase with underscores only)')); +/** + * Metadata item-name grammar — the ONE segment source (#12194, stage 1 of the + * #12176 maintainer-ruled retirement of compound `
/` addressing, + * 2026-08-25). + * + * Both patterns below are built from this segment so the item-name grammar has + * a single declaration: {@link METADATA_ITEM_NAME_PATTERN} makes the dot + * qualifier OPTIONAL (a flat `crm_lead` and a qualified `crm_lead.pipeline` + * are both item names), while `QUALIFIED_ITEM_NAME_PATTERN` REQUIRES it (the + * `ViewItemNameSchema` identity in `ui/view.zod.ts`, where the prefix must + * recover the owning object). Extend the segment here, never by minting a + * sibling regex — two spellings of one grammar is how the `/meta` door ended + * up accepting `''`, `//` and `'Views/All Leads'` while spec declared a strict + * dotted identity nothing enforced. + */ +const ITEM_NAME_SEGMENT = '[a-z][a-z0-9_]*'; + +/** + * The enforced metadata item-name grammar: lowercase snake_case segments, + * optionally dot-qualified — `/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/`. + * + * Decided by the declaration, not ad hoc: no `/`, no empty string, no + * whitespace, no uppercase, no leading/trailing/double dots. Enforced at the + * metadata publish door (`@objectstack/metadata-protocol` `saveMetaItem` / + * `publishMetaItem`); `deleteMetaItem` and the read doors deliberately stay + * open so pre-grammar residue rows remain listable and clearable. + */ +export const METADATA_ITEM_NAME_PATTERN = new RegExp( + `^${ITEM_NAME_SEGMENT}(\\.${ITEM_NAME_SEGMENT})*$`, +); + +/** + * The dot-REQUIRED variant of {@link METADATA_ITEM_NAME_PATTERN}: at least one + * qualifier segment. `ViewItemNameSchema` (`ui/view.zod.ts`) pins independent + * view-item identity on it (`.` — the object is recovered + * from the prefix). Same segment source; only the arity differs. + */ +export const QUALIFIED_ITEM_NAME_PATTERN = new RegExp( + `^${ITEM_NAME_SEGMENT}(\\.${ITEM_NAME_SEGMENT})+$`, +); + +/** + * Metadata Item Name + * + * The addressing identity of a metadata item — the `name` half of the + * `type`/`name` pair that keys `sys_metadata` and the `/api/v1/meta` URL + * space. Lowercase snake_case segments, optionally dot-qualified + * (`crm_lead`, `crm_lead.pipeline`). + * + * A slash never belongs in an item name: the compound `
/` + * convention is retired (#12176 — sub-resource identity is spelled with a + * dot; containment is expressed by structure, never by a separator inside + * the identity string). + * + * @example Valid + * - 'crm_lead' + * - 'crm_lead.pipeline' + * - 'sys_user' + * @example Invalid (refused at the publish door) + * - 'views/all_leads' (slash — retired compound addressing; write `views_all_leads` or a dotted qualified name) + * - '' (empty) + * - 'Views/All Leads' (uppercase, whitespace, slash) + * - '.a', 'a.', 'a..b' (leading/trailing/double dots) + */ +export const MetadataItemNameSchema = lazySchema(() => z + .string() + .regex(METADATA_ITEM_NAME_PATTERN, { + message: + 'Metadata item name must be lowercase snake_case segments, optionally dot-qualified ' + + '(e.g. "crm_lead" or "crm_lead.pipeline"). No slashes, spaces, uppercase, empty ' + + 'segments, or leading/trailing dots.', + }) + .describe('Metadata item name (lowercase snake_case segments, optionally dot-qualified)')); + /** * Event Name Identifier - * + * * Specialized identifier for event names that encourages dot notation. * Used in event-driven systems, message queues, and webhooks. * @@ -111,4 +185,5 @@ export const EventNameSchema = lazySchema(() => z */ export type SystemIdentifier = z.input; export type SnakeCaseIdentifier = z.input; +export type MetadataItemName = z.input; export type EventName = z.input; diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index c00d62ddaf..9c01585b12 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -267,7 +267,7 @@ import type * as M170 from './ui/component.zod.js'; import type * as M183 from './api/sortability.zod.js'; // --------------------------------------------------------------------------- -// 837 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 838 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1033,6 +1033,7 @@ export type Iso496 = Assert, z.infe export type Iso497 = Assert, z.infer< typeof M113.SystemIdentifierSchema > >>; export type Iso498 = Assert, z.infer< typeof M113.SnakeCaseIdentifierSchema > >>; export type Iso499 = Assert, z.infer< typeof M113.EventNameSchema > >>; +export type Iso862 = Assert, z.infer< typeof M113.MetadataItemNameSchema > >>; // shared/mapping.zod.ts // Graduated INTO the isomorphic set at protocol 17: #5552 retired `transform` and the @@ -1675,7 +1676,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 837 isomorphic pins', () => { + it('still declares all 838 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -1995,9 +1996,19 @@ describe('ADR-0122 type-alias convention', () => { // anywhere, so the two shapes coincide and ADR-0122 gives each a pin // rather than an `XParsed`. Ids `Iso859`/`Iso860`/`Iso861`, the next // free ones — ids are claims about pins, not positions. + // + // 837 -> 838 is #12194's `MetadataItemNameSchema` — the item-name grammar + // (shared/identifiers.zod.ts). A bare `z.string().regex()` with no + // `.default()`, `.transform()`, `.catch()` or `.pipe()`, so `z.input` and + // `z.infer` are both `string` and ADR-0122 gives it a pin rather than an + // `XParsed`, exactly like its three identifier siblings on the lines + // above it. (Authored as 834 -> 835 with id `Iso859`; restated from the + // post-merge base after #11924's +3 landed first and took 859-861 — the + // pin was renumbered to `Iso862`, the next free one, because ids are + // claims about pins, not positions.) const self = readFileSync(fileURLToPath(import.meta.url), 'utf8'); const pins = self.match(/^export type Iso\d+ = Assert): ViewParsed { * Qualified view-item identity: `.` — dotted snake_case * segments, e.g. `crm_lead.pipeline`. Globally unique, and the object can be * recovered from the prefix, so the registry key never collides across objects. + * + * The dot-REQUIRED arity of the platform's one item-name grammar + * (`QUALIFIED_ITEM_NAME_PATTERN`, `shared/identifiers.zod.ts` — #12194): same + * segment source as `MetadataItemNameSchema`, which the metadata publish door + * enforces with the qualifier optional. Grammar changes belong there, not here. */ export const ViewItemNameSchema = z .string() .regex( - /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/, + QUALIFIED_ITEM_NAME_PATTERN, 'View item name must be a dotted snake_case qualified name, e.g. "crm_lead.pipeline".', ) .describe('Globally-unique view id, `.`.'); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 5a46c235aa..732bb58659 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -371,6 +371,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.item-name-grammar.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.item-name-grammar.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts", "verb": "delete",