Skip to content

Commit df657d9

Browse files
os-muskclaude
andauthored
fix(objectql): carry an ADR-0112 envelope on the install-time namespace conflict refusal (#14474) (#14738)
* wip: #14474 recovered from container restart Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * fix(runtime): keep the tracker id out of the vocabulary row's runtime string prose check:doc-authoring reds on an internal issue id inside sibling-package string prose: a runtime string reaches operators who cannot resolve one. The anchor moves to the adjacent // comment, where the reader who CAN resolve it looks. Same edit in the changeset, which compiles into release notes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 96b7723 commit df657d9

5 files changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
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)
7+
8+
`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.
9+
10+
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:
11+
12+
- before: `500` with `error.code: INTERNAL_ERROR`, carrying the refusal's prose
13+
- after: `422` with `error.code: VALIDATION_ERROR` and `error.declaredCode: NAMESPACE_CONFLICT`
14+
15+
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.
16+
17+
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.
18+
19+
`@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.

packages/objectql/src/registry-artifact-co-ownership.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,13 @@ describe('ADR-0130 D1 + D3 — the gate relaxation and the object-name check are
199199
expect(err.namespace).toBe('crm');
200200
expect(err.existingPackageId).toBe('com.acme.crm');
201201
expect(err.incomingPackageId).toBe('com.acme.crm.billing');
202+
// [#14474] The ADR-0112 envelope, asserted the same way this file already
203+
// asserts its D3 sibling's (`caught?.code` / `caught?.status` below). The
204+
// instance check above is NOT a substitute: it stayed green through every
205+
// year this class carried no `code` and no `status` at all, which is
206+
// precisely how the refusal reached `POST /api/v1/packages` as a 500.
207+
expect((refused as Envelope).code).toBe('NAMESPACE_CONFLICT');
208+
expect((refused as Envelope).status).toBe(422);
202209
// Nothing half-applied: the refused package is not recorded.
203210
expect(engineOf(kernel).registry.getPackage('com.acme.crm.billing')).toBeUndefined();
204211
});

packages/objectql/src/registry-namespace-install-gate.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,29 @@ describe('SchemaRegistry — namespace install gate (ADR-0048 Phase 1)', () => {
5656
expect(registry.getNamespaceOwners('crm')).toEqual(['com.acme.crm']);
5757
});
5858

59+
it('carries the ADR-0112 envelope: code NAMESPACE_CONFLICT + status 422', () => {
60+
// [#14474] The assertion the instance checks above cannot make, and the
61+
// reason this defect survived: `toThrowError(NamespaceConflictError)` and
62+
// `toBeInstanceOf(NamespaceConflictError)` are TRUE of a class carrying no
63+
// `code` and no `status`, so both stayed green while `POST /api/v1/packages`
64+
// answered this refusal as `500 INTERNAL_ERROR`. Measured on a booted stack
65+
// before the envelope landed; `422` with `declaredCode: NAMESPACE_CONFLICT`
66+
// after it. `resolveThrownHttpError` reads exactly these two fields off the
67+
// throw, so they are what the door's answer is MADE of — asserting the
68+
// class instead asserts something the wire never sees.
69+
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
70+
let caught: (Error & { code?: string; status?: number }) | undefined;
71+
try {
72+
registry.installPackage(manifest('com.beta.crm', 'crm') as any);
73+
} catch (e) { caught = e as Error & { code?: string; status?: number }; }
74+
75+
expect(caught?.code).toBe('NAMESPACE_CONFLICT');
76+
expect(caught?.status).toBe(422);
77+
// The prose is unchanged by the envelope — this card added fields, it did
78+
// not rewrite a sentence. Its first clause is what an operator reads.
79+
expect(caught?.message).toContain('Namespace conflict: namespace "crm"');
80+
});
81+
5982
it('allows the same package to reinstall/reload its own namespace', () => {
6083
registry.installPackage(manifest('com.acme.crm', 'crm') as any);
6184
expect(() =>

packages/objectql/src/registry.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1252,10 +1252,29 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest {
12521252
* install up front with an actionable error, instead of letting a half-applied
12531253
* install blow up later at table creation. Shareable platform namespaces
12541254
* (`base`/`system`/`sys`) are exempt.
1255+
*
1256+
* [#14474] Carries the ADR-0112 envelope (`code` + `status`), like its sibling
1257+
* {@link ArtifactObjectNameConflictError} below. Unlike that sibling, this
1258+
* refusal IS reachable from a wire: `POST /api/v1/packages`
1259+
* (`packages/runtime/src/domains/packages.ts`) calls `installPackage` with no
1260+
* artifact scope — which this gate, unlike the D3 object-name one, does not
1261+
* need — and the domain's terminal catch answers `errorFromThrown(e, 500)`.
1262+
* `resolveThrownHttpError` reads `.status`/`.code` off the throw, so with no
1263+
* envelope the door fell through to that `500` fallback. Measured on a booted
1264+
* stack before this change: `500 INTERNAL_ERROR` carrying this refusal's prose,
1265+
* which tells an operator "the server broke" when the truth is "your package's
1266+
* namespace is already taken" — it invites a retry instead of a rename. With
1267+
* the envelope the same door answers `422`. The message is unchanged: it was
1268+
* already correct and specific.
12551269
*/
12561270
export class NamespaceConflictError extends Error {
1271+
readonly code = 'NAMESPACE_CONFLICT';
1272+
readonly status = 422;
1273+
/** The namespace both packages claim. */
12571274
readonly namespace: string;
1275+
/** The installed package that already owns the namespace. */
12581276
readonly existingPackageId: string;
1277+
/** The package whose install this refusal stopped. */
12591278
readonly incomingPackageId: string;
12601279

12611280
constructor(namespace: string, existingPackageId: string, incomingPackageId: string) {

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,44 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
570570
'that a live wire code is outside the vocabulary; it does not prescribe the remedy.',
571571
},
572572

573+
// ── pending registration [#14474]: an install-time refusal that GAINED an
574+
// ── envelope, so the scan can see it for the first time ────────────────
575+
// Not a widened scan and not a new producer: `NamespaceConflictError` has
576+
// thrown from `SchemaRegistry.installPackage` since ADR-0048 Phase 1, but
577+
// it carried no `code` at all, so there was no stamp for any pattern to
578+
// match. #14474 gave it the ADR-0112 envelope its three install-time
579+
// siblings already carried, which is what put a site here to classify.
580+
// The door narrowing its `why` names is #9106's — the file header above
581+
// carries it. The anchor lives here rather than in the string, because a
582+
// runtime string reaches operators who cannot resolve a tracker id.
583+
{
584+
code: 'NAMESPACE_CONFLICT',
585+
file: 'packages/objectql/src/registry.ts',
586+
shape: 'classfield',
587+
door: 'dispatcher',
588+
verdict: 'pending-registration',
589+
why:
590+
'ADR-0048 Phase 1 — the install-time namespace gate\'s refusal, raised by ' +
591+
'`SchemaRegistry.installPackage` when a package\'s `manifest.namespace` is already owned by an ' +
592+
'installed package that is not a co-owner of it (ADR-0130 D1). ⭐ Its reachability is what ' +
593+
'separates it from the three ADR-0130 install-time rows below, whose `door: none` turns on ' +
594+
'needing an artifact install SCOPE that no HTTP caller builds: this gate needs no scope, so the ' +
595+
'ordinary one-package install reaches it. MEASURED on a booted stack (`@objectstack/verify` ' +
596+
'`bootStack`, dev admin, two `POST /api/v1/packages` installs declaring one namespace), not ' +
597+
'inferred from the call graph. Before the envelope the door answered `500` with ' +
598+
'`code: INTERNAL_ERROR` — `packages/runtime/src/domains/packages.ts` catches and calls ' +
599+
'`errorFromThrown(e, 500)`, and `resolveThrownHttpError` found neither `.status` nor `.code` to ' +
600+
'read, so the caller\'s fallback stood. With the envelope the SAME request answers `422` and ' +
601+
'the body carries `declaredCode: NAMESPACE_CONFLICT` beside `code: VALIDATION_ERROR` (the ' +
602+
'member 422 derives through `standardErrorCodeForHttpStatus`, which does not name 422 and ' +
603+
'buckets it as a client error). That demote is the door narrowing described in this file\'s ' +
604+
'header, and it is exactly what ' +
605+
'a `pending-registration` row records: the body PARSES, and what the producer loses instead is ' +
606+
'its semantic code, silently absent from `error.code` until a ledger row lands. ⛔ Registering ' +
607+
'it is the `packages/spec` lane\'s call and is NOT made here — this row is that batch\'s input, ' +
608+
'and registering the code is what ratchets the row out again.',
609+
},
610+
573611
// ── boot refusals: no HTTP boundary exists yet ─────────────────────────
574612
// [#9460] The four `MigrationJournalRefusal` codes below arrive through the
575613
// same code-carrying-helper shape as `owd_widening_forbidden` — a class

0 commit comments

Comments
 (0)