From eeee851e26a776bd90f6e280b3dc9c60bcc28ccd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:18:22 +0000 Subject: [PATCH 1/2] wip: #14474 recovered from container restart Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- ...try-namespace-conflict-refusal-envelope.md | 19 +++++++++++ .../registry-artifact-co-ownership.test.ts | 7 ++++ .../registry-namespace-install-gate.test.ts | 23 +++++++++++++ packages/objectql/src/registry.ts | 19 +++++++++++ .../src/dispatcher-error-vocabulary.ts | 34 +++++++++++++++++++ 5 files changed, 102 insertions(+) create mode 100644 .changeset/registry-namespace-conflict-refusal-envelope.md diff --git a/.changeset/registry-namespace-conflict-refusal-envelope.md b/.changeset/registry-namespace-conflict-refusal-envelope.md new file mode 100644 index 0000000000..519cbdd91f --- /dev/null +++ b/.changeset/registry-namespace-conflict-refusal-envelope.md @@ -0,0 +1,19 @@ +--- +"@objectstack/objectql": patch +"@objectstack/runtime": patch +--- + +fix(objectql): the ADR-0048 install-time namespace gate's refusal carries an ADR-0112 envelope, so `POST /packages` answers 422 instead of 500 (#14474) + +`NamespaceConflictError` — 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) — carried `namespace` / `existingPackageId` / `incomingPackageId` but no `code` and no `status`. It now carries `code: 'NAMESPACE_CONFLICT'` and `status: 422`, the same three-field envelope shape as its sibling `ArtifactObjectNameConflictError` in the same file. The message text is byte-for-byte unchanged: the prose was already correct and specific, and this change adds fields rather than rewriting a sentence. + +Why it matters, measured rather than read: unlike its three install-time siblings, this refusal is reachable from a wire. `POST /api/v1/packages` calls `installPackage` with no artifact scope — which this gate, unlike the ADR-0130 D3 object-name one, does not need — and the domain's terminal catch answers `errorFromThrown(e, 500)`. `resolveThrownHttpError` reads `.status` / `.code` off the throw and falls to the caller's fallback when it finds neither. Observed on a booted stack, two installs declaring one namespace: + +- before: `500` with `error.code: INTERNAL_ERROR`, carrying the refusal's prose +- after: `422` with `error.code: VALIDATION_ERROR` and `error.declaredCode: NAMESPACE_CONFLICT` + +A refusal the platform decided is a client-side conflict was telling operators the server had broken, which invites a retry instead of a rename. + +Not narrowed, not widened: no accept-set changes, no export changes, and no ledger registration. `NAMESPACE_CONFLICT` is not an `ErrorCode` member, so the door's #9106 narrowing demotes it off `error.code` onto the wire's open `declaredCode` sibling and `error.code` stays the closed member 422 derives. + +`@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `pending-registration`, door `dispatcher` — the measured verdict, not the expected one). That row is the input to a ledger-registration batch in the `packages/spec` lane; registering the code is what ratchets the row back out and what would let `error.code` carry the semantic spelling. diff --git a/packages/objectql/src/registry-artifact-co-ownership.test.ts b/packages/objectql/src/registry-artifact-co-ownership.test.ts index 4e989f08f1..d05f5007d9 100644 --- a/packages/objectql/src/registry-artifact-co-ownership.test.ts +++ b/packages/objectql/src/registry-artifact-co-ownership.test.ts @@ -199,6 +199,13 @@ describe('ADR-0130 D1 + D3 — the gate relaxation and the object-name check are expect(err.namespace).toBe('crm'); expect(err.existingPackageId).toBe('com.acme.crm'); expect(err.incomingPackageId).toBe('com.acme.crm.billing'); + // [#14474] The ADR-0112 envelope, asserted the same way this file already + // asserts its D3 sibling's (`caught?.code` / `caught?.status` below). The + // instance check above is NOT a substitute: it stayed green through every + // year this class carried no `code` and no `status` at all, which is + // precisely how the refusal reached `POST /api/v1/packages` as a 500. + expect((refused as Envelope).code).toBe('NAMESPACE_CONFLICT'); + expect((refused as Envelope).status).toBe(422); // Nothing half-applied: the refused package is not recorded. expect(engineOf(kernel).registry.getPackage('com.acme.crm.billing')).toBeUndefined(); }); diff --git a/packages/objectql/src/registry-namespace-install-gate.test.ts b/packages/objectql/src/registry-namespace-install-gate.test.ts index 59b7a4693f..fe36a63340 100644 --- a/packages/objectql/src/registry-namespace-install-gate.test.ts +++ b/packages/objectql/src/registry-namespace-install-gate.test.ts @@ -56,6 +56,29 @@ describe('SchemaRegistry — namespace install gate (ADR-0048 Phase 1)', () => { expect(registry.getNamespaceOwners('crm')).toEqual(['com.acme.crm']); }); + it('carries the ADR-0112 envelope: code NAMESPACE_CONFLICT + status 422', () => { + // [#14474] The assertion the instance checks above cannot make, and the + // reason this defect survived: `toThrowError(NamespaceConflictError)` and + // `toBeInstanceOf(NamespaceConflictError)` are TRUE of a class carrying no + // `code` and no `status`, so both stayed green while `POST /api/v1/packages` + // answered this refusal as `500 INTERNAL_ERROR`. Measured on a booted stack + // before the envelope landed; `422` with `declaredCode: NAMESPACE_CONFLICT` + // after it. `resolveThrownHttpError` reads exactly these two fields off the + // throw, so they are what the door's answer is MADE of — asserting the + // class instead asserts something the wire never sees. + registry.installPackage(manifest('com.acme.crm', 'crm') as any); + let caught: (Error & { code?: string; status?: number }) | undefined; + try { + registry.installPackage(manifest('com.beta.crm', 'crm') as any); + } catch (e) { caught = e as Error & { code?: string; status?: number }; } + + expect(caught?.code).toBe('NAMESPACE_CONFLICT'); + expect(caught?.status).toBe(422); + // The prose is unchanged by the envelope — this card added fields, it did + // not rewrite a sentence. Its first clause is what an operator reads. + expect(caught?.message).toContain('Namespace conflict: namespace "crm"'); + }); + it('allows the same package to reinstall/reload its own namespace', () => { registry.installPackage(manifest('com.acme.crm', 'crm') as any); expect(() => diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 462992fe38..6f4c2b2f08 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1252,10 +1252,29 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest { * install up front with an actionable error, instead of letting a half-applied * install blow up later at table creation. Shareable platform namespaces * (`base`/`system`/`sys`) are exempt. + * + * [#14474] Carries the ADR-0112 envelope (`code` + `status`), like its sibling + * {@link ArtifactObjectNameConflictError} below. Unlike that sibling, this + * refusal IS reachable from a wire: `POST /api/v1/packages` + * (`packages/runtime/src/domains/packages.ts`) calls `installPackage` with no + * artifact scope — which this gate, unlike the D3 object-name one, does not + * need — and the domain's terminal catch answers `errorFromThrown(e, 500)`. + * `resolveThrownHttpError` reads `.status`/`.code` off the throw, so with no + * envelope the door fell through to that `500` fallback. Measured on a booted + * stack before this change: `500 INTERNAL_ERROR` carrying this refusal's prose, + * which tells an operator "the server broke" when the truth is "your package's + * namespace is already taken" — it invites a retry instead of a rename. With + * the envelope the same door answers `422`. The message is unchanged: it was + * already correct and specific. */ export class NamespaceConflictError extends Error { + readonly code = 'NAMESPACE_CONFLICT'; + readonly status = 422; + /** The namespace both packages claim. */ readonly namespace: string; + /** The installed package that already owns the namespace. */ readonly existingPackageId: string; + /** The package whose install this refusal stopped. */ readonly incomingPackageId: string; constructor(namespace: string, existingPackageId: string, incomingPackageId: string) { diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 0cf58df0e9..68411c0dbd 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -570,6 +570,40 @@ 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. + { + code: 'NAMESPACE_CONFLICT', + file: 'packages/objectql/src/registry.ts', + shape: 'classfield', + 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 #9106 door narrowing, 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.', + }, + // ── boot refusals: no HTTP boundary exists yet ───────────────────────── // [#9460] The four `MigrationJournalRefusal` codes below arrive through the // same code-carrying-helper shape as `owd_widening_forbidden` — a class From 966dd3897eef76d5f3cf911bc94b4fa0dd50470d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 22:47:39 +0000 Subject: [PATCH 2/2] fix(runtime): keep the tracker id out of the vocabulary row's runtime string prose check:doc-authoring reds on an internal issue id inside sibling-package string prose: a runtime string reaches operators who cannot resolve one. The anchor moves to the adjacent // comment, where the reader who CAN resolve it looks. Same edit in the changeset, which compiles into release notes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --- .changeset/registry-namespace-conflict-refusal-envelope.md | 2 +- packages/runtime/src/dispatcher-error-vocabulary.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.changeset/registry-namespace-conflict-refusal-envelope.md b/.changeset/registry-namespace-conflict-refusal-envelope.md index 519cbdd91f..05d92f8674 100644 --- a/.changeset/registry-namespace-conflict-refusal-envelope.md +++ b/.changeset/registry-namespace-conflict-refusal-envelope.md @@ -14,6 +14,6 @@ Why it matters, measured rather than read: unlike its three install-time sibling A refusal the platform decided is a client-side conflict was telling operators the server had broken, which invites a retry instead of a rename. -Not narrowed, not widened: no accept-set changes, no export changes, and no ledger registration. `NAMESPACE_CONFLICT` is not an `ErrorCode` member, so the door's #9106 narrowing demotes it off `error.code` onto the wire's open `declaredCode` sibling and `error.code` stays the closed member 422 derives. +Not narrowed, not widened: no accept-set changes, no export changes, and no ledger registration. `NAMESPACE_CONFLICT` is not an `ErrorCode` member, so the door's narrowing demotes it off `error.code` onto the wire's open `declaredCode` sibling and `error.code` stays the closed member 422 derives. `@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `pending-registration`, door `dispatcher` — the measured verdict, not the expected one). That row is the input to a ledger-registration batch in the `packages/spec` lane; registering the code is what ratchets the row back out and what would let `error.code` carry the semantic spelling. diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 68411c0dbd..3690a65db6 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -577,6 +577,9 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ // 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', @@ -597,7 +600,8 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ '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 #9106 door narrowing, and it is exactly what ' + + '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, ' +