Skip to content

Commit 9c7d9d4

Browse files
os-muskclaude
andauthored
fix(objectql): carry an ADR-0112 envelope on registerObject's cross-package ownership refusal (#14367) (#14476)
* fix(objectql): carry an ADR-0112 envelope on registerObject's cross-package ownership refusal The ADR-0029 D3 refusal (a package claiming `own` on an object name another package already owns) threw a bare `Error`. It is now `ObjectOwnershipConflictError` — `code: 'OBJECT_OWNERSHIP_CONFLICT'`, `status: 422`, the two package ids and the object name as fields — with the message text byte-for-byte unchanged. The dispatcher error-code vocabulary gains its classification row (boot-refusal, door none, measured), the existing subject tests assert the envelope instead of a bare throw, and a new pin covers the class, the message fence and the D9 §6.1 late-install branch that must stay a non-refusal. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 * chore(runtime): keep the tracker id out of the vocabulary row's prose (check:doc-authoring) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 76820fd commit 9c7d9d4

6 files changed

Lines changed: 298 additions & 10 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(objectql): `SchemaRegistry.registerObject`'s cross-package ownership refusal carries an ADR-0112 envelope (#14367)
7+
8+
The ADR-0029 D3 refusal — a package claiming `own` on an object name a DIFFERENT package already owns — was a bare `Error`: no `code`, no `status`. It is now `ObjectOwnershipConflictError` with `code: 'OBJECT_OWNERSHIP_CONFLICT'` and `status: 422`, plus `objectName` / `existingPackageId` / `incomingPackageId` as fields, the same shape as the sibling `ArtifactObjectNameConflictError`. The message text is byte-for-byte unchanged, so every message-substring assertion and every forwarder that interpolates it (`console.warn`, the per-record `errors` count) reads what it read before.
9+
10+
Why it matters: a rejection test on this path could only ever be a bare `toThrow()`, and a throw-shaped assertion stays green against an unrelated `Error` from anywhere on the path — measured when the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check was ablated and its "refused" assertion stayed green because this refusal fired one step later. Rejection tests can now assert `code` + `status` on this path, and the existing sites that asserted only the message do.
11+
12+
Not narrowed, not widened: no accept-set changes. The ADR-0029 D9 §6.1 late-install branch (a tenant-authored sitting owner is re-classified as the code package's overlay layer) is not a refusal and is unchanged.
13+
14+
`@objectstack/runtime` carries the classification row for the new code in the dispatcher error-code vocabulary (verdict `boot-refusal`, door `none`: measured on this tree, every path to the refusal either aborts boot inside plugin init or catches below any HTTP door, and the two HTTP install sites never call `registerObject`).

‎packages/objectql/src/metadata-facade.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,16 @@ describe('MetadataFacade object write/read round-trip', () => {
200200

201201
// ADR-0029 — one owner per object. The contributor write runs first
202202
// precisely so the refusal leaves the generic map untouched too.
203+
// ADR-0112 envelope — `code` + `status`, never a bare `toThrow()`
204+
// (#14367); the message assertion stays beside it, since the text is
205+
// the contract the forwarders interpolate.
203206
await expect(
204207
facade.register('object', 'task', { ...taskDefinition(), _packageId: 'com.example.other' }),
205-
).rejects.toThrow(/already owned by package "com.example.owner"/);
208+
).rejects.toMatchObject({
209+
code: 'OBJECT_OWNERSHIP_CONFLICT',
210+
status: 422,
211+
message: expect.stringMatching(/already owned by package "com.example.owner"/),
212+
});
206213

207214
expect((registry as any).metadata.get('object')?.size ?? 0).toBe(0);
208215
expect(((await facade.getObject('task')) as any).label).toBe('Owned');

‎packages/objectql/src/registry-object-overlay-layer.test.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@ const fieldNames = (r: SchemaRegistry, name: string) =>
7575
const kinds = (r: SchemaRegistry, name: string) =>
7676
r.getObjectContributors(name).map((c) => c.ownership);
7777

78+
/**
79+
* What a synchronous registration REFUSED with, or `undefined` when it did not
80+
* refuse. The refusal assertions below read the ADR-0112 envelope off it
81+
* (`code` + `status`), never a bare `toThrow()`: a throw-shaped assertion is
82+
* satisfied by any `Error` from anywhere on the path, which is exactly how an
83+
* ablated sibling check stayed green (#14367).
84+
*/
85+
const refusalOf = (run: () => unknown): (Error & { code?: unknown; status?: unknown }) | undefined => {
86+
try {
87+
run();
88+
return undefined;
89+
} catch (e) {
90+
return e as Error & { code?: unknown; status?: unknown };
91+
}
92+
};
93+
7894
/** The registry as a package boot leaves it, plus the tenant's layer. */
7995
function overlaidRegistry(name = 'myapp_invoice', binding: string = APP_PKG) {
8096
const r = silent();
@@ -290,8 +306,10 @@ describe('ADR-0029 D9.5 — the single-owner assertion, unchanged, plus one clas
290306

291307
it('a second cross-package OWNER is still refused at registration', () => {
292308
const r = overlaidRegistry();
293-
expect(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG))
294-
.toThrow(/already owned by package "app\.myapp"/);
309+
const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));
310+
// ADR-0112 envelope — `code` + `status`, never a bare `toThrow()` (#14367).
311+
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
312+
expect(refused?.message).toMatch(/already owned by package "app\.myapp"/);
295313
});
296314

297315
/**
@@ -450,8 +468,10 @@ describe('ADR-0029 D9 §6.1 — LATE INSTALL: the code layer takes ownership', (
450468
it('does NOT re-classify a packaged owner — a second code package is still refused', () => {
451469
const r = silent();
452470
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);
453-
expect(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG))
454-
.toThrow(/already owned by package "app\.myapp"/);
471+
const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));
472+
// ADR-0112 envelope — `code` + `status`, never a bare `toThrow()` (#14367).
473+
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
474+
expect(refused?.message).toMatch(/already owned by package "app\.myapp"/);
455475
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
456476
});
457477

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #14367 — `SchemaRegistry.registerObject`'s cross-package ownership refusal
5+
* carries an ADR-0112 envelope.
6+
*
7+
* ## What this pins, and why an envelope rather than a throw
8+
*
9+
* The refusal (ADR-0029 D3, single owner per object name) used to be a bare
10+
* `Error`. Measured while reverse-verifying the install-time
11+
* `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up (#14163): with that
12+
* check ablated, `expect(refused).toBeDefined()` STAYED GREEN, because this
13+
* refusal fired one step later and looked, to a throw-shaped assertion,
14+
* exactly like the check that had just been deleted. Only the envelope
15+
* assertion (`code` + `status`) went red. So every rejection test on this
16+
* path could only be a bare `toThrow()` — precisely the assertion ADR-0112
17+
* and ADR-0130 D3 rule out by name.
18+
*
19+
* Three facts, each its own case so a failure reads as the specific
20+
* regression:
21+
*
22+
* 1. the refusal is `ObjectOwnershipConflictError` with `code` +
23+
* `status: 422` (and the two package ids + the object name as fields);
24+
* 2. the message text is byte-for-byte what the bare `Error` carried —
25+
* the fence that keeps every message-substring assertion and every
26+
* `console.warn` forwarder unchanged;
27+
* 3. the ADR-0029 D9 §6.1 late-install branch beside it (a TENANT-authored
28+
* sitting owner) is NOT a refusal and does not throw this class — or
29+
* anything.
30+
*/
31+
32+
import { describe, it, expect } from 'vitest';
33+
import { ObjectOwnershipConflictError, SchemaRegistry } from './registry.js';
34+
35+
const APP_PKG = 'app.myapp';
36+
const OTHER_PKG = 'app.otherapp';
37+
38+
const packagedBody = (name: string) => ({
39+
name,
40+
label: 'Invoice',
41+
fields: {
42+
name: { name: 'name', type: 'text', label: 'Name' },
43+
packaged_only: { name: 'packaged_only', type: 'text', label: 'Packaged only' },
44+
},
45+
});
46+
47+
const silent = () => {
48+
const r = new SchemaRegistry({ multiTenant: false });
49+
r.logLevel = 'silent';
50+
return r;
51+
};
52+
53+
const kinds = (r: SchemaRegistry, name: string) =>
54+
r.getObjectContributors(name).map((c) => c.ownership);
55+
56+
/** What a synchronous registration REFUSED with, or `undefined` when it did not refuse. */
57+
const refusalOf = (run: () => unknown): unknown => {
58+
try {
59+
run();
60+
return undefined;
61+
} catch (e) {
62+
return e;
63+
}
64+
};
65+
66+
/**
67+
* The text the bare `Error` carried, spelled out in full rather than matched
68+
* by substring: a substring match would stay green through a rewording that
69+
* still contained the fragment, and the whole point of the fence is that the
70+
* forwarders' `console.warn` lines and the existing regex assertions read the
71+
* SAME bytes as before.
72+
*/
73+
const LEGACY_MESSAGE =
74+
'Object "myapp_invoice" is already owned by package "app.myapp". ' +
75+
"Package \"app.otherapp\" cannot claim ownership. Use 'extend' to add fields.";
76+
77+
describe('#14367 — the cross-package ownership refusal is an ADR-0112 envelope', () => {
78+
it('refuses a second code package with `ObjectOwnershipConflictError`: code + status 422, both packages and the object named', () => {
79+
const r = silent();
80+
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);
81+
82+
const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));
83+
84+
expect(refused).toBeInstanceOf(ObjectOwnershipConflictError);
85+
// The envelope — never a bare `toThrow()`.
86+
expect(refused).toMatchObject({ code: 'OBJECT_OWNERSHIP_CONFLICT', status: 422 });
87+
const err = refused as ObjectOwnershipConflictError;
88+
expect(err.name).toBe('ObjectOwnershipConflictError');
89+
expect(err.objectName).toBe('myapp_invoice');
90+
expect(err.existingPackageId).toBe(APP_PKG);
91+
expect(err.incomingPackageId).toBe(OTHER_PKG);
92+
// Nothing half-applied: the sitting owner is untouched.
93+
expect(kinds(r, 'myapp_invoice')).toEqual(['own']);
94+
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
95+
});
96+
97+
it('keeps the message text byte-for-byte — the fence every substring assertion and forwarder relies on', () => {
98+
const r = silent();
99+
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);
100+
101+
const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, OTHER_PKG));
102+
103+
expect((refused as Error).message).toBe(LEGACY_MESSAGE);
104+
// …and the existing sites' regex still matches it, which is the same fact
105+
// from the other side.
106+
expect((refused as Error).message).toMatch(/already owned by package "app\.myapp"/);
107+
});
108+
109+
it('the class is constructible on its own with the same envelope and the same text', () => {
110+
// Pinned directly so a change to the constructor's message template is a
111+
// change to THIS line, not only to whatever registry path happens to
112+
// exercise it.
113+
const err = new ObjectOwnershipConflictError('myapp_invoice', APP_PKG, OTHER_PKG);
114+
expect(err).toBeInstanceOf(Error);
115+
expect(err.code).toBe('OBJECT_OWNERSHIP_CONFLICT');
116+
expect(err.status).toBe(422);
117+
expect(err.message).toBe(LEGACY_MESSAGE);
118+
});
119+
120+
/**
121+
* THE FENCE (ADR-0029 D9 §6.1). A code package registering an object a
122+
* TENANT row already holds is a late install, not a refusal: the code layer
123+
* takes ownership and the tenant contribution becomes its overlay. Out of
124+
* scope for the envelope by ruling, and pinned here so the envelope cannot
125+
* creep onto it: the branch throws nothing at all.
126+
*/
127+
it('does NOT refuse the D9 §6.1 late install — a tenant-authored sitting owner is re-classified, nothing is thrown', () => {
128+
const r = silent();
129+
r.registerObject(
130+
{ ...packagedBody('myapp_invoice'), _provenance: 'org' } as any,
131+
'sys_metadata',
132+
);
133+
134+
const refused = refusalOf(() => r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG));
135+
136+
expect(refused).toBeUndefined();
137+
expect(refused).not.toBeInstanceOf(ObjectOwnershipConflictError);
138+
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'overlay']);
139+
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
140+
});
141+
142+
/** The remedy the message prescribes is not a claim: `extend` from another package is accepted. */
143+
it("accepts the message's own remedy — an `extend` from the other package is not an ownership claim", () => {
144+
const r = silent();
145+
r.registerObject(packagedBody('myapp_invoice') as any, APP_PKG);
146+
147+
const refused = refusalOf(() =>
148+
r.registerObject(
149+
{ name: 'myapp_invoice', fields: { ext_field: { name: 'ext_field', type: 'text' } } } as any,
150+
OTHER_PKG, undefined, 'extend',
151+
),
152+
);
153+
154+
expect(refused).toBeUndefined();
155+
expect(kinds(r, 'myapp_invoice')).toEqual(['own', 'extend']);
156+
expect(r.getObjectOwner('myapp_invoice')?.packageId).toBe(APP_PKG);
157+
});
158+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,6 +1271,61 @@ export class ArtifactObjectNameConflictError extends Error {
12711271
}
12721272
}
12731273

