From 5ca78183503e186b61c7b5daa029a577ee52292a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 07:40:36 +0000 Subject: [PATCH] feat(objectql): publish the registry's three conflict error codes as importable constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `NamespaceConflictError`, `ArtifactObjectNameConflictError` and `ObjectOwnershipConflictError` each already tell the reader to identify them by `code` rather than `instanceof`, and offered nothing to import. Convert the three inline literals to exported `*_CODE` constants on the shape the six existing constants in this package already use, and re-export them from the package barrel. The strings are byte-identical to the literals they replace: this moves where a spelling lives, never what it says. The three rows in `packages/runtime/src/dispatcher-error-vocabulary.ts` move from `shape: 'classfield'` to `shape: 'classconst'` — measured, not assumed: `check:dispatcher-error-vocabulary` reconciles in both directions and went red with 3 stale-row plus 3 unclassified-site findings until the rows were updated. The site census is unchanged at 66/66 classified either side. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ --- .../registry-conflict-code-constants.md | 19 ++++ packages/objectql/src/index.ts | 18 +++ .../registry-conflict-code-constants.test.ts | 107 ++++++++++++++++++ packages/objectql/src/registry.ts | 56 ++++++++- .../src/dispatcher-error-vocabulary.ts | 18 ++- 5 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 .changeset/registry-conflict-code-constants.md create mode 100644 packages/objectql/src/registry-conflict-code-constants.test.ts diff --git a/.changeset/registry-conflict-code-constants.md b/.changeset/registry-conflict-code-constants.md new file mode 100644 index 0000000000..8b53f1bbd5 --- /dev/null +++ b/.changeset/registry-conflict-code-constants.md @@ -0,0 +1,19 @@ +--- +"@objectstack/objectql": minor +--- + +The registry's three conflict refusals now publish their error `code` as an importable constant. + +`SchemaRegistry`'s install-time and registration refusals each already told the reader, in their own docblocks, to identify them by `code` rather than `instanceof` — and offered nothing to import. `NAMESPACE_CONFLICT`, `DUPLICATE_ARTIFACT_OBJECT_NAME` and `OBJECT_OWNERSHIP_CONFLICT` were inline string literals, so the only way to follow that instruction was to re-spell the string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can then drift from what the engine throws with no compile error to say so. + +Three new exports from `@objectstack/objectql`: + +- `NAMESPACE_CONFLICT_CODE` — the ADR-0048 Phase 1 install-time namespace gate's refusal. +- `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` — the ADR-0130 D3 one-artifact object-name refusal. +- `OBJECT_OWNERSHIP_CONFLICT_CODE` — the ADR-0029 D3 single-owner-per-object-name refusal. + +**Why `code` and not `instanceof`.** This package declares both realms in its own `exports` (`import` reaches `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding the other realm's copy of a class gets `instanceof` === false — measured, and silent. A `code` compare is the check that survives crossing that boundary. + +**Nothing about the wire changed.** Each constant holds text byte-identical to the literal it replaces; the refusals throw the same `code`, the same `status: 422` and the same message as before. Existing consumers that spell the string themselves keep working unchanged — this adds an affordance, it removes nothing. + +**The error classes stay unexported, deliberately.** Publishing them would publish the `instanceof` route this convention exists to replace. diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index 6c11543865..d98189fbf5 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -66,6 +66,24 @@ export { } from './registry.js'; export type { InjectedColumnProvenance } from './registry.js'; +// [#16159] The three ADR-0112 `code` strings the registry's install-time and +// registration refusals carry, as constants a consumer can import instead of +// re-spelling. Exported for the reason #14936 established and measured: this +// package declares BOTH realms in its own `exports`, so a consumer holding +// the other realm's copy of a class gets `instanceof` === false, silently — +// a `code` compare is the only check that survives the split, and these are +// how a consumer performs it without authoring the string itself (and so +// without acquiring a `check:error-code-provenance` stamp site of its own). +// ⛔ The classes themselves stay unexported deliberately: exporting them +// would publish the `instanceof` route this convention exists to replace. +// See the shared docblock over the constants in `registry.ts` for the full +// reasoning and for why the `*_CODE` spelling is load-bearing. +export { + NAMESPACE_CONFLICT_CODE, + DUPLICATE_ARTIFACT_OBJECT_NAME_CODE, + OBJECT_OWNERSHIP_CONFLICT_CODE, +} from './registry.js'; + // [#14553] The navigation-contribution group diagnostic (ADR-0029 D7, // ADR-0112 D6c). Exported because `os build` is the SECOND door that has to // answer "does this group id resolve?" — over a composed artifact, at compile diff --git a/packages/objectql/src/registry-conflict-code-constants.test.ts b/packages/objectql/src/registry-conflict-code-constants.test.ts new file mode 100644 index 0000000000..f4c953b118 --- /dev/null +++ b/packages/objectql/src/registry-conflict-code-constants.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #16159 — the registry's three conflict refusals publish their ADR-0112 + * `code` as an importable constant. + * + * ## What this pins, and why each assertion is here + * + * `NamespaceConflictError`, `ArtifactObjectNameConflictError` and + * `ObjectOwnershipConflictError` each already told the reader, in their own + * docblocks, to identify them by `code`. Until this change the code was an + * inline string literal, so the only way to follow that instruction was to + * RE-SPELL it in the consumer's package — which acquires a + * `check:error-code-provenance` stamp site there and can drift from what this + * engine throws with no compile error to say so. + * + * Four facts, each its own case so a failure reads as the specific regression: + * + * 1. each constant holds the exact wire string. Spelled literally HERE on + * purpose: the test layer is outside `check:error-code-provenance`'s + * scanned population, so pinning it costs no stamp site while making a + * silent rename of a published code impossible to pass off as "still the + * same code". ⛔ This is the byte-identity fence the card asked for — the + * conversion moves where a spelling lives, never what it says. + * 2. the constant IS the code the thrown refusal carries, asserted together + * with `status`. ⛔ Never a bare `toThrow()`: #14367 measured on this very + * path that a throw-shaped assertion stayed GREEN with the install-time + * check one layer up ablated, because a second refusal fired one step + * later and was indistinguishable to `toThrow()`. + * 3. the constants are reachable from the package BARREL. This is the whole + * affordance the card buys — a constant a consumer cannot import is not + * an answer to "catch it by `code`" — and it is what a re-export deleted + * by a future barrel edit would lose silently. + * 4. a `code` compare matches a foreign-realm copy of the refusal where + * `instanceof` returns false. THE CONTROL, and the reason the convention + * exists (#14936): `@objectstack/objectql` declares both realms in its + * own `exports`, so a consumer holding the other realm's copy gets + * `instanceof` === false, silently. Without this case the others would + * pass just as happily against an `instanceof`-based recommendation. + */ + +import { describe, it, expect } from 'vitest'; +import { + NAMESPACE_CONFLICT_CODE, + DUPLICATE_ARTIFACT_OBJECT_NAME_CODE, + OBJECT_OWNERSHIP_CONFLICT_CODE, + NamespaceConflictError, + ArtifactObjectNameConflictError, + ObjectOwnershipConflictError, +} from './registry.js'; +import * as barrel from './index.js'; + +describe('#16159 the registry conflict codes are published constants', () => { + it('each constant holds the exact wire string it replaced', () => { + expect(NAMESPACE_CONFLICT_CODE).toBe('NAMESPACE_CONFLICT'); + expect(DUPLICATE_ARTIFACT_OBJECT_NAME_CODE).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME'); + expect(OBJECT_OWNERSHIP_CONFLICT_CODE).toBe('OBJECT_OWNERSHIP_CONFLICT'); + }); + + it('the constant IS the code the thrown namespace refusal carries, at its 422 status', () => { + const err = new NamespaceConflictError('crm', 'app.crm', 'app.other'); + expect(err.code).toBe(NAMESPACE_CONFLICT_CODE); + expect(err.status).toBe(422); + // ADR-0112 D5's spelling, which is what a consumer holding the THROWN + // error reads out of the CLI `--json` envelope. Asserting only `status` + // would not notice these two drifting apart. + expect(err.httpStatus).toBe(422); + }); + + it('the constant IS the code the thrown artifact object-name refusal carries, at its 422 status', () => { + const err = new ArtifactObjectNameConflictError('crm_account', 'app.crm', 'app.other'); + expect(err.code).toBe(DUPLICATE_ARTIFACT_OBJECT_NAME_CODE); + expect(err.status).toBe(422); + expect(err.httpStatus).toBe(422); + }); + + it('the constant IS the code the thrown ownership refusal carries, at its 422 status', () => { + const err = new ObjectOwnershipConflictError('crm_account', 'app.crm', 'app.other'); + expect(err.code).toBe(OBJECT_OWNERSHIP_CONFLICT_CODE); + expect(err.status).toBe(422); + expect(err.httpStatus).toBe(422); + }); + + it('all three are re-exported from the package barrel, which is where a consumer reaches them', () => { + // Identity, not equality: a barrel that re-declared the string instead of + // re-exporting the constant would satisfy `toBe` on the VALUE while having + // re-introduced exactly the second spelling this card exists to remove. + expect(barrel.NAMESPACE_CONFLICT_CODE).toBe(NAMESPACE_CONFLICT_CODE); + expect(barrel.DUPLICATE_ARTIFACT_OBJECT_NAME_CODE).toBe(DUPLICATE_ARTIFACT_OBJECT_NAME_CODE); + expect(barrel.OBJECT_OWNERSHIP_CONFLICT_CODE).toBe(OBJECT_OWNERSHIP_CONFLICT_CODE); + }); + + it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => { + // What a consumer holding the other realm's copy of this module actually + // has: a structurally identical refusal from a DIFFERENT class object. + class NamespaceConflictErrorOtherRealmCopy extends Error { + readonly code = 'NAMESPACE_CONFLICT'; + readonly status = 422; + } + const fromOtherRealm = new NamespaceConflictErrorOtherRealmCopy(); + + // THE CONTROL. Without this line the assertion below would pass against an + // `instanceof` recommendation too, i.e. against the defect. + expect(fromOtherRealm instanceof NamespaceConflictError).toBe(false); + expect(fromOtherRealm.code).toBe(NAMESPACE_CONFLICT_CODE); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index c5c0574678..47e39d4c7d 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1261,6 +1261,56 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest { return out as ObjectStackManifest; } +/** + * [#16159] The ADR-0112 `code` strings this file's three install-time and + * registration refusals carry, as constants a consumer can import. + * + * Every one of the three classes below already tells the reader, in its own + * docblock, that it "carries the ADR-0112 envelope (`code` + `status`)". That + * convention is sound and `instanceof` is not: `@objectstack/objectql` + * declares BOTH realms in its own `exports` (`import` reaches + * `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding + * the other realm's copy of a class gets `instanceof` === false — measured, + * and silent (#14936). A `code` compare is the check that survives crossing + * that boundary. Until now a consumer following the convention had to + * RE-SPELL the string itself, which acquires a `check:error-code-provenance` + * stamp site in the consumer's own package and can then drift from what this + * engine throws with no compile error to say so. + * + * ⛔ The strings are byte-identical to the literals they replace. This moves + * where a spelling lives, never what it says; renaming any of these codes is + * a separate breaking decision and never a rider on this conversion. + * + * The `*_CODE` NAME is load-bearing rather than cosmetic, in two gates at + * once: it is the shape `check:error-code-provenance`'s `constdef` pattern + * can see, and `readonly code = X_CODE;` is the shape + * `check:dispatcher-error-vocabulary` classifies as `classconst` (the three + * rows in `packages/runtime/src/dispatcher-error-vocabulary.ts` move from + * `classfield` to `classconst` with this change, and the scanner resolves the + * constant back to the same value). ⛔ Never rename out of that shape to + * quiet a gate: a spelling a gate cannot see is the failure mode the gate + * exists to catch, not a clean result. + * + * Shape and placement follow the six `*_CODE` constants already in this + * package (`DUPLICATE_RECORD_CODE`, `HOOK_TARGET_REBIND_ERROR_CODE`, + * `HOOK_UNSCOPED_DATA_ACCESS_CODE`, + * `MULTI_UPDATE_HOOK_KEY_DIVERGENCE_CODE`, `EMPTY_CREDENTIAL_REFUSAL_CODE`, + * `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`) — re-exported from the + * `index.ts` barrel and, like all six, deliberately NOT from the lean + * `core.ts` entry, which carries none of them. + * + * ⛔ Deliberately NOT a recognizer factory: #16156 measured that a + * `makeRecognizer(code)` signature still requires every call site to supply + * the code, which RELOCATES the literal rather than removing it. + */ +export const NAMESPACE_CONFLICT_CODE = 'NAMESPACE_CONFLICT' as const; + +/** {@link ArtifactObjectNameConflictError}'s code — ADR-0130 D3, one artifact. */ +export const DUPLICATE_ARTIFACT_OBJECT_NAME_CODE = 'DUPLICATE_ARTIFACT_OBJECT_NAME' as const; + +/** {@link ObjectOwnershipConflictError}'s code — ADR-0029 D3, single owner per name. */ +export const OBJECT_OWNERSHIP_CONFLICT_CODE = 'OBJECT_OWNERSHIP_CONFLICT' as const; + /** * Raised when a package is installed whose `manifest.namespace` is already owned * by a **different** installed package in this installation (ADR-0048 Phase 1). @@ -1289,7 +1339,7 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest { * already correct and specific. */ export class NamespaceConflictError extends Error { - readonly code = 'NAMESPACE_CONFLICT'; + readonly code = NAMESPACE_CONFLICT_CODE; readonly status = 422; /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ readonly httpStatus = 422; @@ -1403,7 +1453,7 @@ function declaredOwnedObjectNames(manifest: ObjectStackManifest): string[] { * repository's rejection tests assert against, never a bare throw. */ export class ArtifactObjectNameConflictError extends Error { - readonly code = 'DUPLICATE_ARTIFACT_OBJECT_NAME'; + readonly code = DUPLICATE_ARTIFACT_OBJECT_NAME_CODE; readonly status = 422; /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ readonly httpStatus = 422; @@ -1464,7 +1514,7 @@ export class ArtifactObjectNameConflictError extends Error { * nothing is refused there. */ export class ObjectOwnershipConflictError extends Error { - readonly code = 'OBJECT_OWNERSHIP_CONFLICT'; + readonly code = OBJECT_OWNERSHIP_CONFLICT_CODE; readonly status = 422; /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ readonly httpStatus = 422; diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index e6cc03efe5..7abeec943d 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -624,7 +624,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ { code: 'NAMESPACE_CONFLICT', file: 'packages/objectql/src/registry.ts', - shape: 'classfield', + // [#16159] `classconst`, not `classfield`, since the literal became the + // exported `NAMESPACE_CONFLICT_CODE` constant in the producer. The VALUE is + // byte-identical and the scanner resolves the constant back to it; only the + // spelling the scan matches on moved. The verdict below is untouched. + shape: 'classconst', door: 'dispatcher', verdict: 'pending-registration', why: @@ -884,7 +888,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ { code: 'DUPLICATE_ARTIFACT_OBJECT_NAME', file: 'packages/objectql/src/registry.ts', - shape: 'classfield', + // [#16159] `classconst`, not `classfield`, since the literal became the + // exported `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` constant in the producer. The VALUE is + // byte-identical and the scanner resolves the constant back to it; only the + // spelling the scan matches on moved. The verdict below is untouched. + shape: 'classconst', door: 'none', verdict: 'boot-refusal', why: @@ -912,7 +920,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ { code: 'OBJECT_OWNERSHIP_CONFLICT', file: 'packages/objectql/src/registry.ts', - shape: 'classfield', + // [#16159] `classconst`, not `classfield`, since the literal became the + // exported `OBJECT_OWNERSHIP_CONFLICT_CODE` constant in the producer. The VALUE is + // byte-identical and the scanner resolves the constant back to it; only the + // spelling the scan matches on moved. The verdict below is untouched. + shape: 'classconst', door: 'none', verdict: 'boot-refusal', why: