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
19 changes: 19 additions & 0 deletions .changeset/registry-namespace-conflict-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
7 changes: 7 additions & 0 deletions packages/objectql/src/registry-artifact-co-ownership.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(() =>
Expand Down
19 changes: 19 additions & 0 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -570,6 +570,44 @@ 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',
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 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.',
},

// ── 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
19 changes: 19 additions & 0 deletions .changeset/registry-namespace-conflict-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
7 changes: 7 additions & 0 deletions packages/objectql/src/registry-artifact-co-ownership.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(() =>
Expand Down
19 changes: 19 additions & 0 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -570,6 +570,44 @@ 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',
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 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.',
},

// ── 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
19 changes: 19 additions & 0 deletions .changeset/registry-namespace-conflict-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
7 changes: 7 additions & 0 deletions packages/objectql/src/registry-artifact-co-ownership.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(() =>
Expand Down
19 changes: 19 additions & 0 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -570,6 +570,44 @@ 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',
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 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.',
},

// ── 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
19 changes: 19 additions & 0 deletions .changeset/registry-namespace-conflict-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
7 changes: 7 additions & 0 deletions packages/objectql/src/registry-artifact-co-ownership.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(() =>
Expand Down
19 changes: 19 additions & 0 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -570,6 +570,44 @@ 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',
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 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.',
},

// ── 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
19 changes: 19 additions & 0 deletions .changeset/registry-namespace-conflict-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
7 changes: 7 additions & 0 deletions packages/objectql/src/registry-artifact-co-ownership.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(() =>
Expand Down
19 changes: 19 additions & 0 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -570,6 +570,44 @@ 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',
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 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.',
},

// ── 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
19 changes: 19 additions & 0 deletions .changeset/registry-namespace-conflict-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
7 changes: 7 additions & 0 deletions packages/objectql/src/registry-artifact-co-ownership.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(() =>
Expand Down
19 changes: 19 additions & 0 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -570,6 +570,44 @@ 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',
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 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.',
},

// ── 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
19 changes: 19 additions & 0 deletions .changeset/registry-namespace-conflict-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
7 changes: 7 additions & 0 deletions packages/objectql/src/registry-artifact-co-ownership.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(() =>
Expand Down
19 changes: 19 additions & 0 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -570,6 +570,44 @@ 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',
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 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.',
},

// ── 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
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
19 changes: 19 additions & 0 deletions .changeset/registry-namespace-conflict-refusal-envelope.md
Original file line numberDiff line numberDiff line change
@@ -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 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.
7 changes: 7 additions & 0 deletions packages/objectql/src/registry-artifact-co-ownership.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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();
});
Expand Down
23 changes: 23 additions & 0 deletions packages/objectql/src/registry-namespace-install-gate.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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(() =>
Expand Down
19 changes: 19 additions & 0 deletions packages/objectql/src/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -570,6 +570,44 @@ 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',
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 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.',
},

// ── 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
Expand Down
Loading