1274+
/**
1275+
* [ADR-0029 D3] The cross-package ownership refusal: a package claims `own`
1276+
* on an object name that a DIFFERENT package already owns. Raised by
1277+
* {@link SchemaRegistry.registerObject} — the single-owner-per-object-name
1278+
* invariant, enforced at the one choke point every registration path goes
1279+
* through (the manifest load path via `ObjectQL.registerApp`, the metadata
1280+
* bridge, the `sys_metadata` hydration seams). The remedy the message names is
1281+
* the supported one: `extend` merges fields into the owner's definition instead
1282+
* of claiming a second owner.
1283+
*
1284+
* Carries the ADR-0112 envelope (`code` + `status`) — the shape this
1285+
* repository's rejection tests assert against, never a bare throw. Before it
1286+
* carried one, this refusal was a bare `Error`, and a throw-shaped assertion
1287+
* on the install-time `DUPLICATE_ARTIFACT_OBJECT_NAME` check one layer up
1288+
* stayed green with that check ablated: this refusal fired one step later and
1289+
* was indistinguishable to `toThrow()` (#14367). Only an envelope assertion
1290+
* can tell the two refusals apart, and only if both carry one.
1291+
*
1292+
* The message text is byte-for-byte what the bare `Error` carried, so every
1293+
* message-substring assertion and every forwarder that interpolates it into a
1294+
* `console.warn` or a per-record `errors` count reads exactly what it read
1295+
* before. The two package ids are typed as the contributor stores them
1296+
* (`ObjectContributor.packageId` is `string | undefined`, #12623) rather than
1297+
* narrowed, so the message stays identical on a package-less call too.
1298+
*
1299+
* ⛔ Not the ADR-0029 D9 §6.1 late-install branch beside it: a TENANT-authored
1300+
* sitting owner is re-classified as the code package's overlay layer, and
1301+
* nothing is refused there.
1302+
*/
1303+
export class ObjectOwnershipConflictError extends Error {
1304+
readonly code = 'OBJECT_OWNERSHIP_CONFLICT';
1305+
readonly status = 422;
1306+
/** The fully-qualified object name both packages claim. */
1307+
readonly objectName: string;
1308+
/** The package that already owns the name. */
1309+
readonly existingPackageId: string | undefined;
1310+
/** The package whose `own` claim this refusal stopped. */
1311+
readonly incomingPackageId: string | undefined;
1312+
1313+
constructor(
1314+
objectName: string,
1315+
existingPackageId: string | undefined,
1316+
incomingPackageId: string | undefined,
1317+
) {
1318+
super(
1319+
`Object "${objectName}" is already owned by package "${existingPackageId}". ` +
1320+
`Package "${incomingPackageId}" cannot claim ownership. Use 'extend' to add fields.`
1321+
);
1322+
this.name = 'ObjectOwnershipConflictError';
1323+
this.objectName = objectName;
1324+
this.existingPackageId = existingPackageId;
1325+
this.incomingPackageId = incomingPackageId;
1326+
}
1327+
}
1328+
12741329
// [#10062] `isTenantAuthored` and `isCodeArtifactBody` used to be defined here.
12751330
// They now live in `@objectstack/metadata-core`
12761331
// (`code-artifact-provenance.ts`), imported at the top of this file and
@@ -1681,7 +1736,9 @@ export class SchemaRegistry {
16811736
* REPLACES the base at resolution; ADR-0029 D9) | 'extend' (additive merge)
16821737
* @param priority - Merge priority (lower applied first, higher wins on conflict)
16831738
*
1684-
* @throws Error if trying to 'own' an object that already has a PACKAGED owner
1739+
* @throws {ObjectOwnershipConflictError} ADR-0112 envelope (`code` +
1740+
* `status: 422`) if trying to 'own' an object that already has a PACKAGED
1741+
* owner from another package
16851742
*/
16861743
registerObject(
16871744
schema: ServiceObject,
@@ -1797,10 +1854,10 @@ export class SchemaRegistry {
17971854
`the tenant contribution (${existingOwner.packageId}) becomes its overlay layer.`
17981855
);
17991856
} else if (existingOwner && existingOwner.packageId !== packageId) {
1800-
throw new Error(
1801-
`Object "${fqn}" is already owned by package "${existingOwner.packageId}". ` +
1802-
`Package "${packageId}" cannot claim ownership. Use 'extend' to add fields.`
1803-
);
1857+
// [ADR-0029 D3] Two packages claiming one name — the cross-package
1858+
// refusal, carried as an ADR-0112 envelope (`code` + `status: 422`)
1859+
// with the message text unchanged. See {@link ObjectOwnershipConflictError}.
1860+
throw new ObjectOwnershipConflictError(fqn, existingOwner.packageId, packageId);
18041861
} else if (existingOwner) {
18051862
// Remove existing owner contribution from same package (re-registration).
18061863
// Normal path (metadata rebuild / HMR / multi-project seed replays the

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,38 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [
744744
'evidence of a door. If an install door ever answers with this code itself, the verdict becomes ' +
745745
'pending-registration and it belongs in the ledger batch.'
746746
},
747+
{
748+
code: 'OBJECT_OWNERSHIP_CONFLICT',
749+
file: 'packages/objectql/src/registry.ts',
750+
shape: 'classfield',
751+
door: 'none',
752+
verdict: 'boot-refusal',
753+
why:
754+
'ADR-0029 D3 — the refusal for a package claiming `own` on an object name a DIFFERENT package ' +
755+
'already owns, raised by `SchemaRegistry.registerObject` (the ONE spelling of this refusal — ' +
756+
'the ADR-0029 D9 §6.1 late-install branch beside it re-classifies a tenant-authored ' +
757+
'sitting owner and refuses nothing). Measured on this tree, every path to it either aborts boot ' +
758+
'or catches below any door. `ObjectQL.registerApp` (`packages/objectql/src/engine.ts`) lets it ' +
759+
'propagate to `ManifestService.register()` (`packages/objectql/src/plugin.ts`), whose callers ' +
760+
'are the population the three ADR-0130 rows above record: boot-time `manifest.register()` ' +
761+
'inside plugin init (`packages/runtime/src/app-plugin.ts`, the platform app plugins, the ' +
762+
'service plugins), where a throw aborts boot before any HTTP boundary exists; the rehydrate ' +
763+
'loop in `packages/cloud-connection/src/marketplace-install-local-plugin.ts`, which catches per ' +
764+
'entry and logs; and the import route in that same file, which catches and answers with its ' +
765+
'OWN registered `PLUGIN_REGISTER_FAILED` at 422, interpolating this refusal\'s MESSAGE into ' +
766+
'that envelope. Every other caller catches it in-process: `ObjectQL.registerPlugin` ' +
767+
'(`logger.warn`), the `ObjectQLPlugin` metadata bridge\'s reload ingest and `subscribe(\'object\')` ' +
768+
'handler (`logger.warn`), and `metadata-protocol`\'s `applyObjectRegistryMutation` ' +
769+
'(`console.warn`) and `loadMetaFromDb` (the per-record `errors` count). The two HTTP install ' +
770+
'sites — `POST /packages` in `packages/runtime/src/domains/packages.ts` and ' +
771+
'`protocol.installPackage` — call `SchemaRegistry.installPackage`, which records the package ' +
772+
'and never calls `registerObject`, so neither can raise it; `MetadataFacade.register(\'object\')` ' +
773+
'would propagate it, and has no production instantiation. So the code reaches a reader only ' +
774+
'inside a message string, never as `error.code`. Its `status: 422` is the ADR-0112 envelope ' +
775+
'shape this repo\'s rejection tests assert on, not evidence of a door. If a door ever answers ' +
776+
'with this code itself, the verdict becomes pending-registration and it belongs in the ledger ' +
777+
'batch.'
778+
},
747779
// ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ──
748780
//
749781
// The 29 rows below are the whole verdict cost of widening `codehelper` to

0 commit comments

Comments
 (0)