Skip to content

Commit 60ed718

Browse files
huangyiireneclaude
andauthored
feat(spec): register NAMESPACE_CONFLICT in ERROR_CODE_LEDGER (#16252)
* feat(spec): register NAMESPACE_CONFLICT in ERROR_CODE_LEDGER `POST /api/v1/packages` now answers an install-time namespace collision with `error.code: "NAMESPACE_CONFLICT"` instead of the generic member 422 derives. The refusal has carried the ADR-0112 envelope (`code` + `status: 422`) since the engine half landed, but the code was not an `ErrorCode` member, so the dispatcher door's narrowing demoted the spelling onto the open `declaredCode` sibling and put `VALIDATION_ERROR` in the closed slot. 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. Registered under `@objectstack/objectql`, the package whose `SchemaRegistry.installPackage` stamps it (the only stamp site in `packages/**` non-test source, so the provenance gate is satisfied by the owner key alone). The now-discharged `pending-registration` row ratchets out of `packages/runtime/src/dispatcher-error-vocabulary.ts` in the same commit: registration is what makes that row stale, and both the gate (`check:dispatcher-error-vocabulary`) and the conformance suite's ratchet case red on a registered code that still carries one, so the two halves cannot be split across PRs. `packages/runtime/src/package-door-namespace-conflict-code.test.ts` drives the real route over a real `SchemaRegistry` and asserts the body, so the reachability the removed row asserted without pinning is now held by a test. Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(docs): regenerate api references on the merged tree The os-regen driver deferred content/docs/references/api/{contract,error-code-ledger}.mdx during the merge of origin/main. Regenerated from the merged spec source, so the vocabulary count reflects the UNION of both sides rather than either side's textual +296. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b9ac29 commit 60ed718

6 files changed

Lines changed: 235 additions & 43 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/runtime": minor
4+
---
5+
6+
`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.
7+
8+
**The wire, before and after** — measured through the shipped door (`HttpDispatcher.handlePackages` over a real `SchemaRegistry`), not derived from the call graph:
9+
10+
- before: `422` with `error.code: "VALIDATION_ERROR"` and `error.declaredCode: "NAMESPACE_CONFLICT"`
11+
- after: `422` with `error.code: "NAMESPACE_CONFLICT"` and **no** `declaredCode` — with the spelling registered there is nothing left to demote
12+
13+
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.
14+
15+
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`.
16+
17+
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.

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **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) |
30+
| **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) |
3131
| **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) |
3232
| **message** | `string` || Readable error message |
3333
| **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);
224224
* `METADATA_NOT_FOUND`
225225
* `METADATA_SCHEMA_INVALID`
226226
* `MULTI_UPDATE_HOOK_KEY_DIVERGENCE`
227+
* `NAMESPACE_CONFLICT`
227228
* `NAMESPACE_PREFIX`
228229
* `NEEDS_PASSWORD`
229230
* `NODE_FAILURE`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ const result = ErrorCode.parse(data);
340340
* `METADATA_NOT_FOUND`
341341
* `METADATA_SCHEMA_INVALID`
342342
* `MULTI_UPDATE_HOOK_KEY_DIVERGENCE`
343+
* `NAMESPACE_CONFLICT`
343344
* `NAMESPACE_PREFIX`
344345
* `NEEDS_PASSWORD`
345346
* `NODE_FAILURE`

packages/runtime/src/dispatcher-error-vocabulary.ts

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -611,48 +611,6 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
611611
'that a live wire code is outside the vocabulary; it does not prescribe the remedy.',
612612
},
613613

