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-conflict-code-constants.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions packages/objectql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
107 changes: 107 additions & 0 deletions packages/objectql/src/registry-conflict-code-constants.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 53 additions & 3 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 15 additions & 3 deletions packages/runtime/src/dispatcher-error-vocabulary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading