Skip to content

Commit 5ca7818

Browse files
committed
feat(objectql): publish the registry's three conflict error codes as importable constants
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
1 parent a3bbb8b commit 5ca7818

5 files changed

Lines changed: 212 additions & 6 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": minor
3+
---
4+
5+
The registry's three conflict refusals now publish their error `code` as an importable constant.
6+
7+
`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.
8+
9+
Three new exports from `@objectstack/objectql`:
10+
11+
- `NAMESPACE_CONFLICT_CODE` — the ADR-0048 Phase 1 install-time namespace gate's refusal.
12+
- `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` — the ADR-0130 D3 one-artifact object-name refusal.
13+
- `OBJECT_OWNERSHIP_CONFLICT_CODE` — the ADR-0029 D3 single-owner-per-object-name refusal.
14+
15+
**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.
16+
17+
**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.
18+
19+
**The error classes stay unexported, deliberately.** Publishing them would publish the `instanceof` route this convention exists to replace.

packages/objectql/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,24 @@ export {
6666
} from './registry.js';
6767
export type { InjectedColumnProvenance } from './registry.js';
6868

69+
// [#16159] The three ADR-0112 `code` strings the registry's install-time and
70+
// registration refusals carry, as constants a consumer can import instead of
71+
// re-spelling. Exported for the reason #14936 established and measured: this
72+
// package declares BOTH realms in its own `exports`, so a consumer holding
73+
// the other realm's copy of a class gets `instanceof` === false, silently —
74+
// a `code` compare is the only check that survives the split, and these are
75+
// how a consumer performs it without authoring the string itself (and so
76+
// without acquiring a `check:error-code-provenance` stamp site of its own).
77+
// ⛔ The classes themselves stay unexported deliberately: exporting them
78+
// would publish the `instanceof` route this convention exists to replace.
79+
// See the shared docblock over the constants in `registry.ts` for the full
80+
// reasoning and for why the `*_CODE` spelling is load-bearing.
81+
export {
82+
NAMESPACE_CONFLICT_CODE,
83+
DUPLICATE_ARTIFACT_OBJECT_NAME_CODE,
84+
OBJECT_OWNERSHIP_CONFLICT_CODE,
85+
} from './registry.js';
86+
6987
// [#14553] The navigation-contribution group diagnostic (ADR-0029 D7,
7088
// ADR-0112 D6c). Exported because `os build` is the SECOND door that has to
7189
// answer "does this group id resolve?" — over a composed artifact, at compile
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #16159 — the registry's three conflict refusals publish their ADR-0112
5+
* `code` as an importable constant.
6+
*
7+
* ## What this pins, and why each assertion is here
8+
*
9+
* `NamespaceConflictError`, `ArtifactObjectNameConflictError` and
10+
* `ObjectOwnershipConflictError` each already told the reader, in their own
11+
* docblocks, to identify them by `code`. Until this change the code was an
12+
* inline string literal, so the only way to follow that instruction was to
13+
* RE-SPELL it in the consumer's package — which acquires a
14+
* `check:error-code-provenance` stamp site there and can drift from what this
15+
* engine throws with no compile error to say so.
16+
*
17+
* Four facts, each its own case so a failure reads as the specific regression:
18+
*
19+
* 1. each constant holds the exact wire string. Spelled literally HERE on
20+
* purpose: the test layer is outside `check:error-code-provenance`'s
21+
* scanned population, so pinning it costs no stamp site while making a
22+
* silent rename of a published code impossible to pass off as "still the
23+
* same code". ⛔ This is the byte-identity fence the card asked for — the
24+
* conversion moves where a spelling lives, never what it says.
25+
* 2. the constant IS the code the thrown refusal carries, asserted together
26+
* with `status`. ⛔ Never a bare `toThrow()`: #14367 measured on this very
27+
* path that a throw-shaped assertion stayed GREEN with the install-time
28+
* check one layer up ablated, because a second refusal fired one step
29+
* later and was indistinguishable to `toThrow()`.
30+
* 3. the constants are reachable from the package BARREL. This is the whole
31+
* affordance the card buys — a constant a consumer cannot import is not
32+
* an answer to "catch it by `code`" — and it is what a re-export deleted
33+
* by a future barrel edit would lose silently.
34+
* 4. a `code` compare matches a foreign-realm copy of the refusal where
35+
* `instanceof` returns false. THE CONTROL, and the reason the convention
36+
* exists (#14936): `@objectstack/objectql` declares both realms in its
37+
* own `exports`, so a consumer holding the other realm's copy gets
38+
* `instanceof` === false, silently. Without this case the others would
39+
* pass just as happily against an `instanceof`-based recommendation.
40+
*/
41+
42+
import { describe, it, expect } from 'vitest';
43+
import {
44+
NAMESPACE_CONFLICT_CODE,
45+
DUPLICATE_ARTIFACT_OBJECT_NAME_CODE,
46+
OBJECT_OWNERSHIP_CONFLICT_CODE,
47+
NamespaceConflictError,
48+
ArtifactObjectNameConflictError,
49+
ObjectOwnershipConflictError,
50+
} from './registry.js';
51+
import * as barrel from './index.js';
52+
53+
describe('#16159 the registry conflict codes are published constants', () => {
54+
it('each constant holds the exact wire string it replaced', () => {
55+
expect(NAMESPACE_CONFLICT_CODE).toBe('NAMESPACE_CONFLICT');
56+
expect(DUPLICATE_ARTIFACT_OBJECT_NAME_CODE).toBe('DUPLICATE_ARTIFACT_OBJECT_NAME');
57+
expect(OBJECT_OWNERSHIP_CONFLICT_CODE).toBe('OBJECT_OWNERSHIP_CONFLICT');
58+
});
59+
60+
it('the constant IS the code the thrown namespace refusal carries, at its 422 status', () => {
61+
const err = new NamespaceConflictError('crm', 'app.crm', 'app.other');
62+
expect(err.code).toBe(NAMESPACE_CONFLICT_CODE);
63+
expect(err.status).toBe(422);
64+
// ADR-0112 D5's spelling, which is what a consumer holding the THROWN
65+
// error reads out of the CLI `--json` envelope. Asserting only `status`
66+
// would not notice these two drifting apart.
67+
expect(err.httpStatus).toBe(422);
68+
});
69+
70+
it('the constant IS the code the thrown artifact object-name refusal carries, at its 422 status', () => {
71+
const err = new ArtifactObjectNameConflictError('crm_account', 'app.crm', 'app.other');
72+
expect(err.code).toBe(DUPLICATE_ARTIFACT_OBJECT_NAME_CODE);
73+
expect(err.status).toBe(422);
74+
expect(err.httpStatus).toBe(422);
75+
});
76+
77+
it('the constant IS the code the thrown ownership refusal carries, at its 422 status', () => {
78+
const err = new ObjectOwnershipConflictError('crm_account', 'app.crm', 'app.other');
79+
expect(err.code).toBe(OBJECT_OWNERSHIP_CONFLICT_CODE);
80+
expect(err.status).toBe(422);
81+
expect(err.httpStatus).toBe(422);
82+
});
83+
84+
it('all three are re-exported from the package barrel, which is where a consumer reaches them', () => {
85+
// Identity, not equality: a barrel that re-declared the string instead of
86+
// re-exporting the constant would satisfy `toBe` on the VALUE while having
87+
// re-introduced exactly the second spelling this card exists to remove.
88+
expect(barrel.NAMESPACE_CONFLICT_CODE).toBe(NAMESPACE_CONFLICT_CODE);
89+
expect(barrel.DUPLICATE_ARTIFACT_OBJECT_NAME_CODE).toBe(DUPLICATE_ARTIFACT_OBJECT_NAME_CODE);
90+
expect(barrel.OBJECT_OWNERSHIP_CONFLICT_CODE).toBe(OBJECT_OWNERSHIP_CONFLICT_CODE);
91+
});
92+
93+
it("a `code` compare matches the OTHER realm's copy — the exact case `instanceof` gets wrong", () => {
94+
// What a consumer holding the other realm's copy of this module actually
95+
// has: a structurally identical refusal from a DIFFERENT class object.
96+
class NamespaceConflictErrorOtherRealmCopy extends Error {
97+
readonly code = 'NAMESPACE_CONFLICT';
98+
readonly status = 422;
99+
}
100+
const fromOtherRealm = new NamespaceConflictErrorOtherRealmCopy();
101+
102+
// THE CONTROL. Without this line the assertion below would pass against an
103+
// `instanceof` recommendation too, i.e. against the defect.
104+
expect(fromOtherRealm instanceof NamespaceConflictError).toBe(false);
105+
expect(fromOtherRealm.code).toBe(NAMESPACE_CONFLICT_CODE);
106+
});
107+
});

packages/objectql/src/registry.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,56 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest {
12611261
return out as ObjectStackManifest;
12621262
}
12631263

1264+
/**
1265+
* [#16159] The ADR-0112 `code` strings this file's three install-time and
1266+
* registration refusals carry, as constants a consumer can import.
1267+
*
1268+
* Every one of the three classes below already tells the reader, in its own
1269+
* docblock, that it "carries the ADR-0112 envelope (`code` + `status`)". That
1270+
* convention is sound and `instanceof` is not: `@objectstack/objectql`
1271+
* declares BOTH realms in its own `exports` (`import` reaches
1272+
* `dist/index.mjs`, `require` reaches `dist/index.js`), so a consumer holding
1273+
* the other realm's copy of a class gets `instanceof` === false — measured,
1274+
* and silent (#14936). A `code` compare is the check that survives crossing
1275+
* that boundary. Until now a consumer following the convention had to
1276+
* RE-SPELL the string itself, which acquires a `check:error-code-provenance`
1277+
* stamp site in the consumer's own package and can then drift from what this
1278+
* engine throws with no compile error to say so.
1279+
*
1280+
* ⛔ The strings are byte-identical to the literals they replace. This moves
1281+
* where a spelling lives, never what it says; renaming any of these codes is
1282+
* a separate breaking decision and never a rider on this conversion.
1283+
*
1284+
* The `*_CODE` NAME is load-bearing rather than cosmetic, in two gates at
1285+
* once: it is the shape `check:error-code-provenance`'s `constdef` pattern
1286+
* can see, and `readonly code = X_CODE;` is the shape
1287+
* `check:dispatcher-error-vocabulary` classifies as `classconst` (the three
1288+
* rows in `packages/runtime/src/dispatcher-error-vocabulary.ts` move from
1289+
* `classfield` to `classconst` with this change, and the scanner resolves the
1290+
* constant back to the same value). ⛔ Never rename out of that shape to
1291+
* quiet a gate: a spelling a gate cannot see is the failure mode the gate
1292+
* exists to catch, not a clean result.
1293+
*
1294+
* Shape and placement follow the six `*_CODE` constants already in this
1295+
* package (`DUPLICATE_RECORD_CODE`, `HOOK_TARGET_REBIND_ERROR_CODE`,
1296+
* `HOOK_UNSCOPED_DATA_ACCESS_CODE`,
1297+
* `MULTI_UPDATE_HOOK_KEY_DIVERGENCE_CODE`, `EMPTY_CREDENTIAL_REFUSAL_CODE`,
1298+
* `SYSTEM_WRITE_ORGANIZATION_REQUIRED_CODE`) — re-exported from the
1299+
* `index.ts` barrel and, like all six, deliberately NOT from the lean
1300+
* `core.ts` entry, which carries none of them.
1301+
*
1302+
* ⛔ Deliberately NOT a recognizer factory: #16156 measured that a
1303+
* `makeRecognizer(code)` signature still requires every call site to supply
1304+
* the code, which RELOCATES the literal rather than removing it.
1305+
*/
1306+
export const NAMESPACE_CONFLICT_CODE = 'NAMESPACE_CONFLICT' as const;
1307+
1308+
/** {@link ArtifactObjectNameConflictError}'s code — ADR-0130 D3, one artifact. */
1309+
export const DUPLICATE_ARTIFACT_OBJECT_NAME_CODE = 'DUPLICATE_ARTIFACT_OBJECT_NAME' as const;
1310+
1311+
/** {@link ObjectOwnershipConflictError}'s code — ADR-0029 D3, single owner per name. */
1312+
export const OBJECT_OWNERSHIP_CONFLICT_CODE = 'OBJECT_OWNERSHIP_CONFLICT' as const;
1313+
12641314
/**
12651315
* Raised when a package is installed whose `manifest.namespace` is already owned
12661316
* by a **different** installed package in this installation (ADR-0048 Phase 1).
@@ -1289,7 +1339,7 @@ function toRecordManifest(manifest: ObjectStackManifest): ObjectStackManifest {
12891339
* already correct and specific.
12901340
*/
12911341
export class NamespaceConflictError extends Error {
1292-
readonly code = 'NAMESPACE_CONFLICT';
1342+
readonly code = NAMESPACE_CONFLICT_CODE;
12931343
readonly status = 422;
12941344
/** 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. */
12951345
readonly httpStatus = 422;
@@ -1403,7 +1453,7 @@ function declaredOwnedObjectNames(manifest: ObjectStackManifest): string[] {
14031453
* repository's rejection tests assert against, never a bare throw.
14041454
*/
14051455
export class ArtifactObjectNameConflictError extends Error {
1406-
readonly code = 'DUPLICATE_ARTIFACT_OBJECT_NAME';
1456+
readonly code = DUPLICATE_ARTIFACT_OBJECT_NAME_CODE;
14071457
readonly status = 422;
14081458
/** 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. */
14091459
readonly httpStatus = 422;
@@ -1464,7 +1514,7 @@ export class ArtifactObjectNameConflictError extends Error {
14641514
* nothing is refused there.
14651515
*/
14661516
export class ObjectOwnershipConflictError extends Error {
1467-
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
1517+
readonly code = OBJECT_OWNERSHIP_CONFLICT_CODE;
14681518
readonly status = 422;
14691519
/** 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. */
14701520
readonly httpStatus = 422;

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

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -624,7 +624,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
624624
{
625625
code: 'NAMESPACE_CONFLICT',
626626
file: 'packages/objectql/src/registry.ts',
627-
shape: 'classfield',
627+
// [#16159] `classconst`, not `classfield`, since the literal became the
628+
// exported `NAMESPACE_CONFLICT_CODE` constant in the producer. The VALUE is
629+
// byte-identical and the scanner resolves the constant back to it; only the
630+
// spelling the scan matches on moved. The verdict below is untouched.
631+
shape: 'classconst',
628632
door: 'dispatcher',
629633
verdict: 'pending-registration',
630634
why:
@@ -884,7 +888,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
884888
{
885889
code: 'DUPLICATE_ARTIFACT_OBJECT_NAME',
886890
file: 'packages/objectql/src/registry.ts',
887-
shape: 'classfield',
891+
// [#16159] `classconst`, not `classfield`, since the literal became the
892+
// exported `DUPLICATE_ARTIFACT_OBJECT_NAME_CODE` constant in the producer. The VALUE is
893+
// byte-identical and the scanner resolves the constant back to it; only the
894+
// spelling the scan matches on moved. The verdict below is untouched.
895+
shape: 'classconst',
888896
door: 'none',
889897
verdict: 'boot-refusal',
890898
why:
@@ -912,7 +920,11 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
912920
{
913921
code: 'OBJECT_OWNERSHIP_CONFLICT',
914922
file: 'packages/objectql/src/registry.ts',
915-
shape: 'classfield',
923+
// [#16159] `classconst`, not `classfield`, since the literal became the
924+
// exported `OBJECT_OWNERSHIP_CONFLICT_CODE` constant in the producer. The VALUE is
925+
// byte-identical and the scanner resolves the constant back to it; only the
926+
// spelling the scan matches on moved. The verdict below is untouched.
927+
shape: 'classconst',
916928
door: 'none',
917929
verdict: 'boot-refusal',
918930
why:

0 commit comments

Comments
 (0)