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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/register-namespace-conflict-error-code.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/references/api/contract.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/error-code-ledger.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
42 changes: 0 additions & 42 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
186 changes: 186 additions & 0 deletions packages/runtime/src/package-door-namespace-conflict-code.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

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');
});
});
29 changes: 29 additions & 0 deletions packages/spec/src/api/error-code-ledger.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading