diff --git a/.changeset/register-namespace-conflict-error-code.md b/.changeset/register-namespace-conflict-error-code.md new file mode 100644 index 0000000000..3fb5ca5e50 --- /dev/null +++ b/.changeset/register-namespace-conflict-error-code.md @@ -0,0 +1,17 @@ +--- +"@objectstack/spec": minor +"@objectstack/runtime": minor +--- + +`POST /api/v1/packages` now answers an install-time namespace collision with `error.code: "NAMESPACE_CONFLICT"`. `NAMESPACE_CONFLICT` is registered in `ERROR_CODE_LEDGER` under `@objectstack/objectql`, so the closed ADR-0112 vocabulary (`StandardErrorCode ∪ ERROR_CODE_LEDGER`) gains one member and a caller can branch on the refusal directly. + +**The wire, before and after** — measured through the shipped door (`HttpDispatcher.handlePackages` over a real `SchemaRegistry`), not derived from the call graph: + +- before: `422` with `error.code: "VALIDATION_ERROR"` and `error.declaredCode: "NAMESPACE_CONFLICT"` +- after: `422` with `error.code: "NAMESPACE_CONFLICT"` and **no** `declaredCode` — with the spelling registered there is nothing left to demote + +The status, the message and the throw are unchanged. `NamespaceConflictError` (`@objectstack/objectql`'s `SchemaRegistry.installPackage`, ADR-0048 Phase 1 / ADR-0130 D1) has carried `code` and `status: 422` since the envelope landed; what changed is that the door's #9106 narrowing no longer demotes the spelling. Until now a caller wanting to tell "your namespace is taken, rename it" from every other `422` had to read `declaredCode` — the channel ADR-0112 declares as the open, not-guaranteed one — because `error.code` carried the generic member `422` derives. + +Scope of the widening: one new accept value on `ApiErrorSchema.code`; no export changes, no schema-shape changes, and nothing narrowed. A consumer that treats `error.code` as a closed set it enumerates locally will see a value it does not know, which is what a vocabulary widening means and why this is a `minor`. + +The now-discharged `pending-registration` row ratchets out of `packages/runtime`'s dispatcher-error-vocabulary table in the same change — registration is what makes that row stale, and `pnpm check:dispatcher-error-vocabulary` fails on a registered code still carrying one. The door's answer is pinned in `packages/runtime/src/package-door-namespace-conflict-code.test.ts`, which drives the real route and asserts the body, so the reachability the removed row asserted is now held by a test rather than by a claim. diff --git a/content/docs/references/api/contract.mdx b/content/docs/references/api/contract.mdx index bb91d3ec42..b54b5ce9ca 100644 --- a/content/docs/references/api/contract.mdx +++ b/content/docs/references/api/contract.mdx @@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +296 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | +| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +297 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) | | **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) | | **message** | `string` | ✅ | Readable error message | | **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. | @@ -224,6 +224,7 @@ const result = ApiErrorSchema.parse(data); * `METADATA_NOT_FOUND` * `METADATA_SCHEMA_INVALID` * `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` +* `NAMESPACE_CONFLICT` * `NAMESPACE_PREFIX` * `NEEDS_PASSWORD` * `NODE_FAILURE` diff --git a/content/docs/references/api/error-code-ledger.mdx b/content/docs/references/api/error-code-ledger.mdx index b7ba7f976d..f3d7c8038b 100644 --- a/content/docs/references/api/error-code-ledger.mdx +++ b/content/docs/references/api/error-code-ledger.mdx @@ -340,6 +340,7 @@ const result = ErrorCode.parse(data); * `METADATA_NOT_FOUND` * `METADATA_SCHEMA_INVALID` * `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` +* `NAMESPACE_CONFLICT` * `NAMESPACE_PREFIX` * `NEEDS_PASSWORD` * `NODE_FAILURE` diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 7abeec943d..0d3038f5e6 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -611,48 +611,6 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'that a live wire code is outside the vocabulary; it does not prescribe the remedy.', }, - // ── pending registration [#14474]: an install-time refusal that GAINED an - // ── envelope, so the scan can see it for the first time ──────────────── - // Not a widened scan and not a new producer: `NamespaceConflictError` has - // thrown from `SchemaRegistry.installPackage` since ADR-0048 Phase 1, but - // it carried no `code` at all, so there was no stamp for any pattern to - // match. #14474 gave it the ADR-0112 envelope its three install-time - // siblings already carried, which is what put a site here to classify. - // The door narrowing its `why` names is #9106's — the file header above - // carries it. The anchor lives here rather than in the string, because a - // runtime string reaches operators who cannot resolve a tracker id. - { - code: 'NAMESPACE_CONFLICT', - file: 'packages/objectql/src/registry.ts', - // [#16159] `classconst`, not `classfield`, since the literal became the - // exported `NAMESPACE_CONFLICT_CODE` constant in the producer. The VALUE is - // byte-identical and the scanner resolves the constant back to it; only the - // spelling the scan matches on moved. The verdict below is untouched. - shape: 'classconst', - door: 'dispatcher', - verdict: 'pending-registration', - why: - 'ADR-0048 Phase 1 — the install-time namespace gate\'s refusal, raised by ' + - '`SchemaRegistry.installPackage` when a package\'s `manifest.namespace` is already owned by an ' + - 'installed package that is not a co-owner of it (ADR-0130 D1). ⭐ Its reachability is what ' + - 'separates it from the three ADR-0130 install-time rows below, whose `door: none` turns on ' + - 'needing an artifact install SCOPE that no HTTP caller builds: this gate needs no scope, so the ' + - 'ordinary one-package install reaches it. MEASURED on a booted stack (`@objectstack/verify` ' + - '`bootStack`, dev admin, two `POST /api/v1/packages` installs declaring one namespace), not ' + - 'inferred from the call graph. Before the envelope the door answered `500` with ' + - '`code: INTERNAL_ERROR` — `packages/runtime/src/domains/packages.ts` catches and calls ' + - '`errorFromThrown(e, 500)`, and `resolveThrownHttpError` found neither `.status` nor `.code` to ' + - 'read, so the caller\'s fallback stood. With the envelope the SAME request answers `422` and ' + - 'the body carries `declaredCode: NAMESPACE_CONFLICT` beside `code: VALIDATION_ERROR` (the ' + - 'member 422 derives through `standardErrorCodeForHttpStatus`, which does not name 422 and ' + - 'buckets it as a client error). That demote is the door narrowing described in this file\'s ' + - 'header, and it is exactly what ' + - 'a `pending-registration` row records: the body PARSES, and what the producer loses instead is ' + - 'its semantic code, silently absent from `error.code` until a ledger row lands. ⛔ Registering ' + - 'it is the `packages/spec` lane\'s call and is NOT made here — this row is that batch\'s input, ' + - 'and registering the code is what ratchets the row out again.', - }, - // ── pending registration [#14921]: a metadata-tree refusal that reaches a // ── dispatcher-door read ─────────────────────────────────────────────── // Not a widened scan and not a demotion: this producer is NEW. #14921 made diff --git a/packages/runtime/src/package-door-namespace-conflict-code.test.ts b/packages/runtime/src/package-door-namespace-conflict-code.test.ts new file mode 100644 index 0000000000..c9854e491a --- /dev/null +++ b/packages/runtime/src/package-door-namespace-conflict-code.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14748] `POST /api/v1/packages` answers a namespace collision with + * `error.code: NAMESPACE_CONFLICT` — the wire half of registering the code in + * `ERROR_CODE_LEDGER`. + * + * ## What changed, and why a pin belongs here rather than beside the throw + * + * `NamespaceConflictError` (`packages/objectql/src/registry.ts`) has carried + * the ADR-0112 envelope (`code` + `status: 422`) since #14474, and + * `packages/objectql/src/registry-namespace-install-gate.test.ts` asserts both + * fields ON THE THROW. That is a different claim from this one. Until the + * ledger row landed, `NAMESPACE_CONFLICT` was not an `ErrorCode` member, so the + * #9106 door narrowing DEMOTED the spelling onto the wire's open `declaredCode` + * sibling and put `VALIDATION_ERROR` — the member 422 derives through + * `standardErrorCodeForHttpStatus` — in the closed `error.code` slot: + * + * before: {"code":"VALIDATION_ERROR", "declaredCode":"NAMESPACE_CONFLICT", …} + * after: {"code":"NAMESPACE_CONFLICT", …} ← `declaredCode` gone: nothing to demote + * + * So the throw's envelope and the wire's envelope were two different bodies, + * and a suite asserting the first could not see the second. Registration is + * what closes that gap, and the gap is invisible to every existing suite. This + * file is the assertion that it stays closed. + * + * ⭐ It is also the permanent answer to the reachability claim the removed + * `pending-registration` row asserted without pinning — the row's `door: + * 'dispatcher'` verdict rested on a temporary probe test that was never in the + * tree (#14745 residue 2). This suite makes the same claim by DRIVING the door. + * + * ## What is real here and what is doubled + * + * Real: `HttpDispatcher.handlePackages` (the shipped route), the terminal + * `catch` that answers `errorFromThrown(e, 500)`, `resolveThrownHttpError`'s + * registered/demoted decision, `buildApiError`, and the actual `SchemaRegistry` + * — so the refusal under test is the one production raises, from the same + * `installPackage` call, not a hand-built stand-in carrying the same fields. + * + * Doubled: only the kernel's service lookup, which hands the door that + * registry. There is no protocol service, so the install takes the documented + * fallback limb (`registry.installPackage(manifest, settings)`) — the same + * primitive the protocol limb calls underneath. + * + * ## Reverse verification — direction predicted BEFORE running + * + * Removing `'NAMESPACE_CONFLICT'` from the `@objectstack/objectql` list in + * `packages/spec/src/api/error-code-ledger.zod.ts` and rebuilding was predicted + * to turn section 1 RED (`error.code` back to `VALIDATION_ERROR`, and + * `declaredCode` reappearing) and section 2's registration assertion RED, while + * leaving section 3 — an unregistered spelling at the same door — GREEN in both + * directions, since nothing about that limb depends on this row. The measured + * result is recorded in the PR body. + * + * ⛔ Never a bare `toThrow()` here: the door does not throw, it ANSWERS, and + * the whole subject is what the answer carries. + */ + +import { describe, it, expect } from 'vitest'; +import { + ApiErrorSchema, + BaseResponseSchema, + ErrorCode, + envelopeViolations, + standardErrorCodeForHttpStatus, +} from '@objectstack/spec/api'; +import { SchemaRegistry } from '@objectstack/objectql'; +import { HttpDispatcher } from './http-dispatcher.js'; + +/** + * [#7033 / #7023] `/packages` carries an anonymous-deny floor and every + * state-changing route demands `manage_metadata`. Without a caller these cases + * would stop at the 401 long before the install gate they are named after. + */ +const PKG_ADMIN = () => ({ + request: {}, + executionContext: { + userId: 'u_pkg_admin', + systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'], + }, +}) as any; + +const manifest = (id: string, namespace: string) => ({ id, name: id, namespace, version: '1.0.0' }); + +/** + * The door over a REAL registry. `collisionPolicy: 'error'` is the default + * posture the gate refuses under; `OS_METADATA_COLLISION=warn` downgrades it, + * which is `registry-namespace-install-gate.test.ts`'s territory, not this + * file's. + */ +function makeDoor(registry: SchemaRegistry) { + const kernel: any = { + getService: (name: string) => + name === 'objectql' ? Promise.resolve({ registry }) : null, + context: { getService: () => null }, + }; + return new HttpDispatcher(kernel); +} + +function freshRegistry(): SchemaRegistry { + const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' }); + (registry as any).logLevel = 'silent'; + return registry; +} + +/** `POST /api/v1/packages` with `manifest` as the body, exactly as the route reads it. */ +const install = (dispatcher: HttpDispatcher, body: unknown) => + dispatcher.handlePackages('', 'POST', body, {}, PKG_ADMIN()); + +/** Every assertion an error body must satisfy whatever produced it (mirrors the conformance suite). */ +function expectConformantError(response: { status: number; body?: any } | undefined) { + expect(response, 'the door produced no response').toBeTruthy(); + const body = response!.body; + expect(BaseResponseSchema.safeParse(body).success).toBe(true); + expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]); + expect(body.success).toBe(false); + const parsed = ApiErrorSchema.safeParse(body.error); + expect(parsed.error?.issues ?? []).toEqual([]); + return body.error as Record; +} + +describe('#14748 — the install-time namespace refusal carries its own code on the wire', () => { + it('section 1: a second package claiming an owned namespace answers 422 NAMESPACE_CONFLICT', async () => { + const registry = freshRegistry(); + const dispatcher = makeDoor(registry); + + const first = await install(dispatcher, manifest('com.acme.crm', 'crm')); + expect(first.handled).toBe(true); + expect(first.response?.status, 'the first install must SUCCEED, or the refusal below is vacuous').toBe(201); + + const refused = await install(dispatcher, manifest('com.beta.crm', 'crm')); + expect(refused.handled).toBe(true); + expect(refused.response?.status).toBe(422); + + const error = expectConformantError(refused.response); + + // ⭐ The one line this card exists for. + expect(error.code).toBe('NAMESPACE_CONFLICT'); + + // And the demote is GONE, not merely joined: with the code registered + // there is nothing left for `demotedDeclaredCode` to carry, so the + // sibling field is absent rather than duplicating `code`. + expect(error.declaredCode).toBeUndefined(); + + // The prose is unchanged by registration — this card added a ledger + // row, it did not rewrite the sentence an operator reads. + expect(error.message).toContain('Namespace conflict: namespace "crm"'); + }); + + it('section 2: the code is a member of the closed vocabulary, and the status cannot have invented it', () => { + // The registration itself, asserted against the union `ApiErrorSchema.code` + // parses with — this is what section 1 depends on. + expect(ErrorCode.safeParse('NAMESPACE_CONFLICT').success).toBe(true); + + // The control that makes section 1 discriminating: 422 does NOT derive + // this member, so a body carrying it proves the PRODUCER's code was + // carried through, never re-derived from the status. + expect(standardErrorCodeForHttpStatus(422)).not.toBe('NAMESPACE_CONFLICT'); + }); + + it('section 3: an UNREGISTERED spelling at the same door still demotes — the control', async () => { + // Without this, section 1 would also be satisfied by a door that + // carries every producer spelling verbatim, which is exactly the + // pre-#9106 behaviour the narrowing removed. The registry is doubled + // for this one case only: no shipped producer spells an unregistered + // code at this door — the dispatcher-vocabulary gate exists to keep it + // that way — so the limb has to be driven deliberately. + const conflict = Object.assign(new Error('a tenant refusal'), { + code: 'A_TENANT_SPELLING_NO_LEDGER_KNOWS', + status: 422, + }); + const dispatcher = makeDoor({ + installPackage: () => { throw conflict; }, + getPackage: () => undefined, + getAllPackages: () => [], + } as unknown as SchemaRegistry); + + const refused = await install(dispatcher, manifest('com.gamma.crm', 'crm')); + const error = expectConformantError(refused.response); + + expect(refused.response?.status).toBe(422); + expect(error.code).toBe(standardErrorCodeForHttpStatus(422)); + expect(error.code).not.toBe('A_TENANT_SPELLING_NO_LEDGER_KNOWS'); + expect(error.declaredCode).toBe('A_TENANT_SPELLING_NO_LEDGER_KNOWS'); + }); +}); diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index ef4c66ae91..579ecea1db 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -593,6 +593,35 @@ export const ERROR_CODE_LEDGER = { // decision. `MultiUpdateHookKeyDivergenceError`, // `multi-update-hook-key-divergence.ts`. 'MULTI_UPDATE_HOOK_KEY_DIVERGENCE', + // [#14748] the ADR-0048 Phase 1 install-time namespace gate's refusal: a + // package's `manifest.namespace` is already owned by an INSTALLED package + // that is not a co-owner of it (ADR-0130 D1), so the install is refused up + // front rather than allowed to half-apply and fail later at table + // creation. `NamespaceConflictError`, `registry.ts`. + // + // Registered because the refusal is WIRE-REACHABLE and its condition has a + // caller remedy no standard member carries. `POST /api/v1/packages` + // (`packages/runtime/src/domains/packages.ts`) calls `installPackage` with + // no artifact install SCOPE — which this gate, unlike the ADR-0130 D3 + // object-name one, does not need — so an ordinary one-package install + // reaches it, and the domain's terminal catch answers through + // `errorFromThrown`. #14474 gave the throw its ADR-0112 envelope (`code` + + // `status: 422`); until this row landed the door's #9106 narrowing demoted + // the spelling onto the open `declaredCode` sibling and put the closed + // member 422 derives (`VALIDATION_ERROR`) in `error.code`, so a caller + // wanting to tell "your namespace is taken, rename it" from every other 422 + // had to read the channel ADR-0112 declares as NOT guaranteed. + // + // Not a VALIDATION_ERROR synonym: the manifest parses and every field is + // well-formed — what is refused is the INSTALLATION-WIDE uniqueness of the + // namespace against packages already present, which the request body cannot + // express and the caller fixes by renaming or uninstalling, not by + // correcting a field. Nor a duplicate of `@objectstack/metadata-protocol`'s + // `NAMESPACE_PREFIX`: that one refuses a metadata NAME that does not carry + // its own package's declared prefix (`validateObjectNamespacePrefix`, a + // publish pre-flight); this one refuses the PREFIX itself, at install, as + // already owned by someone else. + 'NAMESPACE_CONFLICT', // [#11142/#11230] a by-id update carried an `options.where.id` that is not // the bound payload `data.id` — a truthy scalar naming a DIFFERENT row // (#11142), or a non-scalar predicate over a row SET (#11230, which also