614-
// ── pending registration [#14474]: an install-time refusal that GAINED an
615-
// ── envelope, so the scan can see it for the first time ────────────────
616-
// Not a widened scan and not a new producer: `NamespaceConflictError` has
617-
// thrown from `SchemaRegistry.installPackage` since ADR-0048 Phase 1, but
618-
// it carried no `code` at all, so there was no stamp for any pattern to
619-
// match. #14474 gave it the ADR-0112 envelope its three install-time
620-
// siblings already carried, which is what put a site here to classify.
621-
// The door narrowing its `why` names is #9106's — the file header above
622-
// carries it. The anchor lives here rather than in the string, because a
623-
// runtime string reaches operators who cannot resolve a tracker id.
624-
{
625-
code: 'NAMESPACE_CONFLICT',
626-
file: 'packages/objectql/src/registry.ts',
627-
// [#16159] `classconst`, not `classfield`, since the literal became the
628-
// exported `NAMESPACE_CONFLICT_CODE` constant in the producer. The VALUE is
629-
// byte-identical and the scanner resolves the constant back to it; only the
630-
// spelling the scan matches on moved. The verdict below is untouched.
631-
shape: 'classconst',
632-
door: 'dispatcher',
633-
verdict: 'pending-registration',
634-
why:
635-
'ADR-0048 Phase 1 — the install-time namespace gate\'s refusal, raised by ' +
636-
'`SchemaRegistry.installPackage` when a package\'s `manifest.namespace` is already owned by an ' +
637-
'installed package that is not a co-owner of it (ADR-0130 D1). ⭐ Its reachability is what ' +
638-
'separates it from the three ADR-0130 install-time rows below, whose `door: none` turns on ' +
639-
'needing an artifact install SCOPE that no HTTP caller builds: this gate needs no scope, so the ' +
640-
'ordinary one-package install reaches it. MEASURED on a booted stack (`@objectstack/verify` ' +
641-
'`bootStack`, dev admin, two `POST /api/v1/packages` installs declaring one namespace), not ' +
642-
'inferred from the call graph. Before the envelope the door answered `500` with ' +
643-
'`code: INTERNAL_ERROR` — `packages/runtime/src/domains/packages.ts` catches and calls ' +
644-
'`errorFromThrown(e, 500)`, and `resolveThrownHttpError` found neither `.status` nor `.code` to ' +
645-
'read, so the caller\'s fallback stood. With the envelope the SAME request answers `422` and ' +
646-
'the body carries `declaredCode: NAMESPACE_CONFLICT` beside `code: VALIDATION_ERROR` (the ' +
647-
'member 422 derives through `standardErrorCodeForHttpStatus`, which does not name 422 and ' +
648-
'buckets it as a client error). That demote is the door narrowing described in this file\'s ' +
649-
'header, and it is exactly what ' +
650-
'a `pending-registration` row records: the body PARSES, and what the producer loses instead is ' +
651-
'its semantic code, silently absent from `error.code` until a ledger row lands. ⛔ Registering ' +
652-
'it is the `packages/spec` lane\'s call and is NOT made here — this row is that batch\'s input, ' +
653-
'and registering the code is what ratchets the row out again.',
654-
},
655-
656614
// ── pending registration [#14921]: a metadata-tree refusal that reaches a
657615
// ── dispatcher-door read ───────────────────────────────────────────────
658616
// Not a widened scan and not a demotion: this producer is NEW. #14921 made
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#14748] `POST /api/v1/packages` answers a namespace collision with
5+
* `error.code: NAMESPACE_CONFLICT` — the wire half of registering the code in
6+
* `ERROR_CODE_LEDGER`.
7+
*
8+
* ## What changed, and why a pin belongs here rather than beside the throw
9+
*
10+
* `NamespaceConflictError` (`packages/objectql/src/registry.ts`) has carried
11+
* the ADR-0112 envelope (`code` + `status: 422`) since #14474, and
12+
* `packages/objectql/src/registry-namespace-install-gate.test.ts` asserts both
13+
* fields ON THE THROW. That is a different claim from this one. Until the
14+
* ledger row landed, `NAMESPACE_CONFLICT` was not an `ErrorCode` member, so the
15+
* #9106 door narrowing DEMOTED the spelling onto the wire's open `declaredCode`
16+
* sibling and put `VALIDATION_ERROR` — the member 422 derives through
17+
* `standardErrorCodeForHttpStatus` — in the closed `error.code` slot:
18+
*
19+
* before: {"code":"VALIDATION_ERROR", "declaredCode":"NAMESPACE_CONFLICT", …}
20+
* after: {"code":"NAMESPACE_CONFLICT", …} ← `declaredCode` gone: nothing to demote
21+
*
22+
* So the throw's envelope and the wire's envelope were two different bodies,
23+
* and a suite asserting the first could not see the second. Registration is
24+
* what closes that gap, and the gap is invisible to every existing suite. This
25+
* file is the assertion that it stays closed.
26+
*
27+
* ⭐ It is also the permanent answer to the reachability claim the removed
28+
* `pending-registration` row asserted without pinning — the row's `door:
29+
* 'dispatcher'` verdict rested on a temporary probe test that was never in the
30+
* tree (#14745 residue 2). This suite makes the same claim by DRIVING the door.
31+
*
32+
* ## What is real here and what is doubled
33+
*
34+
* Real: `HttpDispatcher.handlePackages` (the shipped route), the terminal
35+
* `catch` that answers `errorFromThrown(e, 500)`, `resolveThrownHttpError`'s
36+
* registered/demoted decision, `buildApiError`, and the actual `SchemaRegistry`
37+
* — so the refusal under test is the one production raises, from the same
38+
* `installPackage` call, not a hand-built stand-in carrying the same fields.
39+
*
40+
* Doubled: only the kernel's service lookup, which hands the door that
41+
* registry. There is no protocol service, so the install takes the documented
42+
* fallback limb (`registry.installPackage(manifest, settings)`) — the same
43+
* primitive the protocol limb calls underneath.
44+
*
45+
* ## Reverse verification — direction predicted BEFORE running
46+
*
47+
* Removing `'NAMESPACE_CONFLICT'` from the `@objectstack/objectql` list in
48+
* `packages/spec/src/api/error-code-ledger.zod.ts` and rebuilding was predicted
49+
* to turn section 1 RED (`error.code` back to `VALIDATION_ERROR`, and
50+
* `declaredCode` reappearing) and section 2's registration assertion RED, while
51+
* leaving section 3 — an unregistered spelling at the same door — GREEN in both
52+
* directions, since nothing about that limb depends on this row. The measured
53+
* result is recorded in the PR body.
54+
*
55+
* ⛔ Never a bare `toThrow()` here: the door does not throw, it ANSWERS, and
56+
* the whole subject is what the answer carries.
57+
*/
58+
59+
import { describe, it, expect } from 'vitest';
60+
import {
61+
ApiErrorSchema,
62+
BaseResponseSchema,
63+
ErrorCode,
64+
envelopeViolations,
65+
standardErrorCodeForHttpStatus,
66+
} from '@objectstack/spec/api';
67+
import { SchemaRegistry } from '@objectstack/objectql';
68+
import { HttpDispatcher } from './http-dispatcher.js';
69+
70+
/**
71+
* [#7033 / #7023] `/packages` carries an anonymous-deny floor and every
72+
* state-changing route demands `manage_metadata`. Without a caller these cases
73+
* would stop at the 401 long before the install gate they are named after.
74+
*/
75+
const PKG_ADMIN = () => ({
76+
request: {},
77+
executionContext: {
78+
userId: 'u_pkg_admin',
79+
systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'],
80+
},
81+
}) as any;
82+
83+
const manifest = (id: string, namespace: string) => ({ id, name: id, namespace, version: '1.0.0' });
84+
85+
/**
86+
* The door over a REAL registry. `collisionPolicy: 'error'` is the default
87+
* posture the gate refuses under; `OS_METADATA_COLLISION=warn` downgrades it,
88+
* which is `registry-namespace-install-gate.test.ts`'s territory, not this
89+
* file's.
90+
*/
91+
function makeDoor(registry: SchemaRegistry) {
92+
const kernel: any = {
93+
getService: (name: string) =>
94+
name === 'objectql' ? Promise.resolve({ registry }) : null,
95+
context: { getService: () => null },
96+
};
97+
return new HttpDispatcher(kernel);
98+
}
99+
100+
function freshRegistry(): SchemaRegistry {
101+
const registry = new SchemaRegistry({ multiTenant: false, collisionPolicy: 'error' });
102+
(registry as any).logLevel = 'silent';
103+
return registry;
104+
}
105+
106+
/** `POST /api/v1/packages` with `manifest` as the body, exactly as the route reads it. */
107+
const install = (dispatcher: HttpDispatcher, body: unknown) =>
108+
dispatcher.handlePackages('', 'POST', body, {}, PKG_ADMIN());
109+
110+
/** Every assertion an error body must satisfy whatever produced it (mirrors the conformance suite). */
111+
function expectConformantError(response: { status: number; body?: any } | undefined) {
112+
expect(response, 'the door produced no response').toBeTruthy();
113+
const body = response!.body;
114+
expect(BaseResponseSchema.safeParse(body).success).toBe(true);
115+
expect(envelopeViolations(body), `not the declared envelope: ${JSON.stringify(body)}`).toEqual([]);
116+
expect(body.success).toBe(false);
117+
const parsed = ApiErrorSchema.safeParse(body.error);
118+
expect(parsed.error?.issues ?? []).toEqual([]);
119+
return body.error as Record<string, unknown>;
120+
}
121+
122+
describe('#14748 — the install-time namespace refusal carries its own code on the wire', () => {
123+
it('section 1: a second package claiming an owned namespace answers 422 NAMESPACE_CONFLICT', async () => {
124+
const registry = freshRegistry();
125+
const dispatcher = makeDoor(registry);
126+
127+
const first = await install(dispatcher, manifest('com.acme.crm', 'crm'));
128+
expect(first.handled).toBe(true);
129+
expect(first.response?.status, 'the first install must SUCCEED, or the refusal below is vacuous').toBe(201);
130+
131+
const refused = await install(dispatcher, manifest('com.beta.crm', 'crm'));
132+
expect(refused.handled).toBe(true);
133+
expect(refused.response?.status).toBe(422);
134+
135+
const error = expectConformantError(refused.response);
136+
137+
// ⭐ The one line this card exists for.
138+
expect(error.code).toBe('NAMESPACE_CONFLICT');
139+
140+
// And the demote is GONE, not merely joined: with the code registered
141+
// there is nothing left for `demotedDeclaredCode` to carry, so the
142+
// sibling field is absent rather than duplicating `code`.
143+
expect(error.declaredCode).toBeUndefined();
144+
145+
// The prose is unchanged by registration — this card added a ledger
146+
// row, it did not rewrite the sentence an operator reads.
147+
expect(error.message).toContain('Namespace conflict: namespace "crm"');
148+
});
149+
150+
it('section 2: the code is a member of the closed vocabulary, and the status cannot have invented it', () => {
151+
// The registration itself, asserted against the union `ApiErrorSchema.code`
152+
// parses with — this is what section 1 depends on.
153+
expect(ErrorCode.safeParse('NAMESPACE_CONFLICT').success).toBe(true);
154+
155+
// The control that makes section 1 discriminating: 422 does NOT derive
156+
// this member, so a body carrying it proves the PRODUCER's code was
157+
// carried through, never re-derived from the status.
158+
expect(standardErrorCodeForHttpStatus(422)).not.toBe('NAMESPACE_CONFLICT');
159+
});
160+
161+
it('section 3: an UNREGISTERED spelling at the same door still demotes — the control', async () => {
162+
// Without this, section 1 would also be satisfied by a door that
163+
// carries every producer spelling verbatim, which is exactly the
164+
// pre-#9106 behaviour the narrowing removed. The registry is doubled
165+
// for this one case only: no shipped producer spells an unregistered
166+
// code at this door — the dispatcher-vocabulary gate exists to keep it
167+
// that way — so the limb has to be driven deliberately.
168+
const conflict = Object.assign(new Error('a tenant refusal'), {
169+
code: 'A_TENANT_SPELLING_NO_LEDGER_KNOWS',
170+
status: 422,
171+
});
172+
const dispatcher = makeDoor({
173+
installPackage: () => { throw conflict; },
174+
getPackage: () => undefined,
175+
getAllPackages: () => [],
176+
} as unknown as SchemaRegistry);
177+
178+
const refused = await install(dispatcher, manifest('com.gamma.crm', 'crm'));
179+
const error = expectConformantError(refused.response);
180+
181+
expect(refused.response?.status).toBe(422);
182+
expect(error.code).toBe(standardErrorCodeForHttpStatus(422));
183+
expect(error.code).not.toBe('A_TENANT_SPELLING_NO_LEDGER_KNOWS');
184+
expect(error.declaredCode).toBe('A_TENANT_SPELLING_NO_LEDGER_KNOWS');
185+
});
186+
});

packages/spec/src/api/error-code-ledger.zod.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,35 @@ export const ERROR_CODE_LEDGER = {
593593
// decision. `MultiUpdateHookKeyDivergenceError`,
594594
// `multi-update-hook-key-divergence.ts`.
595595
'MULTI_UPDATE_HOOK_KEY_DIVERGENCE',
596+
// [#14748] the ADR-0048 Phase 1 install-time namespace gate's refusal: a
597+
// package's `manifest.namespace` is already owned by an INSTALLED package
598+
// that is not a co-owner of it (ADR-0130 D1), so the install is refused up
599+
// front rather than allowed to half-apply and fail later at table
600+
// creation. `NamespaceConflictError`, `registry.ts`.
601+
//
602+
// Registered because the refusal is WIRE-REACHABLE and its condition has a
603+
// caller remedy no standard member carries. `POST /api/v1/packages`
604+
// (`packages/runtime/src/domains/packages.ts`) calls `installPackage` with
605+
// no artifact install SCOPE — which this gate, unlike the ADR-0130 D3
606+
// object-name one, does not need — so an ordinary one-package install
607+
// reaches it, and the domain's terminal catch answers through
608+
// `errorFromThrown`. #14474 gave the throw its ADR-0112 envelope (`code` +
609+
// `status: 422`); until this row landed the door's #9106 narrowing demoted
610+
// the spelling onto the open `declaredCode` sibling and put the closed
611+
// member 422 derives (`VALIDATION_ERROR`) in `error.code`, so a caller
612+
// wanting to tell "your namespace is taken, rename it" from every other 422
613+
// had to read the channel ADR-0112 declares as NOT guaranteed.
614+
//
615+
// Not a VALIDATION_ERROR synonym: the manifest parses and every field is
616+
// well-formed — what is refused is the INSTALLATION-WIDE uniqueness of the
617+
// namespace against packages already present, which the request body cannot
618+
// express and the caller fixes by renaming or uninstalling, not by
619+
// correcting a field. Nor a duplicate of `@objectstack/metadata-protocol`'s
620+
// `NAMESPACE_PREFIX`: that one refuses a metadata NAME that does not carry
621+
// its own package's declared prefix (`validateObjectNamespacePrefix`, a
622+
// publish pre-flight); this one refuses the PREFIX itself, at install, as
623+
// already owned by someone else.
624+
'NAMESPACE_CONFLICT',
596625
// [#11142/#11230] a by-id update carried an `options.where.id` that is not
597626
// the bound payload `data.id` — a truthy scalar naming a DIFFERENT row
598627
// (#11142), or a non-scalar predicate over a row SET (#11230, which also

0 commit comments

Comments
 (0)